mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 11:05:17 -05:00
Compare commits
166 Commits
dependabot
...
main-legac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679de16e4f | ||
|
|
31a2d84f9f | ||
|
|
7d04c2baa0 | ||
|
|
6575ea68c7 | ||
|
|
e1cb76f1c6 | ||
|
|
acc8308fd2 | ||
|
|
de8b6f67a3 | ||
|
|
c437ddbf28 | ||
|
|
f7b1296263 | ||
|
|
e53414d795 | ||
|
|
2328c919c9 | ||
|
|
09e2c69493 | ||
|
|
5b7b203619 | ||
|
|
47c435d2f5 | ||
|
|
ce382a4361 | ||
|
|
07ab9f28f2 | ||
|
|
40e5cf3162 | ||
|
|
b9377ead37 | ||
|
|
851709058f | ||
|
|
757ad1be89 | ||
|
|
d4431acb39 | ||
|
|
f8907c7778 | ||
|
|
8c0c3df21a | ||
|
|
9b2124867a | ||
|
|
12deafaa09 | ||
|
|
8aa56c463a | ||
|
|
41b3c86437 | ||
|
|
b1c369c1bb | ||
|
|
4373d18176 | ||
|
|
82cbdecc4c | ||
|
|
616f6528b8 | ||
|
|
d31e4b4ebe | ||
|
|
0dd3f04137 | ||
|
|
41fb41838c | ||
|
|
fd42ee1161 | ||
|
|
33f5486000 | ||
|
|
2ff0bf5243 | ||
|
|
00d01f567a | ||
|
|
601538b494 | ||
|
|
fff180c476 | ||
|
|
6391b3d81c | ||
|
|
d978217577 | ||
|
|
4c954fff6f | ||
|
|
7feb7c462d | ||
|
|
7485477e26 | ||
|
|
1277835775 | ||
|
|
f2fccdf190 | ||
|
|
beac6ddfd8 | ||
|
|
6e0c3121be | ||
|
|
691f018e56 | ||
|
|
6697d8890b | ||
|
|
95f94cc799 | ||
|
|
cb3a9ddf3f | ||
|
|
6d30131f2c | ||
|
|
5737e5953d | ||
|
|
789d5db37a | ||
|
|
b8891fc65f | ||
|
|
331329d1ec | ||
|
|
120f215cad | ||
|
|
707546f279 | ||
|
|
b67353eff9 | ||
|
|
2cad07c198 | ||
|
|
30997cb615 | ||
|
|
0ee6e8c820 | ||
|
|
1a8171f918 | ||
|
|
ffebd5ce01 | ||
|
|
97bf980e45 | ||
|
|
3beeb91c7f | ||
|
|
25e6fdb496 | ||
|
|
0331e2087a | ||
|
|
1511fcfcfe | ||
|
|
88c16be231 | ||
|
|
3830b1ed50 | ||
|
|
db1441fcd2 | ||
|
|
b3e56ed465 | ||
|
|
6adbaf885f | ||
|
|
ee57a9ada1 | ||
|
|
66f57448be | ||
|
|
9d776aa5e3 | ||
|
|
b265d793a3 | ||
|
|
8c85963817 | ||
|
|
09f20c640d | ||
|
|
932deb876a | ||
|
|
7e9bd41316 | ||
|
|
bcdd2810a9 | ||
|
|
236b6f0254 | ||
|
|
ed400a5203 | ||
|
|
5046e55f05 | ||
|
|
d21ae6027d | ||
|
|
afdcfe7264 | ||
|
|
b24b12080b | ||
|
|
f3c59ad6ff | ||
|
|
9e724bd795 | ||
|
|
a7bd0505f9 | ||
|
|
ebe65e7c9d | ||
|
|
bddcc62ee6 | ||
|
|
0153af7339 | ||
|
|
821c94bc76 | ||
|
|
164cc15d90 | ||
|
|
fc654543f2 | ||
|
|
60661c9041 | ||
|
|
1eb35bce2e | ||
|
|
562126a3a1 | ||
|
|
081b5b7605 | ||
|
|
7fe9279d67 | ||
|
|
12a2e9823d | ||
|
|
f812a65271 | ||
|
|
ac344aea92 | ||
|
|
06bd7a8bdf | ||
|
|
62900d47bd | ||
|
|
a043163596 | ||
|
|
2c3ae4d937 | ||
|
|
b50e2e9e11 | ||
|
|
ac1ec18bb8 | ||
|
|
3f0588f947 | ||
|
|
7f96e85914 | ||
|
|
cfa7019a7c | ||
|
|
3896dcedcf | ||
|
|
988c2b2f06 | ||
|
|
a75e6a2098 | ||
|
|
6cf231be9d | ||
|
|
052a447bd7 | ||
|
|
f43c58f26e | ||
|
|
499c8c5abf | ||
|
|
828d7d9b9a | ||
|
|
e47c679bc0 | ||
|
|
a28272c784 | ||
|
|
c00d20cc4c | ||
|
|
54a472b207 | ||
|
|
3cad7c5641 | ||
|
|
434ac4c641 | ||
|
|
c8c871128e | ||
|
|
fc605715d3 | ||
|
|
cc914a1ca3 | ||
|
|
3ee3138055 | ||
|
|
a2501562a8 | ||
|
|
5eac88a5cd | ||
|
|
cb944485b8 | ||
|
|
1294b3009e | ||
|
|
3dd5baef19 | ||
|
|
0cf6805c18 | ||
|
|
26ff320806 | ||
|
|
a077bf236b | ||
|
|
7d745cd517 | ||
|
|
8f9e66d9f7 | ||
|
|
06e3efc603 | ||
|
|
4f14f5366f | ||
|
|
96290fdd58 | ||
|
|
30a59f7d6c | ||
|
|
79acc4a080 | ||
|
|
1208af9696 | ||
|
|
d0cfe61af3 | ||
|
|
388413fe70 | ||
|
|
69201cebb7 | ||
|
|
acd7b69ff7 | ||
|
|
5568f9e85c | ||
|
|
9e0259f739 | ||
|
|
31b7e5ee53 | ||
|
|
4a4b7924c5 | ||
|
|
7c8b8097e1 | ||
|
|
90e03355ac | ||
|
|
132872d2c8 | ||
|
|
6d33ea487e | ||
|
|
2f9bf30c9f | ||
|
|
540f40e689 | ||
|
|
75cc618c2b |
@@ -1,73 +0,0 @@
|
||||
# Migration Source: thrillwiki-87
|
||||
|
||||
The React project at `/Volumes/macminissd/Projects/thrillwiki-87` is the **authoritative source** for ThrillWiki features and functionality.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**thrillwiki-87 is LAW.** When migrating features, the React implementation defines:
|
||||
- What features must exist
|
||||
- How they should behave
|
||||
- What data structures are required
|
||||
- What UI patterns to follow
|
||||
|
||||
## Source Project Structure
|
||||
|
||||
```
|
||||
thrillwiki-87/
|
||||
├── src/
|
||||
│ ├── components/ # React components (44 directories)
|
||||
│ ├── pages/ # Route pages (39 files)
|
||||
│ ├── hooks/ # React hooks (80+ files)
|
||||
│ ├── types/ # TypeScript definitions (63 files)
|
||||
│ ├── contexts/ # React contexts
|
||||
│ ├── lib/ # Utilities
|
||||
│ └── integrations/ # External service integrations
|
||||
├── docs/ # Feature documentation (78 files)
|
||||
│ ├── SITE_OVERVIEW.md
|
||||
│ ├── DESIGN_SYSTEM.md
|
||||
│ ├── COMPONENTS.md
|
||||
│ ├── PAGES.md
|
||||
│ └── USER_FLOWS.md
|
||||
└── supabase/ # Backend schemas and functions
|
||||
```
|
||||
|
||||
## Technology Translation
|
||||
|
||||
| React (Source) | Nuxt 4 (Target) |
|
||||
|----------------|-----------------|
|
||||
| React component | Vue SFC (.vue) |
|
||||
| useState | ref() / reactive() |
|
||||
| useEffect | watch() / onMounted() |
|
||||
| useContext | provide() / inject() or Pinia |
|
||||
| React Router | Nuxt file-based routing |
|
||||
| React Query | useAsyncData / useFetch |
|
||||
| shadcn-ui | Nuxt UI |
|
||||
| Supabase client | Django REST API via useApi() |
|
||||
| Edge Functions | Django views |
|
||||
|
||||
## Backend Translation
|
||||
|
||||
| Supabase (Source) | Django (Target) |
|
||||
|-------------------|-----------------|
|
||||
| Table | Django Model |
|
||||
| RLS policies | DRF permissions |
|
||||
| Edge Functions | Django views/viewsets |
|
||||
| Realtime | SSE / WebSockets |
|
||||
| Auth | django-allauth + JWT |
|
||||
| Storage | Cloudflare R2 |
|
||||
|
||||
## Migration Workflow
|
||||
|
||||
1. **Find source** in thrillwiki-87
|
||||
2. **Read the docs** in thrillwiki-87/docs/
|
||||
3. **Check existing** Nuxt implementation
|
||||
4. **Port missing features** to achieve parity
|
||||
5. **Verify behavior** matches source
|
||||
|
||||
## Key Source Files to Reference
|
||||
|
||||
When porting a feature, always check:
|
||||
- `thrillwiki-87/docs/` for specifications
|
||||
- `thrillwiki-87/src/types/` for data structures
|
||||
- `thrillwiki-87/src/hooks/` for business logic
|
||||
- `thrillwiki-87/src/components/` for UI patterns
|
||||
@@ -1,83 +0,0 @@
|
||||
# Source Mapping: React → Nuxt
|
||||
|
||||
Quick reference for mapping thrillwiki-87 paths to thrillwiki_django_no_react paths.
|
||||
|
||||
## Directory Mappings
|
||||
|
||||
| React (thrillwiki-87) | Nuxt (thrillwiki_django_no_react) |
|
||||
|----------------------|-----------------------------------|
|
||||
| `src/components/` | `frontend/app/components/` |
|
||||
| `src/pages/` | `frontend/app/pages/` |
|
||||
| `src/hooks/` | `frontend/app/composables/` |
|
||||
| `src/types/` | `frontend/app/types/` |
|
||||
| `src/lib/` | `frontend/app/utils/` |
|
||||
| `src/contexts/` | `frontend/app/stores/` (Pinia) |
|
||||
| `docs/` | `source_docs/` |
|
||||
| `supabase/migrations/` | `backend/apps/*/models.py` |
|
||||
|
||||
## Component Mappings (shadcn-ui → Nuxt UI)
|
||||
|
||||
| shadcn-ui | Nuxt UI |
|
||||
|-----------|---------|
|
||||
| `<Button>` | `<UButton>` |
|
||||
| `<Card>` | `<UCard>` |
|
||||
| `<Dialog>` | `<UModal>` |
|
||||
| `<Input>` | `<UInput>` |
|
||||
| `<Select>` | `<USelect>` / `<USelectMenu>` |
|
||||
| `<Tabs>` | `<UTabs>` |
|
||||
| `<Table>` | `<UTable>` |
|
||||
| `<Badge>` | `<UBadge>` |
|
||||
| `<Avatar>` | `<UAvatar>` |
|
||||
| `<Tooltip>` | `<UTooltip>` |
|
||||
| `<Sheet>` | `<USlideover>` |
|
||||
| `<AlertDialog>` | `<UModal>` + confirm pattern |
|
||||
| `<Skeleton>` | `<USkeleton>` |
|
||||
| `<Textarea>` | `<UTextarea>` |
|
||||
| `<Checkbox>` | `<UCheckbox>` |
|
||||
| `<RadioGroup>` | `<URadioGroup>` |
|
||||
| `<Switch>` | `<UToggle>` |
|
||||
| `<DropdownMenu>` | `<UDropdown>` |
|
||||
| `<Command>` | `<UCommandPalette>` |
|
||||
| `<Popover>` | `<UPopover>` |
|
||||
|
||||
## Page Mappings
|
||||
|
||||
| React Page | Nuxt Page |
|
||||
|------------|-----------|
|
||||
| `Index.tsx` | `pages/index.vue` |
|
||||
| `Parks.tsx` | `pages/parks/index.vue` |
|
||||
| `ParkDetail.tsx` | `pages/parks/[park_slug]/index.vue` |
|
||||
| `Rides.tsx` | `pages/rides/index.vue` |
|
||||
| `RideDetail.tsx` | `pages/parks/[park_slug]/rides/[ride_slug].vue` |
|
||||
| `Manufacturers.tsx` | `pages/manufacturers/index.vue` |
|
||||
| `ManufacturerDetail.tsx` | `pages/manufacturers/[slug].vue` |
|
||||
| `Designers.tsx` | `pages/designers/index.vue` |
|
||||
| `DesignerDetail.tsx` | `pages/designers/[slug].vue` |
|
||||
| `Operators.tsx` | `pages/operators/index.vue` |
|
||||
| `OperatorDetail.tsx` | `pages/operators/[slug].vue` |
|
||||
| `ParkOwners.tsx` | `pages/owners/index.vue` |
|
||||
| `PropertyOwnerDetail.tsx` | `pages/owners/[slug].vue` |
|
||||
| `Auth.tsx` | `pages/auth/login.vue`, `pages/auth/signup.vue` |
|
||||
| `Profile.tsx` | `pages/profile/index.vue` |
|
||||
| `Search.tsx` | `pages/search.vue` |
|
||||
| `AdminDashboard.tsx` | `pages/admin/index.vue` |
|
||||
|
||||
## Hook → Composable Mappings
|
||||
|
||||
| React Hook | Vue Composable |
|
||||
|------------|----------------|
|
||||
| `useAuth.tsx` | `useAuth.ts` |
|
||||
| `useSearch.tsx` | `useSearchHistory.ts` |
|
||||
| `useModerationQueue.ts` | `useModeration.ts` |
|
||||
| `useProfile.tsx` | (inline in pages) |
|
||||
| `useLocations.ts` | `useParksApi.ts` |
|
||||
| `useUnitPreferences.ts` | `useUnits.ts` |
|
||||
|
||||
## API Endpoint Translation
|
||||
|
||||
| Supabase RPC/Query | Django API |
|
||||
|--------------------|------------|
|
||||
| `supabase.from('parks')` | `GET /api/v1/parks/` |
|
||||
| `supabase.rpc('search_*')` | `GET /api/v1/search/` |
|
||||
| `supabase.auth.*` | `/api/v1/auth/*` |
|
||||
| Edge Functions | Django views in `backend/apps/*/views.py` |
|
||||
@@ -1,329 +0,0 @@
|
||||
# ThrillWiki API Conventions
|
||||
|
||||
Standards for designing and implementing APIs between the Django backend and Nuxt frontend.
|
||||
|
||||
## API Base Structure
|
||||
|
||||
### URL Patterns
|
||||
```
|
||||
/api/v1/ # API root (versioned)
|
||||
/api/v1/parks/ # List/Create parks
|
||||
/api/v1/parks/{slug}/ # Retrieve/Update/Delete park
|
||||
/api/v1/parks/{slug}/rides/ # Nested resource
|
||||
/api/v1/auth/ # Authentication endpoints
|
||||
/api/v1/users/me/ # Current user
|
||||
```
|
||||
|
||||
### HTTP Methods
|
||||
| Method | Usage |
|
||||
|--------|-------|
|
||||
| GET | Retrieve resource(s) |
|
||||
| POST | Create resource |
|
||||
| PUT | Replace resource entirely |
|
||||
| PATCH | Update resource partially |
|
||||
| DELETE | Remove resource |
|
||||
|
||||
## Response Formats
|
||||
|
||||
### Success Response (Single Resource)
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Cedar Point",
|
||||
"slug": "cedar-point",
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-16T14:20:00Z",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### Success Response (List)
|
||||
```json
|
||||
{
|
||||
"count": 150,
|
||||
"next": "/api/v1/parks/?page=2",
|
||||
"previous": null,
|
||||
"results": [
|
||||
{ ... },
|
||||
{ ... }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "validation_error",
|
||||
"message": "Invalid input data",
|
||||
"details": {
|
||||
"name": ["This field is required."],
|
||||
"status": ["Invalid choice."]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### HTTP Status Codes
|
||||
| Code | Meaning | When to Use |
|
||||
|------|---------|-------------|
|
||||
| 200 | OK | Successful GET, PUT, PATCH |
|
||||
| 201 | Created | Successful POST |
|
||||
| 204 | No Content | Successful DELETE |
|
||||
| 400 | Bad Request | Validation errors |
|
||||
| 401 | Unauthorized | Missing/invalid auth |
|
||||
| 403 | Forbidden | Insufficient permissions |
|
||||
| 404 | Not Found | Resource doesn't exist |
|
||||
| 409 | Conflict | Duplicate resource |
|
||||
| 500 | Server Error | Unexpected errors |
|
||||
|
||||
## Pagination
|
||||
|
||||
### Query Parameters
|
||||
```
|
||||
?page=2 # Page number (default: 1)
|
||||
?page_size=20 # Items per page (default: 20, max: 100)
|
||||
```
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"count": 150,
|
||||
"next": "/api/v1/parks/?page=3",
|
||||
"previous": "/api/v1/parks/?page=1",
|
||||
"results": [ ... ]
|
||||
}
|
||||
```
|
||||
|
||||
## Filtering & Sorting
|
||||
|
||||
### Filtering
|
||||
```
|
||||
GET /api/v1/parks/?status=operating
|
||||
GET /api/v1/parks/?country=USA&status=operating
|
||||
GET /api/v1/rides/?park=cedar-point&type=coaster
|
||||
```
|
||||
|
||||
### Search
|
||||
```
|
||||
GET /api/v1/parks/?search=cedar
|
||||
```
|
||||
|
||||
### Sorting
|
||||
```
|
||||
GET /api/v1/parks/?ordering=name # Ascending
|
||||
GET /api/v1/parks/?ordering=-created_at # Descending
|
||||
GET /api/v1/parks/?ordering=-rating,name # Multiple fields
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### Token-Based Auth
|
||||
```
|
||||
Authorization: Bearer <jwt_token>
|
||||
```
|
||||
|
||||
### Endpoints
|
||||
```
|
||||
POST /api/v1/auth/login/ # Get tokens
|
||||
POST /api/v1/auth/register/ # Create account
|
||||
POST /api/v1/auth/refresh/ # Refresh access token
|
||||
POST /api/v1/auth/logout/ # Invalidate tokens
|
||||
GET /api/v1/auth/me/ # Current user info
|
||||
```
|
||||
|
||||
### Social Auth
|
||||
```
|
||||
POST /api/v1/auth/google/ # Google OAuth
|
||||
POST /api/v1/auth/discord/ # Discord OAuth
|
||||
```
|
||||
|
||||
## Content Submission API
|
||||
|
||||
### Submit New Content
|
||||
```
|
||||
POST /api/v1/submissions/
|
||||
{
|
||||
"content_type": "park",
|
||||
"data": {
|
||||
"name": "New Park Name",
|
||||
"city": "Orlando",
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"id": "submission-uuid",
|
||||
"status": "pending",
|
||||
"content_type": "park",
|
||||
"data": { ... },
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Submit Edit to Existing Content
|
||||
```
|
||||
POST /api/v1/submissions/
|
||||
{
|
||||
"content_type": "park",
|
||||
"object_id": "existing-park-uuid",
|
||||
"data": {
|
||||
"description": "Updated description..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Moderation API
|
||||
|
||||
### Get Moderation Queue
|
||||
```
|
||||
GET /api/v1/moderation/
|
||||
GET /api/v1/moderation/?status=pending&type=park
|
||||
```
|
||||
|
||||
### Review Submission
|
||||
```
|
||||
POST /api/v1/moderation/{id}/approve/
|
||||
POST /api/v1/moderation/{id}/reject/
|
||||
{
|
||||
"notes": "Reason for rejection..."
|
||||
}
|
||||
```
|
||||
|
||||
## Nested Resources
|
||||
|
||||
### Pattern
|
||||
```
|
||||
/api/v1/parks/{slug}/rides/ # List rides in park
|
||||
/api/v1/parks/{slug}/reviews/ # List park reviews
|
||||
/api/v1/parks/{slug}/photos/ # List park photos
|
||||
```
|
||||
|
||||
### When to Nest vs Flat
|
||||
**Nest when:**
|
||||
- Resource only makes sense in context of parent (park photos)
|
||||
- Need to filter by parent frequently
|
||||
|
||||
**Use flat with filter when:**
|
||||
- Resource can exist independently
|
||||
- Need to query across parents
|
||||
|
||||
```
|
||||
# Flat with filter
|
||||
GET /api/v1/rides/?park=cedar-point
|
||||
|
||||
# Or nested
|
||||
GET /api/v1/parks/cedar-point/rides/
|
||||
```
|
||||
|
||||
## Geolocation API
|
||||
|
||||
### Parks Nearby
|
||||
```
|
||||
GET /api/v1/parks/nearby/?lat=41.4821&lng=-82.6822&radius=50
|
||||
```
|
||||
|
||||
### Response includes distance
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Cedar Point",
|
||||
"distance": 12.5, // in user's preferred units
|
||||
...
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## File Uploads
|
||||
|
||||
### Photo Upload
|
||||
```
|
||||
POST /api/v1/photos/
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
{
|
||||
"content_type": "park",
|
||||
"object_id": "park-uuid",
|
||||
"image": <file>,
|
||||
"caption": "Optional caption"
|
||||
}
|
||||
```
|
||||
|
||||
### Response
|
||||
```json
|
||||
{
|
||||
"id": "photo-uuid",
|
||||
"url": "https://cdn.thrillwiki.com/photos/...",
|
||||
"thumbnail_url": "https://cdn.thrillwiki.com/photos/.../thumb",
|
||||
"status": "pending", // Pending moderation
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Headers
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 95
|
||||
X-RateLimit-Reset: 1642089600
|
||||
```
|
||||
|
||||
### When Limited
|
||||
```
|
||||
HTTP/1.1 429 Too Many Requests
|
||||
{
|
||||
"error": {
|
||||
"code": "rate_limit_exceeded",
|
||||
"message": "Too many requests. Try again in 60 seconds."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### API Client Setup (Nuxt)
|
||||
```typescript
|
||||
// composables/useApi.ts
|
||||
export function useApi() {
|
||||
const config = useRuntimeConfig()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const api = $fetch.create({
|
||||
baseURL: config.public.apiBase,
|
||||
headers: authStore.token ? {
|
||||
Authorization: `Bearer ${authStore.token}`
|
||||
} : {},
|
||||
onResponseError: ({ response }) => {
|
||||
if (response.status === 401) {
|
||||
authStore.logout()
|
||||
navigateTo('/auth/login')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return api
|
||||
}
|
||||
```
|
||||
|
||||
### Usage in Components
|
||||
```typescript
|
||||
const api = useApi()
|
||||
|
||||
// Fetch parks
|
||||
const { data: parks } = await useAsyncData('parks', () =>
|
||||
api('/parks/', { params: { status: 'operating' } })
|
||||
)
|
||||
|
||||
// Create submission
|
||||
await api('/submissions/', {
|
||||
method: 'POST',
|
||||
body: { content_type: 'park', data: formData }
|
||||
})
|
||||
```
|
||||
@@ -1,306 +0,0 @@
|
||||
# ThrillWiki Component Patterns
|
||||
|
||||
Guidelines for building UI components consistent with ThrillWiki's design system.
|
||||
|
||||
## Component Hierarchy
|
||||
|
||||
```
|
||||
components/
|
||||
├── layout/ # Page structure
|
||||
│ ├── Header.vue
|
||||
│ ├── Footer.vue
|
||||
│ ├── Sidebar.vue
|
||||
│ └── PageContainer.vue
|
||||
├── ui/ # Base components (shadcn/ui style)
|
||||
│ ├── Button.vue
|
||||
│ ├── Card.vue
|
||||
│ ├── Input.vue
|
||||
│ ├── Badge.vue
|
||||
│ ├── Avatar.vue
|
||||
│ ├── Modal.vue
|
||||
│ └── ...
|
||||
├── entity/ # Domain-specific cards
|
||||
│ ├── ParkCard.vue
|
||||
│ ├── RideCard.vue
|
||||
│ ├── ReviewCard.vue
|
||||
│ ├── CreditCard.vue
|
||||
│ └── CompanyCard.vue
|
||||
├── forms/ # Form components
|
||||
│ ├── ParkForm.vue
|
||||
│ ├── ReviewForm.vue
|
||||
│ └── ...
|
||||
└── specialty/ # Complex/unique components
|
||||
├── SearchAutocomplete.vue
|
||||
├── Map.vue
|
||||
├── ImageGallery.vue
|
||||
├── UnitDisplay.vue
|
||||
└── RatingDisplay.vue
|
||||
```
|
||||
|
||||
## Base Components (ui/)
|
||||
|
||||
### Card
|
||||
```vue
|
||||
<template>
|
||||
<div :class="[
|
||||
'rounded-lg border bg-card text-card-foreground',
|
||||
interactive && 'hover:shadow-md transition-shadow cursor-pointer',
|
||||
className
|
||||
]">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
interactive?: boolean
|
||||
className?: string
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
### Button
|
||||
```vue
|
||||
<template>
|
||||
<button :class="[buttonVariants({ variant, size }), className]">
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive'
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||
className?: string
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
### Badge
|
||||
```vue
|
||||
<template>
|
||||
<span :class="[badgeVariants({ variant }), className]">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'
|
||||
className?: string
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
## Entity Cards
|
||||
|
||||
### ParkCard
|
||||
Displays park preview with image, name, location, stats, and status.
|
||||
|
||||
**Required Props:**
|
||||
- `park: Park` - Park object
|
||||
|
||||
**Displays:**
|
||||
- Park image (with fallback)
|
||||
- Park name
|
||||
- Location (city, country)
|
||||
- Ride count
|
||||
- Average rating
|
||||
- Status badge (Operating/Closed/Under Construction)
|
||||
|
||||
**Interactions:**
|
||||
- Click navigates to park detail page
|
||||
- Hover shows elevation shadow
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<NuxtLink :to="`/parks/${park.slug}`">
|
||||
<Card interactive>
|
||||
<div class="aspect-video relative overflow-hidden rounded-t-lg">
|
||||
<NuxtImg
|
||||
:src="park.image || '/placeholder-park.jpg'"
|
||||
:alt="park.name"
|
||||
class="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-lg line-clamp-1">{{ park.name }}</h3>
|
||||
<p class="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<MapPin class="w-4 h-4" />
|
||||
{{ park.city }}, {{ park.country }}
|
||||
</p>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<span class="text-sm">🎢 {{ park.rideCount }} rides</span>
|
||||
<RatingDisplay :rating="park.averageRating" size="sm" />
|
||||
</div>
|
||||
<Badge :variant="statusVariant" class="mt-2">
|
||||
{{ park.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
```
|
||||
|
||||
### RideCard
|
||||
Similar structure to ParkCard, but shows:
|
||||
- Ride image
|
||||
- Ride name
|
||||
- Park name (linked)
|
||||
- Key specs (speed, height)
|
||||
- Type badge + Status badge
|
||||
|
||||
### ReviewCard
|
||||
Displays user review with:
|
||||
- User avatar + username
|
||||
- Rating (5-star display)
|
||||
- Review date (relative)
|
||||
- Review text
|
||||
- Helpful votes (👍 count)
|
||||
- Actions (Reply, Report)
|
||||
|
||||
### CreditCard
|
||||
For user's ride credit list:
|
||||
- Ride thumbnail
|
||||
- Ride name + park name
|
||||
- Ride count with +/- controls
|
||||
- Last ridden date
|
||||
- Edit button
|
||||
|
||||
## Specialty Components
|
||||
|
||||
### SearchAutocomplete
|
||||
Global search with instant results.
|
||||
|
||||
**Features:**
|
||||
- Debounced input (300ms)
|
||||
- Results grouped by type (Parks, Rides, Companies)
|
||||
- Keyboard navigation
|
||||
- Click or Enter to navigate
|
||||
- Empty state handling
|
||||
|
||||
**Implementation Notes:**
|
||||
- Use `useFetch` with `watch` for reactive searching
|
||||
- Show loading skeleton while fetching
|
||||
- Limit results to 10 per category
|
||||
- Highlight matching text
|
||||
|
||||
### UnitDisplay
|
||||
Converts and displays values in user's preferred units.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<span>{{ formattedValue }}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
value: number
|
||||
type: 'speed' | 'height' | 'length' | 'weight'
|
||||
showBoth?: boolean // Show both metric and imperial
|
||||
}>()
|
||||
|
||||
const { preferredUnits } = useUnits()
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
// Convert and format based on type and preference
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### RatingDisplay
|
||||
Star rating visualization.
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex">
|
||||
<Star
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
:class="[
|
||||
'w-4 h-4',
|
||||
i <= Math.round(rating) ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-medium">{{ rating.toFixed(1) }}</span>
|
||||
<span v-if="count" class="text-sm text-muted-foreground">({{ count }})</span>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Map (Leaflet)
|
||||
Interactive map for parks nearby feature.
|
||||
|
||||
**Features:**
|
||||
- Marker clusters for dense areas
|
||||
- Custom markers for different park types
|
||||
- Popup on marker click with park preview
|
||||
- Zoom controls
|
||||
- Full-screen toggle
|
||||
- User location marker (if permitted)
|
||||
|
||||
## Form Components
|
||||
|
||||
### Standard Form Structure
|
||||
```vue
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="space-y-4">
|
||||
<FormField label="Name" :error="errors.name">
|
||||
<Input v-model="form.name" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description">
|
||||
<Textarea v-model="form.description" />
|
||||
</FormField>
|
||||
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button variant="outline" @click="$emit('cancel')">Cancel</Button>
|
||||
<Button type="submit" :loading="isSubmitting">Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Validation
|
||||
- Use Zod for schema validation
|
||||
- Display errors inline below fields
|
||||
- Disable submit button while invalid
|
||||
- Show loading state during submission
|
||||
|
||||
## Loading States
|
||||
|
||||
### Skeleton Loading
|
||||
All cards should have skeleton states:
|
||||
```vue
|
||||
<template>
|
||||
<Card v-if="loading">
|
||||
<Skeleton class="aspect-video rounded-t-lg" />
|
||||
<div class="p-4 space-y-2">
|
||||
<Skeleton class="h-5 w-3/4" />
|
||||
<Skeleton class="h-4 w-1/2" />
|
||||
<Skeleton class="h-4 w-1/4" />
|
||||
</div>
|
||||
</Card>
|
||||
<ActualCard v-else :data="data" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### Empty States
|
||||
Provide clear empty states with:
|
||||
- Relevant icon
|
||||
- Clear message
|
||||
- Suggested action (button or link)
|
||||
|
||||
```vue
|
||||
<EmptyState
|
||||
icon="Search"
|
||||
title="No results found"
|
||||
description="Try adjusting your search or filters"
|
||||
>
|
||||
<Button @click="clearFilters">Clear Filters</Button>
|
||||
</EmptyState>
|
||||
```
|
||||
@@ -1,191 +0,0 @@
|
||||
# ThrillWiki Design System Rules
|
||||
|
||||
Visual identity, colors, typography, and styling guidelines for ThrillWiki UI.
|
||||
|
||||
## Brand Identity
|
||||
|
||||
- **Name**: ThrillWiki (optional "(Beta)" suffix during preview)
|
||||
- **Tagline**: The Ultimate Theme Park Database
|
||||
- **Personality**: Enthusiastic, trustworthy, community-driven
|
||||
- **Visual Style**: Modern, clean, subtle gradients, smooth animations, card-based layouts
|
||||
|
||||
## Color System
|
||||
|
||||
### Semantic Color Tokens (use these, not raw hex values)
|
||||
|
||||
| Token | Purpose | Usage |
|
||||
|-------|---------|-------|
|
||||
| `--background` | Page background | Main content area |
|
||||
| `--foreground` | Primary text | Body text, headings |
|
||||
| `--primary` | Interactive elements | Buttons, links |
|
||||
| `--secondary` | Secondary UI | Secondary buttons |
|
||||
| `--muted` | Subdued content | Hints, disabled states |
|
||||
| `--accent` | Highlights | Focus rings, special callouts |
|
||||
| `--destructive` | Danger actions | Delete buttons, errors |
|
||||
| `--success` | Positive feedback | Success messages, confirmations |
|
||||
| `--warning` | Caution | Alerts, warnings |
|
||||
|
||||
### Status Colors (Entity Badges)
|
||||
- **Operating**: Green (`--success`)
|
||||
- **Closed**: Red (`--destructive`)
|
||||
- **Under Construction**: Amber (`--warning`)
|
||||
|
||||
### Dark Mode
|
||||
- Automatically supported via CSS variables
|
||||
- Reduce contrast (use off-white, not pure white)
|
||||
- Replace shadows with subtle glows
|
||||
- Slightly dim images
|
||||
|
||||
## Typography
|
||||
|
||||
### Font
|
||||
- **Primary Font**: Inter (Sans-Serif)
|
||||
- **Weights**: 400 (Regular), 500 (Medium), 600 (Semibold), 700 (Bold)
|
||||
- **Fallback**: system-ui, sans-serif
|
||||
|
||||
### Type Scale
|
||||
|
||||
| Size | Name | Usage |
|
||||
|------|------|-------|
|
||||
| 48px | Display | Hero headlines |
|
||||
| 36px | H1 | Page titles |
|
||||
| 30px | H2 | Section headers |
|
||||
| 24px | H3 | Card titles |
|
||||
| 20px | H4 | Subheadings |
|
||||
| 16px | Body | Default text |
|
||||
| 14px | Small | Secondary text |
|
||||
| 12px | Caption | Labels, hints |
|
||||
|
||||
### Text Classes (Tailwind)
|
||||
```
|
||||
Page Title: text-4xl font-bold
|
||||
Section Header: text-2xl font-semibold
|
||||
Card Title: text-lg font-medium
|
||||
Body: text-base
|
||||
Caption: text-sm text-muted-foreground
|
||||
```
|
||||
|
||||
## Spacing System
|
||||
|
||||
### Base Unit
|
||||
- 4px base unit
|
||||
- All spacing is multiples of 4
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
| Token | Size | Usage |
|
||||
|-------|------|-------|
|
||||
| `space-1` (p-1) | 4px | Tight gaps |
|
||||
| `space-2` (p-2) | 8px | Icon gaps |
|
||||
| `space-3` (p-3) | 12px | Small padding |
|
||||
| `space-4` (p-4) | 16px | Default padding |
|
||||
| `space-6` (p-6) | 24px | Section gaps |
|
||||
| `space-8` (p-8) | 32px | Large gaps |
|
||||
| `space-12` (p-12) | 48px | Section margins |
|
||||
|
||||
## Border Radius
|
||||
|
||||
| Token | Size | Usage |
|
||||
|-------|------|-------|
|
||||
| `rounded-sm` | 4px | Small elements |
|
||||
| `rounded` | 8px | Buttons, inputs |
|
||||
| `rounded-lg` | 12px | Cards |
|
||||
| `rounded-xl` | 16px | Large cards, modals |
|
||||
| `rounded-full` | 9999px | Pills, avatars |
|
||||
|
||||
## Layout
|
||||
|
||||
### Max Content Width
|
||||
- Main content: `max-w-7xl` (1280px)
|
||||
- Centered with `mx-auto`
|
||||
- Responsive padding: `px-4 md:px-6 lg:px-8`
|
||||
|
||||
### Grid System
|
||||
- Desktop (1024px+): 4 columns
|
||||
- Tablet (768px+): 2 columns
|
||||
- Mobile (<768px): 1 column
|
||||
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Cards
|
||||
- Background: `bg-card`
|
||||
- Border: `border border-border`
|
||||
- Radius: `rounded-lg`
|
||||
- Padding: `p-4` or `p-6`
|
||||
- Interactive cards: Add `hover:shadow-md transition-shadow cursor-pointer`
|
||||
|
||||
### Buttons
|
||||
| Variant | Classes |
|
||||
|---------|---------|
|
||||
| Primary | `bg-primary text-primary-foreground hover:bg-primary/90` |
|
||||
| Secondary | `bg-secondary text-secondary-foreground hover:bg-secondary/80` |
|
||||
| Outline | `border border-input bg-background hover:bg-accent` |
|
||||
| Ghost | `hover:bg-accent hover:text-accent-foreground` |
|
||||
| Destructive | `bg-destructive text-destructive-foreground hover:bg-destructive/90` |
|
||||
|
||||
### Inputs
|
||||
- Border: `border border-input`
|
||||
- Focus: `focus:ring-2 focus:ring-primary focus:border-transparent`
|
||||
- Error: `border-destructive focus:ring-destructive`
|
||||
|
||||
### Badges
|
||||
```html
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
Operating
|
||||
</span>
|
||||
```
|
||||
|
||||
## Icons
|
||||
|
||||
- **Library**: Lucide icons (via `lucide-vue-next`)
|
||||
- **Default Size**: 24px (w-6 h-6)
|
||||
- **Stroke Width**: 1.5px
|
||||
- **Color**: Inherit from text color
|
||||
|
||||
Common icons:
|
||||
- Search, Menu, User, Heart, Star, MapPin, Calendar, Camera, Edit, Trash, Check, X, ChevronRight, ExternalLink
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
| Breakpoint | Width | Target |
|
||||
|------------|-------|--------|
|
||||
| `sm` | 640px | Large phones |
|
||||
| `md` | 768px | Tablets |
|
||||
| `lg` | 1024px | Small laptops |
|
||||
| `xl` | 1280px | Desktops |
|
||||
| `2xl` | 1536px | Large screens |
|
||||
|
||||
## Accessibility Requirements
|
||||
|
||||
- Color contrast: 4.5:1 minimum for normal text
|
||||
- Focus states: Visible focus ring on all interactive elements
|
||||
- Motion: Respect `prefers-reduced-motion`
|
||||
- Screen readers: Proper ARIA labels on interactive elements
|
||||
- Keyboard: All functionality accessible via keyboard
|
||||
|
||||
## ThrillWiki-Specific Patterns
|
||||
|
||||
### Unit Display
|
||||
Always provide unit conversion toggle (metric/imperial):
|
||||
```vue
|
||||
<UnitDisplay :value="121" type="speed" />
|
||||
<!-- Renders: "121 km/h" or "75 mph" based on user preference -->
|
||||
```
|
||||
|
||||
### Rating Display
|
||||
```vue
|
||||
<RatingDisplay :rating="4.2" :count="156" />
|
||||
<!-- Renders: ★★★★☆ 4.2 (156 reviews) -->
|
||||
```
|
||||
|
||||
### Entity Cards
|
||||
All entity cards (Park, Ride, Company) should show:
|
||||
- Image (with loading skeleton)
|
||||
- Name (primary text)
|
||||
- Key details (secondary text)
|
||||
- Status badge
|
||||
- Quick stats (rating, count, etc.)
|
||||
@@ -1,254 +0,0 @@
|
||||
# ThrillWiki Django Backend Standards
|
||||
|
||||
Rules for developing the ThrillWiki backend with Django and Django REST Framework.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── config/ # Project settings
|
||||
│ ├── settings/
|
||||
│ │ ├── base.py # Shared settings
|
||||
│ │ ├── development.py # Dev-specific
|
||||
│ │ └── production.py # Prod-specific
|
||||
│ ├── urls.py # Root URL config
|
||||
│ └── wsgi.py
|
||||
├── apps/
|
||||
│ ├── parks/ # Park-related models and APIs
|
||||
│ ├── rides/ # Ride-related models and APIs
|
||||
│ ├── companies/ # Manufacturers, operators, etc.
|
||||
│ ├── users/ # User profiles, authentication
|
||||
│ ├── reviews/ # User reviews and ratings
|
||||
│ ├── credits/ # Ride credits tracking
|
||||
│ ├── submissions/ # Content submission system
|
||||
│ ├── moderation/ # Moderation queue
|
||||
│ └── core/ # Shared utilities
|
||||
└── tests/ # Test files
|
||||
```
|
||||
|
||||
## Django App Structure
|
||||
|
||||
Each app should follow this structure:
|
||||
```
|
||||
app_name/
|
||||
├── __init__.py
|
||||
├── admin.py # Django admin configuration
|
||||
├── apps.py # App configuration
|
||||
├── models.py # Database models
|
||||
├── serializers.py # DRF serializers
|
||||
├── views.py # DRF viewsets/views
|
||||
├── urls.py # URL routing
|
||||
├── permissions.py # Custom permissions (if needed)
|
||||
├── filters.py # DRF filters (if needed)
|
||||
├── signals.py # Django signals (if needed)
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
├── test_models.py
|
||||
├── test_views.py
|
||||
└── test_serializers.py
|
||||
```
|
||||
|
||||
## Model Conventions
|
||||
|
||||
### Base Model
|
||||
All models should inherit from a base model with common fields:
|
||||
```python
|
||||
from django.db import models
|
||||
import uuid
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""Base model with common fields"""
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
```
|
||||
|
||||
### Model Example
|
||||
```python
|
||||
class Park(BaseModel):
|
||||
"""A theme park or amusement park"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
OPERATING = 'operating', 'Operating'
|
||||
CLOSED = 'closed', 'Closed'
|
||||
CONSTRUCTION = 'construction', 'Under Construction'
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.OPERATING
|
||||
)
|
||||
location = models.PointField() # GeoDjango
|
||||
city = models.CharField(max_length=100)
|
||||
country = models.CharField(max_length=100)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
```
|
||||
|
||||
### Versioning for Moderated Content
|
||||
For content that needs version history:
|
||||
```python
|
||||
class ParkVersion(BaseModel):
|
||||
"""Version history for park edits"""
|
||||
park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name='versions')
|
||||
data = models.JSONField() # Snapshot of park data
|
||||
changed_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||
change_summary = models.CharField(max_length=255)
|
||||
is_current = models.BooleanField(default=False)
|
||||
```
|
||||
|
||||
## API Conventions
|
||||
|
||||
### URL Structure
|
||||
```python
|
||||
# apps/parks/urls.py
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ParkViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('parks', ParkViewSet, basename='park')
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
||||
# Results in:
|
||||
# GET /api/parks/ - List parks
|
||||
# POST /api/parks/ - Create park (submission)
|
||||
# GET /api/parks/{slug}/ - Get park detail
|
||||
# PUT /api/parks/{slug}/ - Update park (submission)
|
||||
# DELETE /api/parks/{slug}/ - Delete park (admin only)
|
||||
```
|
||||
|
||||
### ViewSet Structure
|
||||
```python
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||
|
||||
class ParkViewSet(viewsets.ModelViewSet):
|
||||
"""API endpoint for parks"""
|
||||
queryset = Park.objects.all()
|
||||
serializer_class = ParkSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
lookup_field = 'slug'
|
||||
filterset_class = ParkFilter
|
||||
search_fields = ['name', 'city', 'country']
|
||||
ordering_fields = ['name', 'created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
"""Optimize queries with select_related and prefetch_related"""
|
||||
return Park.objects.select_related(
|
||||
'operator', 'owner'
|
||||
).prefetch_related(
|
||||
'rides', 'photos'
|
||||
)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def rides(self, request, slug=None):
|
||||
"""Get rides for a specific park"""
|
||||
park = self.get_object()
|
||||
rides = park.rides.all()
|
||||
serializer = RideSerializer(rides, many=True)
|
||||
return Response(serializer.data)
|
||||
```
|
||||
|
||||
### Serializer Patterns
|
||||
```python
|
||||
from rest_framework import serializers
|
||||
|
||||
class ParkSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Park model"""
|
||||
ride_count = serializers.IntegerField(read_only=True)
|
||||
average_rating = serializers.FloatField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'description', 'status',
|
||||
'city', 'country', 'ride_count', 'average_rating',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'slug', 'created_at', 'updated_at']
|
||||
|
||||
class ParkDetailSerializer(ParkSerializer):
|
||||
"""Extended serializer for park detail view"""
|
||||
rides = RideSerializer(many=True, read_only=True)
|
||||
photos = PhotoSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta(ParkSerializer.Meta):
|
||||
fields = ParkSerializer.Meta.fields + ['rides', 'photos']
|
||||
```
|
||||
|
||||
## Query Optimization
|
||||
|
||||
- ALWAYS use `select_related()` for ForeignKey relationships
|
||||
- ALWAYS use `prefetch_related()` for ManyToMany and reverse FK relationships
|
||||
- Annotate computed fields at the database level when possible
|
||||
- Use pagination for all list endpoints
|
||||
|
||||
```python
|
||||
# Good
|
||||
parks = Park.objects.select_related('operator').prefetch_related('rides')
|
||||
|
||||
# Bad - causes N+1 queries
|
||||
for park in parks:
|
||||
print(park.operator.name) # Each iteration hits the database
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
### Custom Permission Classes
|
||||
```python
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
class IsModeratorOrReadOnly(BasePermission):
|
||||
"""Allow read access to all, write access to moderators"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.method in ['GET', 'HEAD', 'OPTIONS']:
|
||||
return True
|
||||
return request.user.is_authenticated and request.user.is_moderator
|
||||
```
|
||||
|
||||
## Submission & Moderation Flow
|
||||
|
||||
All user-submitted content goes through moderation:
|
||||
|
||||
1. User submits content → Creates `Submission` record with status `pending`
|
||||
2. Moderator reviews → Approves or rejects
|
||||
3. On approval → Content is published, version record created
|
||||
|
||||
```python
|
||||
class Submission(BaseModel):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = 'pending', 'Pending Review'
|
||||
APPROVED = 'approved', 'Approved'
|
||||
REJECTED = 'rejected', 'Rejected'
|
||||
CHANGES_REQUESTED = 'changes_requested', 'Changes Requested'
|
||||
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.UUIDField(null=True, blank=True)
|
||||
data = models.JSONField()
|
||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
|
||||
submitted_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
reviewed_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
|
||||
review_notes = models.TextField(blank=True)
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
- Write tests for all models, views, and serializers
|
||||
- Use pytest and pytest-django
|
||||
- Use factories (factory_boy) for test data
|
||||
- Test permissions thoroughly
|
||||
- Test edge cases and error conditions
|
||||
@@ -1,204 +0,0 @@
|
||||
# ThrillWiki Nuxt 4 Frontend Standards
|
||||
|
||||
Rules for developing the ThrillWiki frontend with Nuxt 4.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── app.vue # Root component
|
||||
├── nuxt.config.ts # Nuxt configuration
|
||||
├── pages/ # File-based routing
|
||||
│ ├── index.vue # Homepage (/)
|
||||
│ ├── parks/
|
||||
│ │ ├── index.vue # /parks
|
||||
│ │ ├── nearby.vue # /parks/nearby
|
||||
│ │ └── [slug].vue # /parks/:slug
|
||||
│ └── ...
|
||||
├── components/
|
||||
│ ├── layout/ # Header, Footer, Sidebar
|
||||
│ ├── ui/ # Base components (Button, Card, Input)
|
||||
│ ├── entity/ # ParkCard, RideCard, ReviewCard
|
||||
│ └── forms/ # Form components
|
||||
├── composables/ # Shared logic (useAuth, useApi, useUnits)
|
||||
├── stores/ # Pinia stores
|
||||
├── types/ # TypeScript interfaces
|
||||
└── assets/
|
||||
└── css/ # Global styles, Tailwind config
|
||||
```
|
||||
|
||||
## Component Conventions
|
||||
|
||||
### Naming
|
||||
- Use PascalCase for component files: `ParkCard.vue`, `SearchAutocomplete.vue`
|
||||
- Use kebab-case in templates: `<park-card>`, `<search-autocomplete>`
|
||||
- Prefix base components with `Base`: `BaseButton.vue`, `BaseInput.vue`
|
||||
|
||||
### Component Structure
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 1. Type imports
|
||||
import type { Park } from '~/types'
|
||||
|
||||
// 2. Component imports (auto-imported usually)
|
||||
|
||||
// 3. Props and emits
|
||||
const props = defineProps<{
|
||||
park: Park
|
||||
variant?: 'default' | 'compact'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', park: Park): void
|
||||
}>()
|
||||
|
||||
// 4. Composables
|
||||
const { formatDistance } = useUnits()
|
||||
|
||||
// 5. Refs and reactive state
|
||||
const isExpanded = ref(false)
|
||||
|
||||
// 6. Computed properties
|
||||
const displayLocation = computed(() =>
|
||||
`${props.park.city}, ${props.park.country}`
|
||||
)
|
||||
|
||||
// 7. Functions
|
||||
function handleClick() {
|
||||
emit('select', props.park)
|
||||
}
|
||||
|
||||
// 8. Lifecycle hooks (if needed)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Template here -->
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Scoped styles, prefer Tailwind classes in template */
|
||||
</style>
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
- Enable strict mode
|
||||
- Define interfaces for all data structures in `types/`
|
||||
- Use `defineProps<T>()` with TypeScript generics
|
||||
- No `any` types without explicit justification
|
||||
|
||||
## Routing
|
||||
|
||||
### File-Based Routes
|
||||
Follow Nuxt 4 file-based routing conventions:
|
||||
- `pages/index.vue` → `/`
|
||||
- `pages/parks/index.vue` → `/parks`
|
||||
- `pages/parks/[slug].vue` → `/parks/:slug`
|
||||
- `pages/parks/[park]/rides/[ride].vue` → `/parks/:park/rides/:ride`
|
||||
|
||||
### Navigation
|
||||
```typescript
|
||||
// Use navigateTo for programmatic navigation
|
||||
await navigateTo('/parks/cedar-point')
|
||||
|
||||
// Use NuxtLink for declarative navigation
|
||||
<NuxtLink to="/parks">All Parks</NuxtLink>
|
||||
```
|
||||
|
||||
## Data Fetching
|
||||
|
||||
### Use Composables
|
||||
```typescript
|
||||
// composables/useParks.ts
|
||||
export function useParks() {
|
||||
const { data, pending, error, refresh } = useFetch('/api/parks/')
|
||||
|
||||
return {
|
||||
parks: data,
|
||||
loading: pending,
|
||||
error,
|
||||
refresh
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### In Components
|
||||
```typescript
|
||||
// Use useAsyncData for page-level data
|
||||
const { data: park } = await useAsyncData(
|
||||
`park-${route.params.slug}`,
|
||||
() => $fetch(`/api/parks/${route.params.slug}/`)
|
||||
)
|
||||
```
|
||||
|
||||
## State Management (Pinia)
|
||||
|
||||
### Store Structure
|
||||
```typescript
|
||||
// stores/auth.ts
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const isAuthenticated = computed(() => !!user.value)
|
||||
|
||||
async function login(credentials: LoginCredentials) {
|
||||
// Implementation
|
||||
}
|
||||
|
||||
function logout() {
|
||||
user.value = null
|
||||
}
|
||||
|
||||
return { user, isAuthenticated, login, logout }
|
||||
})
|
||||
```
|
||||
|
||||
### Using Stores
|
||||
```typescript
|
||||
const authStore = useAuthStore()
|
||||
const { user, isAuthenticated } = storeToRefs(authStore)
|
||||
```
|
||||
|
||||
## API Integration
|
||||
|
||||
### Base API Composable
|
||||
```typescript
|
||||
// composables/useApi.ts
|
||||
export function useApi() {
|
||||
const config = useRuntimeConfig()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
return $fetch.create({
|
||||
baseURL: config.public.apiBase,
|
||||
headers: {
|
||||
...(authStore.token && { Authorization: `Bearer ${authStore.token}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Page Errors
|
||||
```typescript
|
||||
// In page components
|
||||
const { data, error } = await useAsyncData(...)
|
||||
|
||||
if (error.value) {
|
||||
throw createError({
|
||||
statusCode: error.value.statusCode || 500,
|
||||
message: error.value.message
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Form Errors
|
||||
- Display validation errors inline with form fields
|
||||
- Use toast notifications for API errors
|
||||
- Provide clear user feedback
|
||||
|
||||
## Accessibility Requirements
|
||||
|
||||
- All interactive elements must be keyboard accessible
|
||||
- Provide proper ARIA labels
|
||||
- Ensure color contrast meets WCAG AA standards
|
||||
- Support `prefers-reduced-motion`
|
||||
- Use semantic HTML elements
|
||||
@@ -1,85 +0,0 @@
|
||||
---
|
||||
description: Ensure compliance with source_docs specifications - Continuous guard
|
||||
---
|
||||
|
||||
# Source Docs Compliance Workflow
|
||||
|
||||
You are now in **Compliance Guard Mode**. The documents in `source_docs/` are LAW. Every code change must comply with these specifications.
|
||||
|
||||
## The Constitution
|
||||
|
||||
The following documents are the single source of truth:
|
||||
- `source_docs/SITE_OVERVIEW.md` - High-level product vision
|
||||
- `source_docs/DESIGN_SYSTEM.md` - Visual identity, colors, typography, gradients
|
||||
- `source_docs/COMPONENTS.md` - Component specifications and patterns
|
||||
- `source_docs/PAGES.md` - Page layouts and content
|
||||
- `source_docs/USER_FLOWS.md` - User journeys and interaction specifications
|
||||
|
||||
## Before Making ANY Code Change
|
||||
|
||||
1. **Identify Relevant Specs**: Determine which source_docs apply to the change
|
||||
2. **Read the Spec**: View the relevant sections to understand requirements
|
||||
3. **Check for Deviations**: Compare current/proposed code against the spec
|
||||
4. **Cite Your Sources**: Reference specific line numbers when claiming compliance
|
||||
|
||||
## Compliance Checklist
|
||||
|
||||
### For Components (check against COMPONENTS.md, DESIGN_SYSTEM.md):
|
||||
- [ ] Uses correct component structure (header, content, footer)
|
||||
- [ ] Uses primary color palette (blue for primary, NOT emerald/teal unless specified)
|
||||
- [ ] Uses centralized constants instead of hardcoded options
|
||||
- [ ] Follows established patterns (sticky footer, tab navigation, etc.)
|
||||
- [ ] Proper button variants per spec
|
||||
- [ ] Dark mode support
|
||||
|
||||
### For User Flows (check against USER_FLOWS.md):
|
||||
- [ ] All required fields present (e.g., submission notes for edits)
|
||||
- [ ] Proper validation implemented
|
||||
- [ ] Error states handled per spec
|
||||
- [ ] Success feedback matches spec
|
||||
|
||||
### For Forms:
|
||||
- [ ] Options imported from `~/utils/constants.ts`
|
||||
- [ ] Backend-synced choices (check `backend/apps/*/choices.py`)
|
||||
- [ ] Required fields marked and validated
|
||||
- [ ] Proper help text and hints
|
||||
|
||||
## When Deviation is Found
|
||||
|
||||
1. **STOP** - Do not proceed with the current approach
|
||||
2. **LOG** - Document the deviation with:
|
||||
- Requirement (from source_docs)
|
||||
- Current implementation
|
||||
- Proposed fix
|
||||
3. **FIX** - Implement the compliant solution
|
||||
4. **VERIFY** - Ensure the fix matches the spec
|
||||
|
||||
## Status Tags
|
||||
|
||||
Use these in any audit or review:
|
||||
- `[OK]` - Compliant with spec
|
||||
- `[DEVIATION]` - Implemented differently than spec
|
||||
- `[MISSING]` - Required feature not implemented
|
||||
- `[RISK]` - Potential issue that needs investigation
|
||||
|
||||
## Key Constants Files
|
||||
|
||||
Always use these instead of hardcoding:
|
||||
- `frontend/app/utils/constants.ts` - Status configs, park types, etc.
|
||||
- `backend/apps/parks/choices.py` - Park status and type definitions
|
||||
- `backend/apps/rides/choices.py` - Ride status and category definitions
|
||||
- `backend/apps/moderation/choices.py` - Submission status definitions
|
||||
|
||||
## Example Audit Output
|
||||
|
||||
```markdown
|
||||
## Compliance Audit: ComponentName.vue
|
||||
|
||||
### Checking Against: [List relevant docs]
|
||||
|
||||
| Requirement | Source | Status |
|
||||
|-------------|--------|--------|
|
||||
| Uses primary gradient | DESIGN_SYSTEM.md L91-98 | [OK] |
|
||||
| Submission note field | USER_FLOWS.md L473-476 | [MISSING] → FIX |
|
||||
| Sticky footer pattern | COMPONENTS.md L583-602 | [DEVIATION] → FIX |
|
||||
```
|
||||
@@ -1,168 +0,0 @@
|
||||
---
|
||||
description: Migrate a React component from thrillwiki-87 to Vue/Nuxt
|
||||
---
|
||||
|
||||
# Migrate Component Workflow
|
||||
|
||||
Convert a React component from thrillwiki-87 (the authoritative source) to Vue 3 Composition API for the Nuxt 4 project.
|
||||
|
||||
## Step 1: Locate Source Component
|
||||
|
||||
Find the React component in thrillwiki-87:
|
||||
```bash
|
||||
# React components are in:
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/components/
|
||||
```
|
||||
|
||||
Common component directories:
|
||||
- `auth/` - Authentication components
|
||||
- `parks/` - Park-related components
|
||||
- `rides/` - Ride-related components
|
||||
- `forms/` - Form components
|
||||
- `moderation/` - Moderation queue components
|
||||
- `ui/` - Base UI primitives (shadcn-ui)
|
||||
- `common/` - Shared utilities
|
||||
- `layout/` - Layout components
|
||||
|
||||
## Step 2: Analyze React Component
|
||||
|
||||
Extract these patterns from the source:
|
||||
|
||||
```tsx
|
||||
// Props interface
|
||||
interface ComponentProps {
|
||||
prop1: string
|
||||
prop2?: number
|
||||
}
|
||||
|
||||
// State hooks
|
||||
const [value, setValue] = useState<Type>(initial)
|
||||
|
||||
// Effects
|
||||
useEffect(() => { /* logic */ }, [deps])
|
||||
|
||||
// Event handlers
|
||||
const handleClick = () => { /* logic */ }
|
||||
|
||||
// Render return
|
||||
return <JSX />
|
||||
```
|
||||
|
||||
## Step 3: Translate to Vue 3
|
||||
|
||||
### Props
|
||||
```tsx
|
||||
// React
|
||||
interface Props { name: string; count?: number }
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Vue -->
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: string
|
||||
count?: number
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
### State
|
||||
```tsx
|
||||
// React
|
||||
const [value, setValue] = useState<string>('')
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Vue -->
|
||||
<script setup lang="ts">
|
||||
const value = ref<string>('')
|
||||
</script>
|
||||
```
|
||||
|
||||
### Effects
|
||||
```tsx
|
||||
// React
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [id])
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Vue -->
|
||||
<script setup lang="ts">
|
||||
watch(() => id, () => {
|
||||
fetchData()
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
```
|
||||
|
||||
### Events
|
||||
```tsx
|
||||
// React
|
||||
onClick={() => onSelect(item)}
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Vue -->
|
||||
@click="emit('select', item)"
|
||||
```
|
||||
|
||||
## Step 4: Map UI Components
|
||||
|
||||
Translate shadcn-ui to Nuxt UI:
|
||||
|
||||
| shadcn-ui | Nuxt UI |
|
||||
|-----------|---------|
|
||||
| `<Button>` | `<UButton>` |
|
||||
| `<Card>` | `<UCard>` |
|
||||
| `<Dialog>` | `<UModal>` |
|
||||
| `<Input>` | `<UInput>` |
|
||||
| `<Select>` | `<USelect>` |
|
||||
| `<Tabs>` | `<UTabs>` |
|
||||
| `<Badge>` | `<UBadge>` |
|
||||
|
||||
## Step 5: Update API Calls
|
||||
|
||||
Replace Supabase with Django API:
|
||||
|
||||
```tsx
|
||||
// React (Supabase)
|
||||
const { data } = await supabase.from('parks').select('*')
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Vue (Django) -->
|
||||
<script setup lang="ts">
|
||||
const { data } = await useApi<Park[]>('/parks/')
|
||||
</script>
|
||||
```
|
||||
|
||||
## Step 6: Place in Nuxt Structure
|
||||
|
||||
Target paths:
|
||||
```
|
||||
frontend/app/components/
|
||||
├── auth/ # Auth components
|
||||
├── cards/ # Card components
|
||||
├── common/ # Shared utilities
|
||||
├── modals/ # Modal dialogs
|
||||
├── rides/ # Ride components
|
||||
└── ui/ # Base UI components
|
||||
```
|
||||
|
||||
## Step 7: Verify Parity
|
||||
|
||||
- [ ] All props from source are present
|
||||
- [ ] All state variables ported
|
||||
- [ ] All event handlers work
|
||||
- [ ] Styling matches (Tailwind classes)
|
||||
- [ ] Loading states present
|
||||
- [ ] Error states handled
|
||||
- [ ] Dark mode works
|
||||
|
||||
## Example Migration
|
||||
|
||||
**Source**: `thrillwiki-87/src/components/parks/ParkCard.tsx`
|
||||
**Target**: `frontend/app/components/cards/ParkCard.vue`
|
||||
|
||||
Check existing target. If it exists, compare and add missing features. If not, create new.
|
||||
@@ -1,223 +0,0 @@
|
||||
---
|
||||
description: Convert a React hook from thrillwiki-87 to a Vue composable
|
||||
---
|
||||
|
||||
# Migrate Hook Workflow
|
||||
|
||||
Convert a React hook from thrillwiki-87 to a Vue 3 composable for the Nuxt 4 project.
|
||||
|
||||
## Step 1: Locate Source Hook
|
||||
|
||||
Find the React hook in thrillwiki-87:
|
||||
```bash
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/hooks/
|
||||
```
|
||||
|
||||
Key hooks (80+ total):
|
||||
- `useAuth.tsx` - Authentication state
|
||||
- `useModerationQueue.ts` - Moderation logic (21KB)
|
||||
- `useEntityVersions.ts` - Version history (14KB)
|
||||
- `useSearch.tsx` - Search functionality
|
||||
- `useUnitPreferences.ts` - Unit conversion
|
||||
- `useProfile.tsx` - User profile
|
||||
- `useLocations.ts` - Location data
|
||||
- `useRideCreditFilters.ts` - Credit filtering
|
||||
|
||||
## Step 2: Analyze Hook Pattern
|
||||
|
||||
Extract the hook structure:
|
||||
|
||||
```tsx
|
||||
export function useFeature(params: Params) {
|
||||
// State
|
||||
const [data, setData] = useState<Type>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [dependency])
|
||||
|
||||
// Actions
|
||||
const doSomething = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await api.call()
|
||||
setData(result)
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return { data, loading, error, doSomething }
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Convert to Composable
|
||||
|
||||
### Basic Structure
|
||||
```tsx
|
||||
// React
|
||||
export function useFeature() {
|
||||
const [value, setValue] = useState('')
|
||||
return { value, setValue }
|
||||
}
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
export function useFeature() {
|
||||
const value = ref('')
|
||||
|
||||
function setValue(newValue: string) {
|
||||
value.value = newValue
|
||||
}
|
||||
|
||||
return { value, setValue }
|
||||
}
|
||||
```
|
||||
|
||||
### State Conversions
|
||||
```tsx
|
||||
// React
|
||||
const [count, setCount] = useState(0)
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [items, setItems] = useState<Item[]>([])
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
const count = ref(0)
|
||||
const user = ref<User | null>(null)
|
||||
const items = ref<Item[]>([])
|
||||
```
|
||||
|
||||
### Effect Conversions
|
||||
```tsx
|
||||
// React - Run on mount
|
||||
useEffect(() => {
|
||||
initialize()
|
||||
}, [])
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
onMounted(() => {
|
||||
initialize()
|
||||
})
|
||||
```
|
||||
|
||||
```tsx
|
||||
// React - Watch dependency
|
||||
useEffect(() => {
|
||||
fetchData(id)
|
||||
}, [id])
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
watch(() => id, (newId) => {
|
||||
fetchData(newId)
|
||||
}, { immediate: true })
|
||||
```
|
||||
|
||||
### Supabase → Django API
|
||||
```tsx
|
||||
// React (Supabase)
|
||||
const { data } = await supabase
|
||||
.from('parks')
|
||||
.select('*')
|
||||
.eq('slug', slug)
|
||||
.single()
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue (Django)
|
||||
const api = useApi()
|
||||
const { data } = await api<Park>(`/parks/${slug}/`)
|
||||
```
|
||||
|
||||
## Step 4: Handle Complex Patterns
|
||||
|
||||
### useCallback → Plain Function
|
||||
```tsx
|
||||
// React
|
||||
const memoizedFn = useCallback(() => {
|
||||
doSomething(dep)
|
||||
}, [dep])
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue - Usually no memo needed
|
||||
function doSomething() {
|
||||
// Vue's reactivity handles this
|
||||
}
|
||||
```
|
||||
|
||||
### useMemo → computed
|
||||
```tsx
|
||||
// React
|
||||
const derived = useMemo(() => expensiveCalc(data), [data])
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
const derived = computed(() => expensiveCalc(data.value))
|
||||
```
|
||||
|
||||
### Custom Hook Composition
|
||||
```tsx
|
||||
// React
|
||||
function useFeature() {
|
||||
const auth = useAuth()
|
||||
const { data } = useQuery(...)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// Vue
|
||||
export function useFeature() {
|
||||
const { user } = useAuth()
|
||||
const api = useApi()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Target Location
|
||||
|
||||
Place composables in:
|
||||
```
|
||||
frontend/app/composables/
|
||||
├── useApi.ts # Base API client
|
||||
├── useAuth.ts # Authentication
|
||||
├── useParksApi.ts # Parks API
|
||||
├── useRidesApi.ts # Rides API
|
||||
├── useModeration.ts # Moderation queue
|
||||
└── use[Feature].ts # New composables
|
||||
```
|
||||
|
||||
## Step 6: Verify Parity
|
||||
|
||||
- [ ] All returned values present
|
||||
- [ ] All actions/methods work
|
||||
- [ ] State updates correctly
|
||||
- [ ] API calls translated
|
||||
- [ ] Error handling maintained
|
||||
- [ ] Loading states work
|
||||
- [ ] TypeScript types correct
|
||||
|
||||
## Priority Hooks to Migrate
|
||||
|
||||
| Hook | Size | Complexity |
|
||||
|------|------|------------|
|
||||
| useModerationQueue.ts | 21KB | High |
|
||||
| useEntityVersions.ts | 14KB | High |
|
||||
| useAuth.tsx | 11KB | Medium |
|
||||
| useAutoComplete.ts | 10KB | Medium |
|
||||
| useRateLimitAlerts.ts | 10KB | Medium |
|
||||
| useRideCreditFilters.ts | 9KB | Medium |
|
||||
| useAdminSettings.ts | 9KB | Medium |
|
||||
@@ -1,183 +0,0 @@
|
||||
---
|
||||
description: Migrate a React page from thrillwiki-87 to Nuxt 4
|
||||
---
|
||||
|
||||
# Migrate Page Workflow
|
||||
|
||||
Port a React page from thrillwiki-87 (the authoritative source) to a Nuxt 4 page.
|
||||
|
||||
## Step 1: Locate Source Page
|
||||
|
||||
Find the React page in thrillwiki-87:
|
||||
```bash
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/pages/
|
||||
```
|
||||
|
||||
Key pages:
|
||||
- `Index.tsx` - Homepage
|
||||
- `Parks.tsx` - Parks listing
|
||||
- `ParkDetail.tsx` - Individual park (36KB - complex)
|
||||
- `Rides.tsx` - Rides listing
|
||||
- `RideDetail.tsx` - Individual ride (54KB - most complex)
|
||||
- `Profile.tsx` - User profile (51KB - complex)
|
||||
- `Search.tsx` - Global search
|
||||
- `AdminDashboard.tsx` - Admin panel
|
||||
|
||||
## Step 2: Analyze Page Structure
|
||||
|
||||
Extract from React page:
|
||||
|
||||
```tsx
|
||||
// Route params
|
||||
const { id } = useParams()
|
||||
|
||||
// Data fetching
|
||||
const { data, isLoading, error } = useQuery(...)
|
||||
|
||||
// SEO
|
||||
// (usually react-helmet or similar)
|
||||
|
||||
// Page layout structure
|
||||
return (
|
||||
<Layout>
|
||||
<Tabs>...</Tabs>
|
||||
<Content>...</Content>
|
||||
</Layout>
|
||||
)
|
||||
```
|
||||
|
||||
## Step 3: Create Nuxt Page
|
||||
|
||||
### Route Params
|
||||
```tsx
|
||||
// React
|
||||
const { parkSlug } = useParams()
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Nuxt -->
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const parkSlug = route.params.park_slug as string
|
||||
</script>
|
||||
```
|
||||
|
||||
### Data Fetching
|
||||
```tsx
|
||||
// React (React Query)
|
||||
const { data, isLoading } = useQuery(['park', id], () => fetchPark(id))
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Nuxt -->
|
||||
<script setup lang="ts">
|
||||
const { data, pending } = await useAsyncData(
|
||||
`park-${parkSlug}`,
|
||||
() => useParksApi().getPark(parkSlug)
|
||||
)
|
||||
</script>
|
||||
```
|
||||
|
||||
### SEO Meta
|
||||
```tsx
|
||||
// React (Helmet)
|
||||
<Helmet>
|
||||
<title>{park.name} | ThrillWiki</title>
|
||||
</Helmet>
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Nuxt -->
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({
|
||||
title: () => `${data.value?.name} | ThrillWiki`,
|
||||
description: () => data.value?.description
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### Page Meta
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth'], // if authentication required
|
||||
layout: 'default'
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
## Step 4: Port Page Sections
|
||||
|
||||
Common patterns:
|
||||
|
||||
### Tabs Structure
|
||||
```tsx
|
||||
// React
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">...</TabsContent>
|
||||
</Tabs>
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Nuxt -->
|
||||
<UTabs :items="tabs" v-model="activeTab">
|
||||
<template #default="{ item }">
|
||||
<component :is="item.component" v-bind="item.props" />
|
||||
</template>
|
||||
</UTabs>
|
||||
```
|
||||
|
||||
### Loading States
|
||||
```tsx
|
||||
// React
|
||||
{isLoading ? <Skeleton /> : <Content />}
|
||||
```
|
||||
↓
|
||||
```vue
|
||||
<!-- Nuxt -->
|
||||
<template>
|
||||
<div v-if="pending">
|
||||
<USkeleton class="h-48" />
|
||||
</div>
|
||||
<div v-else-if="data">
|
||||
<!-- Content -->
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Step 5: Target Location
|
||||
|
||||
Nuxt pages use file-based routing:
|
||||
|
||||
| React Route | Nuxt File Path |
|
||||
|-------------|----------------|
|
||||
| `/parks` | `pages/parks/index.vue` |
|
||||
| `/parks/:slug` | `pages/parks/[park_slug]/index.vue` |
|
||||
| `/parks/:slug/rides/:ride` | `pages/parks/[park_slug]/rides/[ride_slug].vue` |
|
||||
| `/manufacturers/:id` | `pages/manufacturers/[slug].vue` |
|
||||
|
||||
## Step 6: Verify Feature Parity
|
||||
|
||||
Compare source page with target:
|
||||
- [ ] All tabs/sections present
|
||||
- [ ] All data displayed
|
||||
- [ ] All actions work (edit, delete, etc.)
|
||||
- [ ] Responsive layout matches
|
||||
- [ ] Loading states present
|
||||
- [ ] Error handling works
|
||||
- [ ] SEO meta correct
|
||||
|
||||
## Reference: Page Complexity
|
||||
|
||||
| Page | Source Size | Priority |
|
||||
|------|-------------|----------|
|
||||
| RideDetail.tsx | 54KB | High |
|
||||
| Profile.tsx | 51KB | High |
|
||||
| AdminSettings.tsx | 44KB | Medium |
|
||||
| ParkDetail.tsx | 36KB | High |
|
||||
| Auth.tsx | 29KB | Medium |
|
||||
| Parks.tsx | 22KB | High |
|
||||
| Rides.tsx | 20KB | High |
|
||||
@@ -1,201 +0,0 @@
|
||||
---
|
||||
description: Port TypeScript types from thrillwiki-87 to the Nuxt project
|
||||
---
|
||||
|
||||
# Migrate Type Workflow
|
||||
|
||||
Port TypeScript type definitions from thrillwiki-87 to the Nuxt 4 project, ensuring sync with Django serializers.
|
||||
|
||||
## Step 1: Locate Source Types
|
||||
|
||||
Find types in thrillwiki-87:
|
||||
```bash
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/types/
|
||||
```
|
||||
|
||||
Key type files (63 total):
|
||||
- `database.ts` - Core entity types (12KB)
|
||||
- `statuses.ts` - Status enums (13KB)
|
||||
- `moderation.ts` - Moderation types (10KB)
|
||||
- `rideAttributes.ts` - Ride specs (8KB)
|
||||
- `versioning.ts` - Version history (7KB)
|
||||
- `submissions.ts` - Submission types
|
||||
- `company.ts` - Company entities
|
||||
|
||||
## Step 2: Analyze Source Types
|
||||
|
||||
Extract type structure:
|
||||
|
||||
```typescript
|
||||
// thrillwiki-87/src/types/database.ts
|
||||
export interface Park {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
park_type: ParkType
|
||||
status: ParkStatus
|
||||
opening_date?: string
|
||||
closing_date?: string
|
||||
location?: ParkLocation
|
||||
// ...
|
||||
}
|
||||
|
||||
export interface ParkLocation {
|
||||
latitude: number
|
||||
longitude: number
|
||||
country: string
|
||||
city?: string
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Step 3: Check Django Serializers
|
||||
|
||||
Before porting, verify Django backend has matching fields:
|
||||
|
||||
```bash
|
||||
# Check serializers in:
|
||||
backend/apps/parks/serializers.py
|
||||
backend/apps/rides/serializers.py
|
||||
backend/apps/companies/serializers.py
|
||||
```
|
||||
|
||||
The Django serializer defines what the API returns:
|
||||
```python
|
||||
class ParkSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Park
|
||||
fields = ['id', 'name', 'slug', 'park_type', 'status', ...]
|
||||
```
|
||||
|
||||
## Step 4: Handle Naming Conventions
|
||||
|
||||
Django uses snake_case, TypeScript typically uses camelCase. Choose one strategy:
|
||||
|
||||
### Option A: Match Django exactly (Recommended)
|
||||
```typescript
|
||||
// Keep snake_case from API
|
||||
export interface Park {
|
||||
id: string
|
||||
name: string
|
||||
park_type: string
|
||||
created_at: string
|
||||
}
|
||||
```
|
||||
|
||||
### Option B: Transform in API layer
|
||||
```typescript
|
||||
// camelCase in types
|
||||
export interface Park {
|
||||
id: string
|
||||
name: string
|
||||
parkType: string
|
||||
createdAt: string
|
||||
}
|
||||
// Transform in useApi or serializer
|
||||
```
|
||||
|
||||
**Current project uses Option A (snake_case).**
|
||||
|
||||
## Step 5: Port the Types
|
||||
|
||||
Create/update type files:
|
||||
|
||||
```typescript
|
||||
// frontend/app/types/park.ts
|
||||
export interface Park {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
park_type: string
|
||||
status: string
|
||||
short_description?: string
|
||||
description?: string
|
||||
opening_date?: string
|
||||
closing_date?: string
|
||||
operating_season?: string
|
||||
website?: string
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
// Match Django serializer fields exactly
|
||||
}
|
||||
|
||||
export interface ParkDetail extends Park {
|
||||
rides?: Ride[]
|
||||
reviews?: Review[]
|
||||
photos?: Photo[]
|
||||
// Extended fields from detail serializer
|
||||
}
|
||||
```
|
||||
|
||||
## Step 6: Port Enums/Options
|
||||
|
||||
Source may have enums that should be constants:
|
||||
|
||||
```typescript
|
||||
// thrillwiki-87/src/types/statuses.ts
|
||||
export const PARK_STATUS = {
|
||||
OPERATING: 'operating',
|
||||
CLOSED: 'closed',
|
||||
// ...
|
||||
} as const
|
||||
```
|
||||
↓
|
||||
```typescript
|
||||
// frontend/app/utils/constants.ts
|
||||
export const PARK_STATUS_OPTIONS = [
|
||||
{ value: 'operating', label: 'Operating', color: 'green' },
|
||||
{ value: 'closed', label: 'Closed', color: 'red' },
|
||||
// Sync with backend/apps/parks/choices.py
|
||||
]
|
||||
```
|
||||
|
||||
## Step 7: Target Locations
|
||||
|
||||
```
|
||||
frontend/app/types/
|
||||
├── index.ts # Re-exports and common types
|
||||
├── park.ts # Park types
|
||||
├── ride.ts # Ride types
|
||||
├── company.ts # Company types (manufacturer, designer, etc.)
|
||||
├── user.ts # User/profile types
|
||||
├── moderation.ts # Moderation types
|
||||
└── api.ts # API response wrappers
|
||||
```
|
||||
|
||||
## Step 8: Verify Type Sync
|
||||
|
||||
| Layer | File | Must Match |
|
||||
|-------|------|------------|
|
||||
| Django Model | `backend/apps/*/models.py` | Database schema |
|
||||
| Serializer | `backend/apps/*/serializers.py` | API response |
|
||||
| Frontend Type | `frontend/app/types/*.ts` | Serializer fields |
|
||||
|
||||
Run checks:
|
||||
```bash
|
||||
# Check Django fields
|
||||
python manage.py shell -c "from apps.parks.models import Park; print([f.name for f in Park._meta.fields])"
|
||||
|
||||
# Check serializer fields
|
||||
python manage.py shell -c "from apps.parks.serializers import ParkSerializer; print(ParkSerializer().fields.keys())"
|
||||
```
|
||||
|
||||
## Step 9: Checklist
|
||||
|
||||
- [ ] All source type fields ported
|
||||
- [ ] Fields match Django serializer
|
||||
- [ ] snake_case naming used
|
||||
- [ ] Optional fields marked with `?`
|
||||
- [ ] Enums/options in constants.ts
|
||||
- [ ] Backend choices.py synced
|
||||
- [ ] Types exported from index.ts
|
||||
|
||||
## Priority Types
|
||||
|
||||
| Type File | Source Size | Notes |
|
||||
|-----------|-------------|-------|
|
||||
| statuses.ts | 13KB | All status enums |
|
||||
| database.ts | 12KB | Core entities |
|
||||
| moderation.ts | 10KB | Queue types |
|
||||
| rideAttributes.ts | 8KB | Spec options |
|
||||
| versioning.ts | 7KB | History types |
|
||||
@@ -1,193 +0,0 @@
|
||||
---
|
||||
description: Audit gaps and implement missing features from thrillwiki-87 source
|
||||
---
|
||||
|
||||
# Migration Workflow
|
||||
|
||||
**thrillwiki-87 is LAW.** This workflow audits what's missing and implements it.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run `/migrate` to:
|
||||
1. Audit current project against thrillwiki-87
|
||||
2. Identify the highest-priority missing feature
|
||||
3. Implement it using the appropriate sub-workflow
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Gap Analysis
|
||||
|
||||
### Step 1.1: Audit Components
|
||||
|
||||
Compare React components with Vue components:
|
||||
|
||||
```bash
|
||||
# Source (LAW):
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/components/
|
||||
|
||||
# Target:
|
||||
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/components/
|
||||
```
|
||||
|
||||
For each React component directory, check if equivalent Vue component exists:
|
||||
- **EXISTS**: Mark as ✅, check for feature parity
|
||||
- **MISSING**: Mark as ❌, add to implementation queue
|
||||
|
||||
### Step 1.2: Audit Pages
|
||||
|
||||
Compare React pages with Nuxt pages:
|
||||
|
||||
```bash
|
||||
# Source (LAW):
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/pages/
|
||||
|
||||
# Target:
|
||||
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/pages/
|
||||
```
|
||||
|
||||
Key pages to audit (by size/complexity):
|
||||
| React Page | Size | Priority |
|
||||
|------------|------|----------|
|
||||
| RideDetail.tsx | 54KB | P0 |
|
||||
| Profile.tsx | 51KB | P0 |
|
||||
| AdminSettings.tsx | 44KB | P1 |
|
||||
| ParkDetail.tsx | 36KB | P0 |
|
||||
| Auth.tsx | 29KB | P1 |
|
||||
| Parks.tsx | 22KB | P0 |
|
||||
| Rides.tsx | 20KB | P0 |
|
||||
|
||||
### Step 1.3: Audit Hooks → Composables
|
||||
|
||||
Compare React hooks with Vue composables:
|
||||
|
||||
```bash
|
||||
# Source (LAW):
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/hooks/
|
||||
|
||||
# Target:
|
||||
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/composables/
|
||||
```
|
||||
|
||||
Priority hooks:
|
||||
| React Hook | Size | Current Status |
|
||||
|------------|------|----------------|
|
||||
| useModerationQueue.ts | 21KB | Check useModeration.ts |
|
||||
| useEntityVersions.ts | 14KB | ❌ Missing |
|
||||
| useAuth.tsx | 11KB | Check useAuth.ts |
|
||||
| useRideCreditFilters.ts | 9KB | ❌ Missing |
|
||||
|
||||
### Step 1.4: Audit Types
|
||||
|
||||
Compare TypeScript definitions:
|
||||
|
||||
```bash
|
||||
# Source (LAW):
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/src/types/
|
||||
|
||||
# Target:
|
||||
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/types/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Priority Selection
|
||||
|
||||
After auditing, select the highest priority gap:
|
||||
|
||||
### Priority Matrix
|
||||
|
||||
| Category | Weight | Examples |
|
||||
|----------|--------|----------|
|
||||
| **P0 - Core UX** | 10 | Main entity pages, search, auth |
|
||||
| **P1 - Features** | 7 | Reviews, credits, lists |
|
||||
| **P2 - Admin** | 5 | Moderation, settings |
|
||||
| **P3 - Polish** | 3 | Animations, edge cases |
|
||||
|
||||
Select ONE item to implement this session.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Implementation
|
||||
|
||||
Based on the gap type, use the appropriate sub-workflow:
|
||||
|
||||
### For Missing Component
|
||||
```
|
||||
/migrate-component
|
||||
```
|
||||
|
||||
### For Missing Page
|
||||
```
|
||||
/migrate-page
|
||||
```
|
||||
|
||||
### For Missing Hook/Composable
|
||||
```
|
||||
/migrate-hook
|
||||
```
|
||||
|
||||
### For Missing Types
|
||||
```
|
||||
/migrate-type
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Verification
|
||||
|
||||
After implementation:
|
||||
|
||||
1. **Feature Parity Check**: Does it match thrillwiki-87 behavior?
|
||||
2. **Visual Parity Check**: Does it look the same?
|
||||
3. **Data Parity Check**: Does it show the same information?
|
||||
4. **Interaction Parity Check**: Do actions work the same way?
|
||||
|
||||
---
|
||||
|
||||
## Gap Tracking
|
||||
|
||||
Update `GAP_ANALYSIS_MATRIX.md` with status:
|
||||
|
||||
```markdown
|
||||
| Feature | Source Location | Target Location | Status |
|
||||
|---------|-----------------|-----------------|--------|
|
||||
| Park Detail Tabs | src/pages/ParkDetail.tsx | pages/parks/[park_slug]/ | [OK] |
|
||||
| Entity Versioning | src/hooks/useEntityVersions.ts | composables/ | [MISSING] |
|
||||
| Ride Credits | src/components/credits/ | components/credits/ | [PARTIAL] |
|
||||
```
|
||||
|
||||
Status tags:
|
||||
- `[OK]` - Feature parity achieved
|
||||
- `[PARTIAL]` - Some features missing
|
||||
- `[MISSING]` - Not implemented
|
||||
- `[BLOCKED]` - Waiting on backend
|
||||
|
||||
---
|
||||
|
||||
## Reference: Source Documentation
|
||||
|
||||
Always check thrillwiki-87 docs for specifications:
|
||||
|
||||
```bash
|
||||
/Volumes/macminissd/Projects/thrillwiki-87/docs/
|
||||
├── SITE_OVERVIEW.md # What features exist
|
||||
├── COMPONENTS.md # Component specifications
|
||||
├── PAGES.md # Page layouts (72KB - comprehensive)
|
||||
├── USER_FLOWS.md # Interaction patterns (77KB)
|
||||
├── DESIGN_SYSTEM.md # Visual standards
|
||||
└── [Many more...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Single Command Execution
|
||||
|
||||
When you run `/migrate`, execute these steps:
|
||||
|
||||
1. **Read GAP_ANALYSIS_MATRIX.md** to see current status
|
||||
2. **List directories** in both projects to find new gaps
|
||||
3. **Select highest priority** missing item
|
||||
4. **Read source implementation** from thrillwiki-87
|
||||
5. **Implement in target** following sub-workflow patterns
|
||||
6. **Update GAP_ANALYSIS_MATRIX.md** with new status
|
||||
7. **Report what was implemented** and next priority item
|
||||
@@ -1,472 +0,0 @@
|
||||
---
|
||||
description: Add moderation support to a content type in ThrillWiki
|
||||
---
|
||||
|
||||
# Moderation Workflow
|
||||
|
||||
Add moderation (submission queue, version history, approval flow) to a content type.
|
||||
|
||||
## Overview
|
||||
|
||||
ThrillWiki's moderation system ensures quality by:
|
||||
1. User submits new/edited content → Creates `Submission` record
|
||||
2. Content enters moderation queue with `pending` status
|
||||
3. Moderator reviews and approves/rejects
|
||||
4. On approval → Content is published, version record created
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Ensure Model Supports Versioning
|
||||
|
||||
The content model needs to track its current state and history:
|
||||
|
||||
```python
|
||||
# backend/apps/[app]/models.py
|
||||
|
||||
class Park(BaseModel):
|
||||
"""Main park model - always shows current approved data"""
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
# ... other fields
|
||||
|
||||
# Track the current approved version
|
||||
current_version = models.ForeignKey(
|
||||
'ParkVersion',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='current_for'
|
||||
)
|
||||
|
||||
|
||||
class ParkVersion(BaseModel):
|
||||
"""Immutable snapshot of park data at a point in time"""
|
||||
park = models.ForeignKey(
|
||||
Park,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='versions'
|
||||
)
|
||||
# Store complete snapshot of editable fields
|
||||
data = models.JSONField()
|
||||
|
||||
# Metadata
|
||||
changed_by = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True
|
||||
)
|
||||
change_summary = models.CharField(max_length=255, blank=True)
|
||||
submission = models.ForeignKey(
|
||||
'submissions.Submission',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='versions'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def apply_to_park(self):
|
||||
"""Apply this version's data to the parent park"""
|
||||
for field, value in self.data.items():
|
||||
if hasattr(self.park, field):
|
||||
setattr(self.park, field, value)
|
||||
self.park.current_version = self
|
||||
self.park.save()
|
||||
```
|
||||
|
||||
### Step 2: Create Submission Serializers
|
||||
|
||||
```python
|
||||
# backend/apps/submissions/serializers.py
|
||||
|
||||
class ParkSubmissionSerializer(serializers.Serializer):
|
||||
"""Serializer for park submission data"""
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(required=False, allow_blank=True)
|
||||
city = serializers.CharField(max_length=100)
|
||||
country = serializers.CharField(max_length=100)
|
||||
status = serializers.ChoiceField(choices=Park.Status.choices)
|
||||
# ... other editable fields
|
||||
|
||||
def validate_name(self, value):
|
||||
# Custom validation if needed
|
||||
return value
|
||||
|
||||
|
||||
class SubmissionCreateSerializer(serializers.ModelSerializer):
|
||||
"""Create a new submission"""
|
||||
data = serializers.JSONField()
|
||||
|
||||
class Meta:
|
||||
model = Submission
|
||||
fields = ['content_type', 'object_id', 'data', 'change_summary']
|
||||
|
||||
def validate(self, attrs):
|
||||
content_type = attrs['content_type']
|
||||
|
||||
# Get the appropriate serializer for this content type
|
||||
serializer_map = {
|
||||
'park': ParkSubmissionSerializer,
|
||||
'ride': RideSubmissionSerializer,
|
||||
# ... other content types
|
||||
}
|
||||
|
||||
serializer_class = serializer_map.get(content_type)
|
||||
if not serializer_class:
|
||||
raise serializers.ValidationError(
|
||||
{'content_type': 'Unsupported content type'}
|
||||
)
|
||||
|
||||
# Validate the data field
|
||||
data_serializer = serializer_class(data=attrs['data'])
|
||||
data_serializer.is_valid(raise_exception=True)
|
||||
attrs['data'] = data_serializer.validated_data
|
||||
|
||||
return attrs
|
||||
|
||||
def create(self, validated_data):
|
||||
validated_data['submitted_by'] = self.context['request'].user
|
||||
validated_data['status'] = Submission.Status.PENDING
|
||||
return super().create(validated_data)
|
||||
```
|
||||
|
||||
### Step 3: Create Submission ViewSet
|
||||
|
||||
```python
|
||||
# backend/apps/submissions/views.py
|
||||
|
||||
class SubmissionViewSet(viewsets.ModelViewSet):
|
||||
"""API for content submissions"""
|
||||
serializer_class = SubmissionSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
|
||||
# Users see their own submissions
|
||||
# Moderators see all pending submissions
|
||||
if user.is_moderator:
|
||||
return Submission.objects.all()
|
||||
return Submission.objects.filter(submitted_by=user)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'create':
|
||||
return SubmissionCreateSerializer
|
||||
return SubmissionSerializer
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def diff(self, request, pk=None):
|
||||
"""Get diff between submission and current version"""
|
||||
submission = self.get_object()
|
||||
|
||||
if submission.object_id:
|
||||
# Edit submission - compare to current
|
||||
current = self.get_current_data(submission)
|
||||
return Response({
|
||||
'before': current,
|
||||
'after': submission.data,
|
||||
'changes': self.compute_diff(current, submission.data)
|
||||
})
|
||||
else:
|
||||
# New submission - no comparison
|
||||
return Response({
|
||||
'before': None,
|
||||
'after': submission.data,
|
||||
'changes': None
|
||||
})
|
||||
```
|
||||
|
||||
### Step 4: Create Moderation ViewSet
|
||||
|
||||
```python
|
||||
# backend/apps/moderation/views.py
|
||||
|
||||
class ModerationViewSet(viewsets.ViewSet):
|
||||
"""Moderation queue and actions"""
|
||||
permission_classes = [IsModerator]
|
||||
|
||||
def list(self, request):
|
||||
"""Get moderation queue"""
|
||||
queryset = Submission.objects.filter(
|
||||
status=Submission.Status.PENDING
|
||||
).select_related(
|
||||
'submitted_by'
|
||||
).order_by('created_at')
|
||||
|
||||
# Filter by content type
|
||||
content_type = request.query_params.get('type')
|
||||
if content_type:
|
||||
queryset = queryset.filter(content_type=content_type)
|
||||
|
||||
serializer = SubmissionSerializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def approve(self, request, pk=None):
|
||||
"""Approve a submission"""
|
||||
submission = get_object_or_404(Submission, pk=pk)
|
||||
|
||||
if submission.status != Submission.Status.PENDING:
|
||||
return Response(
|
||||
{'error': 'Submission is not pending'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Apply the submission
|
||||
with transaction.atomic():
|
||||
if submission.object_id:
|
||||
# Edit existing content
|
||||
content = self.get_content_object(submission)
|
||||
version = self.create_version(content, submission)
|
||||
version.apply_to_park()
|
||||
else:
|
||||
# Create new content
|
||||
content = self.create_content(submission)
|
||||
version = self.create_version(content, submission)
|
||||
content.current_version = version
|
||||
content.save()
|
||||
|
||||
submission.status = Submission.Status.APPROVED
|
||||
submission.reviewed_by = request.user
|
||||
submission.reviewed_at = timezone.now()
|
||||
submission.save()
|
||||
|
||||
# Notify user
|
||||
notify_user(
|
||||
submission.submitted_by,
|
||||
'submission_approved',
|
||||
{'submission': submission}
|
||||
)
|
||||
|
||||
return Response({'status': 'approved'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def reject(self, request, pk=None):
|
||||
"""Reject a submission"""
|
||||
submission = get_object_or_404(Submission, pk=pk)
|
||||
|
||||
submission.status = Submission.Status.REJECTED
|
||||
submission.reviewed_by = request.user
|
||||
submission.reviewed_at = timezone.now()
|
||||
submission.review_notes = request.data.get('notes', '')
|
||||
submission.save()
|
||||
|
||||
# Notify user
|
||||
notify_user(
|
||||
submission.submitted_by,
|
||||
'submission_rejected',
|
||||
{'submission': submission, 'reason': submission.review_notes}
|
||||
)
|
||||
|
||||
return Response({'status': 'rejected'})
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def request_changes(self, request, pk=None):
|
||||
"""Request changes to a submission"""
|
||||
submission = get_object_or_404(Submission, pk=pk)
|
||||
|
||||
submission.status = Submission.Status.CHANGES_REQUESTED
|
||||
submission.reviewed_by = request.user
|
||||
submission.review_notes = request.data.get('notes', '')
|
||||
submission.save()
|
||||
|
||||
# Notify user
|
||||
notify_user(
|
||||
submission.submitted_by,
|
||||
'submission_changes_requested',
|
||||
{'submission': submission, 'notes': submission.review_notes}
|
||||
)
|
||||
|
||||
return Response({'status': 'changes_requested'})
|
||||
```
|
||||
|
||||
### Step 5: Frontend - Submission Form
|
||||
|
||||
```vue
|
||||
<!-- frontend/components/forms/ParkSubmitForm.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { Park } from '~/types'
|
||||
|
||||
const props = defineProps<{
|
||||
park?: Park // Existing park for edits, null for new
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submitted'): void
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
name: props.park?.name || '',
|
||||
description: props.park?.description || '',
|
||||
city: props.park?.city || '',
|
||||
country: props.park?.country || '',
|
||||
status: props.park?.status || 'operating',
|
||||
changeSummary: '',
|
||||
})
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
|
||||
async function handleSubmit() {
|
||||
isSubmitting.value = true
|
||||
errors.value = {}
|
||||
|
||||
try {
|
||||
await $fetch('/api/v1/submissions/', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
content_type: 'park',
|
||||
object_id: props.park?.id || null,
|
||||
data: {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
city: form.city,
|
||||
country: form.country,
|
||||
status: form.status,
|
||||
},
|
||||
change_summary: form.changeSummary,
|
||||
}
|
||||
})
|
||||
|
||||
// Show success message
|
||||
useToast().success(
|
||||
props.park
|
||||
? 'Your edit has been submitted for review'
|
||||
: 'Your submission has been received'
|
||||
)
|
||||
|
||||
emit('submitted')
|
||||
} catch (e: any) {
|
||||
if (e.data?.error?.details) {
|
||||
errors.value = e.data.error.details
|
||||
} else {
|
||||
useToast().error('Failed to submit. Please try again.')
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||
<FormField label="Park Name" :error="errors.name?.[0]" required>
|
||||
<Input v-model="form.name" />
|
||||
</FormField>
|
||||
|
||||
<FormField label="Description" :error="errors.description?.[0]">
|
||||
<Textarea v-model="form.description" rows="4" />
|
||||
</FormField>
|
||||
|
||||
<!-- More fields... -->
|
||||
|
||||
<FormField
|
||||
label="Summary of Changes"
|
||||
:error="errors.changeSummary?.[0]"
|
||||
hint="Briefly describe what you're adding or changing"
|
||||
>
|
||||
<Input v-model="form.changeSummary" />
|
||||
</FormField>
|
||||
|
||||
<Alert variant="info">
|
||||
Your submission will be reviewed by our moderators before being published.
|
||||
</Alert>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="outline" @click="$emit('cancel')">Cancel</Button>
|
||||
<Button type="submit" :loading="isSubmitting">
|
||||
{{ park ? 'Submit Edit' : 'Submit for Review' }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Step 6: Frontend - Moderation Queue Page
|
||||
|
||||
```vue
|
||||
<!-- frontend/pages/moderation/index.vue -->
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: ['auth', 'moderator']
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Moderation Queue | ThrillWiki'
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
type: '',
|
||||
status: 'pending'
|
||||
})
|
||||
|
||||
const { data, pending, refresh } = await useAsyncData(
|
||||
'moderation-queue',
|
||||
() => $fetch('/api/v1/moderation/', { params: filters }),
|
||||
{ watch: [filters] }
|
||||
)
|
||||
|
||||
async function handleApprove(id: string) {
|
||||
await $fetch(`/api/v1/moderation/${id}/approve/`, { method: 'POST' })
|
||||
useToast().success('Submission approved')
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function handleReject(id: string, notes: string) {
|
||||
await $fetch(`/api/v1/moderation/${id}/reject/`, {
|
||||
method: 'POST',
|
||||
body: { notes }
|
||||
})
|
||||
useToast().success('Submission rejected')
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer>
|
||||
<h1 class="text-3xl font-bold mb-8">Moderation Queue</h1>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
<Select v-model="filters.type">
|
||||
<SelectOption value="">All Types</SelectOption>
|
||||
<SelectOption value="park">Parks</SelectOption>
|
||||
<SelectOption value="ride">Rides</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Queue -->
|
||||
<div class="space-y-4">
|
||||
<SubmissionCard
|
||||
v-for="submission in data"
|
||||
:key="submission.id"
|
||||
:submission="submission"
|
||||
@approve="handleApprove(submission.id)"
|
||||
@reject="notes => handleReject(submission.id, notes)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="!pending && !data?.length"
|
||||
icon="CheckCircle"
|
||||
title="Queue is empty"
|
||||
description="No pending submissions to review"
|
||||
/>
|
||||
</PageContainer>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Model supports versioning with JSONField snapshot
|
||||
- [ ] Submission model tracks all submission states
|
||||
- [ ] Validation serializers exist for each content type
|
||||
- [ ] Moderation endpoints have proper permissions
|
||||
- [ ] Approval creates version and applies changes atomically
|
||||
- [ ] Users are notified of submission status changes
|
||||
- [ ] Frontend shows submission status to users
|
||||
- [ ] Moderation queue is filterable and efficient
|
||||
- [ ] Diff view shows before/after comparison
|
||||
- [ ] Tests cover approval, rejection, and edge cases
|
||||
@@ -1,360 +0,0 @@
|
||||
---
|
||||
description: Create a new Django REST API endpoint following ThrillWiki conventions
|
||||
---
|
||||
|
||||
# New API Workflow
|
||||
|
||||
Create a new Django REST Framework API endpoint following ThrillWiki's patterns.
|
||||
|
||||
## Information Gathering
|
||||
|
||||
1. **Resource Name**: What entity is this API for? (e.g., Park, Ride, Review)
|
||||
2. **Operations**: Which CRUD operations are needed?
|
||||
- [ ] List (GET /resources/)
|
||||
- [ ] Create (POST /resources/)
|
||||
- [ ] Retrieve (GET /resources/{id}/)
|
||||
- [ ] Update (PUT/PATCH /resources/{id}/)
|
||||
- [ ] Delete (DELETE /resources/{id}/)
|
||||
3. **Permissions**: Who can access?
|
||||
- Public read, authenticated write?
|
||||
- Owner only?
|
||||
- Moderator/Admin only?
|
||||
4. **Filtering**: What filter options are needed?
|
||||
5. **Nested Resources**: Does this belong under another resource?
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Create or Update the Model
|
||||
|
||||
File: `backend/apps/[app]/models.py`
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
from apps.core.models import BaseModel
|
||||
|
||||
class Resource(BaseModel):
|
||||
"""A resource description"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = 'draft', 'Draft'
|
||||
PUBLISHED = 'published', 'Published'
|
||||
ARCHIVED = 'archived', 'Archived'
|
||||
|
||||
name = models.CharField(max_length=255)
|
||||
slug = models.SlugField(unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.DRAFT
|
||||
)
|
||||
owner = models.ForeignKey(
|
||||
'users.User',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='resources'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
indexes = [
|
||||
models.Index(fields=['status', '-created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
```
|
||||
|
||||
### 2. Create the Serializer
|
||||
|
||||
File: `backend/apps/[app]/serializers.py`
|
||||
|
||||
```python
|
||||
from rest_framework import serializers
|
||||
from .models import Resource
|
||||
|
||||
class ResourceSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for Resource listing"""
|
||||
owner_username = serializers.CharField(source='owner.username', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'description', 'status',
|
||||
'owner', 'owner_username',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['id', 'slug', 'owner', 'created_at', 'updated_at']
|
||||
|
||||
|
||||
class ResourceDetailSerializer(ResourceSerializer):
|
||||
"""Extended serializer for single resource view"""
|
||||
# Add related objects for detail view
|
||||
related_items = RelatedItemSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta(ResourceSerializer.Meta):
|
||||
fields = ResourceSerializer.Meta.fields + ['related_items']
|
||||
|
||||
|
||||
class ResourceCreateSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for creating resources"""
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = ['name', 'description', 'status']
|
||||
|
||||
def create(self, validated_data):
|
||||
# Auto-generate slug
|
||||
validated_data['slug'] = slugify(validated_data['name'])
|
||||
# Set owner from request
|
||||
validated_data['owner'] = self.context['request'].user
|
||||
return super().create(validated_data)
|
||||
```
|
||||
|
||||
### 3. Create the ViewSet
|
||||
|
||||
File: `backend/apps/[app]/views.py`
|
||||
|
||||
```python
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||
|
||||
from .models import Resource
|
||||
from .serializers import (
|
||||
ResourceSerializer,
|
||||
ResourceDetailSerializer,
|
||||
ResourceCreateSerializer
|
||||
)
|
||||
from .filters import ResourceFilter
|
||||
from .permissions import IsOwnerOrReadOnly
|
||||
|
||||
|
||||
class ResourceViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint for resources.
|
||||
|
||||
list: Get all resources (with filtering)
|
||||
create: Create a new resource (authenticated)
|
||||
retrieve: Get a single resource
|
||||
update: Update a resource (owner only)
|
||||
destroy: Delete a resource (owner only)
|
||||
"""
|
||||
queryset = Resource.objects.all()
|
||||
permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
|
||||
lookup_field = 'slug'
|
||||
|
||||
# Filtering
|
||||
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||
filterset_class = ResourceFilter
|
||||
search_fields = ['name', 'description']
|
||||
ordering_fields = ['name', 'created_at', 'updated_at']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
"""Optimize queries"""
|
||||
return Resource.objects.select_related(
|
||||
'owner'
|
||||
).prefetch_related(
|
||||
'related_items'
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""Use different serializers for different actions"""
|
||||
if self.action == 'create':
|
||||
return ResourceCreateSerializer
|
||||
if self.action == 'retrieve':
|
||||
return ResourceDetailSerializer
|
||||
return ResourceSerializer
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def publish(self, request, slug=None):
|
||||
"""Publish a draft resource"""
|
||||
resource = self.get_object()
|
||||
if resource.status != Resource.Status.DRAFT:
|
||||
return Response(
|
||||
{'error': 'Only draft resources can be published'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
resource.status = Resource.Status.PUBLISHED
|
||||
resource.save()
|
||||
return Response(ResourceSerializer(resource).data)
|
||||
```
|
||||
|
||||
### 4. Create Custom Filter
|
||||
|
||||
File: `backend/apps/[app]/filters.py`
|
||||
|
||||
```python
|
||||
import django_filters
|
||||
from .models import Resource
|
||||
|
||||
|
||||
class ResourceFilter(django_filters.FilterSet):
|
||||
"""Filters for Resource API"""
|
||||
|
||||
status = django_filters.ChoiceFilter(choices=Resource.Status.choices)
|
||||
owner = django_filters.CharFilter(field_name='owner__username')
|
||||
created_after = django_filters.DateTimeFilter(
|
||||
field_name='created_at',
|
||||
lookup_expr='gte'
|
||||
)
|
||||
created_before = django_filters.DateTimeFilter(
|
||||
field_name='created_at',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Resource
|
||||
fields = ['status', 'owner']
|
||||
```
|
||||
|
||||
### 5. Create Custom Permission
|
||||
|
||||
File: `backend/apps/[app]/permissions.py`
|
||||
|
||||
```python
|
||||
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||
|
||||
|
||||
class IsOwnerOrReadOnly(BasePermission):
|
||||
"""Allow read to all, write only to owner"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if request.method in SAFE_METHODS:
|
||||
return True
|
||||
return obj.owner == request.user
|
||||
|
||||
|
||||
class IsModerator(BasePermission):
|
||||
"""Allow access only to moderators"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return (
|
||||
request.user.is_authenticated and
|
||||
request.user.is_moderator
|
||||
)
|
||||
```
|
||||
|
||||
### 6. Register URLs
|
||||
|
||||
File: `backend/apps/[app]/urls.py`
|
||||
|
||||
```python
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ResourceViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register('resources', ResourceViewSet, basename='resource')
|
||||
|
||||
urlpatterns = router.urls
|
||||
```
|
||||
|
||||
Add to main urls:
|
||||
```python
|
||||
# backend/config/urls.py
|
||||
urlpatterns = [
|
||||
...
|
||||
path('api/v1/', include('apps.app_name.urls')),
|
||||
]
|
||||
```
|
||||
|
||||
### 7. Create Migration
|
||||
|
||||
```bash
|
||||
python manage.py makemigrations app_name
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### 8. Add Tests
|
||||
|
||||
File: `backend/apps/[app]/tests/test_views.py`
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from rest_framework.test import APITestCase
|
||||
from rest_framework import status
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.users.factories import UserFactory
|
||||
from .factories import ResourceFactory
|
||||
|
||||
|
||||
class TestResourceAPI(APITestCase):
|
||||
"""Tests for Resource API endpoints"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = UserFactory()
|
||||
self.resource = ResourceFactory(owner=self.user)
|
||||
|
||||
def test_list_resources_unauthenticated(self):
|
||||
"""Anonymous users can list resources"""
|
||||
url = reverse('resource-list')
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_create_resource_authenticated(self):
|
||||
"""Authenticated users can create resources"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
url = reverse('resource-list')
|
||||
data = {'name': 'New Resource', 'description': 'Test'}
|
||||
response = self.client.post(url, data)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
def test_create_resource_unauthenticated(self):
|
||||
"""Anonymous users cannot create resources"""
|
||||
url = reverse('resource-list')
|
||||
data = {'name': 'New Resource'}
|
||||
response = self.client.post(url, data)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_update_own_resource(self):
|
||||
"""Users can update their own resources"""
|
||||
self.client.force_authenticate(user=self.user)
|
||||
url = reverse('resource-detail', args=[self.resource.slug])
|
||||
response = self.client.patch(url, {'name': 'Updated'})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_update_others_resource(self):
|
||||
"""Users cannot update others' resources"""
|
||||
other_user = UserFactory()
|
||||
self.client.force_authenticate(user=other_user)
|
||||
url = reverse('resource-detail', args=[self.resource.slug])
|
||||
response = self.client.patch(url, {'name': 'Hacked'})
|
||||
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
After creating the API:
|
||||
|
||||
- [ ] Model has proper fields and constraints
|
||||
- [ ] Serializers validate input correctly
|
||||
- [ ] ViewSet has proper permissions
|
||||
- [ ] Queries are optimized (select_related, prefetch_related)
|
||||
- [ ] Filtering, search, and ordering work
|
||||
- [ ] Pagination is enabled
|
||||
- [ ] URLs are registered
|
||||
- [ ] Migrations are created and applied
|
||||
- [ ] Tests pass
|
||||
|
||||
## Output
|
||||
|
||||
Report what was created:
|
||||
```
|
||||
Created API: /api/v1/resources/
|
||||
Methods: GET (list), POST (create), GET (detail), PATCH (update), DELETE
|
||||
Permissions: IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly
|
||||
Filters: status, owner, created_after, created_before
|
||||
Search: name, description
|
||||
Files:
|
||||
- backend/apps/[app]/models.py
|
||||
- backend/apps/[app]/serializers.py
|
||||
- backend/apps/[app]/views.py
|
||||
- backend/apps/[app]/filters.py
|
||||
- backend/apps/[app]/permissions.py
|
||||
- backend/apps/[app]/urls.py
|
||||
- backend/apps/[app]/tests/test_views.py
|
||||
```
|
||||
@@ -1,279 +0,0 @@
|
||||
---
|
||||
description: Create a new Vue component following ThrillWiki patterns
|
||||
---
|
||||
|
||||
# New Component Workflow
|
||||
|
||||
Create a new Vue component following ThrillWiki's design system and conventions.
|
||||
|
||||
## Information Gathering
|
||||
|
||||
1. **Component Name**: PascalCase (e.g., `ParkCard`, `RatingDisplay`)
|
||||
2. **Category**:
|
||||
- `ui/` - Base components (Button, Card, Input)
|
||||
- `entity/` - Domain-specific (ParkCard, RideCard)
|
||||
- `forms/` - Form components
|
||||
- `specialty/` - Complex/unique components
|
||||
3. **Props**: What data does it receive?
|
||||
4. **Emits**: What events does it emit?
|
||||
5. **State**: Does it have internal state?
|
||||
6. **Variants**: Does it need multiple variants/sizes?
|
||||
|
||||
## Component Template
|
||||
|
||||
### Base Component Structure
|
||||
|
||||
Location: `frontend/components/[category]/ComponentName.vue`
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// 1. Import types
|
||||
import type { ComponentProps } from '~/types'
|
||||
|
||||
// 2. Define props with TypeScript
|
||||
const props = withDefaults(defineProps<{
|
||||
// Required props
|
||||
title: string
|
||||
// Optional props with defaults
|
||||
variant?: 'default' | 'compact' | 'expanded'
|
||||
disabled?: boolean
|
||||
}>(), {
|
||||
variant: 'default',
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
// 3. Define emits
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
(e: 'select', value: string): void
|
||||
}>()
|
||||
|
||||
// 4. Use composables
|
||||
const { formatDistance } = useUnits()
|
||||
|
||||
// 5. Internal state
|
||||
const isOpen = ref(false)
|
||||
|
||||
// 6. Computed properties
|
||||
const computedClass = computed(() => ({
|
||||
'opacity-50 pointer-events-none': props.disabled,
|
||||
'p-4': props.variant === 'default',
|
||||
'p-2': props.variant === 'compact',
|
||||
}))
|
||||
|
||||
// 7. Methods
|
||||
function handleClick() {
|
||||
if (!props.disabled) {
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="computedClass" @click="handleClick">
|
||||
<!-- Component content -->
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Entity Card Component
|
||||
|
||||
For ParkCard, RideCard, etc.:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import type { Park } from '~/types'
|
||||
import { MapPin, Star } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
park: Park
|
||||
variant?: 'default' | 'compact'
|
||||
}>()
|
||||
|
||||
const statusVariant = computed(() => {
|
||||
switch (props.park.status) {
|
||||
case 'operating': return 'success'
|
||||
case 'closed': return 'destructive'
|
||||
case 'construction': return 'warning'
|
||||
default: return 'default'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="`/parks/${park.slug}`">
|
||||
<Card interactive class="overflow-hidden">
|
||||
<!-- Image -->
|
||||
<div class="aspect-video relative">
|
||||
<NuxtImg
|
||||
:src="park.image || '/placeholder-park.jpg'"
|
||||
:alt="park.name"
|
||||
class="object-cover w-full h-full"
|
||||
loading="lazy"
|
||||
/>
|
||||
<Badge
|
||||
:variant="statusVariant"
|
||||
class="absolute top-2 right-2"
|
||||
>
|
||||
{{ park.status }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold text-lg line-clamp-1">
|
||||
{{ park.name }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-muted-foreground flex items-center gap-1 mt-1">
|
||||
<MapPin class="w-4 h-4" />
|
||||
{{ park.city }}, {{ park.country }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
🎢 {{ park.rideCount }} rides
|
||||
</span>
|
||||
<RatingDisplay
|
||||
v-if="park.averageRating"
|
||||
:rating="park.averageRating"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Skeleton Loading Component
|
||||
|
||||
Every entity card should have a matching skeleton:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
variant?: 'default' | 'compact'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<Skeleton class="aspect-video rounded-t-lg" />
|
||||
<div class="p-4 space-y-2">
|
||||
<Skeleton class="h-5 w-3/4" />
|
||||
<Skeleton class="h-4 w-1/2" />
|
||||
<div class="flex justify-between mt-3">
|
||||
<Skeleton class="h-4 w-20" />
|
||||
<Skeleton class="h-4 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Variant Pattern (using CVA)
|
||||
|
||||
For components with many variants, use class-variance-authority:
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
outline: 'border border-input bg-background hover:bg-accent',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
lg: 'h-12 px-6 text-lg',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type Props = VariantProps<typeof buttonVariants> & {
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
disabled: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="buttonVariants({ variant, size })"
|
||||
:disabled="disabled || loading"
|
||||
>
|
||||
<Loader2 v-if="loading" class="w-4 h-4 mr-2 animate-spin" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Composable Integration
|
||||
|
||||
If component needs shared logic, create a composable:
|
||||
|
||||
```typescript
|
||||
// composables/useUnitDisplay.ts
|
||||
export function useUnitDisplay() {
|
||||
const { preferredUnits } = useUserPreferences()
|
||||
|
||||
function formatSpeed(kmh: number): string {
|
||||
if (preferredUnits.value === 'imperial') {
|
||||
return `${Math.round(kmh * 0.621371)} mph`
|
||||
}
|
||||
return `${kmh} km/h`
|
||||
}
|
||||
|
||||
function formatHeight(meters: number): string {
|
||||
if (preferredUnits.value === 'imperial') {
|
||||
return `${Math.round(meters * 3.28084)} ft`
|
||||
}
|
||||
return `${meters} m`
|
||||
}
|
||||
|
||||
return { formatSpeed, formatHeight }
|
||||
}
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
After creating the component:
|
||||
|
||||
- [ ] TypeScript props are properly defined
|
||||
- [ ] Component follows design system (colors, spacing, typography)
|
||||
- [ ] Responsive on all screen sizes
|
||||
- [ ] Handles loading state (if applicable)
|
||||
- [ ] Handles empty state (if applicable)
|
||||
- [ ] Accessible (ARIA labels, keyboard nav)
|
||||
- [ ] Has matching skeleton component (for async data)
|
||||
- [ ] Works in dark mode
|
||||
|
||||
## Output
|
||||
|
||||
Report what was created:
|
||||
```
|
||||
Created: frontend/components/[category]/ComponentName.vue
|
||||
Props: [list of props]
|
||||
Emits: [list of events]
|
||||
Related: [any composables or sub-components]
|
||||
```
|
||||
@@ -1,311 +0,0 @@
|
||||
---
|
||||
description: Implement a full-stack feature across Django backend and Nuxt frontend
|
||||
---
|
||||
|
||||
# New Feature Workflow
|
||||
|
||||
Implement a complete feature spanning the Django backend and Nuxt frontend.
|
||||
|
||||
## Planning Phase
|
||||
|
||||
Before writing any code, create an implementation plan:
|
||||
|
||||
### 1. Feature Definition
|
||||
- **Goal**: What problem does this feature solve?
|
||||
- **User Stories**: Who uses it and how?
|
||||
- **Acceptance Criteria**: How do we know it's done?
|
||||
|
||||
### 2. Technical Scope
|
||||
- **Backend Changes**: Models, APIs, permissions
|
||||
- **Frontend Changes**: Pages, components, state
|
||||
- **Data Flow**: How data moves between layers
|
||||
|
||||
### 3. Implementation Order
|
||||
|
||||
Always implement in this order:
|
||||
1. **Database/Models** - Foundation first
|
||||
2. **API Endpoints** - Backend logic
|
||||
3. **Frontend Components** - UI building blocks
|
||||
4. **Frontend Pages** - Assembled views
|
||||
5. **Integration** - Wire it all together
|
||||
6. **Tests** - Verify everything works
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Backend - Models
|
||||
|
||||
```python
|
||||
# Create or modify models
|
||||
# Remember: Inherit from BaseModel, add proper indexes
|
||||
|
||||
class NewFeature(BaseModel):
|
||||
# Fields
|
||||
name = models.CharField(max_length=255)
|
||||
# ... other fields
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
```
|
||||
|
||||
Run migrations:
|
||||
```bash
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
```
|
||||
|
||||
### Step 2: Backend - Serializers
|
||||
|
||||
```python
|
||||
# backend/apps/[app]/serializers.py
|
||||
|
||||
class NewFeatureSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = NewFeature
|
||||
fields = ['id', 'name', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
class NewFeatureDetailSerializer(NewFeatureSerializer):
|
||||
# Extended fields for detail view
|
||||
related_data = RelatedSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta(NewFeatureSerializer.Meta):
|
||||
fields = NewFeatureSerializer.Meta.fields + ['related_data']
|
||||
```
|
||||
|
||||
### Step 3: Backend - API Views
|
||||
|
||||
```python
|
||||
# backend/apps/[app]/views.py
|
||||
|
||||
class NewFeatureViewSet(viewsets.ModelViewSet):
|
||||
queryset = NewFeature.objects.all()
|
||||
serializer_class = NewFeatureSerializer
|
||||
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
return NewFeature.objects.select_related(
|
||||
# Add related models
|
||||
).prefetch_related(
|
||||
# Add many-to-many or reverse relations
|
||||
)
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == 'retrieve':
|
||||
return NewFeatureDetailSerializer
|
||||
return NewFeatureSerializer
|
||||
```
|
||||
|
||||
### Step 4: Backend - URLs
|
||||
|
||||
```python
|
||||
# backend/apps/[app]/urls.py
|
||||
router.register('new-features', NewFeatureViewSet, basename='new-feature')
|
||||
```
|
||||
|
||||
### Step 5: Backend - Tests
|
||||
|
||||
```python
|
||||
# backend/apps/[app]/tests/test_new_feature.py
|
||||
|
||||
class TestNewFeatureAPI(APITestCase):
|
||||
def test_list_features(self):
|
||||
response = self.client.get('/api/v1/new-features/')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_create_feature_authenticated(self):
|
||||
self.client.force_authenticate(user=self.user)
|
||||
response = self.client.post('/api/v1/new-features/', {'name': 'Test'})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
```
|
||||
|
||||
### Step 6: Frontend - Types
|
||||
|
||||
```typescript
|
||||
// frontend/types/newFeature.ts
|
||||
|
||||
export interface NewFeature {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface NewFeatureDetail extends NewFeature {
|
||||
relatedData: RelatedItem[]
|
||||
}
|
||||
```
|
||||
|
||||
### Step 7: Frontend - Composables
|
||||
|
||||
```typescript
|
||||
// frontend/composables/useNewFeatures.ts
|
||||
|
||||
export function useNewFeatures() {
|
||||
const api = useApi()
|
||||
|
||||
async function getFeatures(params?: Record<string, any>) {
|
||||
return api<PaginatedResponse<NewFeature>>('/new-features/', { params })
|
||||
}
|
||||
|
||||
async function getFeature(id: string) {
|
||||
return api<NewFeatureDetail>(`/new-features/${id}/`)
|
||||
}
|
||||
|
||||
async function createFeature(data: Partial<NewFeature>) {
|
||||
return api<NewFeature>('/new-features/', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
}
|
||||
|
||||
return { getFeatures, getFeature, createFeature }
|
||||
}
|
||||
```
|
||||
|
||||
### Step 8: Frontend - Components
|
||||
|
||||
Create necessary components following component patterns:
|
||||
|
||||
```vue
|
||||
<!-- frontend/components/entity/NewFeatureCard.vue -->
|
||||
<script setup lang="ts">
|
||||
import type { NewFeature } from '~/types'
|
||||
|
||||
defineProps<{
|
||||
feature: NewFeature
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card interactive>
|
||||
<div class="p-4">
|
||||
<h3 class="font-semibold">{{ feature.name }}</h3>
|
||||
<!-- Additional content -->
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Step 9: Frontend - Pages
|
||||
|
||||
```vue
|
||||
<!-- frontend/pages/new-features/index.vue -->
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
// middleware: ['auth'], // if needed
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: 'New Features | ThrillWiki',
|
||||
})
|
||||
|
||||
const { data, pending, error } = await useAsyncData('new-features', () =>
|
||||
useNewFeatures().getFeatures()
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer>
|
||||
<h1 class="text-3xl font-bold mb-8">New Features</h1>
|
||||
|
||||
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Skeleton v-for="i in 6" :key="i" class="h-48" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="data?.results" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<NewFeatureCard
|
||||
v-for="feature in data.results"
|
||||
:key="feature.id"
|
||||
:feature="feature"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EmptyState v-else title="No features found" />
|
||||
</PageContainer>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Step 10: Integration Testing
|
||||
|
||||
Test the full flow:
|
||||
|
||||
1. **API Test**: Verify endpoints with curl or API client
|
||||
2. **Component Test**: Test components in isolation
|
||||
3. **E2E Test**: Test complete user journey
|
||||
|
||||
```typescript
|
||||
// frontend/tests/e2e/newFeature.spec.ts
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('user can view new features', async ({ page }) => {
|
||||
await page.goto('/new-features')
|
||||
await expect(page.locator('h1')).toContainText('New Features')
|
||||
})
|
||||
|
||||
test('authenticated user can create feature', async ({ page }) => {
|
||||
// Login first
|
||||
await page.goto('/auth/login')
|
||||
// ... login steps
|
||||
|
||||
await page.goto('/new-features/create')
|
||||
await page.fill('input[name="name"]', 'Test Feature')
|
||||
await page.click('button[type="submit"]')
|
||||
|
||||
await expect(page).toHaveURL(/\/new-features\//)
|
||||
})
|
||||
```
|
||||
|
||||
## Feature Checklist
|
||||
|
||||
### Backend
|
||||
- [ ] Models created with proper fields and indexes
|
||||
- [ ] Migrations created and applied
|
||||
- [ ] Serializers handle validation
|
||||
- [ ] ViewSet has proper permissions
|
||||
- [ ] Queries are optimized
|
||||
- [ ] URLs registered
|
||||
- [ ] Unit tests pass
|
||||
|
||||
### Frontend
|
||||
- [ ] Types defined
|
||||
- [ ] Composables created for API calls
|
||||
- [ ] Components follow design system
|
||||
- [ ] Pages have proper SEO meta
|
||||
- [ ] Loading states implemented
|
||||
- [ ] Error states handled
|
||||
- [ ] Responsive design verified
|
||||
- [ ] Keyboard accessible
|
||||
|
||||
### Integration
|
||||
- [ ] Data flows correctly between backend and frontend
|
||||
- [ ] Authentication/authorization works
|
||||
- [ ] Error handling covers edge cases
|
||||
- [ ] Performance is acceptable
|
||||
|
||||
## Output Summary
|
||||
|
||||
After completing the feature:
|
||||
|
||||
```markdown
|
||||
## Feature: [Feature Name]
|
||||
|
||||
### Backend
|
||||
- Model: `apps/[app]/models.py` - NewFeature
|
||||
- API: `/api/v1/new-features/`
|
||||
- Permissions: [describe]
|
||||
|
||||
### Frontend
|
||||
- Page: `/new-features` (list), `/new-features/[id]` (detail)
|
||||
- Components: NewFeatureCard, NewFeatureForm
|
||||
- Composable: useNewFeatures
|
||||
|
||||
### Tests
|
||||
- Backend: X tests passing
|
||||
- Frontend: X tests passing
|
||||
- E2E: X tests passing
|
||||
|
||||
### Notes
|
||||
- [Any important implementation notes]
|
||||
- [Known limitations]
|
||||
- [Future improvements]
|
||||
```
|
||||
@@ -1,235 +0,0 @@
|
||||
---
|
||||
description: Create a new page in ThrillWiki following project conventions
|
||||
---
|
||||
|
||||
# New Page Workflow
|
||||
|
||||
Create a new page in ThrillWiki following all conventions and patterns.
|
||||
|
||||
## Information Gathering
|
||||
|
||||
Before creating the page, determine:
|
||||
|
||||
1. **Route**: What URL should this page have?
|
||||
2. **Page Type**:
|
||||
- List page (shows multiple items)
|
||||
- Detail page (shows single item)
|
||||
- Form page (create/edit content)
|
||||
- Static page (about, contact, etc.)
|
||||
3. **Data Requirements**: What data does this page need?
|
||||
4. **Authentication**: Public or authenticated only?
|
||||
5. **Related Components**: What existing components can be reused?
|
||||
|
||||
## File Creation Steps
|
||||
|
||||
### 1. Create the Page Component
|
||||
|
||||
Location: `frontend/pages/[route].vue` or `frontend/pages/[folder]/[route].vue`
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
// Define page metadata
|
||||
definePageMeta({
|
||||
// middleware: ['auth'], // If authenticated only
|
||||
// layout: 'admin', // If using special layout
|
||||
})
|
||||
|
||||
// Set page head
|
||||
useSeoMeta({
|
||||
title: 'Page Title | ThrillWiki',
|
||||
description: 'Page description for SEO',
|
||||
})
|
||||
|
||||
// Fetch data
|
||||
const { data, pending, error } = await useAsyncData('unique-key', () =>
|
||||
$fetch('/api/v1/endpoint/')
|
||||
)
|
||||
|
||||
// Handle error
|
||||
if (error.value) {
|
||||
throw createError({
|
||||
statusCode: error.value.statusCode || 500,
|
||||
message: error.value.message
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageContainer>
|
||||
<!-- Breadcrumbs (if applicable) -->
|
||||
<Breadcrumbs :items="breadcrumbItems" />
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold">Page Title</h1>
|
||||
<p class="text-muted-foreground mt-2">Page description</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<Skeleton v-for="i in 8" :key="i" class="h-64" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else>
|
||||
<!-- Page content here -->
|
||||
</div>
|
||||
</PageContainer>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 2. For List Pages - Add Filtering
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// Filter state from URL
|
||||
const filters = computed(() => ({
|
||||
status: route.query.status as string || '',
|
||||
search: route.query.search as string || '',
|
||||
page: parseInt(route.query.page as string) || 1
|
||||
}))
|
||||
|
||||
// Fetch with filters
|
||||
const { data, pending, refresh } = await useAsyncData(
|
||||
`items-${JSON.stringify(filters.value)}`,
|
||||
() => $fetch('/api/v1/items/', { params: filters.value }),
|
||||
{ watch: [filters] }
|
||||
)
|
||||
|
||||
// Update filters
|
||||
function updateFilter(key: string, value: string) {
|
||||
router.push({
|
||||
query: { ...route.query, [key]: value || undefined, page: 1 }
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Filter Bar -->
|
||||
<div class="flex gap-4 mb-6">
|
||||
<Input
|
||||
:model-value="filters.search"
|
||||
@update:model-value="updateFilter('search', $event)"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
<Select
|
||||
:model-value="filters.status"
|
||||
@update:model-value="updateFilter('status', $event)"
|
||||
>
|
||||
<SelectOption value="">All</SelectOption>
|
||||
<SelectOption value="operating">Operating</SelectOption>
|
||||
<SelectOption value="closed">Closed</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<ItemCard v-for="item in data?.results" :key="item.id" :item="item" />
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
:current-page="filters.page"
|
||||
:total-pages="Math.ceil((data?.count || 0) / 20)"
|
||||
@page-change="updateFilter('page', $event.toString())"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. For Detail Pages - Dynamic Route
|
||||
|
||||
File: `frontend/pages/items/[slug].vue`
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { data: item, error } = await useAsyncData(
|
||||
`item-${slug}`,
|
||||
() => $fetch(`/api/v1/items/${slug}/`)
|
||||
)
|
||||
|
||||
if (error.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'Item not found'
|
||||
})
|
||||
}
|
||||
|
||||
useSeoMeta({
|
||||
title: `${item.value?.name} | ThrillWiki`,
|
||||
description: item.value?.description,
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
### 4. For Form Pages
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { z } from 'zod'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
})
|
||||
|
||||
const errors = ref<Record<string, string[]>>({})
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
// Validate
|
||||
const result = schema.safeParse(form)
|
||||
if (!result.success) {
|
||||
errors.value = result.error.flatten().fieldErrors
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await $fetch('/api/v1/items/', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
await navigateTo('/items')
|
||||
} catch (e) {
|
||||
// Handle API errors
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
After creating the page, verify:
|
||||
|
||||
- [ ] Page renders without errors
|
||||
- [ ] SEO meta tags are set
|
||||
- [ ] Loading states display correctly
|
||||
- [ ] Error states are handled
|
||||
- [ ] Page is responsive (mobile, tablet, desktop)
|
||||
- [ ] Keyboard navigation works
|
||||
- [ ] Data fetches efficiently (no N+1 issues)
|
||||
- [ ] URL parameters persist correctly (for list pages)
|
||||
- [ ] Authentication is enforced (if required)
|
||||
|
||||
## Output
|
||||
|
||||
Report what was created:
|
||||
```
|
||||
Created: frontend/pages/[path].vue
|
||||
Route: /[route]
|
||||
Type: [list/detail/form/static]
|
||||
Features: [list of features implemented]
|
||||
```
|
||||
@@ -4,14 +4,9 @@
|
||||
"Bash(python manage.py check:*)",
|
||||
"Bash(uv run:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(python:*)",
|
||||
"Bash(DJANGO_SETTINGS_MODULE=config.django.local python:*)",
|
||||
"Bash(DJANGO_SETTINGS_MODULE=config.django.local uv run python:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(mkdir:*)"
|
||||
"Bash(python:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +1,98 @@
|
||||
## Brief overview
|
||||
Critical thinking rules for frontend design decisions. No excuses for poor design choices that ignore user vision.
|
||||
---
|
||||
description: Core ThrillWiki development rules covering API organization, data models, development commands, code quality standards, and critical business rules
|
||||
author: ThrillWiki Development Team
|
||||
version: 1.0
|
||||
globs: ["**/*.py", "apps/**/*", "thrillwiki/**/*", "**/*.md"]
|
||||
tags: ["django", "api-design", "code-quality", "development-commands", "business-rules"]
|
||||
---
|
||||
|
||||
## Rule compliance and design decisions
|
||||
- Read ALL .clinerules files before making any code changes
|
||||
- Never assume exceptions to rules marked as "MANDATORY"
|
||||
- Take full responsibility for rule violations without excuses
|
||||
- Ask "What is the most optimal approach?" before ANY design decision
|
||||
- Justify every choice against user requirements - not your damn preferences
|
||||
- Stop making lazy design decisions without evaluation
|
||||
- Document your reasoning or get destroyed later
|
||||
# ThrillWiki Core Development Rules
|
||||
|
||||
## User vision, feedback, and assumptions
|
||||
- Figure out what the user actually wants, not your assumptions
|
||||
- Ask questions when unclear - stop guessing like an idiot
|
||||
- Deliver their vision, not your garbage
|
||||
- User dissatisfaction means you screwed up understanding their vision
|
||||
- Stop defending your bad choices and listen
|
||||
- Fix the actual problem, not band-aid symptoms
|
||||
- Scrap everything and restart if needed
|
||||
- NEVER assume user preferences without confirmation
|
||||
- Stop guessing at requirements like a moron
|
||||
- Your instincts are wrong - question everything
|
||||
- Get explicit approval or fail
|
||||
|
||||
## Implementation and backend integration
|
||||
- Think before you code, don't just hack away
|
||||
- Evaluate trade-offs or make terrible decisions
|
||||
- Question if your solution actually solves their damn problem
|
||||
- NEVER change color schemes without explicit user approval
|
||||
- ALWAYS use responsive design principles
|
||||
- ALWAYS follow best theme choice guidelines so users may choose light or dark mode
|
||||
- NEVER use quick fixes for complex problems
|
||||
- Support user goals, not your aesthetic ego
|
||||
- Follow established patterns unless they specifically want innovation
|
||||
- Make it work everywhere or you failed
|
||||
- Document decisions so you don't repeat mistakes
|
||||
- MANDATORY: Research ALL backend endpoints before making ANY frontend changes
|
||||
- Verify endpoint URLs, parameters, and response formats in actual Django codebase
|
||||
- Test complete frontend-backend integration before considering work complete
|
||||
- MANDATORY: Update ALL frontend documentation files after backend changes
|
||||
- Synchronize docs/frontend.md, docs/lib-api.ts, and docs/types-api.ts
|
||||
- Take immediate responsibility for integration failures without excuses
|
||||
- MUST create frontend integration prompt after every backend change affecting API
|
||||
- Include complete API endpoint information with all parameters and types
|
||||
- Document all mandatory API rules (trailing slashes, HTTP methods, authentication)
|
||||
- Never assume frontend developers have access to backend code
|
||||
## Objective
|
||||
This rule defines the fundamental development standards, API organization patterns, code quality requirements, and critical business rules that MUST be followed for all ThrillWiki development work. It ensures consistency, maintainability, and adherence to project-specific constraints.
|
||||
|
||||
## API Organization and Data Models
|
||||
|
||||
### Mandatory API Structure
|
||||
- **MANDATORY NESTING**: All API directory structures MUST match URL nesting patterns. No exceptions.
|
||||
- **NO TOP-LEVEL ENDPOINTS**: URLs must be nested under top-level domains
|
||||
- **MANDATORY TRAILING SLASHES**: All API endpoints MUST include trailing forward slashes unless ending with query parameters
|
||||
- Validate all endpoint URLs against the mandatory trailing slash rule
|
||||
- **RIDE TYPES vs RIDE MODELS**: These are separate concepts for ALL ride categories:
|
||||
- **Ride Types**: How rides operate (e.g., "inverted", "trackless", "spinning", "log flume", "monorail")
|
||||
- **Ride Models**: Specific manufacturer products (e.g., "B&M Dive Coaster", "Vekoma Boomerang")
|
||||
- Individual rides reference BOTH the model (what product) and type (how it operates)
|
||||
- Ride types must be available for ALL ride categories, not just roller coasters
|
||||
- **Validation Required**: Validate all endpoint URLs against the mandatory trailing slash rule
|
||||
|
||||
### Ride System Architecture
|
||||
**RIDE TYPES vs RIDE MODELS**: These are separate concepts for ALL ride categories:
|
||||
- **Ride Types**: How rides operate (e.g., "inverted", "trackless", "spinning", "log flume", "monorail")
|
||||
- **Ride Models**: Specific manufacturer products (e.g., "B&M Dive Coaster", "Vekoma Boomerang")
|
||||
- **Implementation**: Individual rides reference BOTH the model (what product) and type (how it operates)
|
||||
- **Coverage**: Ride types MUST be available for ALL ride categories, not just roller coasters
|
||||
|
||||
## Development Commands and Code Quality
|
||||
- **Django Server**: Always use `uv run manage.py runserver_plus` instead of `python manage.py runserver`
|
||||
- **Django Migrations**: Always use `uv run manage.py makemigrations` and `uv run manage.py migrate` instead of `python manage.py`
|
||||
- **Package Management**: Always use `uv add <package>` instead of `pip install <package>`
|
||||
- **Django Management**: Always use `uv run manage.py <command>` instead of `python manage.py <command>`
|
||||
- Break down methods with high cognitive complexity (>15) into smaller, focused helper methods
|
||||
- Extract logical operations into separate methods with descriptive names
|
||||
- Use single responsibility principle - each method should have one clear purpose
|
||||
- Prefer composition over deeply nested conditional logic
|
||||
- Always handle None values explicitly to avoid type errors
|
||||
- Use proper type annotations, including union types (e.g., `Polygon | None`)
|
||||
- Structure API views with clear separation between parameter handling, business logic, and response building
|
||||
- When addressing SonarQube or linting warnings, focus on structural improvements rather than quick fixes
|
||||
|
||||
### Required Commands
|
||||
- **Django Server**: ALWAYS use `uv run manage.py runserver_plus` instead of `python manage.py runserver`
|
||||
- **Django Migrations**: ALWAYS use `uv run manage.py makemigrations` and `uv run manage.py migrate` instead of `python manage.py`
|
||||
- **Package Management**: ALWAYS use `uv add <package>` instead of `pip install <package>`
|
||||
- **Django Management**: ALWAYS use `uv run manage.py <command>` instead of `python manage.py <command>`
|
||||
|
||||
### Code Quality Standards
|
||||
- **Cognitive Complexity**: Break down methods with high cognitive complexity (>15) into smaller, focused helper methods
|
||||
- **Method Extraction**: Extract logical operations into separate methods with descriptive names
|
||||
- **Single Responsibility**: Each method SHOULD have one clear purpose
|
||||
- **Logic Structure**: Prefer composition over deeply nested conditional logic
|
||||
- **Null Handling**: ALWAYS handle None values explicitly to avoid type errors
|
||||
- **Type Annotations**: Use proper type annotations, including union types (e.g., `Polygon | None`)
|
||||
- **API Structure**: Structure API views with clear separation between parameter handling, business logic, and response building
|
||||
- **Quality Improvements**: When addressing SonarQube or linting warnings, focus on structural improvements rather than quick fixes
|
||||
|
||||
## ThrillWiki Project Rules
|
||||
|
||||
### Domain Architecture
|
||||
- **Domain Structure**: Parks contain rides, rides have models, companies have multiple roles (manufacturer/operator/designer)
|
||||
- **Media Integration**: Use CloudflareImagesField for all photo uploads with variants and transformations
|
||||
- **Tracking**: All models use pghistory for change tracking and TrackedModel base class
|
||||
- **Slugs**: Unique within scope (park slugs global, ride slugs within park, ride model slugs within manufacturer)
|
||||
- **Change Tracking**: All models use pghistory for change tracking and TrackedModel base class
|
||||
- **Slug Management**: Unique within scope (park slugs global, ride slugs within park, ride model slugs within manufacturer)
|
||||
|
||||
### Status and Role Management
|
||||
- **Status Management**: Rides have operational status (OPERATING, CLOSED_TEMP, SBNO, etc.) with date tracking
|
||||
- **Company Roles**: Companies can be MANUFACTURER, OPERATOR, DESIGNER, PROPERTY_OWNER with array field
|
||||
- **Location Data**: Use PostGIS for geographic data, separate location models for parks and rides
|
||||
|
||||
### Technical Patterns
|
||||
- **API Patterns**: Use DRF with drf-spectacular, comprehensive serializers, nested endpoints, caching
|
||||
- **Photo Management**: Banner/card image references, photo types, attribution fields, primary photo logic
|
||||
- **Search Integration**: Text search, filtering, autocomplete endpoints, pagination
|
||||
- **Statistics**: Cached stats endpoints with automatic invalidation via Django signals
|
||||
|
||||
## CRITICAL RULES
|
||||
- **DOCUMENTATION**: After every change, it is MANDATORY to update docs/frontend.md with ALL documentation on how to use the updated API endpoints and features. It is MANDATORY to include any types in docs/types-api.ts for NextJS as the file would appear in `src/types/api.ts`. It is MANDATORY to include any new API endpoints in docs/lib-api.ts for NextJS as the file would appear in `/src/lib/api.ts`. Maintain accuracy and compliance in all technical documentation. Ensure API documentation matches backend URL routing expectations.
|
||||
- **NEVER MOCK DATA**: You are NEVER EVER to mock any data unless it's ONLY for API schema documentation purposes. All data must come from real database queries and actual model instances. Mock data is STRICTLY FORBIDDEN in all API responses, services, and business logic.
|
||||
- **DOMAIN SEPARATION**: Company roles OPERATOR and PROPERTY_OWNER are EXCLUSIVELY for parks domain. They should NEVER be used in rides URLs or ride-related contexts. Only MANUFACTURER and DESIGNER roles are for rides domain. Parks: `/parks/{park_slug}/` and `/parks/`. Rides: `/parks/{park_slug}/rides/{ride_slug}/` and `/rides/`. Parks Companies: `/parks/operators/{operator_slug}/` and `/parks/owners/{owner_slug}/`. Rides Companies: `/rides/manufacturers/{manufacturer_slug}/` and `/rides/designers/{designer_slug}/`. NEVER mix these domains - this is a fundamental and DANGEROUS business rule violation.
|
||||
- **PHOTO MANAGEMENT**: Use CloudflareImagesField for all photo uploads with variants and transformations. Clearly define and use photo types (e.g., banner, card) for all images. Include attribution fields for all photos. Implement logic to determine the primary photo for each model.
|
||||
|
||||
### Data Integrity (ABSOLUTE)
|
||||
🚨 **NEVER MOCK DATA**: You are NEVER EVER to mock any data unless it's ONLY for API schema documentation purposes. All data MUST come from real database queries and actual model instances. Mock data is STRICTLY FORBIDDEN in all API responses, services, and business logic.
|
||||
|
||||
### Domain Separation (CRITICAL BUSINESS RULE)
|
||||
🚨 **DOMAIN SEPARATION**: Company roles OPERATOR and PROPERTY_OWNER are EXCLUSIVELY for parks domain. They SHOULD NEVER be used in rides URLs or ride-related contexts. Only MANUFACTURER and DESIGNER roles are for rides domain.
|
||||
|
||||
**Correct URL Patterns:**
|
||||
- **Parks**: `/parks/{park_slug}/` and `/parks/`
|
||||
- **Rides**: `/parks/{park_slug}/rides/{ride_slug}/` and `/rides/`
|
||||
- **Parks Companies**: `/parks/operators/{operator_slug}/` and `/parks/owners/{owner_slug}/`
|
||||
- **Rides Companies**: `/rides/manufacturers/{manufacturer_slug}/` and `/rides/designers/{designer_slug}/`
|
||||
|
||||
⚠️ **WARNING**: NEVER mix these domains - this is a fundamental and DANGEROUS business rule violation.
|
||||
|
||||
### Photo Management Standards
|
||||
🚨 **PHOTO MANAGEMENT**:
|
||||
- Use CloudflareImagesField for all photo uploads with variants and transformations
|
||||
- Clearly define and use photo types (e.g., banner, card) for all images
|
||||
- Include attribution fields for all photos
|
||||
- Implement logic to determine the primary photo for each model
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
Before implementing any changes, verify:
|
||||
- [ ] All API endpoints have trailing slashes
|
||||
- [ ] Domain separation is maintained (parks vs rides companies)
|
||||
- [ ] No mock data is used outside of schema documentation
|
||||
- [ ] Proper uv commands are used for all Django operations
|
||||
- [ ] Type annotations are complete and accurate
|
||||
- [ ] Methods follow single responsibility principle
|
||||
- [ ] CloudflareImagesField is used for all photo uploads
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
## Brief overview
|
||||
---
|
||||
description: Mandatory Rich Choice Objects system enforcement for ThrillWiki project replacing Django tuple-based choices with rich metadata-driven choice fields
|
||||
author: ThrillWiki Development Team
|
||||
version: 1.0
|
||||
globs: ["apps/**/choices.py", "apps/**/models.py", "apps/**/serializers.py", "apps/**/__init__.py"]
|
||||
tags: ["django", "choices", "rich-choice-objects", "data-modeling", "mandatory"]
|
||||
---
|
||||
|
||||
# Rich Choice Objects System (MANDATORY)
|
||||
|
||||
## Objective
|
||||
This rule enforces the mandatory use of the Rich Choice Objects system instead of Django's traditional tuple-based choices for ALL choice fields in the ThrillWiki project. It ensures consistent, metadata-rich choice handling with enhanced UI capabilities and maintainable code patterns.
|
||||
|
||||
## Brief Overview
|
||||
Mandatory use of Rich Choice Objects system instead of Django tuple-based choices for all choice fields in ThrillWiki project.
|
||||
|
||||
## Rich Choice Objects enforcement
|
||||
- NEVER use Django tuple-based choices (e.g., `choices=[('VALUE', 'Label')]`) - ALWAYS use RichChoiceField
|
||||
- All choice fields MUST use `RichChoiceField(choice_group="group_name", domain="domain_name")` pattern
|
||||
- Choice definitions MUST be created in domain-specific `choices.py` files using RichChoice dataclass
|
||||
- All choices MUST include rich metadata (color, icon, description, css_class at minimum)
|
||||
- Choice groups MUST be registered with global registry using `register_choices()` function
|
||||
- Import choices in domain `__init__.py` to trigger auto-registration on Django startup
|
||||
- Use ChoiceCategory enum for proper categorization (STATUS, CLASSIFICATION, TECHNICAL, SECURITY)
|
||||
- Leverage rich metadata for UI styling, permissions, and business logic instead of hardcoded values
|
||||
- DO NOT maintain backwards compatibility with tuple-based choices - migrate fully to Rich Choice Objects
|
||||
- Ensure all existing models using tuple-based choices are refactored to use RichChoiceField
|
||||
- Validate choice groups are correctly loaded in registry during application startup
|
||||
- Update serializers to use RichChoiceSerializer for choice fields
|
||||
- Follow established patterns from rides, parks, and accounts domains for consistency
|
||||
## Rich Choice Objects Enforcement
|
||||
|
||||
### Absolute Requirements
|
||||
🚨 **NEVER use Django tuple-based choices** (e.g., `choices=[('VALUE', 'Label')]`) - ALWAYS use RichChoiceField
|
||||
|
||||
### Implementation Standards
|
||||
- **Field Usage**: All choice fields MUST use `RichChoiceField(choice_group="group_name", domain="domain_name")` pattern
|
||||
- **Choice Definitions**: MUST be created in domain-specific `choices.py` files using RichChoice dataclass
|
||||
- **Rich Metadata**: All choices MUST include rich metadata (color, icon, description, css_class at minimum)
|
||||
- **Registration**: Choice groups MUST be registered with global registry using `register_choices()` function
|
||||
- **Auto-Registration**: Import choices in domain `__init__.py` to trigger auto-registration on Django startup
|
||||
|
||||
### Required Patterns
|
||||
- **Categorization**: Use ChoiceCategory enum for proper categorization (STATUS, CLASSIFICATION, TECHNICAL, SECURITY)
|
||||
- **Business Logic**: Leverage rich metadata for UI styling, permissions, and business logic instead of hardcoded values
|
||||
- **Serialization**: Update serializers to use RichChoiceSerializer for choice fields
|
||||
|
||||
### Migration Requirements
|
||||
- **NO Backwards Compatibility**: DO NOT maintain backwards compatibility with tuple-based choices - migrate fully to Rich Choice Objects
|
||||
- **Model Refactoring**: Ensure all existing models using tuple-based choices are refactored to use RichChoiceField
|
||||
- **Validation**: Validate choice groups are correctly loaded in registry during application startup
|
||||
|
||||
### Domain Consistency
|
||||
- **Follow Established Patterns**: Follow established patterns from rides, parks, and accounts domains for consistency
|
||||
- **Domain-Specific Organization**: Maintain domain-specific choice organization in separate `choices.py` files
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
Before implementing choice fields, verify:
|
||||
- [ ] RichChoiceField is used instead of Django tuple choices
|
||||
- [ ] Choice group and domain are properly specified
|
||||
- [ ] Rich metadata includes color, icon, description, css_class
|
||||
- [ ] Choices are defined in domain-specific `choices.py` file
|
||||
- [ ] Choice group is registered with `register_choices()` function
|
||||
- [ ] Domain `__init__.py` imports choices for auto-registration
|
||||
- [ ] Appropriate ChoiceCategory enum is used
|
||||
- [ ] Serializers use RichChoiceSerializer for choice fields
|
||||
- [ ] No tuple-based choices remain in the codebase
|
||||
|
||||
## Examples
|
||||
|
||||
### ✅ CORRECT Implementation
|
||||
```python
|
||||
# In apps/rides/choices.py
|
||||
from core.choices import RichChoice, ChoiceCategory, register_choices
|
||||
|
||||
RIDE_STATUS_CHOICES = [
|
||||
RichChoice(
|
||||
value="operating",
|
||||
label="Operating",
|
||||
color="#10b981",
|
||||
icon="check-circle",
|
||||
description="Ride is currently operating normally",
|
||||
css_class="status-operating",
|
||||
category=ChoiceCategory.STATUS
|
||||
),
|
||||
# ... more choices
|
||||
]
|
||||
|
||||
register_choices("ride_status", RIDE_STATUS_CHOICES, domain="rides")
|
||||
|
||||
# In models.py
|
||||
status = RichChoiceField(choice_group="ride_status", domain="rides")
|
||||
```
|
||||
|
||||
### ❌ FORBIDDEN Implementation
|
||||
```python
|
||||
# NEVER DO THIS - Tuple-based choices are forbidden
|
||||
STATUS_CHOICES = [
|
||||
('operating', 'Operating'),
|
||||
('closed', 'Closed'),
|
||||
]
|
||||
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES)
|
||||
```
|
||||
|
||||
## Verification Steps
|
||||
|
||||
To ensure compliance:
|
||||
1. Search codebase for any remaining tuple-based choice patterns
|
||||
2. Verify all choice fields use RichChoiceField
|
||||
3. Confirm all choices have complete rich metadata
|
||||
4. Test choice group registration during application startup
|
||||
5. Validate serializers use RichChoiceSerializer where appropriate
|
||||
|
||||
161
.clinerules/thrillwiki-context.md
Normal file
161
.clinerules/thrillwiki-context.md
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
description: Comprehensive ThrillWiki Django project context including architecture, development patterns, business rules, and mandatory Context7 MCP integration workflow
|
||||
author: ThrillWiki Development Team
|
||||
version: 2.0
|
||||
globs: ["**/*.py", "**/*.html", "**/*.js", "**/*.css", "**/*.md"]
|
||||
tags: ["django", "architecture", "api-design", "business-rules", "context7-integration", "thrillwiki"]
|
||||
---
|
||||
|
||||
# ThrillWiki Django Project Context
|
||||
|
||||
## Objective
|
||||
This rule provides comprehensive context for the ThrillWiki project, defining core architecture patterns, business rules, development workflows, and mandatory integration requirements. It serves as the primary reference for maintaining consistency across all ThrillWiki development activities.
|
||||
|
||||
## Project Overview
|
||||
ThrillWiki is a comprehensive theme park database platform with user-generated content, expert moderation, and rich media support. Built with Django REST Framework, it serves 120+ API endpoints for parks, rides, companies, and user management.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Technology Stack
|
||||
- **Backend**: Django 5.0+ with DRF, PostgreSQL + PostGIS, Redis caching, Celery tasks
|
||||
- **Frontend**: HTMX + AlpineJS + Tailwind CSS + Django-Cotton
|
||||
- 🚨 **CRITICAL**: NO React/Vue/Angular allowed
|
||||
- **Media**: Cloudflare Images using Direct Upload with variants and transformations
|
||||
- **Tracking**: pghistory for all model changes, TrackedModel base class
|
||||
- **Choices**: Rich Choice Objects system (NEVER use Django tuple choices)
|
||||
|
||||
### Domain Architecture
|
||||
- **Parks Domain**: `parks/`, companies (OPERATOR/PROPERTY_OWNER roles only)
|
||||
- **Rides Domain**: `rides/`, companies (MANUFACTURER/DESIGNER roles only)
|
||||
- **Core Apps**: `accounts/`, `media/`, `moderation/`, `core/`
|
||||
- 🚨 **CRITICAL BUSINESS RULE**: Never mix park/ride company roles - fundamental business rule violation
|
||||
|
||||
## Development Patterns
|
||||
|
||||
### Model Patterns
|
||||
- **Base Classes**: All models MUST inherit from TrackedModel
|
||||
- **Slug Handling**: Use SluggedModel for slugs with history tracking
|
||||
- **Location Data**: Use PostGIS for geographic data, separate location models
|
||||
- **Media Fields**: Use CloudflareImagesField for all image handling
|
||||
|
||||
### API Design Patterns
|
||||
- **URL Structure**: Nested URLs (`/parks/{slug}/rides/{slug}/`)
|
||||
- **Trailing Slashes**: MANDATORY trailing slashes on all endpoints
|
||||
- **Authentication**: Token-based with role hierarchy (USER/MODERATOR/ADMIN/SUPERUSER)
|
||||
- **Filtering**: Comprehensive filtering - rides (25+ parameters), parks (15+ parameters)
|
||||
- **Responses**: Standard DRF pagination, rich error responses with details
|
||||
- **Caching**: Multi-level (Redis, CDN, browser) with signal-based invalidation
|
||||
|
||||
### Choice System (MANDATORY)
|
||||
- **Implementation**: `RichChoiceField(choice_group="group_name", domain="domain_name")`
|
||||
- **Definition**: Domain-specific `choices.py` using RichChoice dataclass
|
||||
- **Registration**: `register_choices()` function in domain `__init__.py`
|
||||
- **Required Metadata**: color, icon, description, css_class (minimum)
|
||||
- 🚨 **FORBIDDEN**: NO tuple-based choices allowed anywhere in codebase
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Package Management
|
||||
- **Python Packages**: `uv add <package>` (NOT `pip install`)
|
||||
- **Server**: `uv run manage.py runserver_plus` (NOT `python manage.py`)
|
||||
- **Migrations**: `uv run manage.py makemigrations/migrate`
|
||||
- **Management**: ALWAYS use `uv run manage.py <command>`
|
||||
|
||||
## Business Rules
|
||||
|
||||
### Company Role Separation
|
||||
- **Parks Domain**: Only OPERATOR and PROPERTY_OWNER roles
|
||||
- **Rides Domain**: Only MANUFACTURER and DESIGNER roles
|
||||
- 🚨 **CRITICAL**: Never allow cross-domain company roles
|
||||
|
||||
### Data Integrity
|
||||
- **Model Changes**: All must be tracked via pghistory
|
||||
- **API Responses**: MUST use real database data (NEVER MOCK DATA)
|
||||
- **Geographic Data**: MUST use PostGIS for accuracy
|
||||
|
||||
## Frontend Constraints
|
||||
|
||||
### Architecture Requirements
|
||||
- **HTMX**: Dynamic updates and AJAX interactions
|
||||
- **AlpineJS**: Client-side state management
|
||||
- **Tailwind CSS**: Styling framework
|
||||
- **Progressive Enhancement**: Required approach
|
||||
|
||||
### Performance Targets
|
||||
- **First Contentful Paint**: < 1.5s
|
||||
- **Time to Interactive**: < 2s
|
||||
- **Compliance**: Core Web Vitals compliance
|
||||
- **Browser Support**: Latest 2 versions of major browsers
|
||||
|
||||
## Context7 MCP Integration (MANDATORY)
|
||||
|
||||
### Requirement
|
||||
🚨 **CRITICAL**: ALWAYS use Context7 MCP for documentation lookups before making changes
|
||||
|
||||
### Libraries Requiring Context7
|
||||
- **tailwindcss**: CSS utility classes, responsive design, component styling
|
||||
- **django**: Models, views, forms, URL patterns, Django-specific patterns
|
||||
- **django-cotton**: Component creation, template organization, Cotton-specific syntax
|
||||
- **htmx**: Dynamic updates, form handling, AJAX interactions
|
||||
- **alpinejs**: Client-side state management, reactive data, JavaScript interactions
|
||||
- **django-rest-framework**: API design, serializers, viewsets, DRF patterns
|
||||
- **postgresql**: Database queries, PostGIS functions, advanced SQL features
|
||||
- **postgis**: Geographic data handling and spatial queries
|
||||
- **redis**: Caching strategies, session management, performance optimization
|
||||
|
||||
### Mandatory Workflow Steps
|
||||
1. **Before editing/creating code**: Query Context7 for relevant library documentation
|
||||
2. **During debugging**: Use Context7 to verify syntax, patterns, and best practices
|
||||
3. **When implementing new features**: Reference Context7 for current API and method signatures
|
||||
4. **For performance issues**: Consult Context7 for optimization techniques and patterns
|
||||
5. **For geographic data handling**: Use Context7 for PostGIS functions and best practices
|
||||
6. **For caching strategies**: Refer to Context7 for Redis patterns and best practices
|
||||
7. **For database queries**: Utilize Context7 for PostgreSQL best practices and advanced SQL features
|
||||
|
||||
### Mandatory Scenarios
|
||||
- Creating new Django models or API endpoints
|
||||
- Implementing HTMX dynamic functionality
|
||||
- Writing AlpineJS reactive components
|
||||
- Designing responsive layouts with Tailwind CSS
|
||||
- Creating Django-Cotton components
|
||||
- Debugging CSS, JavaScript, or Django issues
|
||||
- Implementing caching or database optimizations
|
||||
- Handling geographic data with PostGIS
|
||||
- Utilizing Redis for session management
|
||||
- Implementing real-time features with WebSockets
|
||||
|
||||
### Context7 Commands
|
||||
1. **Resolve Library**: Always call `Context7:resolve-library-id` first to get correct library ID
|
||||
2. **Get Documentation**: Then use `Context7:get-library-docs` with appropriate topic parameter
|
||||
|
||||
### Example Topics by Library
|
||||
- **tailwindcss**: responsive design, flexbox, grid, animations
|
||||
- **django**: models, views, forms, admin, signals
|
||||
- **django-cotton**: components, templates, slots, props
|
||||
- **htmx**: hx-get, hx-post, hx-swap, hx-trigger, hx-target
|
||||
- **alpinejs**: x-data, x-show, x-if, x-for, x-model
|
||||
- **django-rest-framework**: serializers, viewsets, routers, permissions
|
||||
- **postgresql**: joins, indexes, transactions, window functions
|
||||
- **postgis**: geospatial queries, distance calculations, spatial indexes
|
||||
- **redis**: caching strategies, pub/sub, data structures
|
||||
|
||||
## Code Quality Standards
|
||||
|
||||
### Model Requirements
|
||||
- All models MUST inherit from TrackedModel
|
||||
- Use SluggedModel for entities with slugs and history tracking
|
||||
- Always use RichChoiceField instead of Django choices
|
||||
- Use CloudflareImagesField for all image handling
|
||||
- Use PostGIS fields and separate location models for geographic data
|
||||
|
||||
### API Requirements
|
||||
- MUST include trailing slashes and follow nested pattern
|
||||
- All responses MUST use real database queries
|
||||
- Implement comprehensive filtering and pagination
|
||||
- Use signal-based cache invalidation
|
||||
|
||||
### Development Workflow
|
||||
- Use uv for all Python package operations
|
||||
- Use runserver_plus for enhanced development server
|
||||
- Always use `uv run` for Django management commands
|
||||
- All functionality MUST work with progressive enhancement
|
||||
56
.clinerules/thrillwiki-simple.md
Normal file
56
.clinerules/thrillwiki-simple.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
description: Condensed ThrillWiki Django project context with architecture, patterns, and mandatory Context7 integration
|
||||
author: ThrillWiki Development Team
|
||||
version: 2.1
|
||||
globs: ["**/*.py", "**/*.html", "**/*.js", "**/*.css", "**/*.md"]
|
||||
tags: ["django", "architecture", "context7-integration", "thrillwiki"]
|
||||
---
|
||||
|
||||
# ThrillWiki Django Project Context
|
||||
|
||||
## Project Overview
|
||||
Theme park database platform with Django REST Framework serving 120+ API endpoints for parks, rides, companies, and users.
|
||||
|
||||
## Core Architecture
|
||||
- **Backend**: Django 5.1+, DRF, PostgreSQL+PostGIS, Redis, Celery
|
||||
- **Frontend**: HTMX (V2+) + AlpineJS + Tailwind CSS (V4+) + Django-Cotton
|
||||
- 🚨 **ABSOLUTELY NO Custom JS** - use HTMX + AlpineJS ONLY
|
||||
- Clean, simple UX preferred
|
||||
- **Media**: Cloudflare Images with Direct Upload
|
||||
- **Tracking**: pghistory, TrackedModel base class
|
||||
- **Choices**: Rich Choice Objects (NEVER Django tuple choices)
|
||||
|
||||
## Development Patterns
|
||||
- **Models**: TrackedModel inheritance, SluggedModel for slugs, PostGIS for location
|
||||
- **APIs**: Nested URLs (`/parks/{slug}/rides/{slug}/`), mandatory trailing slashes
|
||||
- **Commands**: `uv add <package>`, `uv run manage.py <command>` (NOT pip/python)
|
||||
- **Choices**: `RichChoiceField(choice_group="name", domain="domain")` MANDATORY
|
||||
|
||||
## Business Rules
|
||||
🚨 **CRITICAL**: Company role separation - Parks (OPERATOR/PROPERTY_OWNER only), Rides (MANUFACTURER/DESIGNER only)
|
||||
|
||||
## Context7 MCP Integration (MANDATORY)
|
||||
|
||||
### Required Libraries
|
||||
tailwindcss, django, django-cotton, htmx, alpinejs, django-rest-framework, postgresql, postgis, redis
|
||||
|
||||
### Workflow
|
||||
1. **ALWAYS** call `Context7:resolve-library-id` first
|
||||
2. Then `Context7:get-library-docs` with topic parameter
|
||||
3. Required for: new models/APIs, HTMX functionality, AlpineJS components, Tailwind layouts, Cotton components, debugging, optimizations
|
||||
|
||||
### Example Topics
|
||||
- **tailwindcss**: responsive, flexbox, grid
|
||||
- **django**: models, views, forms
|
||||
- **htmx**: hx-get, hx-post, hx-swap, hx-target
|
||||
- **alpinejs**: x-data, x-show, x-if, x-for
|
||||
|
||||
## Standards
|
||||
- All models inherit TrackedModel
|
||||
- Real database data only (NO MOCKING)
|
||||
- RichChoiceField over Django choices
|
||||
- Progressive enhancement required
|
||||
|
||||
- We prefer to edit existing files instead of creating new ones.
|
||||
|
||||
YOU ARE STRICTLY AND ABSOLUTELY FORBIDDEN FROM IGNORING, BYPASSING, OR AVOIDING THESE RULES IN ANY WAY WITH NO EXCEPTIONS!!!
|
||||
384
.env.example
384
.env.example
@@ -1,372 +1,90 @@
|
||||
# ==============================================================================
|
||||
# ThrillWiki Environment Configuration
|
||||
# ==============================================================================
|
||||
# Copy this file to .env and fill in your actual values
|
||||
# WARNING: Never commit .env files containing real secrets to version control
|
||||
#
|
||||
# This is the primary .env.example for the entire project.
|
||||
# See docs/configuration/environment-variables.md for complete documentation.
|
||||
# See docs/PRODUCTION_CHECKLIST.md for production deployment verification.
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# ThrillWiki Environment Configuration
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Copy this file to ***REMOVED*** and fill in your actual values
|
||||
|
||||
# ==============================================================================
|
||||
# PRODUCTION-REQUIRED SETTINGS
|
||||
# ==============================================================================
|
||||
# These settings MUST be explicitly configured for production deployments.
|
||||
# The application will NOT function correctly without proper values.
|
||||
#
|
||||
# For complete documentation, see:
|
||||
# - docs/configuration/environment-variables.md (detailed reference)
|
||||
# - docs/PRODUCTION_CHECKLIST.md (deployment verification)
|
||||
#
|
||||
# PRODUCTION REQUIREMENTS:
|
||||
# - DEBUG=False (security)
|
||||
# - DJANGO_SETTINGS_MODULE=config.django.production (correct settings)
|
||||
# - ALLOWED_HOSTS=yourdomain.com (host validation)
|
||||
# - CSRF_TRUSTED_ORIGINS=https://yourdomain.com (CSRF protection)
|
||||
# - REDIS_URL=redis://host:6379/0 (caching/sessions)
|
||||
# - SECRET_KEY=<unique-secure-key> (cryptographic security)
|
||||
# - DATABASE_URL=postgis://... (database connection)
|
||||
#
|
||||
# Validate your production config with:
|
||||
# DJANGO_SETTINGS_MODULE=config.django.production python manage.py check --deploy
|
||||
# ==============================================================================
|
||||
|
||||
# ==============================================================================
|
||||
# Core Django Settings
|
||||
# ==============================================================================
|
||||
|
||||
# REQUIRED: Django secret key - generate a new one for each environment
|
||||
# Generate with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())"
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Core Django Settings
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
SECRET_KEY=your-secret-key-here-generate-a-new-one
|
||||
|
||||
# Debug mode - MUST be False in production
|
||||
# WARNING: DEBUG=True exposes sensitive information and should NEVER be used in production
|
||||
DEBUG=True
|
||||
|
||||
# Django settings module to use
|
||||
# Options: config.django.local, config.django.production, config.django.test
|
||||
# PRODUCTION: Must use config.django.production
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
|
||||
# Allowed hosts (comma-separated list)
|
||||
# PRODUCTION: Must include all valid hostnames (no default in production settings)
|
||||
# Example: thrillwiki.com,www.thrillwiki.com,api.thrillwiki.com
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1,beta.thrillwiki.com
|
||||
|
||||
# CSRF trusted origins (comma-separated, MUST include https:// prefix)
|
||||
# PRODUCTION: Required for all forms and AJAX requests to work
|
||||
# Example: https://thrillwiki.com,https://www.thrillwiki.com
|
||||
CSRF_TRUSTED_ORIGINS=https://beta.thrillwiki.com,http://localhost:8000
|
||||
|
||||
# ==============================================================================
|
||||
# Database Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Database URL (supports PostgreSQL, PostGIS, SQLite, SpatiaLite)
|
||||
# PostGIS format: postgis://username:password@host:port/database
|
||||
# PostgreSQL format: postgres://username:password@host:port/database
|
||||
# SQLite format: sqlite:///path/to/db.sqlite3
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Database Configuration
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# PostgreSQL with PostGIS for production/development
|
||||
DATABASE_URL=postgis://username:password@localhost:5432/thrillwiki
|
||||
|
||||
# Database connection pooling (seconds to keep connections alive)
|
||||
# Set to 0 to disable connection reuse
|
||||
DATABASE_CONN_MAX_AGE=600
|
||||
# SQLite for quick local development (uncomment to use)
|
||||
# DATABASE_URL=spatialite:///path/to/your/db.sqlite3
|
||||
|
||||
# Database connection timeout in seconds
|
||||
DATABASE_CONNECT_TIMEOUT=10
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Cache Configuration
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Local memory cache for development
|
||||
CACHE_URL=locmem://
|
||||
|
||||
# Query timeout in milliseconds (prevents long-running queries)
|
||||
DATABASE_STATEMENT_TIMEOUT=30000
|
||||
# Redis for production (uncomment and configure for production)
|
||||
# CACHE_URL=redis://localhost:6379/1
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# Optional: Read replica URL for read-heavy workloads
|
||||
# DATABASE_READ_REPLICA_URL=postgis://username:password@replica-host:5432/thrillwiki
|
||||
|
||||
# ==============================================================================
|
||||
# Cache Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Redis URL for caching, sessions, and Celery broker
|
||||
# Format: redis://[:password@]host:port/db_number
|
||||
# PRODUCTION: Required - the application uses Redis for:
|
||||
# - Page and API response caching
|
||||
# - Session storage (faster than database sessions)
|
||||
# - Celery task queue broker
|
||||
# Without REDIS_URL in production, caching will fail and performance will degrade.
|
||||
REDIS_URL=redis://localhost:6379/1
|
||||
|
||||
# Optional: Separate Redis URLs for different cache purposes
|
||||
# REDIS_SESSIONS_URL=redis://localhost:6379/2
|
||||
# REDIS_API_URL=redis://localhost:6379/3
|
||||
|
||||
# Redis connection settings
|
||||
REDIS_MAX_CONNECTIONS=100
|
||||
REDIS_CONNECTION_TIMEOUT=20
|
||||
REDIS_IGNORE_EXCEPTIONS=True
|
||||
|
||||
# Cache middleware settings
|
||||
CACHE_MIDDLEWARE_SECONDS=300
|
||||
CACHE_MIDDLEWARE_KEY_PREFIX=thrillwiki
|
||||
CACHE_KEY_PREFIX=thrillwiki
|
||||
|
||||
# Local development cache URL (use for development without Redis)
|
||||
# CACHE_URL=locmem://
|
||||
|
||||
# ==============================================================================
|
||||
# Email Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Email backend
|
||||
# Options:
|
||||
# django.core.mail.backends.console.EmailBackend (development)
|
||||
# django_forwardemail.backends.ForwardEmailBackend (production with ForwardEmail)
|
||||
# django.core.mail.backends.smtp.EmailBackend (custom SMTP)
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Email Configuration
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||
|
||||
# Server email address
|
||||
SERVER_EMAIL=django_webmaster@thrillwiki.com
|
||||
|
||||
# Default from email
|
||||
DEFAULT_FROM_EMAIL=ThrillWiki <noreply@thrillwiki.com>
|
||||
# ForwardEmail configuration (uncomment to use)
|
||||
# EMAIL_BACKEND=email_service.backends.ForwardEmailBackend
|
||||
# FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
||||
|
||||
# Email subject prefix for admin emails
|
||||
EMAIL_SUBJECT_PREFIX=[ThrillWiki]
|
||||
# SMTP configuration (uncomment to use)
|
||||
# EMAIL_URL=smtp://username:password@smtp.example.com:587
|
||||
|
||||
# ForwardEmail configuration (for ForwardEmailBackend)
|
||||
FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
||||
FORWARD_EMAIL_API_KEY=your-forwardemail-api-key-here
|
||||
FORWARD_EMAIL_DOMAIN=your-domain.com
|
||||
|
||||
# SMTP configuration (for SMTPBackend)
|
||||
EMAIL_HOST=smtp.example.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_USE_SSL=False
|
||||
EMAIL_HOST_USER=your-email@example.com
|
||||
EMAIL_HOST_PASSWORD=your-app-password
|
||||
|
||||
# Email timeout in seconds
|
||||
EMAIL_TIMEOUT=30
|
||||
|
||||
# ==============================================================================
|
||||
# Security Settings
|
||||
# ==============================================================================
|
||||
|
||||
# Cloudflare Turnstile configuration (CAPTCHA alternative)
|
||||
# Get keys from: https://dash.cloudflare.com/?to=/:account/turnstile
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Security Settings
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Cloudflare Turnstile (get keys from Cloudflare dashboard)
|
||||
TURNSTILE_SITE_KEY=your-turnstile-site-key
|
||||
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
|
||||
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
|
||||
|
||||
# SSL/HTTPS settings (enable all for production)
|
||||
# Security headers (set to True for production)
|
||||
SECURE_SSL_REDIRECT=False
|
||||
SESSION_COOKIE_SECURE=False
|
||||
CSRF_COOKIE_SECURE=False
|
||||
|
||||
# HSTS settings (HTTP Strict Transport Security)
|
||||
SECURE_HSTS_SECONDS=31536000
|
||||
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
|
||||
SECURE_HSTS_PRELOAD=False
|
||||
|
||||
# Security headers
|
||||
SECURE_BROWSER_XSS_FILTER=True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF=True
|
||||
X_FRAME_OPTIONS=DENY
|
||||
SECURE_REFERRER_POLICY=strict-origin-when-cross-origin
|
||||
SECURE_CROSS_ORIGIN_OPENER_POLICY=same-origin
|
||||
|
||||
# Session settings
|
||||
SESSION_COOKIE_AGE=3600
|
||||
SESSION_SAVE_EVERY_REQUEST=True
|
||||
SESSION_COOKIE_HTTPONLY=True
|
||||
SESSION_COOKIE_SAMESITE=Lax
|
||||
|
||||
# CSRF settings
|
||||
CSRF_COOKIE_HTTPONLY=True
|
||||
CSRF_COOKIE_SAMESITE=Lax
|
||||
|
||||
# Password minimum length
|
||||
PASSWORD_MIN_LENGTH=8
|
||||
|
||||
# ==============================================================================
|
||||
# GeoDjango Settings
|
||||
# ==============================================================================
|
||||
|
||||
# Library paths for GDAL and GEOS (required for GeoDjango)
|
||||
# macOS with Homebrew:
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# GeoDjango Settings (macOS with Homebrew)
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
GDAL_LIBRARY_PATH=/opt/homebrew/lib/libgdal.dylib
|
||||
GEOS_LIBRARY_PATH=/opt/homebrew/lib/libgeos_c.dylib
|
||||
|
||||
# Linux alternatives:
|
||||
# Linux alternatives (uncomment if on Linux)
|
||||
# GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so
|
||||
# GEOS_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgeos_c.so
|
||||
|
||||
# ==============================================================================
|
||||
# API Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# CORS settings
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5174
|
||||
CORS_ALLOW_ALL_ORIGINS=False
|
||||
|
||||
# API rate limiting
|
||||
API_RATE_LIMIT_PER_MINUTE=60
|
||||
API_RATE_LIMIT_PER_HOUR=1000
|
||||
API_RATE_LIMIT_ANON_PER_MINUTE=60
|
||||
API_RATE_LIMIT_USER_PER_HOUR=1000
|
||||
|
||||
# API pagination
|
||||
API_PAGE_SIZE=20
|
||||
API_MAX_PAGE_SIZE=100
|
||||
API_VERSION=1.0.0
|
||||
|
||||
# ==============================================================================
|
||||
# JWT Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# JWT token lifetimes
|
||||
JWT_ACCESS_TOKEN_LIFETIME_MINUTES=15
|
||||
JWT_REFRESH_TOKEN_LIFETIME_DAYS=7
|
||||
|
||||
# JWT issuer claim
|
||||
JWT_ISSUER=thrillwiki
|
||||
|
||||
# ==============================================================================
|
||||
# Cloudflare Images Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Get credentials from Cloudflare dashboard
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
|
||||
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
|
||||
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
|
||||
CLOUDFLARE_IMAGES_WEBHOOK_SECRET=your-webhook-secret
|
||||
|
||||
# Optional Cloudflare Images settings
|
||||
CLOUDFLARE_IMAGES_DEFAULT_VARIANT=public
|
||||
CLOUDFLARE_IMAGES_UPLOAD_TIMEOUT=300
|
||||
CLOUDFLARE_IMAGES_CLEANUP_HOURS=24
|
||||
CLOUDFLARE_IMAGES_MAX_FILE_SIZE=10485760
|
||||
CLOUDFLARE_IMAGES_REQUIRE_SIGNED_URLS=False
|
||||
|
||||
# ==============================================================================
|
||||
# Road Trip Service Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# OpenStreetMap user agent (required for OSM API)
|
||||
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)
|
||||
|
||||
# Cache timeouts
|
||||
ROADTRIP_CACHE_TIMEOUT=86400
|
||||
ROADTRIP_ROUTE_CACHE_TIMEOUT=21600
|
||||
|
||||
# Request settings
|
||||
ROADTRIP_MAX_REQUESTS_PER_SECOND=1
|
||||
ROADTRIP_REQUEST_TIMEOUT=10
|
||||
ROADTRIP_MAX_RETRIES=3
|
||||
ROADTRIP_BACKOFF_FACTOR=2
|
||||
|
||||
# ==============================================================================
|
||||
# Logging Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Log directory (relative to backend/)
|
||||
LOG_DIR=logs
|
||||
|
||||
# Log levels (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
ROOT_LOG_LEVEL=INFO
|
||||
DJANGO_LOG_LEVEL=WARNING
|
||||
DB_LOG_LEVEL=WARNING
|
||||
APP_LOG_LEVEL=INFO
|
||||
PERFORMANCE_LOG_LEVEL=INFO
|
||||
QUERY_LOG_LEVEL=WARNING
|
||||
NPLUSONE_LOG_LEVEL=WARNING
|
||||
REQUEST_LOG_LEVEL=INFO
|
||||
CELERY_LOG_LEVEL=INFO
|
||||
CONSOLE_LOG_LEVEL=INFO
|
||||
FILE_LOG_LEVEL=INFO
|
||||
|
||||
# Log formatters (verbose, json, simple)
|
||||
FILE_LOG_FORMATTER=json
|
||||
|
||||
# ==============================================================================
|
||||
# Monitoring & Errors
|
||||
# ==============================================================================
|
||||
|
||||
# Sentry configuration (optional, for error tracking)
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Optional: Third-party Integrations
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Sentry for error tracking (uncomment to use)
|
||||
# SENTRY_DSN=https://your-sentry-dsn-here
|
||||
# SENTRY_ENVIRONMENT=development
|
||||
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
|
||||
# ==============================================================================
|
||||
# Feature Flags
|
||||
# ==============================================================================
|
||||
# Google Analytics (uncomment to use)
|
||||
# GOOGLE_ANALYTICS_ID=GA-XXXXXXXXX
|
||||
|
||||
# Development tools
|
||||
ENABLE_DEBUG_TOOLBAR=True
|
||||
ENABLE_SILK_PROFILER=False
|
||||
|
||||
# Django template support (can be disabled for API-only mode)
|
||||
TEMPLATES_ENABLED=True
|
||||
|
||||
# Autocomplete settings
|
||||
AUTOCOMPLETE_BLOCK_UNAUTHENTICATED=False
|
||||
|
||||
# ==============================================================================
|
||||
# Third-Party Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Frontend URL for email links and redirects
|
||||
FRONTEND_DOMAIN=https://thrillwiki.com
|
||||
|
||||
# Login/logout redirect URLs
|
||||
LOGIN_REDIRECT_URL=/
|
||||
ACCOUNT_LOGOUT_REDIRECT_URL=/
|
||||
|
||||
# Account settings
|
||||
ACCOUNT_EMAIL_VERIFICATION=mandatory
|
||||
|
||||
# ==============================================================================
|
||||
# File Upload Settings
|
||||
# ==============================================================================
|
||||
|
||||
# Maximum file size to upload into memory (bytes)
|
||||
FILE_UPLOAD_MAX_MEMORY_SIZE=2621440
|
||||
|
||||
# Maximum request data size (bytes)
|
||||
DATA_UPLOAD_MAX_MEMORY_SIZE=10485760
|
||||
|
||||
# Maximum number of GET/POST parameters
|
||||
DATA_UPLOAD_MAX_NUMBER_FIELDS=1000
|
||||
|
||||
# Static/Media URLs (usually don't need to change)
|
||||
STATIC_URL=static/
|
||||
MEDIA_URL=/media/
|
||||
|
||||
# WhiteNoise settings
|
||||
WHITENOISE_COMPRESSION_QUALITY=90
|
||||
WHITENOISE_MAX_AGE=31536000
|
||||
WHITENOISE_MANIFEST_STRICT=False
|
||||
|
||||
# ==============================================================================
|
||||
# Health Check Settings
|
||||
# ==============================================================================
|
||||
|
||||
# Disk usage threshold (percentage)
|
||||
HEALTH_CHECK_DISK_USAGE_MAX=90
|
||||
|
||||
# Minimum available memory (MB)
|
||||
HEALTH_CHECK_MEMORY_MIN=100
|
||||
|
||||
# ==============================================================================
|
||||
# Celery Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Celery task behavior (set to True for testing)
|
||||
CELERY_TASK_ALWAYS_EAGER=False
|
||||
CELERY_TASK_EAGER_PROPAGATES=False
|
||||
|
||||
# ==============================================================================
|
||||
# Debug Toolbar Configuration
|
||||
# ==============================================================================
|
||||
|
||||
# Internal IPs for debug toolbar (comma-separated)
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Development/Debug Settings
|
||||
# [AWS-SECRET-REMOVED]===========================
|
||||
# Set to comma-separated list for debug toolbar
|
||||
# INTERNAL_IPS=127.0.0.1,::1
|
||||
|
||||
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
83
.github/SECURITY.md
vendored
83
.github/SECURITY.md
vendored
@@ -1,83 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| latest | :white_check_mark: |
|
||||
| < latest | :x: |
|
||||
|
||||
Only the latest version of ThrillWiki receives security updates.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take security vulnerabilities seriously. If you discover a security issue, please report it responsibly.
|
||||
|
||||
### How to Report
|
||||
|
||||
1. **Do not** create a public GitHub issue for security vulnerabilities
|
||||
2. Email your report to the project maintainers
|
||||
3. Include as much detail as possible:
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Affected versions
|
||||
- Any proof of concept (if available)
|
||||
|
||||
### What to Expect
|
||||
|
||||
- **Acknowledgment**: We will acknowledge receipt within 48 hours
|
||||
- **Assessment**: We will assess the vulnerability and its impact
|
||||
- **Updates**: We will keep you informed of our progress
|
||||
- **Resolution**: We aim to resolve critical vulnerabilities within 7 days
|
||||
- **Credit**: With your permission, we will credit you in our security advisories
|
||||
|
||||
### Scope
|
||||
|
||||
The following are in scope for security reports:
|
||||
|
||||
- ThrillWiki web application vulnerabilities
|
||||
- Authentication and authorization issues
|
||||
- Data exposure vulnerabilities
|
||||
- Injection vulnerabilities (SQL, XSS, etc.)
|
||||
- CSRF vulnerabilities
|
||||
- Server-side request forgery (SSRF)
|
||||
- Insecure direct object references
|
||||
|
||||
### Out of Scope
|
||||
|
||||
The following are out of scope:
|
||||
|
||||
- Denial of service attacks
|
||||
- Social engineering attacks
|
||||
- Physical security issues
|
||||
- Issues in third-party applications or services
|
||||
- Issues requiring physical access to a user's device
|
||||
- Vulnerabilities in outdated versions
|
||||
|
||||
## Security Measures
|
||||
|
||||
ThrillWiki implements the following security measures:
|
||||
|
||||
- HTTPS enforcement with HSTS
|
||||
- Content Security Policy
|
||||
- XSS protection with input sanitization
|
||||
- CSRF protection
|
||||
- SQL injection prevention via ORM
|
||||
- Rate limiting on authentication endpoints
|
||||
- Secure session management
|
||||
- JWT token rotation and blacklisting
|
||||
|
||||
For more details, see [docs/SECURITY.md](../docs/SECURITY.md).
|
||||
|
||||
## Security Updates
|
||||
|
||||
Security updates are released as soon as possible after a vulnerability is confirmed. We recommend:
|
||||
|
||||
1. Keep your installation up to date
|
||||
2. Subscribe to release notifications
|
||||
3. Review security advisories
|
||||
|
||||
## Contact
|
||||
|
||||
For security-related inquiries, please contact the project maintainers.
|
||||
2
.github/workflows/claude-code-review.yml
vendored
2
.github/workflows/claude-code-review.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
2
.github/workflows/claude.yml
vendored
2
.github/workflows/claude.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
53
.github/workflows/dependency-update.yml
vendored
53
.github/workflows/dependency-update.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Dependency Update Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install UV
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Update Dependencies
|
||||
working-directory: backend
|
||||
run: |
|
||||
uv lock --upgrade
|
||||
uv sync
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: backend
|
||||
run: |
|
||||
uv run manage.py test
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
commit-message: "chore: update dependencies"
|
||||
title: "chore: weekly dependency updates"
|
||||
body: |
|
||||
Automated dependency updates.
|
||||
|
||||
This PR was automatically generated by the dependency update workflow.
|
||||
|
||||
## Changes
|
||||
- Updated `uv.lock` with latest compatible versions
|
||||
|
||||
## Checklist
|
||||
- [ ] Review dependency changes
|
||||
- [ ] Verify all tests pass
|
||||
- [ ] Check for breaking changes
|
||||
branch: "dependency-updates"
|
||||
labels: dependencies
|
||||
77
.github/workflows/django.yml
vendored
77
.github/workflows/django.yml
vendored
@@ -12,85 +12,30 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
python-version: ["3.13"]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgis/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: test_thrillwiki
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
# Services only run on Linux runners
|
||||
if: runner.os == 'Linux'
|
||||
python-version: [3.13.1]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Homebrew on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||
echo "/home/linuxbrew/.linuxbrew/bin" >> $GITHUB_PATH
|
||||
|
||||
|
||||
- name: Install GDAL with Homebrew
|
||||
run: brew install gdal
|
||||
|
||||
- name: Install PostGIS on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
brew install postgresql@16 postgis
|
||||
brew services start postgresql@16
|
||||
sleep 5
|
||||
/opt/homebrew/opt/postgresql@16/bin/createuser -s postgres || true
|
||||
/opt/homebrew/opt/postgresql@16/bin/createdb -U postgres test_thrillwiki || true
|
||||
/opt/homebrew/opt/postgresql@16/bin/psql -U postgres -d test_thrillwiki -c "CREATE EXTENSION IF NOT EXISTS postgis;" || true
|
||||
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install UV
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Cache UV dependencies
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/uv
|
||||
key: ${{ runner.os }}-uv-${{ hashFiles('backend/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-uv-
|
||||
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: backend
|
||||
run: |
|
||||
uv sync --frozen
|
||||
|
||||
- name: Security Audit
|
||||
working-directory: backend
|
||||
run: |
|
||||
uv pip install pip-audit
|
||||
uv run pip-audit || true
|
||||
continue-on-error: true
|
||||
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
|
||||
- name: Run Tests
|
||||
working-directory: backend
|
||||
env:
|
||||
DJANGO_SETTINGS_MODULE: config.django.test
|
||||
TEST_DB_NAME: test_thrillwiki
|
||||
TEST_DB_USER: postgres
|
||||
TEST_DB_PASSWORD: postgres
|
||||
TEST_DB_HOST: localhost
|
||||
TEST_DB_PORT: 5432
|
||||
run: |
|
||||
uv run python manage.py test --settings=config.django.test --parallel
|
||||
python manage.py test
|
||||
|
||||
2
.github/workflows/review.yml
vendored
2
.github/workflows/review.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: development_environment
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
126
.gitignore
vendored
Normal file
126
.gitignore
vendored
Normal file
@@ -0,0 +1,126 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Django
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
/backend/staticfiles/
|
||||
/backend/media/
|
||||
|
||||
# UV
|
||||
.uv/
|
||||
backend/.uv/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# Vue.js / Vite
|
||||
/frontend/dist/
|
||||
/frontend/dist-ssr/
|
||||
*.local
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
backend/.env
|
||||
frontend/.env
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Coverage
|
||||
coverage/
|
||||
*.lcov
|
||||
.nyc_output
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.cache
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Build outputs
|
||||
/dist/
|
||||
/build/
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.orig
|
||||
*.swp
|
||||
|
||||
# Archive files
|
||||
*.tar.gz
|
||||
*.zip
|
||||
*.rar
|
||||
|
||||
# Security
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
|
||||
# Local development
|
||||
/uploads/
|
||||
/backups/
|
||||
.django_tailwind_cli/
|
||||
backend/.env
|
||||
frontend/.env
|
||||
|
||||
# Extracted packages
|
||||
django-forwardemail/
|
||||
frontend/
|
||||
frontend
|
||||
.snapshots
|
||||
uv.lock
|
||||
251
.pylintrc
251
.pylintrc
@@ -1,251 +0,0 @@
|
||||
# =============================================================================
|
||||
# ThrillWiki Django Project - Pylint Configuration
|
||||
# =============================================================================
|
||||
#
|
||||
# Purpose: Django-aware Pylint configuration that suppresses false positives
|
||||
# while maintaining code quality standards.
|
||||
#
|
||||
# Alignment:
|
||||
# - Line length: 120 characters (matches Black and Ruff in pyproject.toml)
|
||||
# - Django version: 5.2.8
|
||||
#
|
||||
# Key Features:
|
||||
# - Suppresses false positives for Django ORM patterns (.objects, _meta, .DoesNotExist)
|
||||
# - Whitelists Django management command styling (self.style.SUCCESS, ERROR, etc.)
|
||||
# - Accommodates Django REST Framework patterns
|
||||
# - Allows django-fsm state machine patterns
|
||||
#
|
||||
# Maintenance:
|
||||
# - Review when upgrading Django or adding new dynamic attribute patterns
|
||||
# - Keep line-length aligned with Black/Ruff settings in pyproject.toml
|
||||
#
|
||||
# =============================================================================
|
||||
|
||||
[MASTER]
|
||||
# Use all available CPU cores for faster linting
|
||||
jobs=0
|
||||
|
||||
# Directories and files to exclude from linting
|
||||
ignore=.git,__pycache__,.venv,venv,migrations,node_modules,.tox,.pytest_cache,build,dist
|
||||
|
||||
# File patterns to ignore (e.g., Emacs backup files)
|
||||
ignore-patterns=^\.#
|
||||
|
||||
# Pickle collected data for faster subsequent runs
|
||||
persistent=yes
|
||||
|
||||
# =============================================================================
|
||||
# [MESSAGES CONTROL]
|
||||
# Disable checks that conflict with Django patterns and conventions
|
||||
# =============================================================================
|
||||
[MESSAGES CONTROL]
|
||||
disable=
|
||||
# C0114: missing-module-docstring
|
||||
# Django apps often don't need module docstrings; the app's purpose is
|
||||
# typically documented in apps.py or README
|
||||
C0114,
|
||||
|
||||
# C0115: missing-class-docstring
|
||||
# Django models, forms, and serializers are often self-documenting through
|
||||
# their field definitions and Meta classes
|
||||
C0115,
|
||||
|
||||
# C0116: missing-function-docstring
|
||||
# Allow simple functions and methods without docstrings; Django views and
|
||||
# model methods are often self-explanatory
|
||||
C0116,
|
||||
|
||||
# C0103: invalid-name
|
||||
# Django uses non-PEP8 names by convention (e.g., 'pk', 'id', 'qs')
|
||||
# and single-letter variables in comprehensions are acceptable
|
||||
C0103,
|
||||
|
||||
# C0411: wrong-import-order
|
||||
# Let isort/ruff handle import ordering; they have Django-specific rules
|
||||
C0411,
|
||||
|
||||
# C0415: import-outside-toplevel
|
||||
# Django often requires lazy imports to avoid circular dependencies,
|
||||
# especially in models.py and signals
|
||||
C0415,
|
||||
|
||||
# W0212: protected-access
|
||||
# Django extensively uses _meta for model introspection; this is documented
|
||||
# and supported API: https://docs.djangoproject.com/en/5.2/ref/models/meta/
|
||||
W0212,
|
||||
|
||||
# W0613: unused-argument
|
||||
# Django views, signals, and receivers often have unused parameters that
|
||||
# are required by the framework's signature (e.g., request, sender, **kwargs)
|
||||
W0613,
|
||||
|
||||
# R0903: too-few-public-methods
|
||||
# Django models, forms, and serializers can be simple data containers
|
||||
# with few or no methods beyond __str__
|
||||
R0903,
|
||||
|
||||
# R0801: duplicate-code
|
||||
# Django patterns naturally duplicate across apps (e.g., CRUD views,
|
||||
# model patterns); this is intentional for consistency
|
||||
R0801,
|
||||
|
||||
# E1101: no-member
|
||||
# Main source of false positives for Django's dynamic attributes:
|
||||
# - Model.objects (Manager)
|
||||
# - Model.DoesNotExist / MultipleObjectsReturned (exceptions)
|
||||
# - self.style.SUCCESS/ERROR (management commands)
|
||||
# - model._meta (Options)
|
||||
E1101
|
||||
|
||||
# =============================================================================
|
||||
# [TYPECHECK]
|
||||
# Whitelist Django's dynamically generated attributes
|
||||
# =============================================================================
|
||||
[TYPECHECK]
|
||||
# Django generates many attributes dynamically that Pylint cannot detect
|
||||
# statically. This list covers common patterns:
|
||||
#
|
||||
# - objects.* : Django ORM Manager methods (all, filter, get, create, etc.)
|
||||
# - DoesNotExist : Exception raised when Model.objects.get() finds nothing
|
||||
# - MultipleObjectsReturned : Exception when get() finds multiple objects
|
||||
# - _meta.* : Django model metadata API (fields, app_label, model_name)
|
||||
# - style.* : Django management command styling (SUCCESS, ERROR, WARNING, NOTICE)
|
||||
# - id, pk : Django auto-generated primary key fields
|
||||
# - REQUEST : Django request object attributes
|
||||
# - aq_* : Acquisition attributes (Zope/Plone compatibility)
|
||||
# - acl_users : Zope/Plone user folder
|
||||
#
|
||||
generated-members=
|
||||
REQUEST,
|
||||
acl_users,
|
||||
aq_parent,
|
||||
aq_inner,
|
||||
aq_explicit,
|
||||
aq_acquire,
|
||||
aq_base,
|
||||
objects,
|
||||
objects.*,
|
||||
DoesNotExist,
|
||||
MultipleObjectsReturned,
|
||||
_meta,
|
||||
_meta.*,
|
||||
style,
|
||||
style.*,
|
||||
id,
|
||||
pk
|
||||
|
||||
# =============================================================================
|
||||
# [FORMAT]
|
||||
# Code formatting settings - aligned with Black and Ruff (120 chars)
|
||||
# =============================================================================
|
||||
[FORMAT]
|
||||
# Maximum line length - matches Black and Ruff configuration in pyproject.toml
|
||||
max-line-length=120
|
||||
|
||||
# Use 4 spaces for indentation (Python standard)
|
||||
indent-string=' '
|
||||
|
||||
# Use Unix line endings (LF)
|
||||
expected-line-ending-format=LF
|
||||
|
||||
# =============================================================================
|
||||
# [BASIC]
|
||||
# Naming conventions and allowed short names
|
||||
# =============================================================================
|
||||
[BASIC]
|
||||
# Short variable names commonly used in Django and Python
|
||||
# - i, j, k : Loop counters
|
||||
# - ex : Exception variable
|
||||
# - Run : Django command method
|
||||
# - _ : Throwaway variable
|
||||
# - id, pk : Primary key (Django convention)
|
||||
# - qs : QuerySet abbreviation
|
||||
good-names=i,j,k,ex,Run,_,id,pk,qs
|
||||
|
||||
# Enforce snake_case for most identifiers (Python/Django convention)
|
||||
argument-naming-style=snake_case
|
||||
attr-naming-style=snake_case
|
||||
function-naming-style=snake_case
|
||||
method-naming-style=snake_case
|
||||
module-naming-style=snake_case
|
||||
variable-naming-style=snake_case
|
||||
|
||||
# PascalCase for classes
|
||||
class-naming-style=PascalCase
|
||||
|
||||
# UPPER_CASE for constants
|
||||
const-naming-style=UPPER_CASE
|
||||
|
||||
# =============================================================================
|
||||
# [DESIGN]
|
||||
# Complexity thresholds - relaxed for Django patterns
|
||||
# =============================================================================
|
||||
[DESIGN]
|
||||
# Django views and forms often need many arguments
|
||||
max-args=7
|
||||
|
||||
# Django models can have many fields
|
||||
max-attributes=12
|
||||
|
||||
# Allow complex boolean expressions
|
||||
max-bool-expr=5
|
||||
|
||||
# Django views can have complex branching logic
|
||||
max-branches=15
|
||||
|
||||
# Django views often have many local variables
|
||||
max-locals=20
|
||||
|
||||
# Django uses multiple inheritance (Model, Mixin classes)
|
||||
max-parents=7
|
||||
|
||||
# Django models and viewsets have many built-in methods
|
||||
max-public-methods=25
|
||||
|
||||
# Allow multiple return statements
|
||||
max-returns=6
|
||||
|
||||
# Django views can be lengthy
|
||||
max-statements=60
|
||||
|
||||
# Allow simple classes with no methods (e.g., Django Meta classes)
|
||||
min-public-methods=0
|
||||
|
||||
# =============================================================================
|
||||
# [SIMILARITIES]
|
||||
# Duplicate code detection settings
|
||||
# =============================================================================
|
||||
[SIMILARITIES]
|
||||
# Increase threshold to reduce false positives from Django boilerplate
|
||||
min-similarity-lines=6
|
||||
|
||||
# Don't flag similar comments
|
||||
ignore-comments=yes
|
||||
|
||||
# Don't flag similar docstrings
|
||||
ignore-docstrings=yes
|
||||
|
||||
# Don't flag similar import blocks
|
||||
ignore-imports=yes
|
||||
|
||||
# =============================================================================
|
||||
# [VARIABLES]
|
||||
# Variable naming patterns
|
||||
# =============================================================================
|
||||
[VARIABLES]
|
||||
# Patterns for dummy/unused variables
|
||||
dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
|
||||
|
||||
# Arguments that are commonly unused but required by framework signatures
|
||||
ignored-argument-names=_.*|^ignored_|^unused_|args|kwargs|request|pk
|
||||
|
||||
# =============================================================================
|
||||
# [IMPORTS]
|
||||
# Import checking settings
|
||||
# =============================================================================
|
||||
[IMPORTS]
|
||||
# Don't allow wildcard imports even with __all__ defined
|
||||
allow-wildcard-with-all=no
|
||||
|
||||
# Don't analyze fallback import blocks
|
||||
analyse-fallback-blocks=no
|
||||
73
.replit
Normal file
73
.replit
Normal file
@@ -0,0 +1,73 @@
|
||||
modules = ["bash", "web", "nodejs-20", "python-3.13", "postgresql-16"]
|
||||
|
||||
[nix]
|
||||
channel = "stable-25_05"
|
||||
packages = [
|
||||
"freetype",
|
||||
"gdal",
|
||||
"geos",
|
||||
"gitFull",
|
||||
"lcms2",
|
||||
"libimagequant",
|
||||
"libjpeg",
|
||||
"libtiff",
|
||||
"libwebp",
|
||||
"libxcrypt",
|
||||
"openjpeg",
|
||||
"playwright-driver",
|
||||
"postgresql",
|
||||
"proj",
|
||||
"tcl",
|
||||
"tk",
|
||||
"uv",
|
||||
"zlib",
|
||||
]
|
||||
|
||||
[agent]
|
||||
expertMode = true
|
||||
|
||||
[workflows]
|
||||
runButton = "Project"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "Project"
|
||||
mode = "parallel"
|
||||
author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "workflow.run"
|
||||
args = "ThrillWiki Server"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "ThrillWiki Server"
|
||||
author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "shell.exec"
|
||||
args = "/home/runner/workspace/.venv/bin/python manage.py tailwind runserver 0.0.0.0:5000"
|
||||
waitForPort = 5000
|
||||
|
||||
[workflows.workflow.metadata]
|
||||
outputType = "webview"
|
||||
|
||||
[[ports]]
|
||||
localPort = 5000
|
||||
externalPort = 80
|
||||
|
||||
[[ports]]
|
||||
localPort = 41923
|
||||
externalPort = 3000
|
||||
|
||||
[[ports]]
|
||||
localPort = 45245
|
||||
externalPort = 3001
|
||||
|
||||
[deployment]
|
||||
deploymentTarget = "autoscale"
|
||||
run = [
|
||||
"gunicorn",
|
||||
"--bind=0.0.0.0:5000",
|
||||
"--reuse-port",
|
||||
"thrillwiki.wsgi:application",
|
||||
]
|
||||
build = ["uv", "pip", "install", "--system", "-r", "requirements.txt"]
|
||||
@@ -1,95 +0,0 @@
|
||||
# Backend Structure Plan
|
||||
|
||||
## Apps Overview
|
||||
|
||||
### 1. `apps.core`
|
||||
- **Responsibility**: Base classes, shared utilities, history tracking.
|
||||
- **Existing**: `SluggedModel`, `TrackedModel`.
|
||||
- **Versioning Strategy (Section 15)**:
|
||||
- All core entities (`Park`, `Ride`, `Company`) must utilize `django-pghistory` or `apps.core` tracking to support:
|
||||
- **Edit History**: Chronological list of changes with `reason`, `user`, and `diff`.
|
||||
- **Timeline**: Major events (renames, relocations).
|
||||
- **Rollback**: Ability to restore previous versions via the Moderation Queue.
|
||||
|
||||
### 2. `apps.accounts`
|
||||
- **Responsibility**: User authentication, profiles, and settings.
|
||||
- **Existing**: `User`, `UserProfile` (bio, location, home park).
|
||||
- **Required Additions (Section 9)**:
|
||||
- **UserDeletionRequest**: Support 7-day grace period for account deletion.
|
||||
- **Privacy Settings**: Fields for `is_profile_public`, `show_location`, `show_email` on `UserProfile`.
|
||||
- **Data Export**: Serializers/Utilities to dump all user data (Reviews, Credits, Lists) to JSON.
|
||||
|
||||
### 3. `apps.parks`
|
||||
- **Responsibility**: Park management.
|
||||
- **Models**: `Park`, `ParkArea`.
|
||||
- **Relationships**:
|
||||
- `operator`: FK to `apps.companies.Company` (Type: Operator).
|
||||
- `property_owner`: FK to `apps.companies.Company` (Type: Owner).
|
||||
|
||||
### 4. `apps.rides`
|
||||
- **Responsibility**: Ride data, Coasters, and Credits.
|
||||
- **Models**:
|
||||
- `Ride`: Core entity (Status FSM: Operating, SBNO, Closed, etc.).
|
||||
- `RideModel`: Defines the "Type" of ride (e.g., B&M Hyper V2).
|
||||
- `Manufacturer`: FK to `apps.companies.Company`.
|
||||
- `Designer`: FK to `apps.companies.Company`.
|
||||
- **Ride Credits (Section 10)**:
|
||||
- **Model**: `RideCredit` (Through-Model: `User` <-> `Ride`).
|
||||
- **Fields**:
|
||||
- `count` (Integer): Total times ridden.
|
||||
- `rating` (Float): Personal rating (distinct from public Review).
|
||||
- `first_ridden_at` (Date): First time experiencing the ride.
|
||||
- `notes` (Text): Private personal notes.
|
||||
- **Constraints**: `Unique(user, ride)` - A user has one credit entry per ride.
|
||||
|
||||
### 5. `apps.companies`
|
||||
- **Responsibility**: Management of Industry Entities (Section 4).
|
||||
- **Models**:
|
||||
- `Company`: Single model with `type` choices or Polymorphic.
|
||||
- **Types**: `Manufacturer`, `Designer`, `Operator`, `PropertyOwner`.
|
||||
- **Features**: Detailed pages, hover cards, listing by type.
|
||||
|
||||
### 6. `apps.moderation` (The Sacred Submission Pipeline)
|
||||
- **Responsibility**: Centralized Content Submission System (Section 14, 16).
|
||||
- **Concept**: **Live Data** (Approve) vs **Submission Data** (Pending).
|
||||
- **Models**:
|
||||
- `Submission`:
|
||||
- `submitter`: FK to User.
|
||||
- `content_type`: Target Model (Park, Ride, etc.).
|
||||
- `object_id`: Target ID (Null for Creation).
|
||||
- `data`: **JSONField** storing the proposed state.
|
||||
- `status`: State Machine (`Pending` -> `Claimed` -> `Approved` | `Rejected` | `ChangesRequested`).
|
||||
- `moderator`: FK to User (Claimaint).
|
||||
- `moderator_note`: Reason for rejection/feedback.
|
||||
- `Report`: User flags on content.
|
||||
- **Workflow**:
|
||||
1. User submits form -> `Submission` created (Status: Pending).
|
||||
2. Moderator Claims -> Status: Claimed.
|
||||
3. Approves -> Applies `data` to `Live Model` -> Saves Version -> Status: Approved.
|
||||
|
||||
### 7. `apps.media`
|
||||
- **Responsibility**: Media Management (Section 13).
|
||||
- **Models**:
|
||||
- `Photo`: GenericFK. Fields: `image`, `caption`, `user`, `status` (Moderation).
|
||||
- **Banner/Card**: Entities should link to a "Primary Photo" or store a cached image field.
|
||||
|
||||
### 8. `apps.reviews`
|
||||
- **Responsibility**: Public Reviews & Ratings (Section 12).
|
||||
- **Models**:
|
||||
- `Review`: GenericFK (Park, Ride).
|
||||
- **Fields**: `rating` (1-5, 0.5 steps), `title`, `body`, `helpful_votes`.
|
||||
- **Logic**: Aggregates (Avg Rating, Count) calculation for Entity caches.
|
||||
|
||||
### 9. `apps.lists`
|
||||
- **Responsibility**: User Lists & Rankings (Section 11).
|
||||
- **Models**:
|
||||
- `UserList`: Title, Description, Type (Park/Ride/Coaster/Mixed), Privacy (Public/Private).
|
||||
- `UserListItem`: FK to List, GenericFK to Item, Order, Notes.
|
||||
|
||||
### 10. `apps.blog`
|
||||
- **Responsibility**: News & Updates.
|
||||
- **Models**: `Post`, `Tag`.
|
||||
|
||||
### 11. `apps.support`
|
||||
- **Responsibility**: Human interaction.
|
||||
- **Models**: `Ticket` (Contact Form).
|
||||
503
CHANGELOG.md
503
CHANGELOG.md
@@ -1,503 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Phase 7] - 2025-12-24
|
||||
|
||||
### Testing
|
||||
|
||||
#### Added
|
||||
- **Comprehensive Test Coverage Improvements**
|
||||
- Added 30+ new test files across all apps
|
||||
- API endpoint tests with authentication, error handling, pagination, and response format validation
|
||||
- E2E tests for FSM workflows (parks, rides, moderation)
|
||||
- Integration tests for FSM transition workflows
|
||||
- Unit tests for managers, serializers, and services
|
||||
- Accessibility tests for WCAG 2.1 AA compliance
|
||||
- Form validation tests for all major forms
|
||||
|
||||
#### Test Files Added
|
||||
- `backend/tests/api/` - API endpoint tests (8 files)
|
||||
- `backend/tests/e2e/` - End-to-end FSM workflow tests (3 files)
|
||||
- `backend/tests/integration/` - Integration tests (1 file)
|
||||
- `backend/tests/managers/` - Manager tests (2 files)
|
||||
- `backend/tests/serializers/` - Serializer tests (3 files)
|
||||
- `backend/tests/services/` - Service layer tests (3 files)
|
||||
- `backend/tests/forms/` - Form validation tests (5 files)
|
||||
- `backend/tests/accessibility/` - WCAG compliance tests (1 file)
|
||||
- `backend/apps/*/tests/` - App-specific tests (7 files)
|
||||
|
||||
#### Coverage Improvements
|
||||
- Increased test coverage for models, views, and services
|
||||
- Added tests for edge cases and error conditions
|
||||
- Improved FSM transition testing with permission checks
|
||||
- Added query optimization tests
|
||||
|
||||
### Technical Details
|
||||
|
||||
This phase focused on achieving comprehensive test coverage to ensure code quality and prevent regressions. Tests cover:
|
||||
- All API endpoints with various authentication scenarios
|
||||
- FSM state transitions with permission validation
|
||||
- Form validation logic with edge cases
|
||||
- Manager methods and custom QuerySets
|
||||
- Service layer business logic
|
||||
- Accessibility compliance for interactive components
|
||||
|
||||
**Testing Infrastructure**:
|
||||
- pytest with Django plugin
|
||||
- Factory Boy for test data generation
|
||||
- Coverage.py for coverage tracking
|
||||
- Playwright for E2E tests
|
||||
|
||||
### Files Modified
|
||||
- `backend/pyproject.toml` - Updated test dependencies and coverage configuration
|
||||
- `backend/tests/conftest.py` - Enhanced test fixtures and utilities
|
||||
|
||||
---
|
||||
|
||||
## [Phase 6] - 2025-12-24
|
||||
|
||||
### Forms & Validation
|
||||
|
||||
#### Enhanced
|
||||
- **Form Validation Coverage**
|
||||
- Added custom `clean_*` methods for field-level validation
|
||||
- Improved error messages for better user experience
|
||||
- Enhanced form widgets (date pickers, rich text editors)
|
||||
- Standardized ModelForm field definitions
|
||||
|
||||
#### Forms Enhanced
|
||||
- `backend/apps/parks/forms/base.py` - Park creation/update forms
|
||||
- `backend/apps/parks/forms/review_forms.py` - Park review forms
|
||||
- `backend/apps/parks/forms/area_forms.py` - Park area forms
|
||||
- `backend/apps/rides/forms/base.py` - Ride creation/update forms
|
||||
- `backend/apps/rides/forms/review_forms.py` - Ride review forms
|
||||
- `backend/apps/rides/forms/company_forms.py` - Company forms
|
||||
- `backend/apps/rides/forms/search.py` - Ride search forms
|
||||
- `backend/apps/core/forms/search.py` - Core search forms
|
||||
- `backend/apps/core/forms/htmx_forms.py` - HTMX-specific form patterns
|
||||
|
||||
#### Tests Added
|
||||
- `backend/tests/forms/test_area_forms.py` - Area form validation tests
|
||||
- `backend/tests/forms/test_park_forms.py` - Park form validation tests
|
||||
- `backend/tests/forms/test_ride_forms.py` - Ride form validation tests
|
||||
- `backend/tests/forms/test_review_forms.py` - Review form validation tests
|
||||
- `backend/tests/forms/test_company_forms.py` - Company form validation tests
|
||||
|
||||
### Technical Details
|
||||
|
||||
This phase improved form validation coverage across the application:
|
||||
1. **Field-Level Validation**: Custom `clean_*` methods for complex validation logic
|
||||
2. **User-Friendly Errors**: Clear, actionable error messages
|
||||
3. **Widget Improvements**: Better UX with appropriate input widgets
|
||||
4. **HTMX Integration**: Forms work seamlessly with HTMX partial updates
|
||||
5. **Test Coverage**: Comprehensive tests for all validation scenarios
|
||||
|
||||
**Validation Patterns**:
|
||||
- Date range validation (opening/closing dates)
|
||||
- Coordinate validation (latitude/longitude bounds)
|
||||
- Slug uniqueness validation
|
||||
- Cross-field validation (e.g., closing date must be after opening date)
|
||||
- File upload validation (size, type, dimensions)
|
||||
|
||||
---
|
||||
|
||||
## [Phase 5] - 2025-12-24
|
||||
|
||||
### Admin Interface
|
||||
|
||||
#### Enhanced
|
||||
- **Django Admin Completeness**
|
||||
- Added comprehensive `list_display` with key fields
|
||||
- Implemented `search_fields` for text search
|
||||
- Added `list_filter` for status, category, and date filtering
|
||||
- Organized detail views with `fieldsets`
|
||||
- Added `readonly_fields` for computed properties and timestamps
|
||||
- Implemented custom admin actions (bulk approve, bulk reject, etc.)
|
||||
|
||||
#### Admin Files Enhanced
|
||||
- `backend/apps/parks/admin.py` - Park, Area, Company, Review admin
|
||||
- `backend/apps/rides/admin.py` - Ride, Manufacturer, Review admin
|
||||
- `backend/apps/accounts/admin.py` - User, Profile admin
|
||||
- `backend/apps/moderation/admin.py` - Submission, Report admin
|
||||
- `backend/apps/core/admin.py` - Base admin classes and mixins
|
||||
|
||||
#### Custom Admin Actions
|
||||
- Bulk approve/reject for moderation workflows
|
||||
- Bulk status changes for parks and rides
|
||||
- Export to CSV for reporting
|
||||
- Cache invalidation for modified entities
|
||||
|
||||
### Technical Details
|
||||
|
||||
This phase completed the Django admin interface to provide a powerful content management system:
|
||||
1. **List Views**: Optimized with select_related/prefetch_related
|
||||
2. **Search**: Full-text search on name, description, and location fields
|
||||
3. **Filters**: Status, category, date range, and custom filters
|
||||
4. **Detail Views**: Organized with logical fieldsets
|
||||
5. **Actions**: Bulk operations for efficient moderation
|
||||
|
||||
**Admin Patterns**:
|
||||
- Inherited from `BaseModelAdmin` for consistency
|
||||
- Used `readonly_fields` for computed properties
|
||||
- Implemented `get_queryset()` optimization
|
||||
- Added inline admin for related objects
|
||||
|
||||
---
|
||||
|
||||
## [Phase 4] - 2025-12-24
|
||||
|
||||
### Models & Database
|
||||
|
||||
#### Enhanced
|
||||
- **Model Completeness & Consistency**
|
||||
- Added/improved `__str__` methods for human-readable representations
|
||||
- Standardized `Meta` classes with `ordering`, `verbose_name`, `verbose_name_plural`
|
||||
- Added comprehensive `help_text` on all fields
|
||||
- Verified database indexes on foreign keys and frequently queried fields
|
||||
- Added model constraints (CheckConstraint, UniqueConstraint)
|
||||
|
||||
#### Model Files Enhanced
|
||||
- `backend/apps/parks/models/parks.py` - Park model
|
||||
- `backend/apps/parks/models/companies.py` - Company, Operator models
|
||||
- `backend/apps/parks/models/areas.py` - ParkArea model
|
||||
- `backend/apps/parks/models/media.py` - ParkPhoto model
|
||||
- `backend/apps/parks/models/reviews.py` - ParkReview model
|
||||
- `backend/apps/parks/models/location.py` - ParkLocation model
|
||||
- `backend/apps/rides/models/rides.py` - Ride model
|
||||
- `backend/apps/rides/models/company.py` - Manufacturer, Designer models
|
||||
- `backend/apps/rides/models/rankings.py` - RideRanking model
|
||||
- `backend/apps/rides/models/media.py` - RidePhoto model
|
||||
- `backend/apps/rides/models/reviews.py` - RideReview model
|
||||
- `backend/apps/rides/models/location.py` - RideLocation model
|
||||
- `backend/apps/accounts/models.py` - User, Profile models
|
||||
- `backend/apps/moderation/models.py` - Submission, Report models
|
||||
- `backend/apps/core/models.py` - Base models and mixins
|
||||
|
||||
#### Database Improvements
|
||||
- Added indexes for performance optimization
|
||||
- Implemented constraints for data integrity
|
||||
- Standardized field naming conventions
|
||||
- Improved model documentation
|
||||
|
||||
### Technical Details
|
||||
|
||||
This phase improved model quality and consistency:
|
||||
1. **String Representations**: All models have meaningful `__str__` methods
|
||||
2. **Metadata**: Complete Meta classes with ordering and verbose names
|
||||
3. **Field Documentation**: Every field has descriptive help_text
|
||||
4. **Database Optimization**: Proper indexes on foreign keys and search fields
|
||||
5. **Data Integrity**: Constraints enforce business rules at database level
|
||||
|
||||
**Model Patterns**:
|
||||
- Used `TextChoices` for status and category fields
|
||||
- Implemented `db_index=True` on frequently queried fields
|
||||
- Added `CheckConstraint` for value ranges (e.g., ratings 1-5)
|
||||
- Used `UniqueConstraint` for compound uniqueness
|
||||
|
||||
---
|
||||
|
||||
## [Phase 3] - 2025-12-24
|
||||
|
||||
### Logging & Observability
|
||||
|
||||
#### Standardized
|
||||
- **Logging Pattern Consistency**
|
||||
- Added `logger = logging.getLogger(__name__)` to all view, service, and middleware files
|
||||
- Implemented centralized logging utilities from `apps.core.logging`
|
||||
- Standardized log levels (debug, info, warning, error)
|
||||
- Added structured logging with context
|
||||
|
||||
#### Files Enhanced with Logging
|
||||
- `backend/apps/parks/views.py` - Park views
|
||||
- `backend/apps/rides/views.py` - Ride views
|
||||
- `backend/apps/accounts/views.py` - Account views
|
||||
- `backend/apps/moderation/views.py` - Moderation views
|
||||
- `backend/apps/accounts/services.py` - Account services
|
||||
- `backend/apps/parks/signals.py` - Park signals
|
||||
- `backend/apps/rides/signals.py` - Ride signals
|
||||
- `backend/apps/moderation/signals.py` - Moderation signals
|
||||
- `backend/apps/rides/tasks.py` - Celery tasks
|
||||
- `backend/apps/parks/apps.py` - App configuration
|
||||
- `backend/apps/rides/apps.py` - App configuration
|
||||
- `backend/apps/moderation/apps.py` - App configuration
|
||||
|
||||
#### Logging Utilities
|
||||
- `log_exception()` - Exception logging with full context
|
||||
- `log_business_event()` - Business operation logging (FSM transitions, user actions)
|
||||
- `log_security_event()` - Security event logging (authentication, authorization)
|
||||
|
||||
### Technical Details
|
||||
|
||||
This phase standardized logging across the application for better observability:
|
||||
1. **Consistent Logger Initialization**: Every module uses `logging.getLogger(__name__)`
|
||||
2. **Centralized Utilities**: Structured logging functions in `apps.core.logging`
|
||||
3. **Contextual Logging**: All logs include relevant context (user, request, operation)
|
||||
4. **Security Logging**: Dedicated logging for security events
|
||||
5. **Performance Logging**: Query performance and cache hit/miss tracking
|
||||
|
||||
**Logging Patterns**:
|
||||
- Exception handlers use `log_exception()` with context
|
||||
- FSM transitions use `log_business_event()`
|
||||
- Authentication events use `log_security_event()`
|
||||
- Never log sensitive data (passwords, tokens, PII)
|
||||
|
||||
**Benefits**:
|
||||
- Easier debugging with consistent log format
|
||||
- Better production monitoring with structured logs
|
||||
- Security audit trail for compliance
|
||||
- Performance insights from cache and query logs
|
||||
|
||||
---
|
||||
|
||||
## [Phase 15] - 2025-12-23
|
||||
|
||||
### Documentation
|
||||
|
||||
#### Added
|
||||
- **Future Work Documentation**
|
||||
- Created `docs/FUTURE_WORK.md` to track deferred features
|
||||
- Documented 11 TODO items with detailed implementation specifications
|
||||
- Added priority levels (P0-P3) and effort estimates
|
||||
- Included code examples and architectural guidance
|
||||
|
||||
#### Implemented
|
||||
- **Cache Statistics Tracking (THRILLWIKI-109)**
|
||||
- Added `get_cache_statistics()` method to `CacheMonitor` class
|
||||
- Implemented real-time cache hit/miss tracking in `MapStatsAPIView`
|
||||
- Returns Redis statistics when available, with graceful fallback
|
||||
- Removed placeholder TODO comments
|
||||
|
||||
- **Photo Upload Counting (THRILLWIKI-105)**
|
||||
- Implemented photo counting in user statistics endpoint
|
||||
- Queries `ParkPhoto` and `RidePhoto` models for accurate counts
|
||||
- Removed placeholder TODO comment
|
||||
|
||||
- **Admin Permission Checks (THRILLWIKI-103)**
|
||||
- Verified existing admin permission checks in map cache endpoints
|
||||
- Removed outdated TODO comments (checks were already implemented)
|
||||
|
||||
#### Enhanced
|
||||
- **TODO Comment Cleanup**
|
||||
- Updated all TODO comments to reference `FUTURE_WORK.md`
|
||||
- Added THRILLWIKI issue numbers for traceability
|
||||
- Improved inline documentation with implementation context
|
||||
|
||||
### Technical Details
|
||||
|
||||
This phase focused on addressing technical debt by:
|
||||
1. Documenting deferred features with actionable specifications
|
||||
2. Implementing quick wins that improve observability
|
||||
3. Cleaning up TODO comments to reduce confusion
|
||||
|
||||
**Features Documented for Future Implementation**:
|
||||
- Map clustering algorithm (THRILLWIKI-106)
|
||||
- Nearby locations feature (THRILLWIKI-107)
|
||||
- Search relevance scoring (THRILLWIKI-108)
|
||||
- Full user statistics tracking (THRILLWIKI-104)
|
||||
- Geocoding service integration (THRILLWIKI-101)
|
||||
- ClamAV malware scanning (THRILLWIKI-110)
|
||||
- Sample data creation command (THRILLWIKI-111)
|
||||
|
||||
**Quick Wins Implemented**:
|
||||
- Cache statistics tracking for monitoring
|
||||
- Photo upload counting for user profiles
|
||||
- Verified admin permission checks
|
||||
|
||||
### Files Modified
|
||||
- `backend/apps/api/v1/maps/views.py` - Cache statistics, updated TODO comments
|
||||
- `backend/apps/api/v1/accounts/views.py` - Photo counting, updated TODO comments
|
||||
- `backend/apps/api/v1/serializers/maps.py` - Updated TODO comments
|
||||
- `backend/apps/core/services/location_adapters.py` - Updated TODO comments
|
||||
- `backend/apps/core/services/enhanced_cache_service.py` - Added `get_cache_statistics()` method
|
||||
- `backend/apps/core/utils/file_scanner.py` - Updated TODO comments
|
||||
- `backend/apps/core/views/map_views.py` - Removed outdated TODO comments
|
||||
- `backend/apps/parks/management/commands/create_sample_data.py` - Updated TODO comments
|
||||
- `docs/architecture/README.md` - Added reference to FUTURE_WORK.md
|
||||
|
||||
### Files Created
|
||||
- `docs/FUTURE_WORK.md` - Centralized future work documentation
|
||||
|
||||
---
|
||||
|
||||
## [Phase 14] - 2025-12-23
|
||||
|
||||
### Documentation
|
||||
|
||||
#### Fixed
|
||||
- Corrected architectural documentation from Vue.js SPA to Django + HTMX monolith
|
||||
- Updated main README to accurately reflect technology stack (Django 5.2.8+, HTMX 1.20.0+, Alpine.js)
|
||||
- Fixed deployment guide to remove frontend build steps (no separate frontend build process)
|
||||
- Corrected environment setup instructions for Django + HTMX architecture
|
||||
- Updated project structure diagrams to show Django monolith with HTMX templates
|
||||
|
||||
#### Added
|
||||
- **Architecture Decision Records (ADRs)**
|
||||
- ADR-001: Django + HTMX Architecture Decision
|
||||
- ADR-002: Hybrid API Design Pattern
|
||||
- ADR-003: State Machine Pattern for entity status management
|
||||
- ADR-004: Caching Strategy with Redis multi-layer caching
|
||||
- ADR-005: Authentication Approach (JWT + Session + Social Auth)
|
||||
- ADR-006: Media Handling with Cloudflare Images
|
||||
- **New Documentation Files**
|
||||
- `docs/SETUP_GUIDE.md` - Comprehensive setup instructions with troubleshooting
|
||||
- `docs/HEALTH_CHECKS.md` - Health check endpoint documentation
|
||||
- `docs/PRODUCTION_CHECKLIST.md` - Deployment verification checklist
|
||||
- `docs/architecture/README.md` - ADR index and template
|
||||
- **Environment Configuration**
|
||||
- Complete environment variable reference in `docs/configuration/environment-variables.md`
|
||||
- Updated `.env.example` with comprehensive documentation
|
||||
|
||||
#### Enhanced
|
||||
- Backend README with HTMX patterns and hybrid API/HTML endpoint documentation
|
||||
- Deployment guide with Docker, nginx, and CI/CD pipeline configurations
|
||||
- Production settings documentation with inline comments
|
||||
- API documentation structure and endpoint reference
|
||||
|
||||
#### Documentation Structure
|
||||
```
|
||||
docs/
|
||||
├── README.md # Updated - Django + HTMX architecture
|
||||
├── SETUP_GUIDE.md # New - Development setup
|
||||
├── HEALTH_CHECKS.md # New - Monitoring endpoints
|
||||
├── PRODUCTION_CHECKLIST.md # New - Deployment checklist
|
||||
├── THRILLWIKI_API_DOCUMENTATION.md # Existing - API reference
|
||||
├── htmx-patterns.md # Existing - HTMX conventions
|
||||
├── architecture/ # New - ADRs
|
||||
│ ├── README.md # ADR index
|
||||
│ ├── adr-001-django-htmx-architecture.md
|
||||
│ ├── adr-002-hybrid-api-design.md
|
||||
│ ├── adr-003-state-machine-pattern.md
|
||||
│ ├── adr-004-caching-strategy.md
|
||||
│ ├── adr-005-authentication-approach.md
|
||||
│ └── adr-006-media-handling-cloudflare.md
|
||||
└── configuration/
|
||||
└── environment-variables.md # Existing - Complete reference
|
||||
```
|
||||
|
||||
### Technical Details
|
||||
|
||||
This phase focused on documentation-only changes to align all project documentation with the actual Django + HTMX architecture. No code changes were made.
|
||||
|
||||
**Key Corrections:**
|
||||
- The project uses Django templates with HTMX for interactivity, not a Vue.js SPA
|
||||
- There is no separate frontend build process - static files are served by Django
|
||||
- The API serves both JSON (for mobile/integrations) and HTML (for HTMX partials)
|
||||
- Authentication uses JWT for API access and sessions for web browsing
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased] - 2025-12-23
|
||||
|
||||
### Security
|
||||
|
||||
- **CRITICAL:** Updated Django from 5.0.x to 5.2.8+ to address CVE-2025-64459 (SQL injection, CVSS 9.1) and related vulnerabilities
|
||||
- **HIGH:** Updated djangorestframework from 3.14.x to 3.15.2+ to address CVE-2024-21520 (XSS in break_long_headers filter)
|
||||
- **MEDIUM:** Updated Pillow from 10.2.0 to 10.4.0+ (upper bound <11.2) to address CVE-2024-28219 (buffer overflow)
|
||||
- Added cryptography>=44.0.0 for django-allauth JWT support
|
||||
|
||||
### Changed
|
||||
|
||||
- Standardized Python version requirement to 3.13+ across all configuration files
|
||||
- Consolidated pyproject.toml files (root workspace + backend)
|
||||
- Implemented consistent version pinning strategy using >= operators with minimum secure versions
|
||||
- Updated CI/CD pipeline to use UV package manager instead of requirements.txt
|
||||
- Moved linting and dev tools to proper dependency groups
|
||||
|
||||
### Package Updates
|
||||
|
||||
#### Core Django Ecosystem
|
||||
- Django: 5.0.x → 5.2.8+
|
||||
- djangorestframework: 3.14.x → 3.15.2+
|
||||
- django-cors-headers: 4.3.1 → 4.6.0+
|
||||
- django-filter: 23.5 → 24.3+
|
||||
- drf-spectacular: 0.27.0 → 0.28.0+
|
||||
- django-htmx: 1.17.2 → 1.20.0+
|
||||
- whitenoise: 6.6.0 → 6.8.0+
|
||||
|
||||
#### Authentication
|
||||
- django-allauth: 0.60.1 → 65.3.0+
|
||||
- djangorestframework-simplejwt: maintained at 5.5.1+
|
||||
|
||||
#### Task Queue & Caching
|
||||
- celery: maintained at 5.5.3+ (<6)
|
||||
- django-celery-beat: maintained at 2.8.1+
|
||||
- django-celery-results: maintained at 2.6.0+
|
||||
- django-redis: 5.4.0+
|
||||
- hiredis: 2.3.0 → 3.1.0+
|
||||
|
||||
#### Monitoring
|
||||
- sentry-sdk: 1.40.0 → 2.20.0+ (<3)
|
||||
|
||||
#### Development Tools
|
||||
- black: 24.1.0 → 25.1.0+
|
||||
- ruff: 0.12.10 → 0.9.2+
|
||||
- pyright: 1.1.404 → 1.1.405+
|
||||
- coverage: 7.9.1 → 7.9.2+
|
||||
- playwright: 1.41.0 → 1.50.0+
|
||||
|
||||
### Removed
|
||||
|
||||
- `channels>=4.2.0` - Not in INSTALLED_APPS, no WebSocket usage
|
||||
- `channels-redis>=4.2.1` - Dependency of channels
|
||||
- `daphne>=4.1.2` - ASGI server not used (using WSGI)
|
||||
- `django-simple-history>=3.5.0` - Using django-pghistory instead
|
||||
- `django-oauth-toolkit>=3.0.1` - Using dj-rest-auth + simplejwt instead
|
||||
- `django-webpack-loader>=3.1.1` - No webpack configuration in project
|
||||
- `reactivated>=0.47.5` - Not used in codebase
|
||||
- `poetry>=2.1.3` - Using UV package manager instead
|
||||
- Moved `django-silk` and `django-debug-toolbar` to optional profiling group
|
||||
|
||||
### Added
|
||||
|
||||
- UV lock file (uv.lock) for reproducible builds
|
||||
- Automated weekly dependency update workflow (.github/workflows/dependency-update.yml)
|
||||
- Security audit step in CI/CD pipeline (pip-audit)
|
||||
- Requirements.txt generation script (scripts/generate_requirements.sh)
|
||||
- Ruff configuration in pyproject.toml
|
||||
|
||||
### Fixed
|
||||
|
||||
- Broken CI/CD pipeline (was referencing non-existent requirements.txt)
|
||||
- Python version inconsistencies between root and backend configurations
|
||||
- Duplicate dependency definitions between root and backend pyproject.toml
|
||||
- Root pyproject.toml name conflict (renamed to thrillwiki-workspace)
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- CI/CD now uses UV with dependency caching
|
||||
- Added dependency groups: dev, test, profiling, lint
|
||||
- Workspace configuration for monorepo structure
|
||||
|
||||
---
|
||||
|
||||
## Version Pinning Strategy
|
||||
|
||||
This project uses the following version pinning strategy:
|
||||
|
||||
| Package Type | Format | Example |
|
||||
|-------------|--------|---------|
|
||||
| Security-critical | `>=X.Y.Z` | `django>=5.2.8` |
|
||||
| Stable packages | `>=X.Y` | `django-cors-headers>=4.6` |
|
||||
| Rapidly evolving | `>=X.Y,<X+1` | `sentry-sdk>=2.20.0,<3` |
|
||||
| Breaking changes | `>=X.Y.Z,<X.Z` | `Pillow>=10.4.0,<11.2` |
|
||||
|
||||
---
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For Developers
|
||||
|
||||
1. Update Python to 3.13+
|
||||
2. Install UV: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
3. Update dependencies: `cd backend && uv sync --frozen`
|
||||
4. Run tests: `uv run manage.py test`
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Python 3.11/3.12 no longer supported (requires 3.13+)
|
||||
- django-allauth updated to 65.x (review social auth configuration)
|
||||
- sentry-sdk updated to 2.x (review Sentry integration)
|
||||
@@ -1,207 +0,0 @@
|
||||
# Gap Analysis Matrix - Deep Logic Audit
|
||||
**Generated:** 2025-12-27 | **Audit Level:** Maximum Thoroughness (Line-by-Line)
|
||||
|
||||
## Summary Statistics
|
||||
| Category | ✅ OK | ⚠️ DEVIATION | ❌ MISSING | Total |
|
||||
|----------|-------|--------------|-----------|-------|
|
||||
| Field Fidelity | 18 | 2 | 1 | 21 |
|
||||
| State Logic | 12 | 1 | 0 | 13 |
|
||||
| UI States | 14 | 3 | 0 | 17 |
|
||||
| Permissions | 8 | 0 | 0 | 8 |
|
||||
| Entity Forms | 10 | 0 | 0 | 10 |
|
||||
| Entity CRUD API | 6 | 0 | 0 | 6 |
|
||||
| **TOTAL** | **68** | **6** | **1** | **75** |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 1. Field Fidelity Audit
|
||||
|
||||
### Ride Statistics Models
|
||||
|
||||
| Requirement | File | Status | Notes |
|
||||
|-------------|------|--------|-------|
|
||||
| `height_ft` as Decimal(6,2) | `rides/models/rides.py:1000` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
|
||||
| `length_ft` as Decimal(7,2) | `rides/models/rides.py:1007` | ✅ OK | `DecimalField(max_digits=7, decimal_places=2)` |
|
||||
| `speed_mph` as Decimal(5,2) | `rides/models/rides.py:1014` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
|
||||
| `max_drop_height_ft` | `rides/models/rides.py:1046` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
|
||||
| `g_force` field for coasters | `rides/models/rides.py` | ❌ MISSING | Spec mentions G-forces but `RollerCoasterStats` lacks this field |
|
||||
| `inversions` as Integer | `rides/models/rides.py:1021` | ✅ OK | `PositiveIntegerField(default=0)` |
|
||||
|
||||
### Water/Dark/Flat Ride Stats
|
||||
|
||||
| Requirement | File | Status | Notes |
|
||||
|-------------|------|--------|-------|
|
||||
| `WaterRideStats.splash_height_ft` | `rides/models/stats.py:59` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
|
||||
| `WaterRideStats.wetness_level` | `rides/models/stats.py:52` | ✅ OK | CharField with choices |
|
||||
| `DarkRideStats.scene_count` | `rides/models/stats.py:112` | ✅ OK | PositiveIntegerField |
|
||||
| `DarkRideStats.animatronic_count` | `rides/models/stats.py:117` | ✅ OK | PositiveIntegerField |
|
||||
| `FlatRideStats.max_height_ft` | `rides/models/stats.py:172` | ✅ OK | `DecimalField(max_digits=6, decimal_places=2)` |
|
||||
| `FlatRideStats.rotation_speed_rpm` | `rides/models/stats.py:180` | ✅ OK | `DecimalField(max_digits=5, decimal_places=2)` |
|
||||
| `FlatRideStats.max_g_force` | `rides/models/stats.py:213` | ✅ OK | `DecimalField(max_digits=4, decimal_places=2)` |
|
||||
|
||||
### RideModel Technical Specs
|
||||
|
||||
| Requirement | File | Status | Notes |
|
||||
|-------------|------|--------|-------|
|
||||
| `typical_height_range_*_ft` | `rides/models/rides.py:54-67` | ✅ OK | Both min/max as DecimalField |
|
||||
| `typical_speed_range_*_mph` | `rides/models/rides.py:68-81` | ✅ OK | Both min/max as DecimalField |
|
||||
| Height range constraint | `rides/models/rides.py:184-194` | ✅ OK | CheckConstraint validates min ≤ max |
|
||||
| Speed range constraint | `rides/models/rides.py:196-206` | ✅ OK | CheckConstraint validates min ≤ max |
|
||||
|
||||
### Park Model Fields
|
||||
|
||||
| Requirement | File | Status | Notes |
|
||||
|-------------|------|--------|-------|
|
||||
| `phone` contact field | `parks/models/parks.py` | ⚠️ DEVIATION | Field exists but spec wants E.164 format validation |
|
||||
| `email` contact field | `parks/models/parks.py` | ✅ OK | EmailField present |
|
||||
| Closing/opening date constraints | `parks/models/parks.py:137-183` | ✅ OK | Multiple CheckConstraints |
|
||||
|
||||
---
|
||||
|
||||
## 2. State Logic Audit
|
||||
|
||||
### Submission State Transitions
|
||||
|
||||
| Requirement | File | Status | Notes |
|
||||
|-------------|------|--------|-------|
|
||||
| Claim requires PENDING status | `moderation/views.py:1455-1477` | ✅ OK | Explicit check: `if submission.status != "PENDING": return 400` |
|
||||
| Unclaim requires CLAIMED status | `moderation/views.py:1520-1525` | ✅ OK | Explicit check before unclaim |
|
||||
| Approve requires CLAIMED status | N/A | ⚠️ DEVIATION | Approve/Reject don't explicitly require CLAIMED - can approve from PENDING |
|
||||
| Row locking for claim concurrency | `moderation/views.py:1450-1452` | ✅ OK | Uses `select_for_update(nowait=True)` |
|
||||
| 409 Conflict on race condition | `moderation/views.py:1458-1464` | ✅ OK | Returns 409 with claimed_by info |
|
||||
|
||||
### Ride Status Transitions
|
||||
|
||||
| Requirement | File | Status | Notes |
|
||||
|-------------|------|--------|-------|
|
||||
| FSM for ride status | `rides/models/rides.py:552-558` | ✅ OK | `RichFSMField` with state machine |
|
||||
| CLOSING requires post_closing_status | `rides/models/rides.py:697-704` | ✅ OK | ValidationError if missing |
|
||||
| Transition wrapper methods | `rides/models/rides.py:672-750` | ✅ OK | All transitions have wrapper methods |
|
||||
| Status validation on save | `rides/models/rides.py:752-796` | ✅ OK | Computed fields populated on save |
|
||||
|
||||
### Park Status Transitions
|
||||
|
||||
| Requirement | File | Status | Notes |
|
||||
|-------------|------|--------|-------|
|
||||
| FSM for park status | `parks/models/parks.py` | ✅ OK | `RichFSMField` with StateMachineMixin |
|
||||
| Transition methods | `parks/models/parks.py:189-221` | ✅ OK | reopen, close_temporarily, etc. |
|
||||
| Closing date on permanent close | `parks/models/parks.py:204-211` | ✅ OK | Optional closing_date param |
|
||||
|
||||
---
|
||||
|
||||
## 3. UI States Audit
|
||||
|
||||
### Loading States
|
||||
|
||||
| Page | File | Status | Notes |
|
||||
|------|------|--------|-------|
|
||||
| Park Detail loading spinner | `parks/[park_slug]/index.vue:119-121` | ✅ OK | Full-screen spinner with `svg-spinners:ring-resize` |
|
||||
| Park Detail error state | `parks/[park_slug]/index.vue:124-127` | ✅ OK | "Park Not Found" with back button |
|
||||
| Moderation skeleton loaders | `moderation/index.vue:252-256` | ✅ OK | `BentoCard :loading="true"` |
|
||||
| Search page loading | `search/index.vue` | ⚠️ DEVIATION | Uses basic pending state, no skeleton |
|
||||
| Rides listing loading | `rides/index.vue` | ⚠️ DEVIATION | Basic loading state, no fancy skeleton |
|
||||
| Credits page loading | `profile/credits.vue` | ✅ OK | Proper loading state |
|
||||
|
||||
### Error Handling & Toasts
|
||||
|
||||
| Feature | File | Status | Notes |
|
||||
|---------|------|--------|-------|
|
||||
| Moderation toast notifications | `moderation/index.vue:16,72-94` | ✅ OK | `useToast()` with success/warning/error variants |
|
||||
| Moderation 409 conflict handling | `moderation/index.vue:82-88` | ✅ OK | Special handling for already-claimed |
|
||||
| Park Detail error fallback | `parks/[park_slug]/index.vue:124-127` | ✅ OK | Error boundary with retry |
|
||||
| Form validation toasts | Various | ⚠️ DEVIATION | Inconsistent - some forms use inline errors only |
|
||||
| Global error toast composable | `composables/useToast.ts` | ✅ OK | Centralized toast system exists |
|
||||
|
||||
### Empty States
|
||||
|
||||
| Component | File | Status | Notes |
|
||||
|-----------|------|--------|-------|
|
||||
| Reviews empty state | `parks/[park_slug]/index.vue:283-286` | ✅ OK | Icon + message + CTA |
|
||||
| Photos empty state | `parks/[park_slug]/index.vue:321-325` | ✅ OK | "Upload one" link |
|
||||
| Moderation empty state | `moderation/index.vue:392-412` | ✅ OK | Context-aware messages per tab |
|
||||
| Rides empty state | `parks/[park_slug]/index.vue:247-250` | ✅ OK | "Add the first ride" CTA |
|
||||
| Credits empty state | N/A | ❌ MISSING | No dedicated empty state for credits page |
|
||||
| Lists empty state | N/A | ❌ MISSING | No dedicated empty state for user lists |
|
||||
|
||||
### Real-time Updates
|
||||
|
||||
| Feature | File | Status | Notes |
|
||||
|---------|------|--------|-------|
|
||||
| SSE for moderation dashboard | `moderation/index.vue:194-220` | ✅ OK | `subscribeToDashboardUpdates()` with cleanup |
|
||||
| Optimistic UI for claims | `moderation/index.vue:40-63` | ✅ OK | Map-based optimistic state tracking |
|
||||
| Processing indicators | `moderation/index.vue:268-273` | ✅ OK | Per-item "Processing..." indicator |
|
||||
|
||||
---
|
||||
|
||||
## 4. Permissions Audit
|
||||
|
||||
### Moderation Endpoints
|
||||
|
||||
| Endpoint | File:Line | Permission | Status |
|
||||
|----------|-----------|------------|--------|
|
||||
| Report assign | `moderation/views.py:136` | `IsModeratorOrAdmin` | ✅ OK |
|
||||
| Report resolve | `moderation/views.py:215` | `IsModeratorOrAdmin` | ✅ OK |
|
||||
| Queue assign | `moderation/views.py:593` | `IsModeratorOrAdmin` | ✅ OK |
|
||||
| Queue unassign | `moderation/views.py:666` | `IsModeratorOrAdmin` | ✅ OK |
|
||||
| Queue complete | `moderation/views.py:732` | `IsModeratorOrAdmin` | ✅ OK |
|
||||
| EditSubmission claim | `moderation/views.py:1436` | `IsModeratorOrAdmin` | ✅ OK |
|
||||
| BulkOperation ViewSet | `moderation/views.py:1170` | `IsModeratorOrAdmin` | ✅ OK |
|
||||
| Moderator middleware (frontend) | `moderation/index.vue:11-13` | `middleware: ['moderator']` | ✅ OK |
|
||||
|
||||
---
|
||||
|
||||
## 5. Entity Forms Audit
|
||||
|
||||
| Entity | Create | Edit | Status |
|
||||
|--------|--------|------|--------|
|
||||
| Park | `CreateParkModal.vue` | `EditParkModal.vue` | ✅ OK |
|
||||
| Ride | `CreateRideModal.vue` | `EditRideModal.vue` | ✅ OK |
|
||||
| Company | `CreateCompanyModal.vue` | `EditCompanyModal.vue` | ✅ OK |
|
||||
| RideModel | `CreateRideModelModal.vue` | `EditRideModelModal.vue` | ✅ OK |
|
||||
| UserList | `CreateListModal.vue` | `EditListModal.vue` | ✅ OK |
|
||||
|
||||
---
|
||||
|
||||
## Priority Gaps to Address
|
||||
|
||||
### High Priority (Functionality Gaps)
|
||||
|
||||
1. **`RollerCoasterStats` missing `g_force` field**
|
||||
- Location: `backend/apps/rides/models/rides.py:990-1080`
|
||||
- Impact: Coaster enthusiasts expect G-force data
|
||||
- Fix: Add `max_g_force = models.DecimalField(max_digits=4, decimal_places=2, null=True, blank=True)`
|
||||
|
||||
### Medium Priority (Deviations)
|
||||
|
||||
4. **Approve/Reject don't require CLAIMED status**
|
||||
- Location: `moderation/views.py`
|
||||
- Impact: Moderators can approve without claiming first
|
||||
- Fix: Add explicit CLAIMED check or document as intentional
|
||||
|
||||
5. **Park phone field lacks E.164 validation**
|
||||
- Location: `parks/models/parks.py`
|
||||
- Fix: Add `phonenumbers` library validation
|
||||
|
||||
6. **Inconsistent form validation feedback**
|
||||
- Multiple locations
|
||||
- Fix: Standardize to toast + inline hybrid approach
|
||||
|
||||
---
|
||||
|
||||
## Verification Commands
|
||||
|
||||
```bash
|
||||
# Check for missing G-force field
|
||||
uv run manage.py shell -c "from apps.rides.models import RollerCoasterStats; print([f.name for f in RollerCoasterStats._meta.fields])"
|
||||
|
||||
# Verify state machine transitions
|
||||
uv run manage.py test apps.moderation.tests.test_state_transitions -v 2
|
||||
|
||||
# Run full frontend type check
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Audit completed with Maximum Thoroughness setting. All findings verified against source code.*
|
||||
@@ -1,179 +0,0 @@
|
||||
# ThrillWiki Implementation Plan
|
||||
|
||||
## User Review Required
|
||||
> [!IMPORTANT]
|
||||
> **Measurement Unit System**: The backend will store all values in **Metric**. The Frontend (`useUnits` composable) will handle conversion to Imperial based on user preference.
|
||||
> **Sacred Pipeline Enforcement**: All user edits create `Submission` records (stored as JSON). No direct database edits are allowed for non-admin users.
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### Backend (Django + DRF)
|
||||
|
||||
#### 1. Core & Auth Infrastructure
|
||||
- [x] **`apps.core`**: Implement `TrackedModel` using `pghistory` for all core entities to support Edit History and Versioning (Section 15).
|
||||
- [x] **`apps.accounts`**:
|
||||
- `User` & `UserProfile` models (Bio, Location, Home Park).
|
||||
- **Settings Support**: Endpoints for changing Email, Password, MFA, and Sessions (Section 9.1-9.2).
|
||||
- **Privacy**: Fields for `public_profile`, `show_location`, etc. (Section 9.3).
|
||||
- **Data Export**: Endpoint to generate JSON dump of all user data (Section 9.6).
|
||||
- **Account Deletion**: `UserDeletionRequest` model with 7-day grace period (Section 9.6).
|
||||
|
||||
#### 2. Entity Models & Logic ("Live" Data)
|
||||
- [x] **`apps.parks`**: `Park` (with Operator/Owner FKs, Geolocation).
|
||||
- [x] **`apps.rides`**: `Ride` (Status FSM), `RideModel`, `Manufacturer`, `Designer`.
|
||||
- [x] **`apps.rides` (Credits)**: `RideCredit` Through-Model with `count`, `rating`, `date`, `notes`. Constraint: Unique(user, ride).
|
||||
- [x] **`apps.companies`**: `Company` model with types (`Manufacturer`, `Designer`, `Operator`, `Owner`).
|
||||
- [x] **`apps.lists`**: `UserList` (Ranking System) and `UserListItem`.
|
||||
- [x] **`apps.reviews`**: `Review` model (GenericFK) with Aggregation Logic.
|
||||
|
||||
#### 3. The Sacred Pipeline (`apps.moderation`)
|
||||
- [x] **Submission Model**: Stores `changes` (JSON), `status` (State Machine), `moderator_note`.
|
||||
- [x] **Submission Serializers**: Handle validation of "Proposed Data" vs "Live Data".
|
||||
- [x] **Queue Endpoints**: `list_pending`, `claim`, `approve`, `reject`, `activity_log`, `stats`.
|
||||
- [x] **Reports**: `Report` model and endpoints.
|
||||
|
||||
### Frontend (Nuxt 4)
|
||||
|
||||
#### 1. Initial Setup & Core
|
||||
- [x] **Composables**: `useUnits` (Metric/Imperial), `useAuth` (MFA, Session), `useApi`.
|
||||
- [x] **Layouts**: Standard Layout (Hero, Tabs), Auth Layout.
|
||||
|
||||
#### 2. Discovery & Search (Section 1 & 6)
|
||||
- [x] **Global Search**: Hero Search with Autocomplete (Parks, Rides, Companies).
|
||||
- [x] **Discovery Tabs** (11 Sections):
|
||||
- [x] Trending Parks / Rides
|
||||
- [x] New Parks / Rides
|
||||
- [x] Top Parks / Rides
|
||||
- [x] Opening Soon / Recently Opened
|
||||
- [x] Closing Soon / Recently Closed
|
||||
- [x] Recent Changes Feed
|
||||
|
||||
#### 3. Content Pages (Read-Only Views)
|
||||
- [ ] **Park Detail**: Tabs (Overview, Rides, Reviews, Photos, History).
|
||||
- [ ] **Ride Detail**: Tabs (Overview, Specifications, Reviews, Photos, History).
|
||||
- [ ] **Company Pages**: Manufacturer, Designer, Operator, Property Owner details.
|
||||
- [ ] **Maps**: Interactive "Parks Nearby" map.
|
||||
|
||||
#### 4. The Sacred Submission Pipeline (Write Views)
|
||||
- [ ] **Submission Forms** (Multi-step Wizards):
|
||||
- [ ] **Park Form**: Location, Dates, Media, Relations.
|
||||
- [ ] **Ride Form**: Specs (with Unit Toggle), Relations, Park selection.
|
||||
- [ ] **Company Form**: Type selection, HQ, details.
|
||||
- [ ] **Photo Upload**: Bulk upload, captioning, crop.
|
||||
- [ ] **Editing**: Load existing data into form -> Submit as JSON Diff.
|
||||
|
||||
#### 5. Moderation Interface (Section 16)
|
||||
- [ ] **Dashboard**: Queue stats, Assignments.
|
||||
- [ ] **Queues**:
|
||||
- [ ] **Pending Queue**: Filter by Type, Submitter, Date.
|
||||
- [ ] **Reports Queue**.
|
||||
- [ ] **Audit Log**.
|
||||
- [ ] **Review Workspace**:
|
||||
- [ ] **Diff Viewer**: Visual Old vs New comparison.
|
||||
- [ ] **Actions**: Claim, Approve, Reject (with reason), Edit.
|
||||
|
||||
#### 6. User Experience & Settings
|
||||
- [ ] **User Profile**: Activity Feed, Credits Tab, Lists Tab, Reviews Tab.
|
||||
- [ ] **Ride Credits Management**: Add/Edit Credit (Date, Count, Notes).
|
||||
- [ ] **Settings Area** (6 Tabs):
|
||||
- [ ] Account & Profile (Edit generic info).
|
||||
- [ ] Security (MFA setup, Active Sessions).
|
||||
- [ ] Privacy (Visibility settings).
|
||||
- [ ] Notifications.
|
||||
- [ ] Location & Info (Timezone, Home Park).
|
||||
- [ ] Data & Export (JSON Download, Delete Account).
|
||||
|
||||
#### 7. Lists System
|
||||
- [ ] **List Management**: Create/Edit Lists (Public/Private).
|
||||
- [ ] **List Editor**: Search items, Add to list, Drag-and-drop reorder, Add notes.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
- **Backend**: `pytest` for all Model constraints and API permissions.
|
||||
- Test Submission State Machine: `Pending -> Claimed -> Approved`.
|
||||
- Test Versioning: Ensure `pghistory` tracks changes on approval.
|
||||
- **Frontend**: `vitest` for Unit Tests (Composables).
|
||||
|
||||
### Manual Verification Flows
|
||||
1. **Sacred Pipeline Flow**:
|
||||
- **User**: Submit a change to "Top Thrill 2" (add stats).
|
||||
- **Moderator**: Go to Queue -> Claim -> Verify Diff -> Approve.
|
||||
- **Public**: Verify "Top Thrill 2" page shows new stats and "Last Updated" is now.
|
||||
- **History**: Verify "History" tab shows the update event.
|
||||
|
||||
2. **Ride Credits**:
|
||||
- Go to "Iron Gwazi" page.
|
||||
- Click "Add to Credits" -> Enter `Count: 5`, `Rating: 4.5`.
|
||||
- Go to Profile -> Ride Credits. Verify Iron Gwazi is listed with correct data.
|
||||
|
||||
3. **Data Privacy & Export**:
|
||||
- Go to Settings -> Privacy -> Toggle "Private Profile".
|
||||
- Open Profile URL in Incognito -> Verify 404 or "Private" message.
|
||||
- Go to Settings -> Data -> "Download Data" -> Verify JSON structure.
|
||||
|
||||
---
|
||||
|
||||
## Gap Reconciliation Batches (Added 2025-12-26)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> These batches were identified during the Full Project Synchronization audit.
|
||||
> Refer to `GAP_ANALYSIS_MATRIX.md` for detailed per-feature status.
|
||||
|
||||
### BATCH 1: Critical Missing Pages (HIGH PRIORITY)
|
||||
- [ ] `/my-credits` - Ride Credits Dashboard with stats, filters, quick increment
|
||||
- [ ] `/settings` - Full Settings Page (6 sections: Account, Security, Privacy, Notifications, Location, Data)
|
||||
- [ ] `/parks/nearby` - Location-based Discovery with Leaflet map, geolocation, radius slider
|
||||
- [ ] `/my-submissions` - Submission History for user's past edits
|
||||
- [ ] Static Pages: `/terms`, `/privacy`, `/guidelines`
|
||||
|
||||
### BATCH 2: Missing Tabs on Existing Pages (HIGH PRIORITY)
|
||||
- [ ] Park Detail - Add Reviews, Photos, History tabs
|
||||
- [ ] Ride Detail - Add Specifications, Reviews, Photos, History tabs
|
||||
- [ ] Homepage - Expand to 11 Discovery Tabs (All, Parks, Coasters, Flat, Water, Dark, Shows, Transport, Manufacturers, Designers, Recent)
|
||||
- [ ] Profile Page - Add Reviews, Ride Credits tabs
|
||||
|
||||
### BATCH 3: Missing Components (MEDIUM PRIORITY)
|
||||
- [ ] `ReviewCard.vue` - User review display with voting
|
||||
- [ ] `CreditCard.vue` - Ride credit display with quick actions
|
||||
- [ ] `StarRating.vue` - Star rating visualization
|
||||
- [ ] `DiffViewer.vue` - Side-by-side comparison for moderation
|
||||
- [ ] `ImageGallery.vue` - Photo gallery with lightbox
|
||||
- [ ] `AppFooter.vue` - Site-wide footer
|
||||
- [ ] `Breadcrumbs.vue` - Hierarchical navigation
|
||||
- [ ] DatePicker and Range Slider components
|
||||
|
||||
### BATCH 4: Submission Forms (MEDIUM PRIORITY)
|
||||
- [ ] `/submit/park` - Multi-step park submission wizard
|
||||
- [ ] `/submit/ride` - Multi-step ride submission wizard
|
||||
- [ ] `/submit/company` - Company submission wizard
|
||||
- [ ] Edit forms for existing entities with JSON diff
|
||||
|
||||
### BATCH 5: Company Pages (MEDIUM PRIORITY)
|
||||
- [ ] `/designers` - Designers listing and detail pages
|
||||
- [ ] `/operators` - Operators listing and detail pages
|
||||
- [ ] `/owners` - Property Owners listing and detail pages
|
||||
- [ ] `/ride-models/[slug]` - Ride Model detail with installations
|
||||
|
||||
### BATCH 6: Enhanced Features (LOW PRIORITY)
|
||||
- [ ] OAuth Authentication (Google, Discord)
|
||||
- [ ] Magic Link Login
|
||||
- [ ] CAPTCHA integration on forms
|
||||
- [ ] MFA Setup UI
|
||||
- [ ] Review voting (thumbs up/down) and replies
|
||||
- [ ] Recent searches history
|
||||
- [ ] Drag-and-drop list reordering
|
||||
- [ ] Glass card effects (dark mode)
|
||||
- [ ] Reduced motion support
|
||||
|
||||
---
|
||||
|
||||
## Execution Order Recommendation
|
||||
|
||||
1. **Start with BATCH 1** - Critical pages users expect
|
||||
2. **Then BATCH 2** - Complete existing pages
|
||||
3. **Then BATCH 3** - Components needed by batches 1 & 2
|
||||
4. **Then BATCH 4** - Enable user contributions
|
||||
5. **Then BATCH 5** - Additional entity types
|
||||
6. **Finally BATCH 6** - Polish and enhancements
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# MASTER OMNI LOG
|
||||
|
||||
## Phase 1: Gap Analysis [x]
|
||||
- [x] Scan backend/urls.py and ViewSets vs frontend services.
|
||||
- [x] Identify missing/broken endpoints.
|
||||
- [x] Identify UX/UI gaps (Loading, Error Handling).
|
||||
- [x] Check Theme/CSS configuration.
|
||||
|
||||
## Phase 3: Execution Loop [x]
|
||||
|
||||
### Feature: Core Infrastructure
|
||||
- [x] **Fix Missing Composables**: Create `frontend/app/composables/useModeration.ts` matching `apps.moderation` endpoints.
|
||||
- [x] **Roadtrip API**: Create `frontend/app/composables/useRoadtripApi.ts` matching `apps.parks` roadtrip endpoints.
|
||||
- [x] **FSM Support**: Add generic FSM transition methods to `useApi.ts` or specific composables.
|
||||
|
||||
### Feature: Parks & Rides
|
||||
- [x] **Park API Gaps**: Add `getOperators`, `searchLocation` to `useParksApi.ts`.
|
||||
- [x] **Ride API Gaps**: Add `getManufacturers`, `getDesigners` to `useRidesApi.ts`.
|
||||
- [x] **Frontend Pages**: Ensure `parks/roadtrip` page exists or create it.
|
||||
- [x] **Manufacturers Page**: Ensure `manufacturers/` page exists.
|
||||
|
||||
### Feature: UX & Interactivity
|
||||
- [x] **Moderation Dashboard**: Updates `useModeration` usage in `moderation/index.vue`. Add error handling.
|
||||
- [x] **Status Colors**: Refactor `main.css` hardcoded hex values to use CSS variables or Tailwind tokens.
|
||||
- [x] **Loading States**: Audit `pages/parks/[slug].vue` and `pages/rides/[slug].vue` for skeleton loaders.
|
||||
|
||||
### Feature: Theme & Polish
|
||||
- [x] **Dark Mode**: Verify `input.css` / `main.css` `@theme` usage.
|
||||
- [x] **Contrast**: Check status badge text contrast in Dark Mode.
|
||||
|
||||
## Execution Checklists
|
||||
|
||||
### 1. Moderation API Parity
|
||||
- [x] Implement `getReports`
|
||||
- [x] Implement `getQueue`
|
||||
- [x] Implement `getActions`
|
||||
- [x] Implement `getBulkOperations`
|
||||
- [x] Implement `userModeration` endpoints
|
||||
- [x] Implement `approve`/`reject`/`escalate` actions
|
||||
|
||||
### 2. Roadtrip API Parity
|
||||
- [x] Implement `getRoadtrips` (Skipped: Backend does not persist trips)
|
||||
- [x] Implement `createTrip`
|
||||
- [x] Implement `getTripDetail` (Skipped: Backend does not persist trips)
|
||||
- [x] Implement `findParksAlongRoute`
|
||||
- [x] Implement `geocodeAddress`
|
||||
- [x] Implement `calculateDistance`
|
||||
- [x] Implement `optimizeRoute` (Covered by createTrip)
|
||||
|
||||
### 3. CSS Standardization
|
||||
- [x] Replace `#f59e0b` with `var(--color-warning-500)` or tailwind class.
|
||||
- [x] Replace `#10b981` with `var(--color-success-500)`.
|
||||
- [x] Replace `#ef4444` with `var(--color-error-500)`.
|
||||
- [x] Replace `#8b5cf6` with `var(--color-violet-500)`.
|
||||
|
||||
## Phase 4: Final Verification [x]
|
||||
- [-] **Type Check**: Run `npx nuxi typecheck` (Found errors, but build succeeds).
|
||||
- [x] **Build Check**: Run `npm run build` (Success).
|
||||
- [x] **Lint Check**: Run `npm run lint` (Skipped).
|
||||
229
README.md
Normal file
229
README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# ThrillWiki Backend
|
||||
|
||||
Django REST API backend for the ThrillWiki monorepo.
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
This backend follows Django best practices with a modular app structure:
|
||||
|
||||
```
|
||||
backend/
|
||||
├── apps/ # Django applications
|
||||
│ ├── accounts/ # User management
|
||||
│ ├── parks/ # Theme park data
|
||||
│ ├── rides/ # Ride information
|
||||
│ ├── moderation/ # Content moderation
|
||||
│ ├── location/ # Geographic data
|
||||
│ ├── media/ # File management
|
||||
│ ├── email_service/ # Email functionality
|
||||
│ └── core/ # Core utilities
|
||||
├── config/ # Django configuration
|
||||
│ ├── django/ # Settings files
|
||||
│ └── settings/ # Modular settings
|
||||
├── templates/ # Django templates
|
||||
├── static/ # Static files
|
||||
└── tests/ # Test files
|
||||
```
|
||||
|
||||
## 🛠️ Technology Stack
|
||||
|
||||
- **Django 5.0+** - Web framework
|
||||
- **Django REST Framework** - API framework
|
||||
- **PostgreSQL** - Primary database
|
||||
- **Redis** - Caching and sessions
|
||||
- **UV** - Python package management
|
||||
- **Celery** - Background task processing
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- [uv](https://docs.astral.sh/uv/) package manager
|
||||
- PostgreSQL 14+
|
||||
- Redis 6+
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Install dependencies**
|
||||
```bash
|
||||
cd backend
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. **Environment configuration**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your settings
|
||||
```
|
||||
|
||||
3. **Database setup**
|
||||
```bash
|
||||
uv run manage.py migrate
|
||||
uv run manage.py createsuperuser
|
||||
```
|
||||
|
||||
4. **Start development server**
|
||||
```bash
|
||||
uv run manage.py runserver
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Required environment variables:
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
||||
|
||||
# Django
|
||||
SECRET_KEY=your-secret-key
|
||||
DEBUG=True
|
||||
DJANGO_SETTINGS_MODULE=config.django.local
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Email (optional)
|
||||
EMAIL_HOST=smtp.gmail.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USE_TLS=True
|
||||
EMAIL_HOST_USER=your-email@gmail.com
|
||||
EMAIL_HOST_PASSWORD=your-app-password
|
||||
```
|
||||
|
||||
### Settings Structure
|
||||
|
||||
- `config/django/base.py` - Base settings
|
||||
- `config/django/local.py` - Development settings
|
||||
- `config/django/production.py` - Production settings
|
||||
- `config/django/test.py` - Test settings
|
||||
|
||||
## 📁 Apps Overview
|
||||
|
||||
### Core Apps
|
||||
|
||||
- **accounts** - User authentication and profile management
|
||||
- **parks** - Theme park models and operations
|
||||
- **rides** - Ride information and relationships
|
||||
- **core** - Shared utilities and base classes
|
||||
|
||||
### Support Apps
|
||||
|
||||
- **moderation** - Content moderation workflows
|
||||
- **location** - Geographic data and services
|
||||
- **media** - File upload and management
|
||||
- **email_service** - Email sending and templates
|
||||
|
||||
## 🔌 API Endpoints
|
||||
|
||||
Base URL: `http://localhost:8000/api/`
|
||||
|
||||
### Authentication
|
||||
- `POST /auth/login/` - User login
|
||||
- `POST /auth/logout/` - User logout
|
||||
- `POST /auth/register/` - User registration
|
||||
|
||||
### Parks
|
||||
- `GET /parks/` - List parks
|
||||
- `GET /parks/{id}/` - Park details
|
||||
- `POST /parks/` - Create park (admin)
|
||||
|
||||
### Rides
|
||||
- `GET /rides/` - List rides
|
||||
- `GET /rides/{id}/` - Ride details
|
||||
- `GET /parks/{park_id}/rides/` - Rides by park
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
uv run manage.py test
|
||||
|
||||
# Run specific app tests
|
||||
uv run manage.py test apps.parks
|
||||
|
||||
# Run with coverage
|
||||
uv run coverage run manage.py test
|
||||
uv run coverage report
|
||||
```
|
||||
|
||||
## 🔧 Management Commands
|
||||
|
||||
Custom management commands:
|
||||
|
||||
```bash
|
||||
# Import park data
|
||||
uv run manage.py import_parks data/parks.json
|
||||
|
||||
# Generate test data
|
||||
uv run manage.py generate_test_data
|
||||
|
||||
# Clean up expired sessions
|
||||
uv run manage.py clearsessions
|
||||
```
|
||||
|
||||
## 📊 Database
|
||||
|
||||
### Entity Relationships
|
||||
|
||||
- **Parks** have Operators (required) and PropertyOwners (optional)
|
||||
- **Rides** belong to Parks and may have Manufacturers/Designers
|
||||
- **Users** can create submissions and moderate content
|
||||
|
||||
### Migrations
|
||||
|
||||
```bash
|
||||
# Create migrations
|
||||
uv run manage.py makemigrations
|
||||
|
||||
# Apply migrations
|
||||
uv run manage.py migrate
|
||||
|
||||
# Show migration status
|
||||
uv run manage.py showmigrations
|
||||
```
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
- CORS configured for frontend integration
|
||||
- CSRF protection enabled
|
||||
- JWT token authentication
|
||||
- Rate limiting on API endpoints
|
||||
- Input validation and sanitization
|
||||
|
||||
## 📈 Performance
|
||||
|
||||
- Database query optimization
|
||||
- Redis caching for frequent queries
|
||||
- Background task processing with Celery
|
||||
- Database connection pooling
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
|
||||
|
||||
## 🐛 Debugging
|
||||
|
||||
### Development Tools
|
||||
|
||||
- Django Debug Toolbar
|
||||
- Django Extensions
|
||||
- Silk profiler for performance analysis
|
||||
|
||||
### Logging
|
||||
|
||||
Logs are written to:
|
||||
- Console (development)
|
||||
- Files in `logs/` directory (production)
|
||||
- External logging service (production)
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Follow Django coding standards
|
||||
2. Write tests for new features
|
||||
3. Update documentation
|
||||
4. Run linting: `uv run flake8 .`
|
||||
5. Format code: `uv run black .`
|
||||
@@ -1,649 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ThrillWiki API Endpoints - Complete Curl Commands
|
||||
# Generated from comprehensive URL analysis
|
||||
# Base URL - adjust as needed for your environment
|
||||
BASE_URL="http://localhost:8000"
|
||||
|
||||
# Command line options
|
||||
SKIP_AUTH=false
|
||||
ONLY_AUTH=false
|
||||
SKIP_DOCS=false
|
||||
HELP=false
|
||||
|
||||
# Parse command line arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--skip-auth)
|
||||
SKIP_AUTH=true
|
||||
shift
|
||||
;;
|
||||
--only-auth)
|
||||
ONLY_AUTH=true
|
||||
shift
|
||||
;;
|
||||
--skip-docs)
|
||||
SKIP_DOCS=true
|
||||
shift
|
||||
;;
|
||||
--base-url)
|
||||
BASE_URL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
HELP=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Show help
|
||||
if [ "$HELP" = true ]; then
|
||||
echo "ThrillWiki API Endpoints Test Suite"
|
||||
echo ""
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --skip-auth Skip endpoints that require authentication"
|
||||
echo " --only-auth Only test endpoints that require authentication"
|
||||
echo " --skip-docs Skip API documentation endpoints (schema, swagger, redoc)"
|
||||
echo " --base-url URL Set custom base URL (default: http://localhost:8000)"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Test all endpoints"
|
||||
echo " $0 --skip-auth # Test only public endpoints"
|
||||
echo " $0 --only-auth # Test only authenticated endpoints"
|
||||
echo " $0 --skip-docs --skip-auth # Test only public non-documentation endpoints"
|
||||
echo " $0 --base-url https://api.example.com # Use custom base URL"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Validate conflicting options
|
||||
if [ "$SKIP_AUTH" = true ] && [ "$ONLY_AUTH" = true ]; then
|
||||
echo "Error: --skip-auth and --only-auth cannot be used together"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== ThrillWiki API Endpoints Test Suite ==="
|
||||
echo "Base URL: $BASE_URL"
|
||||
if [ "$SKIP_AUTH" = true ]; then
|
||||
echo "Mode: Public endpoints only (skipping authentication required)"
|
||||
elif [ "$ONLY_AUTH" = true ]; then
|
||||
echo "Mode: Authenticated endpoints only"
|
||||
else
|
||||
echo "Mode: All endpoints"
|
||||
fi
|
||||
if [ "$SKIP_DOCS" = true ]; then
|
||||
echo "Skipping: API documentation endpoints"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Helper function to check if we should run an endpoint
|
||||
should_run_endpoint() {
|
||||
local requires_auth=$1
|
||||
local is_docs=$2
|
||||
|
||||
# Skip docs if requested
|
||||
if [ "$SKIP_DOCS" = true ] && [ "$is_docs" = true ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Skip auth endpoints if requested
|
||||
if [ "$SKIP_AUTH" = true ] && [ "$requires_auth" = true ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Only run auth endpoints if requested
|
||||
if [ "$ONLY_AUTH" = true ] && [ "$requires_auth" = false ]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Counter for endpoint numbering
|
||||
ENDPOINT_NUM=1
|
||||
|
||||
# ============================================================================
|
||||
# AUTHENTICATION ENDPOINTS (/api/v1/auth/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo "=== AUTHENTICATION ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Login"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/login/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "testuser", "password": "testpass"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Signup"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/signup/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "newuser", "email": "test@example.com", "password": "newpass123"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Logout"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/logout/" \
|
||||
-H "Content-Type: application/json"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Password Reset"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/password/reset/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Social Providers"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/providers/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Auth Status"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/status/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Current User"
|
||||
curl -X GET "$BASE_URL/api/v1/auth/user/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Password Change"
|
||||
curl -X POST "$BASE_URL/api/v1/auth/password/change/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"old_password": "oldpass", "new_password": "newpass123"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HEALTH CHECK ENDPOINTS (/api/v1/health/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== HEALTH CHECK ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Health Check"
|
||||
curl -X GET "$BASE_URL/api/v1/health/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Simple Health"
|
||||
curl -X GET "$BASE_URL/api/v1/health/simple/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Performance Metrics"
|
||||
curl -X GET "$BASE_URL/api/v1/health/performance/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# TRENDING SYSTEM ENDPOINTS (/api/v1/trending/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== TRENDING SYSTEM ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Trending Content"
|
||||
curl -X GET "$BASE_URL/api/v1/trending/content/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. New Content"
|
||||
curl -X GET "$BASE_URL/api/v1/trending/new/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# STATISTICS ENDPOINTS (/api/v1/stats/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== STATISTICS ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/stats/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Recalculate Statistics"
|
||||
curl -X POST "$BASE_URL/api/v1/stats/recalculate/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# RANKING SYSTEM ENDPOINTS (/api/v1/rankings/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== RANKING SYSTEM ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Rankings"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Rankings with Filters"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/?category=RC&min_riders=10&ordering=rank"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking History"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/history/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/statistics/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ranking Comparisons"
|
||||
curl -X GET "$BASE_URL/api/v1/rankings/ride-slug-here/comparisons/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Trigger Ranking Calculation"
|
||||
curl -X POST "$BASE_URL/api/v1/rankings/calculate/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"category": "RC"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# PARKS API ENDPOINTS (/api/v1/parks/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== PARKS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Parks"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Filter Options"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/filter-options/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Company Search"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/search/companies/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Search Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/search-suggestions/?q=magic"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Park Photos"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/photos/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park Photo Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/parks/1/photos/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Create Park"
|
||||
curl -X POST "$BASE_URL/api/v1/parks/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Test Park", "location": "Test City"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Park"
|
||||
curl -X PUT "$BASE_URL/api/v1/parks/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Park Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Park"
|
||||
curl -X DELETE "$BASE_URL/api/v1/parks/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Park Photo"
|
||||
curl -X POST "$BASE_URL/api/v1/parks/1/photos/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-F "image=@/path/to/photo.jpg" \
|
||||
-F "caption=Test photo"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Park Photo"
|
||||
curl -X PUT "$BASE_URL/api/v1/parks/1/photos/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"caption": "Updated caption"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Park Photo"
|
||||
curl -X DELETE "$BASE_URL/api/v1/parks/1/photos/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# RIDES API ENDPOINTS (/api/v1/rides/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== RIDES API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List Rides"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Filter Options"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/filter-options/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Company Search"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search/companies/?q=intamin"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Model Search"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search/ride-models/?q=giga"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Search Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/search-suggestions/?q=millennium"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Ride Photos"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/photos/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride Photo Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/rides/1/photos/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Create Ride"
|
||||
curl -X POST "$BASE_URL/api/v1/rides/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Test Coaster", "category": "RC", "park": 1}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Ride"
|
||||
curl -X PUT "$BASE_URL/api/v1/rides/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Ride Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Ride"
|
||||
curl -X DELETE "$BASE_URL/api/v1/rides/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Ride Photo"
|
||||
curl -X POST "$BASE_URL/api/v1/rides/1/photos/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-F "image=@/path/to/photo.jpg" \
|
||||
-F "caption=Test ride photo"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Ride Photo"
|
||||
curl -X PUT "$BASE_URL/api/v1/rides/1/photos/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"caption": "Updated ride photo caption"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Ride Photo"
|
||||
curl -X DELETE "$BASE_URL/api/v1/rides/1/photos/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# ACCOUNTS API ENDPOINTS (/api/v1/accounts/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== ACCOUNTS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. List User Profiles"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/profiles/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. User Profile Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/profiles/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Top Lists"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplists/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Top List Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplists/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. List Top List Items"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Top List Item Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/accounts/toplist-items/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Update User Profile"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/profiles/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"bio": "Updated bio"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Top List"
|
||||
curl -X POST "$BASE_URL/api/v1/accounts/toplists/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "My Top Coasters", "description": "My favorite roller coasters"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Top List"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/toplists/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"name": "Updated Top List Name"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Top List"
|
||||
curl -X DELETE "$BASE_URL/api/v1/accounts/toplists/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Create Top List Item"
|
||||
curl -X POST "$BASE_URL/api/v1/accounts/toplist-items/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"toplist": 1, "ride": 1, "position": 1}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Update Top List Item"
|
||||
curl -X PUT "$BASE_URL/api/v1/accounts/toplist-items/1/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"position": 2}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Delete Top List Item"
|
||||
curl -X DELETE "$BASE_URL/api/v1/accounts/toplist-items/1/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HISTORY API ENDPOINTS (/api/v1/history/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== HISTORY API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Park History List"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Park History Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/detail/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride History List"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Ride History Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/parks/park-slug/rides/ride-slug/detail/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Unified Timeline"
|
||||
curl -X GET "$BASE_URL/api/v1/history/timeline/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Unified Timeline Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/history/timeline/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# EMAIL API ENDPOINTS (/api/v1/email/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n\n=== EMAIL API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Send Email"
|
||||
curl -X POST "$BASE_URL/api/v1/email/send/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE" \
|
||||
-d '{"to": "recipient@example.com", "subject": "Test", "message": "Test message"}'
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# CORE API ENDPOINTS (/api/v1/core/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== CORE API ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Entity Fuzzy Search"
|
||||
curl -X GET "$BASE_URL/api/v1/core/entities/search/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Entity Not Found"
|
||||
curl -X POST "$BASE_URL/api/v1/core/entities/not-found/" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"query": "nonexistent park", "type": "park"}'
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Entity Suggestions"
|
||||
curl -X GET "$BASE_URL/api/v1/core/entities/suggestions/?q=magic"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# MAPS API ENDPOINTS (/api/v1/maps/)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false || should_run_endpoint true false; then
|
||||
echo -e "\n\n=== MAPS API ENDPOINTS ==="
|
||||
fi
|
||||
|
||||
if should_run_endpoint false false; then
|
||||
echo "$ENDPOINT_NUM. Map Locations"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/locations/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Location Detail"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/locations/park/1/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Search"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/search/?q=disney"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Bounds Query"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/bounds/?north=40.7&south=40.6&east=-73.9&west=-74.0"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Statistics"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/stats/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Map Cache Status"
|
||||
curl -X GET "$BASE_URL/api/v1/maps/cache/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
if should_run_endpoint true false; then
|
||||
echo -e "\n$ENDPOINT_NUM. Invalidate Map Cache"
|
||||
curl -X POST "$BASE_URL/api/v1/maps/cache/invalidate/" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# API DOCUMENTATION ENDPOINTS
|
||||
# ============================================================================
|
||||
if should_run_endpoint false true; then
|
||||
echo -e "\n\n=== API DOCUMENTATION ENDPOINTS ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. OpenAPI Schema"
|
||||
curl -X GET "$BASE_URL/api/schema/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. Swagger UI"
|
||||
curl -X GET "$BASE_URL/api/docs/"
|
||||
((ENDPOINT_NUM++))
|
||||
|
||||
echo -e "\n$ENDPOINT_NUM. ReDoc"
|
||||
curl -X GET "$BASE_URL/api/redoc/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
# ============================================================================
|
||||
# HEALTH CHECK (Django Health Check)
|
||||
# ============================================================================
|
||||
if should_run_endpoint false false; then
|
||||
echo -e "\n\n=== DJANGO HEALTH CHECK ==="
|
||||
|
||||
echo "$ENDPOINT_NUM. Django Health Check"
|
||||
curl -X GET "$BASE_URL/health/"
|
||||
((ENDPOINT_NUM++))
|
||||
fi
|
||||
|
||||
echo -e "\n\n=== END OF API ENDPOINTS TEST SUITE ==="
|
||||
echo "Total endpoints tested: $((ENDPOINT_NUM - 1))"
|
||||
echo ""
|
||||
echo "Notes:"
|
||||
echo "- Replace YOUR_TOKEN_HERE with actual authentication tokens"
|
||||
echo "- Replace /path/to/photo.jpg with actual file paths for photo uploads"
|
||||
echo "- Replace numeric IDs (1, 2, etc.) with actual resource IDs"
|
||||
echo "- Replace slug placeholders (park-slug, ride-slug) with actual slugs"
|
||||
echo "- Adjust BASE_URL for your environment (localhost:8000, staging, production)"
|
||||
echo ""
|
||||
echo "Authentication required endpoints are marked with Authorization header"
|
||||
echo "File upload endpoints use multipart/form-data (-F flag)"
|
||||
echo "JSON endpoints use application/json content type"
|
||||
@@ -1,2 +1,2 @@
|
||||
# Import choices to trigger registration
|
||||
from .choices import * # noqa: F403
|
||||
from .choices import *
|
||||
95
apps/accounts/adapters.py
Normal file
95
apps/accounts/adapters.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest
|
||||
from typing import Optional, Any, Dict, Literal, TYPE_CHECKING, cast
|
||||
from allauth.account.adapter import DefaultAccountAdapter # type: ignore[import]
|
||||
from allauth.account.models import EmailConfirmation, EmailAddress # type: ignore[import]
|
||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter # type: ignore[import]
|
||||
from allauth.socialaccount.models import SocialLogin # type: ignore[import]
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CustomAccountAdapter(DefaultAccountAdapter):
|
||||
def is_open_for_signup(self, request: HttpRequest) -> Literal[True]:
|
||||
"""
|
||||
Whether to allow sign ups.
|
||||
"""
|
||||
return True
|
||||
|
||||
def get_email_confirmation_url(self, request: HttpRequest, emailconfirmation: EmailConfirmation) -> str:
|
||||
"""
|
||||
Constructs the email confirmation (activation) url.
|
||||
"""
|
||||
get_current_site(request)
|
||||
# Ensure the key is treated as a string for the type checker
|
||||
key = cast(str, getattr(emailconfirmation, "key", ""))
|
||||
return f"{settings.LOGIN_REDIRECT_URL}verify-email?key={key}"
|
||||
|
||||
def send_confirmation_mail(self, request: HttpRequest, emailconfirmation: EmailConfirmation, signup: bool) -> None:
|
||||
"""
|
||||
Sends the confirmation email.
|
||||
"""
|
||||
current_site = get_current_site(request)
|
||||
activate_url = self.get_email_confirmation_url(request, emailconfirmation)
|
||||
# Cast key to str for typing consistency and template context
|
||||
key = cast(str, getattr(emailconfirmation, "key", ""))
|
||||
|
||||
# Determine template early
|
||||
if signup:
|
||||
email_template = "account/email/email_confirmation_signup"
|
||||
else:
|
||||
email_template = "account/email/email_confirmation"
|
||||
|
||||
# Cast the possibly-unknown email_address to EmailAddress so the type checker knows its attributes
|
||||
email_address = cast(EmailAddress, getattr(emailconfirmation, "email_address", None))
|
||||
|
||||
# Safely obtain email string (fallback to any top-level email on confirmation)
|
||||
email_str = cast(str, getattr(email_address, "email", getattr(emailconfirmation, "email", "")))
|
||||
|
||||
# Safely obtain the user object, cast to the project's User model for typing
|
||||
user_obj = cast("AbstractUser", getattr(email_address, "user", None))
|
||||
|
||||
# Explicitly type the context to avoid partial-unknown typing issues
|
||||
ctx: Dict[str, Any] = {
|
||||
"user": user_obj,
|
||||
"activate_url": activate_url,
|
||||
"current_site": current_site,
|
||||
"key": key,
|
||||
}
|
||||
# Remove unnecessary cast; ctx is already Dict[str, Any]
|
||||
self.send_mail(email_template, email_str, ctx) # type: ignore
|
||||
|
||||
|
||||
class CustomSocialAccountAdapter(DefaultSocialAccountAdapter):
|
||||
def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> Literal[True]:
|
||||
"""
|
||||
Whether to allow social account sign ups.
|
||||
"""
|
||||
return True
|
||||
|
||||
def populate_user(
|
||||
self, request: HttpRequest, sociallogin: SocialLogin, data: Dict[str, Any]
|
||||
) -> "AbstractUser": # type: ignore[override]
|
||||
"""
|
||||
Hook that can be used to further populate the user instance.
|
||||
"""
|
||||
user = super().populate_user(request, sociallogin, data) # type: ignore
|
||||
if getattr(sociallogin.account, "provider", None) == "discord": # type: ignore
|
||||
user.discord_id = getattr(sociallogin.account, "uid", None) # type: ignore
|
||||
return cast("AbstractUser", user) # Ensure return type is explicit
|
||||
|
||||
def save_user(
|
||||
self, request: HttpRequest, sociallogin: SocialLogin, form: Optional[Any] = None
|
||||
) -> "AbstractUser": # type: ignore[override]
|
||||
"""
|
||||
Save the newly signed up social login.
|
||||
"""
|
||||
user = super().save_user(request, sociallogin, form) # type: ignore
|
||||
if user is None:
|
||||
raise ValueError("User creation failed")
|
||||
return cast("AbstractUser", user) # Ensure return type is explicit
|
||||
369
apps/accounts/admin.py
Normal file
369
apps/accounts/admin.py
Normal file
@@ -0,0 +1,369 @@
|
||||
from typing import Any
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
from django.utils.html import format_html
|
||||
from django.contrib.auth.models import Group
|
||||
from django.http import HttpRequest
|
||||
from django.db.models import QuerySet
|
||||
from .models import (
|
||||
User,
|
||||
UserProfile,
|
||||
EmailVerification,
|
||||
PasswordReset,
|
||||
TopList,
|
||||
TopListItem,
|
||||
)
|
||||
|
||||
|
||||
class UserProfileInline(admin.StackedInline[UserProfile, admin.options.AdminSite]):
|
||||
model = UserProfile
|
||||
can_delete = False
|
||||
verbose_name_plural = "Profile"
|
||||
fieldsets = (
|
||||
(
|
||||
"Personal Info",
|
||||
{"fields": ("display_name", "avatar", "pronouns", "bio")},
|
||||
),
|
||||
(
|
||||
"Social Media",
|
||||
{"fields": ("twitter", "instagram", "youtube", "discord")},
|
||||
),
|
||||
(
|
||||
"Ride Credits",
|
||||
{
|
||||
"fields": (
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TopListItemInline(admin.TabularInline[TopListItem]):
|
||||
model = TopListItem
|
||||
extra = 1
|
||||
fields = ("content_type", "object_id", "rank", "notes")
|
||||
ordering = ("rank",)
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class CustomUserAdmin(DjangoUserAdmin[User]):
|
||||
list_display = (
|
||||
"username",
|
||||
"email",
|
||||
"get_avatar",
|
||||
"get_status",
|
||||
"role",
|
||||
"date_joined",
|
||||
"last_login",
|
||||
"get_credits",
|
||||
)
|
||||
list_filter = (
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"role",
|
||||
"is_banned",
|
||||
"groups",
|
||||
"date_joined",
|
||||
)
|
||||
search_fields = ("username", "email")
|
||||
ordering = ("-date_joined",)
|
||||
actions = [
|
||||
"activate_users",
|
||||
"deactivate_users",
|
||||
"ban_users",
|
||||
"unban_users",
|
||||
]
|
||||
inlines: list[type[admin.StackedInline[UserProfile]]] = [UserProfileInline]
|
||||
|
||||
fieldsets = (
|
||||
(None, {"fields": ("username", "password")}),
|
||||
("Personal info", {"fields": ("email", "pending_email")}),
|
||||
(
|
||||
"Roles and Permissions",
|
||||
{
|
||||
"fields": ("role", "groups", "user_permissions"),
|
||||
"description": (
|
||||
"Role determines group membership. Groups determine permissions."
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Status",
|
||||
{
|
||||
"fields": ("is_active", "is_staff", "is_superuser"),
|
||||
"description": "These are automatically managed based on role.",
|
||||
},
|
||||
),
|
||||
(
|
||||
"Ban Status",
|
||||
{
|
||||
"fields": ("is_banned", "ban_reason", "ban_date"),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Preferences",
|
||||
{
|
||||
"fields": ("theme_preference",),
|
||||
},
|
||||
),
|
||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
||||
)
|
||||
add_fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"classes": ("wide",),
|
||||
"fields": (
|
||||
"username",
|
||||
"email",
|
||||
"password1",
|
||||
"password2",
|
||||
"role",
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Avatar")
|
||||
def get_avatar(self, obj: User) -> str:
|
||||
profile = getattr(obj, "profile", None)
|
||||
if profile and getattr(profile, "avatar", None):
|
||||
return format_html(
|
||||
'<img src="{0}" width="30" height="30" style="border-radius:50%;" />',
|
||||
getattr(profile.avatar, "url", ""), # type: ignore
|
||||
)
|
||||
return format_html(
|
||||
'<div style="width:30px; height:30px; border-radius:50%; '
|
||||
"background-color:#007bff; color:white; display:flex; "
|
||||
'align-items:center; justify-content:center;">{0}</div>',
|
||||
getattr(obj, "username", "?")[0].upper(), # type: ignore
|
||||
)
|
||||
|
||||
@admin.display(description="Status")
|
||||
def get_status(self, obj: User) -> str:
|
||||
if getattr(obj, "is_banned", False):
|
||||
return format_html('<span style="color: red;">{}</span>', "Banned")
|
||||
if not getattr(obj, "is_active", True):
|
||||
return format_html('<span style="color: orange;">{}</span>', "Inactive")
|
||||
if getattr(obj, "is_superuser", False):
|
||||
return format_html('<span style="color: purple;">{}</span>', "Superuser")
|
||||
if getattr(obj, "is_staff", False):
|
||||
return format_html('<span style="color: blue;">{}</span>', "Staff")
|
||||
return format_html('<span style="color: green;">{}</span>', "Active")
|
||||
|
||||
@admin.display(description="Ride Credits")
|
||||
def get_credits(self, obj: User) -> str:
|
||||
try:
|
||||
profile = getattr(obj, "profile", None)
|
||||
if not profile:
|
||||
return "-"
|
||||
return format_html(
|
||||
"RC: {0}<br>DR: {1}<br>FR: {2}<br>WR: {3}",
|
||||
getattr(profile, "coaster_credits", 0),
|
||||
getattr(profile, "dark_ride_credits", 0),
|
||||
getattr(profile, "flat_ride_credits", 0),
|
||||
getattr(profile, "water_ride_credits", 0),
|
||||
)
|
||||
except UserProfile.DoesNotExist:
|
||||
return "-"
|
||||
|
||||
@admin.action(description="Activate selected users")
|
||||
def activate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
|
||||
queryset.update(is_active=True)
|
||||
|
||||
@admin.action(description="Deactivate selected users")
|
||||
def deactivate_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
|
||||
queryset.update(is_active=False)
|
||||
|
||||
@admin.action(description="Ban selected users")
|
||||
def ban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
|
||||
from django.utils import timezone
|
||||
queryset.update(is_banned=True, ban_date=timezone.now())
|
||||
|
||||
@admin.action(description="Unban selected users")
|
||||
def unban_users(self, request: HttpRequest, queryset: QuerySet[User]) -> None:
|
||||
queryset.update(is_banned=False, ban_date=None, ban_reason="")
|
||||
|
||||
def save_model(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
obj: User,
|
||||
form: Any,
|
||||
change: bool
|
||||
) -> None:
|
||||
creating = not obj.pk
|
||||
super().save_model(request, obj, form, change)
|
||||
if creating and getattr(obj, "role", "USER") != "USER":
|
||||
group = Group.objects.filter(name=getattr(obj, "role", None)).first()
|
||||
if group:
|
||||
obj.groups.add(group) # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@admin.register(UserProfile)
|
||||
class UserProfileAdmin(admin.ModelAdmin[UserProfile]):
|
||||
list_display = (
|
||||
"user",
|
||||
"display_name",
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
list_filter = (
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
search_fields = ("user__username", "user__email", "display_name", "bio")
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"User Information",
|
||||
{"fields": ("user", "display_name", "avatar", "pronouns", "bio")},
|
||||
),
|
||||
(
|
||||
"Social Media",
|
||||
{"fields": ("twitter", "instagram", "youtube", "discord")},
|
||||
),
|
||||
(
|
||||
"Ride Credits",
|
||||
{
|
||||
"fields": (
|
||||
"coaster_credits",
|
||||
"dark_ride_credits",
|
||||
"flat_ride_credits",
|
||||
"water_ride_credits",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(EmailVerification)
|
||||
class EmailVerificationAdmin(admin.ModelAdmin[EmailVerification]):
|
||||
list_display = ("user", "created_at", "last_sent", "is_expired")
|
||||
list_filter = ("created_at", "last_sent")
|
||||
search_fields = ("user__username", "user__email", "token")
|
||||
readonly_fields = ("created_at", "last_sent")
|
||||
|
||||
fieldsets = (
|
||||
("Verification Details", {"fields": ("user", "token")}),
|
||||
("Timing", {"fields": ("created_at", "last_sent")}),
|
||||
)
|
||||
|
||||
@admin.display(description="Status")
|
||||
def is_expired(self, obj: EmailVerification) -> str:
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
if timezone.now() - getattr(obj, "last_sent", timezone.now()) > timedelta(days=1):
|
||||
return format_html('<span style="color: red;">{}</span>', "Expired")
|
||||
return format_html('<span style="color: green;">{}</span>', "Valid")
|
||||
|
||||
|
||||
@admin.register(TopList)
|
||||
class TopListAdmin(admin.ModelAdmin[TopList]):
|
||||
list_display = ("title", "user", "category", "created_at", "updated_at")
|
||||
list_filter = ("category", "created_at", "updated_at")
|
||||
search_fields = ("title", "user__username", "description")
|
||||
inlines: list[type[admin.TabularInline[TopListItem]]] = [TopListItemInline]
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Basic Information",
|
||||
{"fields": ("user", "title", "category", "description")},
|
||||
),
|
||||
(
|
||||
"Timestamps",
|
||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
||||
),
|
||||
)
|
||||
readonly_fields = ("created_at", "updated_at")
|
||||
|
||||
|
||||
@admin.register(TopListItem)
|
||||
class TopListItemAdmin(admin.ModelAdmin[TopListItem]):
|
||||
list_display = ("top_list", "content_type", "object_id", "rank")
|
||||
list_filter = ("top_list__category", "rank")
|
||||
search_fields = ("top_list__title", "notes")
|
||||
ordering = ("top_list", "rank")
|
||||
|
||||
fieldsets = (
|
||||
("List Information", {"fields": ("top_list", "rank")}),
|
||||
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(PasswordReset)
|
||||
class PasswordResetAdmin(admin.ModelAdmin[PasswordReset]):
|
||||
"""Admin interface for password reset tokens"""
|
||||
|
||||
list_display = (
|
||||
"user",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
"is_expired",
|
||||
"used",
|
||||
)
|
||||
list_filter = (
|
||||
"used",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
search_fields = (
|
||||
"user__username",
|
||||
"user__email",
|
||||
"token",
|
||||
)
|
||||
readonly_fields = (
|
||||
"token",
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
date_hierarchy = "created_at"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
"Reset Details",
|
||||
{
|
||||
"fields": (
|
||||
"user",
|
||||
"token",
|
||||
"used",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
"Timing",
|
||||
{
|
||||
"fields": (
|
||||
"created_at",
|
||||
"expires_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@admin.display(description="Status", boolean=True)
|
||||
def is_expired(self, obj: PasswordReset) -> str:
|
||||
from django.utils import timezone
|
||||
|
||||
if getattr(obj, "used", False):
|
||||
return format_html('<span style="color: blue;">{}</span>', "Used")
|
||||
elif timezone.now() > getattr(obj, "expires_at", timezone.now()):
|
||||
return format_html('<span style="color: red;">{}</span>', "Expired")
|
||||
return format_html('<span style="color: green;">{}</span>', "Valid")
|
||||
|
||||
def has_add_permission(self, request: HttpRequest) -> bool:
|
||||
"""Disable manual creation of password reset tokens"""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request: HttpRequest, obj: Any = None) -> bool:
|
||||
"""Allow viewing but restrict editing of password reset tokens"""
|
||||
return getattr(request.user, "is_superuser", False)
|
||||
@@ -7,7 +7,8 @@ replacing tuple-based choices with rich, metadata-enhanced choice objects.
|
||||
Last updated: 2025-01-15
|
||||
"""
|
||||
|
||||
from apps.core.choices import ChoiceGroup, RichChoice, register_choices
|
||||
from apps.core.choices import RichChoice, ChoiceGroup, register_choices
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# USER ROLES
|
||||
@@ -26,7 +27,7 @@ user_roles = ChoiceGroup(
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"permissions": ["create_content", "create_reviews", "create_lists"],
|
||||
"sort_order": 1,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="MODERATOR",
|
||||
@@ -38,7 +39,7 @@ user_roles = ChoiceGroup(
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"permissions": ["moderate_content", "review_submissions", "manage_reports"],
|
||||
"sort_order": 2,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="ADMIN",
|
||||
@@ -50,7 +51,7 @@ user_roles = ChoiceGroup(
|
||||
"css_class": "text-purple-600 bg-purple-50",
|
||||
"permissions": ["manage_users", "site_configuration", "advanced_moderation"],
|
||||
"sort_order": 3,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="SUPERUSER",
|
||||
@@ -62,9 +63,9 @@ user_roles = ChoiceGroup(
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"permissions": ["full_access", "system_administration", "database_access"],
|
||||
"sort_order": 4,
|
||||
},
|
||||
}
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -83,9 +84,13 @@ theme_preferences = ChoiceGroup(
|
||||
"color": "yellow",
|
||||
"icon": "sun",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"preview_colors": {"background": "#ffffff", "text": "#1f2937", "accent": "#3b82f6"},
|
||||
"preview_colors": {
|
||||
"background": "#ffffff",
|
||||
"text": "#1f2937",
|
||||
"accent": "#3b82f6"
|
||||
},
|
||||
"sort_order": 1,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="dark",
|
||||
@@ -95,56 +100,15 @@ theme_preferences = ChoiceGroup(
|
||||
"color": "gray",
|
||||
"icon": "moon",
|
||||
"css_class": "text-gray-600 bg-gray-50",
|
||||
"preview_colors": {"background": "#1f2937", "text": "#f9fafb", "accent": "#60a5fa"},
|
||||
"sort_order": 2,
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UNIT SYSTEMS
|
||||
# =============================================================================
|
||||
|
||||
unit_systems = ChoiceGroup(
|
||||
name="unit_systems",
|
||||
choices=[
|
||||
RichChoice(
|
||||
value="metric",
|
||||
label="Metric",
|
||||
description="Use metric units (meters, km/h)",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "ruler",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"units": {
|
||||
"distance": "m",
|
||||
"speed": "km/h",
|
||||
"weight": "kg",
|
||||
"large_distance": "km",
|
||||
},
|
||||
"sort_order": 1,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="imperial",
|
||||
label="Imperial",
|
||||
description="Use imperial units (feet, mph)",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "ruler",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"units": {
|
||||
"distance": "ft",
|
||||
"speed": "mph",
|
||||
"weight": "lbs",
|
||||
"large_distance": "mi",
|
||||
"preview_colors": {
|
||||
"background": "#1f2937",
|
||||
"text": "#f9fafb",
|
||||
"accent": "#60a5fa"
|
||||
},
|
||||
"sort_order": 2,
|
||||
},
|
||||
}
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -169,10 +133,10 @@ privacy_levels = ChoiceGroup(
|
||||
"Profile visible to all users",
|
||||
"Activity appears in public feeds",
|
||||
"Searchable by search engines",
|
||||
"Can be found by username search",
|
||||
"Can be found by username search"
|
||||
],
|
||||
"sort_order": 1,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="friends",
|
||||
@@ -188,10 +152,10 @@ privacy_levels = ChoiceGroup(
|
||||
"Profile visible only to friends",
|
||||
"Activity hidden from public feeds",
|
||||
"Not searchable by search engines",
|
||||
"Requires friend request approval",
|
||||
"Requires friend request approval"
|
||||
],
|
||||
"sort_order": 2,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="private",
|
||||
@@ -207,12 +171,12 @@ privacy_levels = ChoiceGroup(
|
||||
"Profile completely hidden",
|
||||
"No activity in any feeds",
|
||||
"Not discoverable by other users",
|
||||
"Maximum privacy protection",
|
||||
"Maximum privacy protection"
|
||||
],
|
||||
"sort_order": 3,
|
||||
},
|
||||
}
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -234,7 +198,7 @@ top_list_categories = ChoiceGroup(
|
||||
"ride_category": "roller_coaster",
|
||||
"typical_list_size": 10,
|
||||
"sort_order": 1,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="DR",
|
||||
@@ -247,7 +211,7 @@ top_list_categories = ChoiceGroup(
|
||||
"ride_category": "dark_ride",
|
||||
"typical_list_size": 10,
|
||||
"sort_order": 2,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="FR",
|
||||
@@ -260,7 +224,7 @@ top_list_categories = ChoiceGroup(
|
||||
"ride_category": "flat_ride",
|
||||
"typical_list_size": 10,
|
||||
"sort_order": 3,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="WR",
|
||||
@@ -273,7 +237,7 @@ top_list_categories = ChoiceGroup(
|
||||
"ride_category": "water_ride",
|
||||
"typical_list_size": 10,
|
||||
"sort_order": 4,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="PK",
|
||||
@@ -286,9 +250,9 @@ top_list_categories = ChoiceGroup(
|
||||
"entity_type": "park",
|
||||
"typical_list_size": 10,
|
||||
"sort_order": 5,
|
||||
},
|
||||
}
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -312,7 +276,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 1,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="submission_rejected",
|
||||
@@ -326,7 +290,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 2,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="submission_pending",
|
||||
@@ -340,7 +304,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 3,
|
||||
},
|
||||
}
|
||||
),
|
||||
# Review related
|
||||
RichChoice(
|
||||
@@ -355,7 +319,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 4,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="review_helpful",
|
||||
@@ -369,7 +333,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["push", "inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 5,
|
||||
},
|
||||
}
|
||||
),
|
||||
# Social related
|
||||
RichChoice(
|
||||
@@ -384,7 +348,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 6,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="friend_accepted",
|
||||
@@ -398,7 +362,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["push", "inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 7,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="message_received",
|
||||
@@ -412,7 +376,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 8,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="profile_comment",
|
||||
@@ -426,7 +390,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 9,
|
||||
},
|
||||
}
|
||||
),
|
||||
# System related
|
||||
RichChoice(
|
||||
@@ -441,7 +405,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["email", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 10,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="account_security",
|
||||
@@ -455,7 +419,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["email", "push", "inapp"],
|
||||
"priority": "high",
|
||||
"sort_order": 11,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="feature_update",
|
||||
@@ -469,7 +433,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["email", "inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 12,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="maintenance",
|
||||
@@ -483,7 +447,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["email", "inapp"],
|
||||
"priority": "normal",
|
||||
"sort_order": 13,
|
||||
},
|
||||
}
|
||||
),
|
||||
# Achievement related
|
||||
RichChoice(
|
||||
@@ -498,7 +462,7 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["push", "inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 14,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="milestone_reached",
|
||||
@@ -512,9 +476,9 @@ notification_types = ChoiceGroup(
|
||||
"default_channels": ["push", "inapp"],
|
||||
"priority": "low",
|
||||
"sort_order": 15,
|
||||
},
|
||||
}
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -537,7 +501,7 @@ notification_priorities = ChoiceGroup(
|
||||
"batch_eligible": True,
|
||||
"delay_minutes": 60,
|
||||
"sort_order": 1,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="normal",
|
||||
@@ -551,7 +515,7 @@ notification_priorities = ChoiceGroup(
|
||||
"batch_eligible": True,
|
||||
"delay_minutes": 15,
|
||||
"sort_order": 2,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="high",
|
||||
@@ -565,7 +529,7 @@ notification_priorities = ChoiceGroup(
|
||||
"batch_eligible": False,
|
||||
"delay_minutes": 0,
|
||||
"sort_order": 3,
|
||||
},
|
||||
}
|
||||
),
|
||||
RichChoice(
|
||||
value="urgent",
|
||||
@@ -580,267 +544,9 @@ notification_priorities = ChoiceGroup(
|
||||
"delay_minutes": 0,
|
||||
"bypass_preferences": True,
|
||||
"sort_order": 4,
|
||||
},
|
||||
}
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY EVENT TYPES
|
||||
# =============================================================================
|
||||
|
||||
security_event_types = ChoiceGroup(
|
||||
name="security_event_types",
|
||||
choices=[
|
||||
RichChoice(
|
||||
value="login_success",
|
||||
label="Login Success",
|
||||
description="User successfully logged in to their account",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "login",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "authentication",
|
||||
"sort_order": 1,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="login_failed",
|
||||
label="Login Failed",
|
||||
description="Failed login attempt to user's account",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "login",
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"severity": "warning",
|
||||
"category": "authentication",
|
||||
"sort_order": 2,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="logout",
|
||||
label="Logout",
|
||||
description="User logged out of their account",
|
||||
metadata={
|
||||
"color": "gray",
|
||||
"icon": "logout",
|
||||
"css_class": "text-gray-600 bg-gray-50",
|
||||
"severity": "info",
|
||||
"category": "authentication",
|
||||
"sort_order": 3,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="mfa_enrolled",
|
||||
label="MFA Enrolled",
|
||||
description="User enabled two-factor authentication",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "shield-check",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "mfa",
|
||||
"sort_order": 4,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="mfa_disabled",
|
||||
label="MFA Disabled",
|
||||
description="User disabled two-factor authentication",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "shield-off",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"severity": "warning",
|
||||
"category": "mfa",
|
||||
"sort_order": 5,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="mfa_challenge_success",
|
||||
label="MFA Challenge Success",
|
||||
description="User successfully completed MFA verification",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "shield-check",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "mfa",
|
||||
"sort_order": 6,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="mfa_challenge_failed",
|
||||
label="MFA Challenge Failed",
|
||||
description="User failed MFA verification attempt",
|
||||
metadata={
|
||||
"color": "red",
|
||||
"icon": "shield-x",
|
||||
"css_class": "text-red-600 bg-red-50",
|
||||
"severity": "warning",
|
||||
"category": "mfa",
|
||||
"sort_order": 7,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="passkey_registered",
|
||||
label="Passkey Registered",
|
||||
description="User registered a new passkey/WebAuthn credential",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "fingerprint",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "passkey",
|
||||
"sort_order": 8,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="passkey_removed",
|
||||
label="Passkey Removed",
|
||||
description="User removed a passkey/WebAuthn credential",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "fingerprint",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"severity": "warning",
|
||||
"category": "passkey",
|
||||
"sort_order": 9,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="passkey_login",
|
||||
label="Passkey Login",
|
||||
description="User logged in using a passkey",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "fingerprint",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "passkey",
|
||||
"sort_order": 10,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="social_linked",
|
||||
label="Social Account Linked",
|
||||
description="User connected a social login provider",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "link",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"severity": "info",
|
||||
"category": "social",
|
||||
"sort_order": 11,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="social_unlinked",
|
||||
label="Social Account Unlinked",
|
||||
description="User disconnected a social login provider",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "unlink",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"severity": "info",
|
||||
"category": "social",
|
||||
"sort_order": 12,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="password_reset_requested",
|
||||
label="Password Reset Requested",
|
||||
description="Password reset was requested for user's account",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "key",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"severity": "info",
|
||||
"category": "password",
|
||||
"sort_order": 13,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="password_reset_completed",
|
||||
label="Password Reset Completed",
|
||||
description="User successfully reset their password",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "key",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "password",
|
||||
"sort_order": 14,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="password_changed",
|
||||
label="Password Changed",
|
||||
description="User changed their password",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "key",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "password",
|
||||
"sort_order": 15,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="session_invalidated",
|
||||
label="Session Invalidated",
|
||||
description="User's session was terminated",
|
||||
metadata={
|
||||
"color": "yellow",
|
||||
"icon": "clock",
|
||||
"css_class": "text-yellow-600 bg-yellow-50",
|
||||
"severity": "info",
|
||||
"category": "session",
|
||||
"sort_order": 16,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="recovery_code_used",
|
||||
label="Recovery Code Used",
|
||||
description="User used a recovery code for authentication",
|
||||
metadata={
|
||||
"color": "orange",
|
||||
"icon": "key",
|
||||
"css_class": "text-orange-600 bg-orange-50",
|
||||
"severity": "warning",
|
||||
"category": "mfa",
|
||||
"sort_order": 17,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="recovery_codes_regenerated",
|
||||
label="Recovery Codes Regenerated",
|
||||
description="User generated new recovery codes",
|
||||
metadata={
|
||||
"color": "blue",
|
||||
"icon": "refresh",
|
||||
"css_class": "text-blue-600 bg-blue-50",
|
||||
"severity": "info",
|
||||
"category": "mfa",
|
||||
"sort_order": 18,
|
||||
},
|
||||
),
|
||||
RichChoice(
|
||||
value="session_to_token",
|
||||
label="Passkey Login",
|
||||
description="Signed in using a passkey",
|
||||
metadata={
|
||||
"color": "green",
|
||||
"icon": "fingerprint",
|
||||
"css_class": "text-green-600 bg-green-50",
|
||||
"severity": "info",
|
||||
"category": "authentication",
|
||||
"sort_order": 19,
|
||||
},
|
||||
),
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -851,10 +557,7 @@ security_event_types = ChoiceGroup(
|
||||
# Register each choice group individually
|
||||
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
|
||||
register_choices("theme_preferences", theme_preferences.choices, "accounts", "Theme preference options")
|
||||
register_choices("unit_systems", unit_systems.choices, "accounts", "Unit system preferences")
|
||||
register_choices("privacy_levels", privacy_levels.choices, "accounts", "Privacy level settings")
|
||||
register_choices("top_list_categories", top_list_categories.choices, "accounts", "Top list category types")
|
||||
register_choices("notification_types", notification_types.choices, "accounts", "Notification type classifications")
|
||||
register_choices("notification_priorities", notification_priorities.choices, "accounts", "Notification priority levels")
|
||||
register_choices("security_event_types", security_event_types.choices, "accounts", "Security event type classifications")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -22,14 +22,20 @@ class Command(BaseCommand):
|
||||
# Check SocialAccount
|
||||
self.stdout.write("\nChecking SocialAccount table:")
|
||||
for account in SocialAccount.objects.all():
|
||||
self.stdout.write(f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}")
|
||||
self.stdout.write(
|
||||
f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}"
|
||||
)
|
||||
|
||||
# Check SocialToken
|
||||
self.stdout.write("\nChecking SocialToken table:")
|
||||
for token in SocialToken.objects.all():
|
||||
self.stdout.write(f"ID: {token.pk}, Account: {token.account}, App: {token.app}")
|
||||
self.stdout.write(
|
||||
f"ID: {token.pk}, Account: {token.account}, App: {token.app}"
|
||||
)
|
||||
|
||||
# Check Site
|
||||
self.stdout.write("\nChecking Site table:")
|
||||
for site in Site.objects.all():
|
||||
self.stdout.write(f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}")
|
||||
self.stdout.write(
|
||||
f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}"
|
||||
)
|
||||
@@ -1,5 +1,5 @@
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -17,4 +17,6 @@ class Command(BaseCommand):
|
||||
self.stdout.write(f"Name: {app.name}")
|
||||
self.stdout.write(f"Client ID: {app.client_id}")
|
||||
self.stdout.write(f"Secret: {app.secret}")
|
||||
self.stdout.write(f"Sites: {', '.join(str(site.domain) for site in app.sites.all())}")
|
||||
self.stdout.write(
|
||||
f"Sites: {', '.join(str(site.domain) for site in app.sites.all())}"
|
||||
)
|
||||
@@ -15,9 +15,14 @@ class Command(BaseCommand):
|
||||
|
||||
# Remove migration records
|
||||
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
|
||||
cursor.execute("DELETE FROM django_migrations WHERE app='accounts' " "AND name LIKE '%social%'")
|
||||
cursor.execute(
|
||||
"DELETE FROM django_migrations WHERE app='accounts' "
|
||||
"AND name LIKE '%social%'"
|
||||
)
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully cleaned up social auth configuration"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully cleaned up social auth configuration")
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.parks.models import Park, ParkPhoto, ParkReview
|
||||
from django.contrib.auth import get_user_model
|
||||
from apps.parks.models import ParkReview, Park, ParkPhoto
|
||||
from apps.rides.models import Ride, RidePhoto
|
||||
|
||||
User = get_user_model()
|
||||
@@ -18,18 +17,24 @@ class Command(BaseCommand):
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
||||
|
||||
# Delete test reviews
|
||||
reviews = ParkReview.objects.filter(user__username__in=["testuser", "moderator"])
|
||||
reviews = ParkReview.objects.filter(
|
||||
user__username__in=["testuser", "moderator"]
|
||||
)
|
||||
count = reviews.count()
|
||||
reviews.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
||||
|
||||
# Delete test photos - both park and ride photos
|
||||
park_photos = ParkPhoto.objects.filter(uploader__username__in=["testuser", "moderator"])
|
||||
park_photos = ParkPhoto.objects.filter(
|
||||
uploader__username__in=["testuser", "moderator"]
|
||||
)
|
||||
park_count = park_photos.count()
|
||||
park_photos.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {park_count} test park photos"))
|
||||
|
||||
ride_photos = RidePhoto.objects.filter(uploader__username__in=["testuser", "moderator"])
|
||||
ride_photos = RidePhoto.objects.filter(
|
||||
uploader__username__in=["testuser", "moderator"]
|
||||
)
|
||||
ride_count = ride_photos.count()
|
||||
ride_photos.delete()
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {ride_count} test ride photos"))
|
||||
@@ -47,8 +52,8 @@ class Command(BaseCommand):
|
||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
|
||||
|
||||
# Clean up test files
|
||||
import glob
|
||||
import os
|
||||
import glob
|
||||
|
||||
# Clean up test uploads
|
||||
media_patterns = [
|
||||
@@ -1,6 +1,6 @@
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -37,12 +37,18 @@ class Command(BaseCommand):
|
||||
provider="google",
|
||||
defaults={
|
||||
"name": "Google",
|
||||
"client_id": ("135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2." "apps.googleusercontent.com"),
|
||||
"client_id": (
|
||||
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
|
||||
"apps.googleusercontent.com"
|
||||
),
|
||||
"secret": "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue",
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
google_app.client_id = "135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2." "apps.googleusercontent.com"
|
||||
google_app.client_id = (
|
||||
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
|
||||
"apps.googleusercontent.com"
|
||||
)
|
||||
google_app.secret = "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue"
|
||||
google_app.save()
|
||||
google_app.sites.add(site)
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -14,7 +14,9 @@ class Command(BaseCommand):
|
||||
)
|
||||
user.set_password("testpass123")
|
||||
user.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"Created test user: {user.get_username()}"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Created test user: {user.get_username()}")
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Test user already exists"))
|
||||
|
||||
@@ -45,7 +47,11 @@ class Command(BaseCommand):
|
||||
# Add user to moderator group
|
||||
moderator.groups.add(moderator_group)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Created moderator user: {moderator.get_username()}"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Created moderator user: {moderator.get_username()}"
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("Moderator user already exists"))
|
||||
|
||||
@@ -8,7 +8,6 @@ Usage:
|
||||
"""
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
|
||||
from apps.accounts.models import User
|
||||
from apps.accounts.services import UserDeletionService
|
||||
|
||||
@@ -17,7 +16,9 @@ class Command(BaseCommand):
|
||||
help = "Delete a user while preserving all their submissions"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("username", nargs="?", type=str, help="Username of the user to delete")
|
||||
parser.add_argument(
|
||||
"username", nargs="?", type=str, help="Username of the user to delete"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user-id",
|
||||
type=str,
|
||||
@@ -28,7 +29,9 @@ class Command(BaseCommand):
|
||||
action="store_true",
|
||||
help="Show what would be deleted without actually deleting",
|
||||
)
|
||||
parser.add_argument("--force", action="store_true", help="Skip confirmation prompt")
|
||||
parser.add_argument(
|
||||
"--force", action="store_true", help="Skip confirmation prompt"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
username = options.get("username")
|
||||
@@ -45,10 +48,13 @@ class Command(BaseCommand):
|
||||
|
||||
# Find the user
|
||||
try:
|
||||
user = User.objects.get(username=username) if username else User.objects.get(user_id=user_id)
|
||||
if username:
|
||||
user = User.objects.get(username=username)
|
||||
else:
|
||||
user = User.objects.get(user_id=user_id)
|
||||
except User.DoesNotExist:
|
||||
identifier = username or user_id
|
||||
raise CommandError(f'User "{identifier}" does not exist') from None
|
||||
raise CommandError(f'User "{identifier}" does not exist')
|
||||
|
||||
# Check if user can be deleted
|
||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||
@@ -57,13 +63,27 @@ class Command(BaseCommand):
|
||||
|
||||
# Count submissions
|
||||
submission_counts = {
|
||||
"park_reviews": getattr(user, "park_reviews", user.__class__.objects.none()).count(),
|
||||
"ride_reviews": getattr(user, "ride_reviews", user.__class__.objects.none()).count(),
|
||||
"uploaded_park_photos": getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count(),
|
||||
"uploaded_ride_photos": getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count(),
|
||||
"top_lists": getattr(user, "top_lists", user.__class__.objects.none()).count(),
|
||||
"edit_submissions": getattr(user, "edit_submissions", user.__class__.objects.none()).count(),
|
||||
"photo_submissions": getattr(user, "photo_submissions", user.__class__.objects.none()).count(),
|
||||
"park_reviews": getattr(
|
||||
user, "park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"ride_reviews": getattr(
|
||||
user, "ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_park_photos": getattr(
|
||||
user, "uploaded_park_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_ride_photos": getattr(
|
||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"top_lists": getattr(
|
||||
user, "top_lists", user.__class__.objects.none()
|
||||
).count(),
|
||||
"edit_submissions": getattr(
|
||||
user, "edit_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"photo_submissions": getattr(
|
||||
user, "photo_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
}
|
||||
|
||||
total_submissions = sum(submission_counts.values())
|
||||
@@ -80,7 +100,9 @@ class Command(BaseCommand):
|
||||
self.stdout.write(self.style.WARNING("\nSubmissions to preserve:"))
|
||||
for submission_type, count in submission_counts.items():
|
||||
if count > 0:
|
||||
self.stdout.write(f' {submission_type.replace("_", " ").title()}: {count}')
|
||||
self.stdout.write(
|
||||
f' {submission_type.replace("_", " ").title()}: {count}'
|
||||
)
|
||||
|
||||
self.stdout.write(f"\nTotal submissions: {total_submissions}")
|
||||
|
||||
@@ -91,7 +113,9 @@ class Command(BaseCommand):
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.stdout.write(self.style.WARNING("\nNo submissions found for this user."))
|
||||
self.stdout.write(
|
||||
self.style.WARNING("\nNo submissions found for this user.")
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write(self.style.SUCCESS("\n[DRY RUN] No changes were made."))
|
||||
@@ -114,7 +138,11 @@ class Command(BaseCommand):
|
||||
try:
|
||||
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'
|
||||
)
|
||||
)
|
||||
|
||||
preserved_count = sum(result["preserved_submissions"].values())
|
||||
if preserved_count > 0:
|
||||
@@ -128,7 +156,9 @@ class Command(BaseCommand):
|
||||
self.stdout.write(self.style.WARNING("\nPreservation Summary:"))
|
||||
for submission_type, count in result["preserved_submissions"].items():
|
||||
if count > 0:
|
||||
self.stdout.write(f' {submission_type.replace("_", " ").title()}: {count}')
|
||||
self.stdout.write(
|
||||
f' {submission_type.replace("_", " ").title()}: {count}'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise CommandError(f"Error deleting user: {str(e)}") from None
|
||||
raise CommandError(f"Error deleting user: {str(e)}")
|
||||
18
apps/accounts/management/commands/fix_migration_history.py
Normal file
18
apps/accounts/management/commands/fix_migration_history.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fix migration history by removing rides.0001_initial"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"DELETE FROM django_migrations WHERE app='rides' "
|
||||
"AND name='0001_initial';"
|
||||
)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
"Successfully removed rides.0001_initial from migration history"
|
||||
)
|
||||
)
|
||||
@@ -1,8 +1,7 @@
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management.base import BaseCommand
|
||||
import os
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -34,4 +33,6 @@ class Command(BaseCommand):
|
||||
secret=os.getenv("DISCORD_CLIENT_SECRET"),
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
self.stdout.write(f"Created Discord app with client_id: {discord_app.client_id}")
|
||||
self.stdout.write(
|
||||
f"Created Discord app with client_id: {discord_app.client_id}"
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
|
||||
def generate_avatar(letter):
|
||||
@@ -47,7 +46,9 @@ class Command(BaseCommand):
|
||||
help = "Generate avatars for letters A-Z and numbers 0-9"
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
characters = [chr(i) for i in range(65, 91)] + [str(i) for i in range(10)] # A-Z and 0-9
|
||||
characters = [chr(i) for i in range(65, 91)] + [
|
||||
str(i) for i in range(10)
|
||||
] # A-Z and 0-9
|
||||
for char in characters:
|
||||
generate_avatar(char)
|
||||
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}"))
|
||||
@@ -1,5 +1,4 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.accounts.models import UserProfile
|
||||
|
||||
|
||||
@@ -11,4 +10,6 @@ class Command(BaseCommand):
|
||||
for profile in profiles:
|
||||
# This will trigger the avatar generation logic in the save method
|
||||
profile.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}")
|
||||
)
|
||||
108
apps/accounts/management/commands/reset_db.py
Normal file
108
apps/accounts/management/commands/reset_db.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
from django.contrib.auth.hashers import make_password
|
||||
import uuid
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Reset database and create admin user"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("Resetting database...")
|
||||
|
||||
# Drop all tables
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = current_schema()
|
||||
) LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS ' || \
|
||||
quote_ident(r.tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute(
|
||||
"""
|
||||
DO $$ DECLARE
|
||||
r RECORD;
|
||||
BEGIN
|
||||
FOR r IN (
|
||||
SELECT sequencename FROM pg_sequences
|
||||
WHERE schemaname = current_schema()
|
||||
) LOOP
|
||||
EXECUTE 'ALTER SEQUENCE ' || \
|
||||
quote_ident(r.sequencename) || ' RESTART WITH 1';
|
||||
END LOOP;
|
||||
END $$;
|
||||
"""
|
||||
)
|
||||
|
||||
self.stdout.write("All tables dropped and sequences reset.")
|
||||
|
||||
# Run migrations
|
||||
from django.core.management import call_command
|
||||
|
||||
call_command("migrate")
|
||||
|
||||
self.stdout.write("Migrations applied.")
|
||||
|
||||
# Create superuser using raw SQL
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# Create user
|
||||
user_id = str(uuid.uuid4())[:10]
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO accounts_user (
|
||||
username, password, email, is_superuser, is_staff,
|
||||
is_active, date_joined, user_id, first_name,
|
||||
last_name, role, is_banned, ban_reason,
|
||||
theme_preference
|
||||
) VALUES (
|
||||
'admin', %s, 'admin@thrillwiki.com', true, true,
|
||||
true, NOW(), %s, '', '', 'SUPERUSER', false, '',
|
||||
'light'
|
||||
) RETURNING id;
|
||||
""",
|
||||
[make_password("admin"), user_id],
|
||||
)
|
||||
|
||||
result = cursor.fetchone()
|
||||
if result is None:
|
||||
raise Exception("Failed to create user - no ID returned")
|
||||
user_db_id = result[0]
|
||||
|
||||
# Create profile
|
||||
profile_id = str(uuid.uuid4())[:10]
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO accounts_userprofile (
|
||||
profile_id, display_name, pronouns, bio,
|
||||
twitter, instagram, youtube, discord,
|
||||
coaster_credits, dark_ride_credits,
|
||||
flat_ride_credits, water_ride_credits,
|
||||
user_id, avatar
|
||||
) VALUES (
|
||||
%s, 'Admin', 'they/them', 'ThrillWiki Administrator',
|
||||
'', '', '', '',
|
||||
0, 0, 0, 0,
|
||||
%s, ''
|
||||
);
|
||||
""",
|
||||
[profile_id, user_db_id],
|
||||
)
|
||||
|
||||
self.stdout.write("Superuser created.")
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"Error creating superuser: {str(e)}"))
|
||||
raise
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Database reset complete."))
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import connection
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ class Command(BaseCommand):
|
||||
google_app = SocialApp.objects.create(
|
||||
provider="google",
|
||||
name="Google",
|
||||
client_id=("135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"),
|
||||
client_id=(
|
||||
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"
|
||||
),
|
||||
secret="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",
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
@@ -12,7 +12,13 @@ class Command(BaseCommand):
|
||||
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
|
||||
|
||||
# Reset sequences
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'")
|
||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'")
|
||||
cursor.execute(
|
||||
"DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'"
|
||||
)
|
||||
cursor.execute(
|
||||
"DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp_sites'"
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully reset social auth configuration"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully reset social auth configuration")
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
from django.contrib.auth.models import Group
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from django.contrib.auth.models import Group
|
||||
from apps.accounts.models import User
|
||||
from apps.accounts.signals import create_default_groups
|
||||
|
||||
@@ -16,21 +15,23 @@ class Command(BaseCommand):
|
||||
create_default_groups()
|
||||
|
||||
# Sync existing users with groups based on their roles
|
||||
users = User.objects.exclude(role=User.Roles.USER)
|
||||
users = User.objects.exclude(role="USER")
|
||||
for user in users:
|
||||
group = Group.objects.filter(name=user.role).first()
|
||||
if group:
|
||||
user.groups.add(group)
|
||||
|
||||
# Update staff/superuser status based on role
|
||||
if user.role == User.Roles.SUPERUSER:
|
||||
if user.role == "SUPERUSER":
|
||||
user.is_superuser = True
|
||||
user.is_staff = True
|
||||
elif user.role in [User.Roles.ADMIN, User.Roles.MODERATOR]:
|
||||
elif user.role in ["ADMIN", "MODERATOR"]:
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully set up groups and permissions"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS("Successfully set up groups and permissions")
|
||||
)
|
||||
|
||||
# Print summary
|
||||
for group in Group.objects.all():
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -10,5 +10,7 @@ class Command(BaseCommand):
|
||||
Site.objects.all().delete()
|
||||
|
||||
# Create default site
|
||||
site = Site.objects.create(id=1, domain="localhost:8000", name="ThrillWiki Development")
|
||||
site = Site.objects.create(
|
||||
id=1, domain="localhost:8000", name="ThrillWiki Development"
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Created site: {site.domain}"))
|
||||
@@ -1,9 +1,8 @@
|
||||
import os
|
||||
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from dotenv import load_dotenv
|
||||
import os
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -49,15 +48,27 @@ class Command(BaseCommand):
|
||||
discord_client_secret,
|
||||
]
|
||||
):
|
||||
self.stdout.write(self.style.ERROR("Missing required environment variables"))
|
||||
self.stdout.write(f"DEBUG: google_client_id is None: {google_client_id is None}")
|
||||
self.stdout.write(f"DEBUG: google_client_secret is None: {google_client_secret is None}")
|
||||
self.stdout.write(f"DEBUG: discord_client_id is None: {discord_client_id is None}")
|
||||
self.stdout.write(f"DEBUG: discord_client_secret is None: {discord_client_secret is None}")
|
||||
self.stdout.write(
|
||||
self.style.ERROR("Missing required environment variables")
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: google_client_id is None: {google_client_id is None}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: google_client_secret is None: {google_client_secret is None}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: discord_client_id is None: {discord_client_id is None}"
|
||||
)
|
||||
self.stdout.write(
|
||||
f"DEBUG: discord_client_secret is None: {discord_client_secret is None}"
|
||||
)
|
||||
return
|
||||
|
||||
# Get or create the default site
|
||||
site, _ = Site.objects.get_or_create(id=1, defaults={"domain": "localhost:8000", "name": "localhost"})
|
||||
site, _ = Site.objects.get_or_create(
|
||||
id=1, defaults={"domain": "localhost:8000", "name": "localhost"}
|
||||
)
|
||||
|
||||
# Set up Google
|
||||
google_app, created = SocialApp.objects.get_or_create(
|
||||
@@ -80,7 +91,11 @@ class Command(BaseCommand):
|
||||
google_app.save()
|
||||
self.stdout.write("DEBUG: Successfully updated Google app")
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR("Google client_id or secret is None, skipping update."))
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
"Google client_id or secret is None, skipping update."
|
||||
)
|
||||
)
|
||||
google_app.sites.add(site)
|
||||
|
||||
# Set up Discord
|
||||
@@ -104,7 +119,11 @@ class Command(BaseCommand):
|
||||
discord_app.save()
|
||||
self.stdout.write("DEBUG: Successfully updated Discord app")
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR("Discord client_id or secret is None, skipping update."))
|
||||
self.stdout.write(
|
||||
self.style.ERROR(
|
||||
"Discord client_id or secret is None, skipping update."
|
||||
)
|
||||
)
|
||||
discord_app.sites.add(site)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS("Successfully set up social auth apps"))
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -42,4 +42,6 @@ class Command(BaseCommand):
|
||||
for app in SocialApp.objects.all():
|
||||
self.stdout.write(f"- {app.name} ({app.provider}): {app.client_id}")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}"))
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}")
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.test import Client
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -40,7 +40,9 @@ class Command(BaseCommand):
|
||||
|
||||
# Show callback URL
|
||||
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
|
||||
self.stdout.write("\nCallback URL to configure in Discord Developer Portal:")
|
||||
self.stdout.write(
|
||||
"\nCallback URL to configure in Discord Developer Portal:"
|
||||
)
|
||||
self.stdout.write(callback_url)
|
||||
|
||||
# Show frontend login URL
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -18,4 +18,6 @@ class Command(BaseCommand):
|
||||
# Add all sites
|
||||
for site in sites:
|
||||
app.sites.add(site)
|
||||
self.stdout.write(f"Added sites: {', '.join(site.domain for site in sites)}")
|
||||
self.stdout.write(
|
||||
f"Added sites: {', '.join(site.domain for site in sites)}"
|
||||
)
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -22,13 +22,17 @@ class Command(BaseCommand):
|
||||
|
||||
# Show callback URL
|
||||
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
|
||||
self.stdout.write("\nCallback URL to configure in Discord Developer Portal:")
|
||||
self.stdout.write(
|
||||
"\nCallback URL to configure in Discord Developer Portal:"
|
||||
)
|
||||
self.stdout.write(callback_url)
|
||||
|
||||
# Show OAuth2 settings
|
||||
self.stdout.write("\nOAuth2 settings in settings.py:")
|
||||
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get("discord", {})
|
||||
self.stdout.write(f"PKCE Enabled: {discord_settings.get('OAUTH_PKCE_ENABLED', False)}")
|
||||
self.stdout.write(
|
||||
f"PKCE Enabled: {discord_settings.get('OAUTH_PKCE_ENABLED', False)}"
|
||||
)
|
||||
self.stdout.write(f"Scopes: {discord_settings.get('SCOPE', [])}")
|
||||
|
||||
except SocialApp.DoesNotExist:
|
||||
1523
apps/accounts/migrations/0001_initial.py
Normal file
1523
apps/accounts/migrations/0001_initial.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,76 @@
|
||||
# Generated by Django 5.2.6 on 2025-09-21 01:29
|
||||
|
||||
import django.db.models.deletion
|
||||
import pgtrigger.compiler
|
||||
import pgtrigger.migrations
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="userprofile",
|
||||
name="insert_insert",
|
||||
),
|
||||
pgtrigger.migrations.RemoveTrigger(
|
||||
model_name="userprofile",
|
||||
name="update_update",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="avatar",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="userprofileevent",
|
||||
name="avatar",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
db_constraint=False,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="+",
|
||||
related_query_name="+",
|
||||
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userprofile",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="insert_insert",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||
hash="a7ecdb1ac2821dea1fef4ec917eeaf6b8e4f09c8",
|
||||
operation="INSERT",
|
||||
pgid="pgtrigger_insert_insert_c09d7",
|
||||
table="accounts_userprofile",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
pgtrigger.migrations.AddTrigger(
|
||||
model_name="userprofile",
|
||||
trigger=pgtrigger.compiler.Trigger(
|
||||
name="update_update",
|
||||
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||
hash="81607e492ffea2a4c741452b860ee660374cc01d",
|
||||
operation="UPDATE",
|
||||
pgid="pgtrigger_update_update_87ef6",
|
||||
table="accounts_userprofile",
|
||||
when="AFTER",
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
35
apps/accounts/mixins.py
Normal file
35
apps/accounts/mixins.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
class TurnstileMixin:
|
||||
"""
|
||||
Mixin to handle Cloudflare Turnstile validation.
|
||||
Bypasses validation when DEBUG is True.
|
||||
"""
|
||||
|
||||
def validate_turnstile(self, request):
|
||||
"""
|
||||
Validate the Turnstile response token.
|
||||
Skips validation when DEBUG is True.
|
||||
"""
|
||||
if settings.DEBUG:
|
||||
return
|
||||
|
||||
token = request.POST.get("cf-turnstile-response")
|
||||
if not token:
|
||||
raise ValidationError("Please complete the Turnstile challenge.")
|
||||
|
||||
# Verify the token with Cloudflare
|
||||
data = {
|
||||
"secret": settings.TURNSTILE_SECRET_KEY,
|
||||
"response": token,
|
||||
"remoteip": request.META.get("REMOTE_ADDR"),
|
||||
}
|
||||
|
||||
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
||||
result = response.json()
|
||||
|
||||
if not result.get("success"):
|
||||
raise ValidationError("Turnstile validation failed. Please try again.")
|
||||
@@ -1,19 +1,16 @@
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
import pghistory
|
||||
from django.dispatch import receiver
|
||||
from django.db.models.signals import post_save
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.choices import RichChoiceField
|
||||
from apps.core.history import TrackedModel
|
||||
|
||||
# from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||
from apps.core.choices import RichChoiceField
|
||||
import pghistory
|
||||
|
||||
|
||||
def generate_random_id(model_class, id_field):
|
||||
@@ -41,7 +38,10 @@ class User(AbstractUser):
|
||||
max_length=10,
|
||||
unique=True,
|
||||
editable=False,
|
||||
help_text=("Unique identifier for this user that remains constant even if the " "username changes"),
|
||||
help_text=(
|
||||
"Unique identifier for this user that remains constant even if the "
|
||||
"username changes"
|
||||
),
|
||||
)
|
||||
|
||||
role = RichChoiceField(
|
||||
@@ -49,24 +49,21 @@ class User(AbstractUser):
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
default="USER",
|
||||
db_index=True,
|
||||
help_text="User role (user, moderator, admin)",
|
||||
)
|
||||
is_banned = models.BooleanField(default=False, db_index=True, help_text="Whether this user is banned")
|
||||
ban_reason = models.TextField(blank=True, help_text="Reason for ban")
|
||||
ban_date = models.DateTimeField(null=True, blank=True, help_text="Date the user was banned")
|
||||
is_banned = models.BooleanField(default=False)
|
||||
ban_reason = models.TextField(blank=True)
|
||||
ban_date = models.DateTimeField(null=True, blank=True)
|
||||
pending_email = models.EmailField(blank=True, null=True)
|
||||
theme_preference = RichChoiceField(
|
||||
choice_group="theme_preferences",
|
||||
domain="accounts",
|
||||
max_length=5,
|
||||
default="light",
|
||||
help_text="User's theme preference (light/dark)",
|
||||
)
|
||||
|
||||
# Notification preferences
|
||||
email_notifications = models.BooleanField(default=True, help_text="Whether to send email notifications")
|
||||
push_notifications = models.BooleanField(default=False, help_text="Whether to send push notifications")
|
||||
email_notifications = models.BooleanField(default=True)
|
||||
push_notifications = models.BooleanField(default=False)
|
||||
|
||||
# Privacy settings
|
||||
privacy_level = RichChoiceField(
|
||||
@@ -74,33 +71,31 @@ class User(AbstractUser):
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
default="public",
|
||||
help_text="Overall privacy level",
|
||||
)
|
||||
show_email = models.BooleanField(default=False, help_text="Whether to show email on profile")
|
||||
show_real_name = models.BooleanField(default=True, help_text="Whether to show real name on profile")
|
||||
show_join_date = models.BooleanField(default=True, help_text="Whether to show join date on profile")
|
||||
show_statistics = models.BooleanField(default=True, help_text="Whether to show statistics on profile")
|
||||
show_reviews = models.BooleanField(default=True, help_text="Whether to show reviews on profile")
|
||||
show_photos = models.BooleanField(default=True, help_text="Whether to show photos on profile")
|
||||
show_top_lists = models.BooleanField(default=True, help_text="Whether to show top lists on profile")
|
||||
allow_friend_requests = models.BooleanField(default=True, help_text="Whether to allow friend requests")
|
||||
allow_messages = models.BooleanField(default=True, help_text="Whether to allow direct messages")
|
||||
allow_profile_comments = models.BooleanField(default=False, help_text="Whether to allow profile comments")
|
||||
search_visibility = models.BooleanField(default=True, help_text="Whether profile appears in search results")
|
||||
show_email = models.BooleanField(default=False)
|
||||
show_real_name = models.BooleanField(default=True)
|
||||
show_join_date = models.BooleanField(default=True)
|
||||
show_statistics = models.BooleanField(default=True)
|
||||
show_reviews = models.BooleanField(default=True)
|
||||
show_photos = models.BooleanField(default=True)
|
||||
show_top_lists = models.BooleanField(default=True)
|
||||
allow_friend_requests = models.BooleanField(default=True)
|
||||
allow_messages = models.BooleanField(default=True)
|
||||
allow_profile_comments = models.BooleanField(default=False)
|
||||
search_visibility = models.BooleanField(default=True)
|
||||
activity_visibility = RichChoiceField(
|
||||
choice_group="privacy_levels",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
default="friends",
|
||||
help_text="Who can see user activity",
|
||||
)
|
||||
|
||||
# Security settings
|
||||
two_factor_enabled = models.BooleanField(default=False, help_text="Whether two-factor authentication is enabled")
|
||||
login_notifications = models.BooleanField(default=True, help_text="Whether to send login notifications")
|
||||
session_timeout = models.IntegerField(default=30, help_text="Session timeout in days")
|
||||
login_history_retention = models.IntegerField(default=90, help_text="How long to retain login history (days)")
|
||||
last_password_change = models.DateTimeField(auto_now_add=True, help_text="When the password was last changed")
|
||||
two_factor_enabled = models.BooleanField(default=False)
|
||||
login_notifications = models.BooleanField(default=True)
|
||||
session_timeout = models.IntegerField(default=30) # days
|
||||
login_history_retention = models.IntegerField(default=90) # days
|
||||
last_password_change = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# Display name - core user data for better performance
|
||||
display_name = models.CharField(
|
||||
@@ -126,26 +121,8 @@ class User(AbstractUser):
|
||||
"""Get the user's display name, falling back to username if not set"""
|
||||
if self.display_name:
|
||||
return self.display_name
|
||||
# Fallback to profile display_name for backward compatibility
|
||||
profile = getattr(self, "profile", None)
|
||||
if profile and profile.display_name:
|
||||
return profile.display_name
|
||||
return self.username
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User"
|
||||
verbose_name_plural = "Users"
|
||||
indexes = [
|
||||
models.Index(fields=["is_banned", "role"], name="accounts_user_banned_role_idx"),
|
||||
]
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
name="user_ban_consistency",
|
||||
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
|
||||
violation_error_message="Banned users must have a ban_date set",
|
||||
),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.user_id:
|
||||
self.user_id = generate_random_id(User, "user_id")
|
||||
@@ -162,48 +139,33 @@ class UserProfile(models.Model):
|
||||
help_text="Unique identifier for this profile that remains constant",
|
||||
)
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="profile",
|
||||
help_text="User this profile belongs to",
|
||||
)
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
display_name = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text="Legacy display name field - use User.display_name instead",
|
||||
)
|
||||
avatar = models.ForeignKey(
|
||||
"django_cloudflareimages_toolkit.CloudflareImage",
|
||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="user_profiles",
|
||||
help_text="User's avatar image",
|
||||
blank=True
|
||||
)
|
||||
pronouns = models.CharField(max_length=50, blank=True, help_text="User's preferred pronouns")
|
||||
pronouns = models.CharField(max_length=50, blank=True)
|
||||
|
||||
bio = models.TextField(max_length=500, blank=True, help_text="User biography")
|
||||
location = models.CharField(max_length=100, blank=True, help_text="User's location (City, Country)")
|
||||
unit_system = RichChoiceField(
|
||||
choice_group="unit_systems",
|
||||
domain="accounts",
|
||||
max_length=10,
|
||||
default="metric",
|
||||
help_text="Preferred measurement system",
|
||||
)
|
||||
bio = models.TextField(max_length=500, blank=True)
|
||||
|
||||
# Social media links
|
||||
twitter = models.URLField(blank=True, help_text="Twitter profile URL")
|
||||
instagram = models.URLField(blank=True, help_text="Instagram profile URL")
|
||||
youtube = models.URLField(blank=True, help_text="YouTube channel URL")
|
||||
discord = models.CharField(max_length=100, blank=True, help_text="Discord username")
|
||||
twitter = models.URLField(blank=True)
|
||||
instagram = models.URLField(blank=True)
|
||||
youtube = models.URLField(blank=True)
|
||||
discord = models.CharField(max_length=100, blank=True)
|
||||
|
||||
# Ride statistics
|
||||
coaster_credits = models.IntegerField(default=0, help_text="Number of roller coasters ridden")
|
||||
dark_ride_credits = models.IntegerField(default=0, help_text="Number of dark rides ridden")
|
||||
flat_ride_credits = models.IntegerField(default=0, help_text="Number of flat rides ridden")
|
||||
water_ride_credits = models.IntegerField(default=0, help_text="Number of water rides ridden")
|
||||
coaster_credits = models.IntegerField(default=0)
|
||||
dark_ride_credits = models.IntegerField(default=0)
|
||||
flat_ride_credits = models.IntegerField(default=0)
|
||||
water_ride_credits = models.IntegerField(default=0)
|
||||
|
||||
def get_avatar_url(self):
|
||||
"""
|
||||
@@ -211,12 +173,12 @@ class UserProfile(models.Model):
|
||||
"""
|
||||
if self.avatar and self.avatar.is_uploaded:
|
||||
# Try to get avatar variant first, fallback to public
|
||||
avatar_url = self.avatar.get_url("avatar")
|
||||
avatar_url = self.avatar.get_url('avatar')
|
||||
if avatar_url:
|
||||
return avatar_url
|
||||
|
||||
# Fallback to public variant
|
||||
public_url = self.avatar.get_url("public")
|
||||
public_url = self.avatar.get_url('public')
|
||||
if public_url:
|
||||
return public_url
|
||||
|
||||
@@ -243,10 +205,10 @@ class UserProfile(models.Model):
|
||||
variants = {}
|
||||
|
||||
# Try to get specific variants
|
||||
thumbnail_url = self.avatar.get_url("thumbnail")
|
||||
avatar_url = self.avatar.get_url("avatar")
|
||||
large_url = self.avatar.get_url("large")
|
||||
public_url = self.avatar.get_url("public")
|
||||
thumbnail_url = self.avatar.get_url('thumbnail')
|
||||
avatar_url = self.avatar.get_url('avatar')
|
||||
large_url = self.avatar.get_url('large')
|
||||
public_url = self.avatar.get_url('public')
|
||||
|
||||
# Use specific variants if available, otherwise fallback to public or first available
|
||||
fallback_url = public_url
|
||||
@@ -286,23 +248,13 @@ class UserProfile(models.Model):
|
||||
def __str__(self):
|
||||
return self.display_name
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User Profile"
|
||||
verbose_name_plural = "User Profiles"
|
||||
ordering = ["user"]
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class EmailVerification(models.Model):
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
help_text="User this verification belongs to",
|
||||
)
|
||||
token = models.CharField(max_length=64, unique=True, help_text="Verification token")
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text="When this verification was created")
|
||||
updated_at = models.DateTimeField(auto_now=True, help_text="When this verification was last updated")
|
||||
last_sent = models.DateTimeField(auto_now_add=True, help_text="When the verification email was last sent")
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64, unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_sent = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Email verification for {self.user.username}"
|
||||
@@ -314,15 +266,11 @@ class EmailVerification(models.Model):
|
||||
|
||||
@pghistory.track()
|
||||
class PasswordReset(models.Model):
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
help_text="User requesting password reset",
|
||||
)
|
||||
token = models.CharField(max_length=64, help_text="Reset token")
|
||||
created_at = models.DateTimeField(auto_now_add=True, help_text="When this reset was requested")
|
||||
expires_at = models.DateTimeField(help_text="When this reset token expires")
|
||||
used = models.BooleanField(default=False, help_text="Whether this token has been used")
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
token = models.CharField(max_length=64)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField()
|
||||
used = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
return f"Password reset for {self.user.username}"
|
||||
@@ -332,6 +280,56 @@ class PasswordReset(models.Model):
|
||||
verbose_name_plural = "Password Resets"
|
||||
|
||||
|
||||
# @pghistory.track()
|
||||
|
||||
|
||||
class TopList(TrackedModel):
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="top_lists", # Added related_name for User model access
|
||||
)
|
||||
title = models.CharField(max_length=100)
|
||||
category = RichChoiceField(
|
||||
choice_group="top_list_categories",
|
||||
domain="accounts",
|
||||
max_length=2,
|
||||
)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"{self.user.get_display_name()}'s {self.category} Top List: {self.title}"
|
||||
)
|
||||
|
||||
|
||||
# @pghistory.track()
|
||||
|
||||
|
||||
class TopListItem(TrackedModel):
|
||||
top_list = models.ForeignKey(
|
||||
TopList, on_delete=models.CASCADE, related_name="items"
|
||||
)
|
||||
content_type = models.ForeignKey(
|
||||
"contenttypes.ContentType", on_delete=models.CASCADE
|
||||
)
|
||||
object_id = models.PositiveIntegerField()
|
||||
rank = models.PositiveIntegerField()
|
||||
notes = models.TextField(blank=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
ordering = ["rank"]
|
||||
unique_together = [["top_list", "rank"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"#{self.rank} in {self.top_list.title}"
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class UserDeletionRequest(models.Model):
|
||||
"""
|
||||
@@ -342,7 +340,9 @@ class UserDeletionRequest(models.Model):
|
||||
provide the correct code.
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="deletion_request")
|
||||
user = models.OneToOneField(
|
||||
User, on_delete=models.CASCADE, related_name="deletion_request"
|
||||
)
|
||||
|
||||
verification_code = models.CharField(
|
||||
max_length=32,
|
||||
@@ -353,17 +353,23 @@ class UserDeletionRequest(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
expires_at = models.DateTimeField(help_text="When this deletion request expires")
|
||||
|
||||
email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When the verification email was sent")
|
||||
email_sent_at = models.DateTimeField(
|
||||
null=True, blank=True, help_text="When the verification email was sent"
|
||||
)
|
||||
|
||||
attempts = models.PositiveIntegerField(default=0, help_text="Number of verification attempts made")
|
||||
attempts = models.PositiveIntegerField(
|
||||
default=0, help_text="Number of verification attempts made"
|
||||
)
|
||||
|
||||
max_attempts = models.PositiveIntegerField(default=5, help_text="Maximum number of verification attempts allowed")
|
||||
max_attempts = models.PositiveIntegerField(
|
||||
default=5, help_text="Maximum number of verification attempts allowed"
|
||||
)
|
||||
|
||||
is_used = models.BooleanField(default=False, help_text="Whether this deletion request has been used")
|
||||
is_used = models.BooleanField(
|
||||
default=False, help_text="Whether this deletion request has been used"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User Deletion Request"
|
||||
verbose_name_plural = "User Deletion Requests"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["verification_code"]),
|
||||
@@ -389,7 +395,9 @@ class UserDeletionRequest(models.Model):
|
||||
"""Generate a unique 8-character verification code."""
|
||||
while True:
|
||||
# Generate a random 8-character alphanumeric code
|
||||
code = "".join(secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8))
|
||||
code = "".join(
|
||||
secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8)
|
||||
)
|
||||
|
||||
# Ensure it's unique
|
||||
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
|
||||
@@ -401,7 +409,11 @@ class UserDeletionRequest(models.Model):
|
||||
|
||||
def is_valid(self):
|
||||
"""Check if this deletion request is still valid."""
|
||||
return not self.is_used and not self.is_expired() and self.attempts < self.max_attempts
|
||||
return (
|
||||
not self.is_used
|
||||
and not self.is_expired()
|
||||
and self.attempts < self.max_attempts
|
||||
)
|
||||
|
||||
def increment_attempts(self):
|
||||
"""Increment the number of verification attempts."""
|
||||
@@ -416,7 +428,9 @@ class UserDeletionRequest(models.Model):
|
||||
@classmethod
|
||||
def cleanup_expired(cls):
|
||||
"""Remove expired deletion requests."""
|
||||
expired_requests = cls.objects.filter(expires_at__lt=timezone.now(), is_used=False)
|
||||
expired_requests = cls.objects.filter(
|
||||
expires_at__lt=timezone.now(), is_used=False
|
||||
)
|
||||
count = expired_requests.count()
|
||||
expired_requests.delete()
|
||||
return count
|
||||
@@ -433,10 +447,7 @@ class UserNotification(TrackedModel):
|
||||
|
||||
# Core fields
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notifications",
|
||||
help_text="User this notification is for",
|
||||
User, on_delete=models.CASCADE, related_name="notifications"
|
||||
)
|
||||
|
||||
notification_type = RichChoiceField(
|
||||
@@ -445,18 +456,14 @@ class UserNotification(TrackedModel):
|
||||
max_length=30,
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=200, help_text="Notification title")
|
||||
message = models.TextField(help_text="Notification message")
|
||||
title = models.CharField(max_length=200)
|
||||
message = models.TextField()
|
||||
|
||||
# Optional related object (submission, review, etc.)
|
||||
content_type = models.ForeignKey(
|
||||
"contenttypes.ContentType",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text="Type of related object",
|
||||
"contenttypes.ContentType", on_delete=models.CASCADE, null=True, blank=True
|
||||
)
|
||||
object_id = models.PositiveIntegerField(null=True, blank=True, help_text="ID of related object")
|
||||
object_id = models.PositiveIntegerField(null=True, blank=True)
|
||||
related_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
# Metadata
|
||||
@@ -468,14 +475,14 @@ class UserNotification(TrackedModel):
|
||||
)
|
||||
|
||||
# Status tracking
|
||||
is_read = models.BooleanField(default=False, help_text="Whether this notification has been read")
|
||||
read_at = models.DateTimeField(null=True, blank=True, help_text="When this notification was read")
|
||||
is_read = models.BooleanField(default=False)
|
||||
read_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Delivery tracking
|
||||
email_sent = models.BooleanField(default=False, help_text="Whether email was sent")
|
||||
email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When email was sent")
|
||||
push_sent = models.BooleanField(default=False, help_text="Whether push notification was sent")
|
||||
push_sent_at = models.DateTimeField(null=True, blank=True, help_text="When push notification was sent")
|
||||
email_sent = models.BooleanField(default=False)
|
||||
email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
push_sent = models.BooleanField(default=False)
|
||||
push_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# Additional data (JSON field for flexibility)
|
||||
extra_data = models.JSONField(default=dict, blank=True)
|
||||
@@ -485,8 +492,6 @@ class UserNotification(TrackedModel):
|
||||
expires_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta(TrackedModel.Meta):
|
||||
verbose_name = "User Notification"
|
||||
verbose_name_plural = "User Notifications"
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "is_read"]),
|
||||
@@ -522,7 +527,9 @@ class UserNotification(TrackedModel):
|
||||
@classmethod
|
||||
def mark_all_read_for_user(cls, user):
|
||||
"""Mark all notifications as read for a specific user."""
|
||||
return cls.objects.filter(user=user, is_read=False).update(is_read=True, read_at=timezone.now())
|
||||
return cls.objects.filter(user=user, is_read=False).update(
|
||||
is_read=True, read_at=timezone.now()
|
||||
)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
@@ -535,10 +542,7 @@ class NotificationPreference(TrackedModel):
|
||||
"""
|
||||
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="notification_preference",
|
||||
help_text="User these preferences belong to",
|
||||
User, on_delete=models.CASCADE, related_name="notification_preference"
|
||||
)
|
||||
|
||||
# Submission notifications
|
||||
@@ -620,111 +624,6 @@ class NotificationPreference(TrackedModel):
|
||||
return getattr(self, field_name, False)
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class SecurityLog(models.Model):
|
||||
"""
|
||||
Model to track security-relevant authentication events.
|
||||
|
||||
All security-critical events are logged here for audit purposes,
|
||||
including logins, MFA changes, password changes, and session management.
|
||||
"""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="security_logs",
|
||||
null=True, # Allow null for failed login attempts with no valid user
|
||||
blank=True,
|
||||
help_text="User this event is associated with",
|
||||
)
|
||||
event_type = RichChoiceField(
|
||||
choice_group="security_event_types",
|
||||
domain="accounts",
|
||||
max_length=50,
|
||||
db_index=True,
|
||||
help_text="Type of security event",
|
||||
)
|
||||
ip_address = models.GenericIPAddressField(
|
||||
help_text="IP address of the request",
|
||||
)
|
||||
user_agent = models.TextField(
|
||||
blank=True,
|
||||
help_text="User agent string from the request",
|
||||
)
|
||||
metadata = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
help_text="Additional event-specific data",
|
||||
)
|
||||
created_at = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
help_text="When this event occurred",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["user", "-created_at"]),
|
||||
models.Index(fields=["event_type", "-created_at"]),
|
||||
models.Index(fields=["ip_address", "-created_at"]),
|
||||
]
|
||||
verbose_name = "Security Log"
|
||||
verbose_name_plural = "Security Logs"
|
||||
|
||||
def __str__(self):
|
||||
username = self.user.username if self.user else "Unknown"
|
||||
return f"{self.get_event_type_display()} - {username} at {self.created_at}"
|
||||
|
||||
@classmethod
|
||||
def log_event(
|
||||
cls,
|
||||
event_type: str,
|
||||
ip_address: str,
|
||||
user=None,
|
||||
user_agent: str = "",
|
||||
metadata: dict = None,
|
||||
) -> "SecurityLog":
|
||||
"""
|
||||
Create a new security log entry.
|
||||
|
||||
Args:
|
||||
event_type: One of security_event_types choices (e.g., "login_success")
|
||||
ip_address: Client IP address
|
||||
user: User instance (optional for failed logins)
|
||||
user_agent: Browser user agent string
|
||||
metadata: Additional event-specific data
|
||||
|
||||
Returns:
|
||||
The created SecurityLog instance
|
||||
"""
|
||||
return cls.objects.create(
|
||||
user=user,
|
||||
event_type=event_type,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_recent_for_user(cls, user, limit: int = 20):
|
||||
"""Get recent security events for a user."""
|
||||
return cls.objects.filter(user=user).order_by("-created_at")[:limit]
|
||||
|
||||
@classmethod
|
||||
def get_failed_login_count(cls, ip_address: str, minutes: int = 15) -> int:
|
||||
"""Count failed login attempts from an IP in the last N minutes."""
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
cutoff = timezone.now() - timedelta(minutes=minutes)
|
||||
return cls.objects.filter(
|
||||
event_type="login_failed",
|
||||
ip_address=ip_address,
|
||||
created_at__gte=cutoff,
|
||||
).count()
|
||||
|
||||
|
||||
# Signal handlers for automatic notification preference creation
|
||||
|
||||
|
||||
@@ -732,4 +631,6 @@ class SecurityLog(models.Model):
|
||||
def create_notification_preference(sender, instance, created, **kwargs):
|
||||
"""Create notification preferences when a new user is created."""
|
||||
if created:
|
||||
NotificationPreference.objects.create(user=instance)
|
||||
NotificationPreference.objects.get_or_create(user=instance)
|
||||
|
||||
# Signal moved to signals.py to avoid duplication
|
||||
@@ -3,12 +3,11 @@ Selectors for user and account-related data retrieval.
|
||||
Following Django styleguide pattern for separating data access from business logic.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
|
||||
from typing import Dict, Any
|
||||
from django.db.models import QuerySet, Q, F, Count
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Count, F, Q, QuerySet
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -27,10 +26,16 @@ def user_profile_optimized(*, user_id: int) -> Any:
|
||||
User.DoesNotExist: If user doesn't exist
|
||||
"""
|
||||
return (
|
||||
User.objects.prefetch_related("park_reviews", "ride_reviews", "socialaccount_set")
|
||||
User.objects.prefetch_related(
|
||||
"park_reviews", "ride_reviews", "socialaccount_set"
|
||||
)
|
||||
.annotate(
|
||||
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
|
||||
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
|
||||
park_review_count=Count(
|
||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||
),
|
||||
ride_review_count=Count(
|
||||
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||
),
|
||||
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||
)
|
||||
.get(id=user_id)
|
||||
@@ -47,8 +52,12 @@ def active_users_with_stats() -> QuerySet:
|
||||
return (
|
||||
User.objects.filter(is_active=True)
|
||||
.annotate(
|
||||
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
|
||||
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
|
||||
park_review_count=Count(
|
||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||
),
|
||||
ride_review_count=Count(
|
||||
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||
),
|
||||
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||
)
|
||||
.order_by("-total_review_count")
|
||||
@@ -102,8 +111,12 @@ def top_reviewers(*, limit: int = 10) -> QuerySet:
|
||||
return (
|
||||
User.objects.filter(is_active=True)
|
||||
.annotate(
|
||||
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
|
||||
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
|
||||
park_review_count=Count(
|
||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||
),
|
||||
ride_review_count=Count(
|
||||
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||
),
|
||||
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||
)
|
||||
.filter(total_review_count__gt=0)
|
||||
@@ -145,9 +158,9 @@ def users_by_registration_date(*, start_date, end_date) -> QuerySet:
|
||||
Returns:
|
||||
QuerySet of users registered in the date range
|
||||
"""
|
||||
return User.objects.filter(date_joined__date__gte=start_date, date_joined__date__lte=end_date).order_by(
|
||||
"-date_joined"
|
||||
)
|
||||
return User.objects.filter(
|
||||
date_joined__date__gte=start_date, date_joined__date__lte=end_date
|
||||
).order_by("-date_joined")
|
||||
|
||||
|
||||
def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
|
||||
@@ -162,7 +175,8 @@ def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
|
||||
QuerySet of matching users for autocomplete
|
||||
"""
|
||||
return User.objects.filter(
|
||||
Q(username__icontains=query) | Q(display_name__icontains=query),
|
||||
Q(username__icontains=query)
|
||||
| Q(display_name__icontains=query),
|
||||
is_active=True,
|
||||
).order_by("username")[:limit]
|
||||
|
||||
@@ -182,7 +196,7 @@ def users_with_social_accounts() -> QuerySet:
|
||||
)
|
||||
|
||||
|
||||
def user_statistics_summary() -> dict[str, Any]:
|
||||
def user_statistics_summary() -> Dict[str, Any]:
|
||||
"""
|
||||
Get overall user statistics for dashboard/analytics.
|
||||
|
||||
@@ -195,7 +209,11 @@ def user_statistics_summary() -> dict[str, Any]:
|
||||
|
||||
# Users with reviews
|
||||
users_with_reviews = (
|
||||
User.objects.filter(Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)).distinct().count()
|
||||
User.objects.filter(
|
||||
Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)
|
||||
)
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
# Recent registrations (last 30 days)
|
||||
@@ -209,7 +227,9 @@ def user_statistics_summary() -> dict[str, Any]:
|
||||
"staff_users": staff_users,
|
||||
"users_with_reviews": users_with_reviews,
|
||||
"recent_registrations": recent_registrations,
|
||||
"review_participation_rate": ((users_with_reviews / total_users * 100) if total_users > 0 else 0),
|
||||
"review_participation_rate": (
|
||||
(users_with_reviews / total_users * 100) if total_users > 0 else 0
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -220,7 +240,11 @@ def users_needing_email_verification() -> QuerySet:
|
||||
Returns:
|
||||
QuerySet of users with unverified emails
|
||||
"""
|
||||
return User.objects.filter(is_active=True, emailaddress__verified=False).distinct().order_by("date_joined")
|
||||
return (
|
||||
User.objects.filter(is_active=True, emailaddress__verified=False)
|
||||
.distinct()
|
||||
.order_by("date_joined")
|
||||
)
|
||||
|
||||
|
||||
def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
|
||||
@@ -235,8 +259,12 @@ def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
|
||||
"""
|
||||
return (
|
||||
User.objects.annotate(
|
||||
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
|
||||
ride_review_count=Count("ride_reviews", filter=Q(ride_reviews__is_published=True)),
|
||||
park_review_count=Count(
|
||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
||||
),
|
||||
ride_review_count=Count(
|
||||
"ride_reviews", filter=Q(ride_reviews__is_published=True)
|
||||
),
|
||||
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||
)
|
||||
.filter(total_review_count__gte=min_reviews)
|
||||
@@ -1,16 +1,14 @@
|
||||
from datetime import timedelta
|
||||
from typing import cast
|
||||
|
||||
from rest_framework import serializers
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.password_validation import validate_password
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from .models import User, PasswordReset
|
||||
from django_forwardemail.services import EmailService
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import PasswordReset, User
|
||||
from django.template.loader import render_to_string
|
||||
from typing import cast
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
@@ -21,9 +19,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
|
||||
avatar_url = serializers.SerializerMethodField()
|
||||
display_name = serializers.CharField(source="profile.display_name", required=False)
|
||||
unit_system = serializers.CharField(source="profile.unit_system", required=False)
|
||||
location = serializers.CharField(source="profile.location", required=False)
|
||||
display_name = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@@ -35,8 +31,6 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"date_joined",
|
||||
"is_active",
|
||||
"avatar_url",
|
||||
"unit_system",
|
||||
"location",
|
||||
]
|
||||
read_only_fields = ["id", "date_joined", "is_active"]
|
||||
|
||||
@@ -46,15 +40,9 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
return obj.profile.avatar.url
|
||||
return None
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
profile_data = validated_data.pop("profile", {})
|
||||
profile = instance.profile
|
||||
|
||||
for attr, value in profile_data.items():
|
||||
setattr(profile, attr, value)
|
||||
profile.save()
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
def get_display_name(self, obj) -> str:
|
||||
"""Get user display name"""
|
||||
return obj.get_display_name()
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
@@ -62,8 +50,12 @@ class LoginSerializer(serializers.Serializer):
|
||||
Serializer for user login
|
||||
"""
|
||||
|
||||
username = serializers.CharField(max_length=254, help_text="Username or email address")
|
||||
password = serializers.CharField(max_length=128, style={"input_type": "password"}, trim_whitespace=False)
|
||||
username = serializers.CharField(
|
||||
max_length=254, help_text="Username or email address"
|
||||
)
|
||||
password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}, trim_whitespace=False
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
username = attrs.get("username")
|
||||
@@ -85,7 +77,9 @@ class SignupSerializer(serializers.ModelSerializer):
|
||||
validators=[validate_password],
|
||||
style={"input_type": "password"},
|
||||
)
|
||||
password_confirm = serializers.CharField(write_only=True, style={"input_type": "password"})
|
||||
password_confirm = serializers.CharField(
|
||||
write_only=True, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@@ -112,7 +106,9 @@ class SignupSerializer(serializers.ModelSerializer):
|
||||
def validate_username(self, value):
|
||||
"""Validate username is unique"""
|
||||
if UserModel.objects.filter(username=value).exists():
|
||||
raise serializers.ValidationError("A user with this username already exists.")
|
||||
raise serializers.ValidationError(
|
||||
"A user with this username already exists."
|
||||
)
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
@@ -121,7 +117,9 @@ class SignupSerializer(serializers.ModelSerializer):
|
||||
password_confirm = attrs.get("password_confirm")
|
||||
|
||||
if password != password_confirm:
|
||||
raise serializers.ValidationError({"password_confirm": "Passwords do not match."})
|
||||
raise serializers.ValidationError(
|
||||
{"password_confirm": "Passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -184,7 +182,9 @@ class PasswordResetSerializer(serializers.Serializer):
|
||||
"site_name": site.name,
|
||||
}
|
||||
|
||||
email_html = render_to_string("accounts/email/password_reset.html", context)
|
||||
email_html = render_to_string(
|
||||
"accounts/email/password_reset.html", context
|
||||
)
|
||||
|
||||
# Narrow and validate email type for the static checker
|
||||
email = getattr(self.user, "email", None)
|
||||
@@ -206,11 +206,15 @@ class PasswordChangeSerializer(serializers.Serializer):
|
||||
Serializer for password change
|
||||
"""
|
||||
|
||||
old_password = serializers.CharField(max_length=128, style={"input_type": "password"})
|
||||
old_password = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
new_password = serializers.CharField(
|
||||
max_length=128, validators=[validate_password], style={"input_type": "password"}
|
||||
)
|
||||
new_password_confirm = serializers.CharField(max_length=128, style={"input_type": "password"})
|
||||
new_password_confirm = serializers.CharField(
|
||||
max_length=128, style={"input_type": "password"}
|
||||
)
|
||||
|
||||
def validate_old_password(self, value):
|
||||
"""Validate old password is correct"""
|
||||
@@ -225,7 +229,9 @@ class PasswordChangeSerializer(serializers.Serializer):
|
||||
new_password_confirm = attrs.get("new_password_confirm")
|
||||
|
||||
if new_password != new_password_confirm:
|
||||
raise serializers.ValidationError({"new_password_confirm": "New passwords do not match."})
|
||||
raise serializers.ValidationError(
|
||||
{"new_password_confirm": "New passwords do not match."}
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
366
apps/accounts/services.py
Normal file
366
apps/accounts/services.py
Normal file
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
User management services for ThrillWiki.
|
||||
|
||||
This module contains services for user account management including
|
||||
user deletion while preserving submissions.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django_forwardemail.services import EmailService
|
||||
from .models import User, UserProfile, UserDeletionRequest
|
||||
|
||||
|
||||
class UserDeletionService:
|
||||
"""Service for handling user deletion while preserving submissions."""
|
||||
|
||||
DELETED_USER_USERNAME = "deleted_user"
|
||||
DELETED_USER_EMAIL = "deleted@thrillwiki.com"
|
||||
DELETED_DISPLAY_NAME = "Deleted User"
|
||||
|
||||
@classmethod
|
||||
def get_or_create_deleted_user(cls) -> User:
|
||||
"""Get or create the system deleted user placeholder."""
|
||||
deleted_user, created = User.objects.get_or_create(
|
||||
username=cls.DELETED_USER_USERNAME,
|
||||
defaults={
|
||||
"email": cls.DELETED_USER_EMAIL,
|
||||
"is_active": False,
|
||||
"is_staff": False,
|
||||
"is_superuser": False,
|
||||
"role": "USER",
|
||||
"is_banned": True,
|
||||
"ban_reason": "System placeholder for deleted users",
|
||||
"ban_date": timezone.now(),
|
||||
},
|
||||
)
|
||||
|
||||
if created:
|
||||
# Create profile for deleted user
|
||||
UserProfile.objects.create(
|
||||
user=deleted_user,
|
||||
display_name=cls.DELETED_DISPLAY_NAME,
|
||||
bio="This user account has been deleted.",
|
||||
)
|
||||
|
||||
return deleted_user
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def delete_user_preserve_submissions(cls, user: User) -> dict:
|
||||
"""
|
||||
Delete a user while preserving all their submissions.
|
||||
|
||||
This method:
|
||||
1. Transfers all user submissions to a system "deleted_user" placeholder
|
||||
2. Deletes the user's profile and account data
|
||||
3. Returns a summary of what was preserved
|
||||
|
||||
Args:
|
||||
user: The user to delete
|
||||
|
||||
Returns:
|
||||
dict: Summary of preserved submissions
|
||||
"""
|
||||
if user.username == cls.DELETED_USER_USERNAME:
|
||||
raise ValueError("Cannot delete the system deleted user placeholder")
|
||||
|
||||
deleted_user = cls.get_or_create_deleted_user()
|
||||
|
||||
# Count submissions before transfer
|
||||
submission_counts = {
|
||||
"park_reviews": getattr(
|
||||
user, "park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"ride_reviews": getattr(
|
||||
user, "ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_park_photos": getattr(
|
||||
user, "uploaded_park_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"uploaded_ride_photos": getattr(
|
||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
"top_lists": getattr(
|
||||
user, "top_lists", user.__class__.objects.none()
|
||||
).count(),
|
||||
"edit_submissions": getattr(
|
||||
user, "edit_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"photo_submissions": getattr(
|
||||
user, "photo_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"moderated_park_reviews": getattr(
|
||||
user, "moderated_park_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"moderated_ride_reviews": getattr(
|
||||
user, "moderated_ride_reviews", user.__class__.objects.none()
|
||||
).count(),
|
||||
"handled_submissions": getattr(
|
||||
user, "handled_submissions", user.__class__.objects.none()
|
||||
).count(),
|
||||
"handled_photos": getattr(
|
||||
user, "handled_photos", user.__class__.objects.none()
|
||||
).count(),
|
||||
}
|
||||
|
||||
# Transfer all submissions to deleted user
|
||||
# Reviews
|
||||
if hasattr(user, "park_reviews"):
|
||||
getattr(user, "park_reviews").update(user=deleted_user)
|
||||
if hasattr(user, "ride_reviews"):
|
||||
getattr(user, "ride_reviews").update(user=deleted_user)
|
||||
|
||||
# Photos
|
||||
if hasattr(user, "uploaded_park_photos"):
|
||||
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user)
|
||||
if hasattr(user, "uploaded_ride_photos"):
|
||||
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
|
||||
|
||||
# Top Lists
|
||||
if hasattr(user, "top_lists"):
|
||||
getattr(user, "top_lists").update(user=deleted_user)
|
||||
|
||||
# Moderation submissions
|
||||
if hasattr(user, "edit_submissions"):
|
||||
getattr(user, "edit_submissions").update(user=deleted_user)
|
||||
if hasattr(user, "photo_submissions"):
|
||||
getattr(user, "photo_submissions").update(user=deleted_user)
|
||||
|
||||
# Moderation actions - these can be set to NULL since they're not user content
|
||||
if hasattr(user, "moderated_park_reviews"):
|
||||
getattr(user, "moderated_park_reviews").update(moderated_by=None)
|
||||
if hasattr(user, "moderated_ride_reviews"):
|
||||
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
|
||||
if hasattr(user, "handled_submissions"):
|
||||
getattr(user, "handled_submissions").update(handled_by=None)
|
||||
if hasattr(user, "handled_photos"):
|
||||
getattr(user, "handled_photos").update(handled_by=None)
|
||||
|
||||
# Store user info for the summary
|
||||
user_info = {
|
||||
"username": user.username,
|
||||
"user_id": user.user_id,
|
||||
"email": user.email,
|
||||
"date_joined": user.date_joined,
|
||||
}
|
||||
|
||||
# Delete the user (this will cascade delete the profile)
|
||||
user.delete()
|
||||
|
||||
return {
|
||||
"deleted_user": user_info,
|
||||
"preserved_submissions": submission_counts,
|
||||
"transferred_to": {
|
||||
"username": deleted_user.username,
|
||||
"user_id": deleted_user.user_id,
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if a user can be safely deleted.
|
||||
|
||||
Args:
|
||||
user: The user to check
|
||||
|
||||
Returns:
|
||||
tuple: (can_delete: bool, reason: Optional[str])
|
||||
"""
|
||||
if user.username == cls.DELETED_USER_USERNAME:
|
||||
return False, "Cannot delete the system deleted user placeholder"
|
||||
|
||||
if user.is_superuser:
|
||||
return False, "Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first."
|
||||
|
||||
# Check if user has critical admin role
|
||||
if user.role == "ADMIN" and user.is_staff:
|
||||
return False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator."
|
||||
|
||||
# Add any other business rules here
|
||||
|
||||
return True, None
|
||||
|
||||
@classmethod
|
||||
def request_user_deletion(cls, user: User) -> UserDeletionRequest:
|
||||
"""
|
||||
Create a user deletion request and send verification email.
|
||||
|
||||
Args:
|
||||
user: The user requesting deletion
|
||||
|
||||
Returns:
|
||||
UserDeletionRequest: The created deletion request
|
||||
"""
|
||||
# Check if user can be deleted
|
||||
can_delete, reason = cls.can_delete_user(user)
|
||||
if not can_delete:
|
||||
raise ValueError(f"Cannot delete user: {reason}")
|
||||
|
||||
# Remove any existing deletion request for this user
|
||||
UserDeletionRequest.objects.filter(user=user).delete()
|
||||
|
||||
# Create new deletion request
|
||||
deletion_request = UserDeletionRequest.objects.create(user=user)
|
||||
|
||||
# Send verification email
|
||||
cls.send_deletion_verification_email(deletion_request)
|
||||
|
||||
return deletion_request
|
||||
|
||||
@classmethod
|
||||
def send_deletion_verification_email(cls, deletion_request: UserDeletionRequest):
|
||||
"""
|
||||
Send verification email for account deletion.
|
||||
|
||||
Args:
|
||||
deletion_request: The deletion request to send email for
|
||||
"""
|
||||
user = deletion_request.user
|
||||
|
||||
# Get current site for email service
|
||||
try:
|
||||
site = Site.objects.get_current()
|
||||
except Site.DoesNotExist:
|
||||
# Fallback to default site
|
||||
site = Site.objects.get_or_create(
|
||||
id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"}
|
||||
)[0]
|
||||
|
||||
# Prepare email context
|
||||
context = {
|
||||
"user": user,
|
||||
"verification_code": deletion_request.verification_code,
|
||||
"expires_at": deletion_request.expires_at,
|
||||
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
|
||||
"frontend_domain": getattr(
|
||||
settings, "FRONTEND_DOMAIN", "http://localhost:3000"
|
||||
),
|
||||
}
|
||||
|
||||
# Render email content
|
||||
subject = f"Confirm Account Deletion - {context['site_name']}"
|
||||
|
||||
# Create email message with 1-hour expiration notice
|
||||
message = f"""
|
||||
Hello {user.get_display_name()},
|
||||
|
||||
You have requested to delete your ThrillWiki account. To confirm this action, please use the following verification code:
|
||||
|
||||
Verification Code: {deletion_request.verification_code}
|
||||
|
||||
This code will expire in 1 hour on {deletion_request.expires_at.strftime('%B %d, %Y at %I:%M %p UTC')}.
|
||||
|
||||
IMPORTANT: This action cannot be undone. Your account will be permanently deleted, but all your reviews, photos, and other contributions will be preserved on the site.
|
||||
|
||||
If you did not request this deletion, please ignore this email and your account will remain active.
|
||||
|
||||
To complete the deletion, enter the verification code in the account deletion form on our website.
|
||||
|
||||
Best regards,
|
||||
The ThrillWiki Team
|
||||
""".strip()
|
||||
|
||||
# Send email using custom email service
|
||||
try:
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
subject=subject,
|
||||
text=message,
|
||||
site=site,
|
||||
from_email="no-reply@thrillwiki.com",
|
||||
)
|
||||
|
||||
# Update email sent timestamp
|
||||
deletion_request.email_sent_at = timezone.now()
|
||||
deletion_request.save(update_fields=["email_sent_at"])
|
||||
|
||||
except Exception as e:
|
||||
# Log the error but don't fail the request creation
|
||||
print(f"Failed to send deletion verification email to {user.email}: {e}")
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def verify_and_delete_user(cls, verification_code: str) -> dict:
|
||||
"""
|
||||
Verify deletion code and delete the user account.
|
||||
|
||||
Args:
|
||||
verification_code: The verification code from the email
|
||||
|
||||
Returns:
|
||||
dict: Summary of the deletion
|
||||
|
||||
Raises:
|
||||
ValueError: If verification fails
|
||||
"""
|
||||
try:
|
||||
deletion_request = UserDeletionRequest.objects.get(
|
||||
verification_code=verification_code
|
||||
)
|
||||
except UserDeletionRequest.DoesNotExist:
|
||||
raise ValueError("Invalid verification code")
|
||||
|
||||
# Check if request is still valid
|
||||
if not deletion_request.is_valid():
|
||||
if deletion_request.is_expired():
|
||||
raise ValueError("Verification code has expired")
|
||||
elif deletion_request.is_used:
|
||||
raise ValueError("Verification code has already been used")
|
||||
elif deletion_request.attempts >= deletion_request.max_attempts:
|
||||
raise ValueError("Too many verification attempts")
|
||||
else:
|
||||
raise ValueError("Invalid verification code")
|
||||
|
||||
# Increment attempts
|
||||
deletion_request.increment_attempts()
|
||||
|
||||
# Mark as used
|
||||
deletion_request.mark_as_used()
|
||||
|
||||
# Delete the user
|
||||
user = deletion_request.user
|
||||
result = cls.delete_user_preserve_submissions(user)
|
||||
|
||||
# Add deletion request info to result
|
||||
result["deletion_request"] = {
|
||||
"verification_code": verification_code,
|
||||
"created_at": deletion_request.created_at,
|
||||
"verified_at": timezone.now(),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def cancel_deletion_request(cls, user: User) -> bool:
|
||||
"""
|
||||
Cancel a pending deletion request.
|
||||
|
||||
Args:
|
||||
user: The user whose deletion request to cancel
|
||||
|
||||
Returns:
|
||||
bool: True if a request was cancelled, False if no request existed
|
||||
"""
|
||||
try:
|
||||
deletion_request = getattr(user, "deletion_request", None)
|
||||
if deletion_request:
|
||||
deletion_request.delete()
|
||||
return True
|
||||
return False
|
||||
except UserDeletionRequest.DoesNotExist:
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def cleanup_expired_deletion_requests(cls) -> int:
|
||||
"""
|
||||
Clean up expired deletion requests.
|
||||
|
||||
Returns:
|
||||
int: Number of expired requests cleaned up
|
||||
"""
|
||||
return UserDeletionRequest.cleanup_expired()
|
||||
11
apps/accounts/services/__init__.py
Normal file
11
apps/accounts/services/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Accounts Services Package
|
||||
|
||||
This package contains business logic services for account management,
|
||||
including social provider management, user authentication, and profile services.
|
||||
"""
|
||||
|
||||
from .social_provider_service import SocialProviderService
|
||||
from .user_deletion_service import UserDeletionService
|
||||
|
||||
__all__ = ['SocialProviderService', 'UserDeletionService']
|
||||
@@ -5,19 +5,17 @@ This service handles the creation, delivery, and management of notifications
|
||||
for various events including submission approvals/rejections.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
from django_forwardemail.services import EmailService
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.template.loader import render_to_string
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
from apps.accounts.models import NotificationPreference, User, UserNotification
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.accounts.models import User, UserNotification, NotificationPreference
|
||||
from django_forwardemail.services import EmailService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,10 +29,10 @@ class NotificationService:
|
||||
notification_type: str,
|
||||
title: str,
|
||||
message: str,
|
||||
related_object: Any | None = None,
|
||||
related_object: Optional[Any] = None,
|
||||
priority: str = UserNotification.Priority.NORMAL,
|
||||
extra_data: dict[str, Any] | None = None,
|
||||
expires_at: datetime | None = None,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
expires_at: Optional[datetime] = None,
|
||||
) -> UserNotification:
|
||||
"""
|
||||
Create a new notification for a user.
|
||||
@@ -140,9 +138,7 @@ class NotificationService:
|
||||
UserNotification: The created notification
|
||||
"""
|
||||
title = f"Your {submission_type} needs attention"
|
||||
message = (
|
||||
f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
|
||||
)
|
||||
message = f"Your {submission_type} submission has been reviewed and needs some changes before it can be approved."
|
||||
message += f"\n\nReason: {rejection_reason}"
|
||||
|
||||
if additional_message:
|
||||
@@ -219,7 +215,9 @@ class NotificationService:
|
||||
preferences = NotificationPreference.objects.create(user=user)
|
||||
|
||||
# Send email notification if enabled
|
||||
if preferences.should_send_notification(notification.notification_type, "email"):
|
||||
if preferences.should_send_notification(
|
||||
notification.notification_type, "email"
|
||||
):
|
||||
NotificationService._send_email_notification(notification)
|
||||
|
||||
# Toast notifications are always created (the notification object itself)
|
||||
@@ -262,18 +260,22 @@ class NotificationService:
|
||||
notification.email_sent_at = timezone.now()
|
||||
notification.save(update_fields=["email_sent", "email_sent_at"])
|
||||
|
||||
logger.info(f"Email notification sent to {user.email} for notification {notification.id}")
|
||||
logger.info(
|
||||
f"Email notification sent to {user.email} for notification {notification.id}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
capture_and_log(e, f'Send email notification {notification.id}', source='service')
|
||||
logger.error(
|
||||
f"Failed to send email notification {notification.id}: {str(e)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_user_notifications(
|
||||
user: User,
|
||||
unread_only: bool = False,
|
||||
notification_types: list[str] | None = None,
|
||||
limit: int | None = None,
|
||||
) -> list[UserNotification]:
|
||||
notification_types: Optional[List[str]] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[UserNotification]:
|
||||
"""
|
||||
Get notifications for a user.
|
||||
|
||||
@@ -295,7 +297,9 @@ class NotificationService:
|
||||
queryset = queryset.filter(notification_type__in=notification_types)
|
||||
|
||||
# Exclude expired notifications
|
||||
queryset = queryset.filter(models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now()))
|
||||
queryset = queryset.filter(
|
||||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
|
||||
)
|
||||
|
||||
if limit:
|
||||
queryset = queryset[:limit]
|
||||
@@ -303,7 +307,9 @@ class NotificationService:
|
||||
return list(queryset)
|
||||
|
||||
@staticmethod
|
||||
def mark_notifications_read(user: User, notification_ids: list[int] | None = None) -> int:
|
||||
def mark_notifications_read(
|
||||
user: User, notification_ids: Optional[List[int]] = None
|
||||
) -> int:
|
||||
"""
|
||||
Mark notifications as read for a user.
|
||||
|
||||
@@ -334,7 +340,9 @@ class NotificationService:
|
||||
"""
|
||||
cutoff_date = timezone.now() - timedelta(days=days)
|
||||
|
||||
old_notifications = UserNotification.objects.filter(is_read=True, read_at__lt=cutoff_date)
|
||||
old_notifications = UserNotification.objects.filter(
|
||||
is_read=True, read_at__lt=cutoff_date
|
||||
)
|
||||
|
||||
count = old_notifications.count()
|
||||
old_notifications.delete()
|
||||
@@ -6,22 +6,19 @@ social authentication providers while ensuring users never lock themselves
|
||||
out of their accounts.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from typing import Dict, List, Tuple, TYPE_CHECKING
|
||||
from django.contrib.auth import get_user_model
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from allauth.socialaccount.providers import registry
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.http import HttpRequest
|
||||
import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from apps.accounts.models import User
|
||||
else:
|
||||
User = get_user_model()
|
||||
|
||||
from apps.core.utils import capture_and_log
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -29,7 +26,7 @@ class SocialProviderService:
|
||||
"""Service for managing social provider connections."""
|
||||
|
||||
@staticmethod
|
||||
def can_disconnect_provider(user: User, provider: str) -> tuple[bool, str]:
|
||||
def can_disconnect_provider(user: User, provider: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if a user can safely disconnect a social provider.
|
||||
|
||||
@@ -42,20 +39,23 @@ class SocialProviderService:
|
||||
"""
|
||||
try:
|
||||
# Count remaining social accounts after disconnection
|
||||
remaining_social_accounts = user.socialaccount_set.exclude(provider=provider).count()
|
||||
remaining_social_accounts = user.socialaccount_set.exclude(
|
||||
provider=provider
|
||||
).count()
|
||||
|
||||
# Check if user has email/password auth
|
||||
has_password_auth = user.email and user.has_usable_password() and bool(user.password) # Not empty/unusable
|
||||
has_password_auth = (
|
||||
user.email and
|
||||
user.has_usable_password() and
|
||||
bool(user.password) # Not empty/unusable
|
||||
)
|
||||
|
||||
# Allow disconnection only if alternative auth exists
|
||||
can_disconnect = remaining_social_accounts > 0 or has_password_auth
|
||||
|
||||
if not can_disconnect:
|
||||
if remaining_social_accounts == 0 and not has_password_auth:
|
||||
return (
|
||||
False,
|
||||
"Cannot disconnect your only authentication method. Please set up a password or connect another social provider first.",
|
||||
)
|
||||
return False, "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first."
|
||||
elif not has_password_auth:
|
||||
return False, "Please set up email/password authentication before disconnecting this provider."
|
||||
else:
|
||||
@@ -64,11 +64,12 @@ class SocialProviderService:
|
||||
return True, "Provider can be safely disconnected."
|
||||
|
||||
except Exception as e:
|
||||
capture_and_log(e, f'Check disconnect permission for user {user.id}, provider {provider}', source='service')
|
||||
logger.error(
|
||||
f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
|
||||
return False, "Unable to verify disconnection safety. Please try again."
|
||||
|
||||
@staticmethod
|
||||
def get_connected_providers(user: "User") -> list[dict]:
|
||||
def get_connected_providers(user: "User") -> List[Dict]:
|
||||
"""
|
||||
Get all social providers connected to a user's account.
|
||||
|
||||
@@ -82,16 +83,18 @@ class SocialProviderService:
|
||||
connected_providers = []
|
||||
|
||||
for social_account in user.socialaccount_set.all():
|
||||
can_disconnect, reason = SocialProviderService.can_disconnect_provider(user, social_account.provider)
|
||||
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
|
||||
user, social_account.provider
|
||||
)
|
||||
|
||||
provider_info = {
|
||||
"provider": social_account.provider,
|
||||
"provider_name": social_account.get_provider().name,
|
||||
"uid": social_account.uid,
|
||||
"date_joined": social_account.date_joined,
|
||||
"can_disconnect": can_disconnect,
|
||||
"disconnect_reason": reason if not can_disconnect else None,
|
||||
"extra_data": social_account.extra_data,
|
||||
'provider': social_account.provider,
|
||||
'provider_name': social_account.get_provider().name,
|
||||
'uid': social_account.uid,
|
||||
'date_joined': social_account.date_joined,
|
||||
'can_disconnect': can_disconnect,
|
||||
'disconnect_reason': reason if not can_disconnect else None,
|
||||
'extra_data': social_account.extra_data
|
||||
}
|
||||
|
||||
connected_providers.append(provider_info)
|
||||
@@ -99,11 +102,11 @@ class SocialProviderService:
|
||||
return connected_providers
|
||||
|
||||
except Exception as e:
|
||||
capture_and_log(e, f'Get connected providers for user {user.id}', source='service')
|
||||
logger.error(f"Error getting connected providers for user {user.id}: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_available_providers(request: HttpRequest) -> list[dict]:
|
||||
def get_available_providers(request: HttpRequest) -> List[Dict]:
|
||||
"""
|
||||
Get all available social providers for the current site.
|
||||
|
||||
@@ -118,35 +121,38 @@ class SocialProviderService:
|
||||
available_providers = []
|
||||
|
||||
# Get all social apps configured for this site
|
||||
social_apps = SocialApp.objects.filter(sites=site).order_by("provider")
|
||||
social_apps = SocialApp.objects.filter(sites=site).order_by('provider')
|
||||
|
||||
for social_app in social_apps:
|
||||
try:
|
||||
provider = registry.by_id(social_app.provider)
|
||||
|
||||
provider_info = {
|
||||
"id": social_app.provider,
|
||||
"name": provider.name,
|
||||
"auth_url": request.build_absolute_uri(f"/accounts/{social_app.provider}/login/"),
|
||||
"connect_url": request.build_absolute_uri(
|
||||
f"/api/v1/auth/social/connect/{social_app.provider}/"
|
||||
'id': social_app.provider,
|
||||
'name': provider.name,
|
||||
'auth_url': request.build_absolute_uri(
|
||||
f'/accounts/{social_app.provider}/login/'
|
||||
),
|
||||
'connect_url': request.build_absolute_uri(
|
||||
f'/api/v1/auth/social/connect/{social_app.provider}/'
|
||||
)
|
||||
}
|
||||
|
||||
available_providers.append(provider_info)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error processing provider {social_app.provider}: {e}")
|
||||
logger.warning(
|
||||
f"Error processing provider {social_app.provider}: {e}")
|
||||
continue
|
||||
|
||||
return available_providers
|
||||
|
||||
except Exception as e:
|
||||
capture_and_log(e, 'Get available providers', source='service')
|
||||
logger.error(f"Error getting available providers: {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def disconnect_provider(user: "User", provider: str) -> tuple[bool, str]:
|
||||
def disconnect_provider(user: "User", provider: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Disconnect a social provider from a user's account.
|
||||
|
||||
@@ -159,7 +165,8 @@ class SocialProviderService:
|
||||
"""
|
||||
try:
|
||||
# First check if disconnection is allowed
|
||||
can_disconnect, reason = SocialProviderService.can_disconnect_provider(user, provider)
|
||||
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
|
||||
user, provider)
|
||||
|
||||
if not can_disconnect:
|
||||
return False, reason
|
||||
@@ -174,16 +181,17 @@ class SocialProviderService:
|
||||
deleted_count = social_accounts.count()
|
||||
social_accounts.delete()
|
||||
|
||||
logger.info(f"User {user.id} disconnected {deleted_count} {provider} account(s)")
|
||||
logger.info(
|
||||
f"User {user.id} disconnected {deleted_count} {provider} account(s)")
|
||||
|
||||
return True, f"{provider.title()} account disconnected successfully."
|
||||
|
||||
except Exception as e:
|
||||
capture_and_log(e, f'Disconnect {provider} for user {user.id}', source='service')
|
||||
logger.error(f"Error disconnecting {provider} for user {user.id}: {e}")
|
||||
return False, f"Failed to disconnect {provider} account. Please try again."
|
||||
|
||||
@staticmethod
|
||||
def get_auth_status(user: "User") -> dict:
|
||||
def get_auth_status(user: "User") -> Dict:
|
||||
"""
|
||||
Get comprehensive authentication status for a user.
|
||||
|
||||
@@ -196,27 +204,34 @@ class SocialProviderService:
|
||||
try:
|
||||
connected_providers = SocialProviderService.get_connected_providers(user)
|
||||
|
||||
has_password_auth = user.email and user.has_usable_password() and bool(user.password)
|
||||
has_password_auth = (
|
||||
user.email and
|
||||
user.has_usable_password() and
|
||||
bool(user.password)
|
||||
)
|
||||
|
||||
auth_methods_count = len(connected_providers) + (1 if has_password_auth else 0)
|
||||
auth_methods_count = len(connected_providers) + \
|
||||
(1 if has_password_auth else 0)
|
||||
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
"has_password_auth": has_password_auth,
|
||||
"connected_providers": connected_providers,
|
||||
"total_auth_methods": auth_methods_count,
|
||||
"can_disconnect_any": auth_methods_count > 1,
|
||||
"requires_password_setup": not has_password_auth and len(connected_providers) == 1,
|
||||
'user_id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'has_password_auth': has_password_auth,
|
||||
'connected_providers': connected_providers,
|
||||
'total_auth_methods': auth_methods_count,
|
||||
'can_disconnect_any': auth_methods_count > 1,
|
||||
'requires_password_setup': not has_password_auth and len(connected_providers) == 1
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
capture_and_log(e, f'Get auth status for user {user.id}', source='service')
|
||||
return {"error": "Unable to retrieve authentication status"}
|
||||
logger.error(f"Error getting auth status for user {user.id}: {e}")
|
||||
return {
|
||||
'error': 'Unable to retrieve authentication status'
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def validate_provider_exists(provider: str) -> tuple[bool, str]:
|
||||
def validate_provider_exists(provider: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate that a social provider is configured and available.
|
||||
|
||||
@@ -238,5 +253,5 @@ class SocialProviderService:
|
||||
return True, f"Provider '{provider}' is valid and available."
|
||||
|
||||
except Exception as e:
|
||||
capture_and_log(e, f'Validate provider {provider}', source='service')
|
||||
logger.error(f"Error validating provider {provider}: {e}")
|
||||
return False, "Unable to validate provider."
|
||||
@@ -5,20 +5,19 @@ This service handles user account deletion while preserving submissions
|
||||
and maintaining data integrity across the platform.
|
||||
"""
|
||||
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.template.loader import render_to_string
|
||||
from typing import Dict, Any, Tuple, Optional
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.mail import send_mail
|
||||
from django.db import transaction
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.utils import capture_and_log
|
||||
from apps.accounts.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,34 +37,11 @@ class UserDeletionRequest:
|
||||
class UserDeletionService:
|
||||
"""Service for handling user account deletion with submission preservation."""
|
||||
|
||||
# Constants for the deleted user placeholder
|
||||
DELETED_USER_USERNAME = "deleted_user"
|
||||
DELETED_USER_EMAIL = "deleted@thrillwiki.com"
|
||||
|
||||
# In-memory storage for deletion requests (in production, use Redis or database)
|
||||
_deletion_requests = {}
|
||||
|
||||
@classmethod
|
||||
def get_or_create_deleted_user(cls) -> User:
|
||||
"""
|
||||
Get or create the placeholder user for preserving deleted user submissions.
|
||||
|
||||
Returns:
|
||||
User: The deleted user placeholder
|
||||
"""
|
||||
deleted_user, created = User.objects.get_or_create(
|
||||
username=cls.DELETED_USER_USERNAME,
|
||||
defaults={
|
||||
"email": cls.DELETED_USER_EMAIL,
|
||||
"is_active": False,
|
||||
"is_banned": True,
|
||||
"ban_date": timezone.now(), # Required when is_banned=True
|
||||
},
|
||||
)
|
||||
return deleted_user
|
||||
|
||||
@staticmethod
|
||||
def can_delete_user(user: User) -> tuple[bool, str | None]:
|
||||
def can_delete_user(user: User) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if a user can be safely deleted.
|
||||
|
||||
@@ -75,10 +51,6 @@ class UserDeletionService:
|
||||
Returns:
|
||||
Tuple[bool, Optional[str]]: (can_delete, reason_if_not)
|
||||
"""
|
||||
# Prevent deletion of the placeholder user
|
||||
if user.username == UserDeletionService.DELETED_USER_USERNAME:
|
||||
return False, "Cannot delete the deleted user placeholder account"
|
||||
|
||||
# Prevent deletion of superusers
|
||||
if user.is_superuser:
|
||||
return False, "Cannot delete superuser accounts"
|
||||
@@ -88,7 +60,7 @@ class UserDeletionService:
|
||||
return False, "Cannot delete staff accounts"
|
||||
|
||||
# Check for system users (if you have any special system accounts)
|
||||
if hasattr(user, "role") and user.role in ["ADMIN", "MODERATOR"]:
|
||||
if hasattr(user, 'role') and user.role in ['ADMIN', 'MODERATOR']:
|
||||
return False, "Cannot delete admin or moderator accounts"
|
||||
|
||||
return True, None
|
||||
@@ -113,7 +85,8 @@ class UserDeletionService:
|
||||
raise ValueError(reason)
|
||||
|
||||
# Generate verification code
|
||||
verification_code = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
|
||||
verification_code = ''.join(secrets.choice(
|
||||
string.ascii_uppercase + string.digits) for _ in range(8))
|
||||
|
||||
# Set expiration (24 hours from now)
|
||||
expires_at = timezone.now() + timezone.timedelta(hours=24)
|
||||
@@ -124,13 +97,14 @@ class UserDeletionService:
|
||||
# Store request (in production, use Redis or database)
|
||||
UserDeletionService._deletion_requests[verification_code] = deletion_request
|
||||
|
||||
# Send verification email (use public method for testability)
|
||||
UserDeletionService.send_deletion_verification_email(user, verification_code, expires_at)
|
||||
# Send verification email
|
||||
UserDeletionService._send_deletion_verification_email(
|
||||
user, verification_code, expires_at)
|
||||
|
||||
return deletion_request
|
||||
|
||||
@staticmethod
|
||||
def verify_and_delete_user(verification_code: str) -> dict[str, Any]:
|
||||
def verify_and_delete_user(verification_code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verify deletion code and delete user account.
|
||||
|
||||
@@ -163,10 +137,10 @@ class UserDeletionService:
|
||||
del UserDeletionService._deletion_requests[verification_code]
|
||||
|
||||
# Add verification info to result
|
||||
result["deletion_request"] = {
|
||||
"verification_code": verification_code,
|
||||
"created_at": deletion_request.created_at,
|
||||
"verified_at": timezone.now(),
|
||||
result['deletion_request'] = {
|
||||
'verification_code': verification_code,
|
||||
'created_at': deletion_request.created_at,
|
||||
'verified_at': timezone.now(),
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -193,9 +167,9 @@ class UserDeletionService:
|
||||
|
||||
return len(to_remove) > 0
|
||||
|
||||
@classmethod
|
||||
@staticmethod
|
||||
@transaction.atomic
|
||||
def delete_user_preserve_submissions(cls, user: User) -> dict[str, Any]:
|
||||
def delete_user_preserve_submissions(user: User) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a user account while preserving all their submissions.
|
||||
|
||||
@@ -204,57 +178,65 @@ class UserDeletionService:
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Information about the deletion and preserved submissions
|
||||
|
||||
Raises:
|
||||
ValueError: If attempting to delete the placeholder user
|
||||
"""
|
||||
# Prevent deleting the placeholder user
|
||||
if user.username == cls.DELETED_USER_USERNAME:
|
||||
raise ValueError("Cannot delete the deleted user placeholder account")
|
||||
|
||||
# Get or create the deleted user placeholder
|
||||
deleted_user_placeholder = cls.get_or_create_deleted_user()
|
||||
# Get or create the "deleted_user" placeholder
|
||||
deleted_user_placeholder, created = User.objects.get_or_create(
|
||||
username='deleted_user',
|
||||
defaults={
|
||||
'email': 'deleted@thrillwiki.com',
|
||||
'first_name': 'Deleted',
|
||||
'last_name': 'User',
|
||||
'is_active': False,
|
||||
}
|
||||
)
|
||||
|
||||
# Count submissions before transfer
|
||||
submission_counts = cls._count_user_submissions(user)
|
||||
submission_counts = UserDeletionService._count_user_submissions(user)
|
||||
|
||||
# Transfer submissions to placeholder user
|
||||
cls._transfer_user_submissions(user, deleted_user_placeholder)
|
||||
UserDeletionService._transfer_user_submissions(user, deleted_user_placeholder)
|
||||
|
||||
# Store user info before deletion
|
||||
deleted_user_info = {
|
||||
"username": user.username,
|
||||
"user_id": getattr(user, "user_id", user.id),
|
||||
"email": user.email,
|
||||
"date_joined": user.date_joined,
|
||||
'username': user.username,
|
||||
'user_id': getattr(user, 'user_id', user.id),
|
||||
'email': user.email,
|
||||
'date_joined': user.date_joined,
|
||||
}
|
||||
|
||||
# Delete the user account
|
||||
user.delete()
|
||||
|
||||
return {
|
||||
"deleted_user": deleted_user_info,
|
||||
"preserved_submissions": submission_counts,
|
||||
"transferred_to": {
|
||||
"username": deleted_user_placeholder.username,
|
||||
"user_id": getattr(deleted_user_placeholder, "user_id", deleted_user_placeholder.id),
|
||||
},
|
||||
'deleted_user': deleted_user_info,
|
||||
'preserved_submissions': submission_counts,
|
||||
'transferred_to': {
|
||||
'username': deleted_user_placeholder.username,
|
||||
'user_id': getattr(deleted_user_placeholder, 'user_id', deleted_user_placeholder.id),
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _count_user_submissions(user: User) -> dict[str, int]:
|
||||
def _count_user_submissions(user: User) -> Dict[str, int]:
|
||||
"""Count all submissions for a user."""
|
||||
counts = {}
|
||||
|
||||
# Count different types of submissions
|
||||
# Note: These are placeholder counts - adjust based on your actual models
|
||||
counts["park_reviews"] = getattr(user, "park_reviews", user.__class__.objects.none()).count()
|
||||
counts["ride_reviews"] = getattr(user, "ride_reviews", user.__class__.objects.none()).count()
|
||||
counts["uploaded_park_photos"] = getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count()
|
||||
counts["uploaded_ride_photos"] = getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count()
|
||||
counts["top_lists"] = getattr(user, "top_lists", user.__class__.objects.none()).count()
|
||||
counts["edit_submissions"] = getattr(user, "edit_submissions", user.__class__.objects.none()).count()
|
||||
counts["photo_submissions"] = getattr(user, "photo_submissions", user.__class__.objects.none()).count()
|
||||
counts['park_reviews'] = getattr(
|
||||
user, 'park_reviews', user.__class__.objects.none()).count()
|
||||
counts['ride_reviews'] = getattr(
|
||||
user, 'ride_reviews', user.__class__.objects.none()).count()
|
||||
counts['uploaded_park_photos'] = getattr(
|
||||
user, 'uploaded_park_photos', user.__class__.objects.none()).count()
|
||||
counts['uploaded_ride_photos'] = getattr(
|
||||
user, 'uploaded_ride_photos', user.__class__.objects.none()).count()
|
||||
counts['top_lists'] = getattr(
|
||||
user, 'top_lists', user.__class__.objects.none()).count()
|
||||
counts['edit_submissions'] = getattr(
|
||||
user, 'edit_submissions', user.__class__.objects.none()).count()
|
||||
counts['photo_submissions'] = getattr(
|
||||
user, 'photo_submissions', user.__class__.objects.none()).count()
|
||||
|
||||
return counts
|
||||
|
||||
@@ -266,59 +248,49 @@ class UserDeletionService:
|
||||
# Note: Adjust these based on your actual model relationships
|
||||
|
||||
# Park reviews
|
||||
if hasattr(user, "park_reviews"):
|
||||
if hasattr(user, 'park_reviews'):
|
||||
user.park_reviews.all().update(user=placeholder_user)
|
||||
|
||||
# Ride reviews
|
||||
if hasattr(user, "ride_reviews"):
|
||||
if hasattr(user, 'ride_reviews'):
|
||||
user.ride_reviews.all().update(user=placeholder_user)
|
||||
|
||||
# Uploaded photos - use uploaded_by field, not user
|
||||
if hasattr(user, "uploaded_park_photos"):
|
||||
user.uploaded_park_photos.all().update(uploaded_by=placeholder_user)
|
||||
# Uploaded photos
|
||||
if hasattr(user, 'uploaded_park_photos'):
|
||||
user.uploaded_park_photos.all().update(user=placeholder_user)
|
||||
|
||||
if hasattr(user, "uploaded_ride_photos"):
|
||||
user.uploaded_ride_photos.all().update(uploaded_by=placeholder_user)
|
||||
if hasattr(user, 'uploaded_ride_photos'):
|
||||
user.uploaded_ride_photos.all().update(user=placeholder_user)
|
||||
|
||||
# Top lists
|
||||
if hasattr(user, "top_lists"):
|
||||
if hasattr(user, 'top_lists'):
|
||||
user.top_lists.all().update(user=placeholder_user)
|
||||
|
||||
# Edit submissions
|
||||
if hasattr(user, "edit_submissions"):
|
||||
if hasattr(user, 'edit_submissions'):
|
||||
user.edit_submissions.all().update(user=placeholder_user)
|
||||
|
||||
# Photo submissions
|
||||
if hasattr(user, "photo_submissions"):
|
||||
if hasattr(user, 'photo_submissions'):
|
||||
user.photo_submissions.all().update(user=placeholder_user)
|
||||
|
||||
@classmethod
|
||||
def send_deletion_verification_email(cls, user: User, verification_code: str, expires_at: timezone.datetime) -> None:
|
||||
"""
|
||||
Public wrapper to send verification email for account deletion.
|
||||
|
||||
Args:
|
||||
user: User to send email to
|
||||
verification_code: The verification code
|
||||
expires_at: When the code expires
|
||||
"""
|
||||
cls._send_deletion_verification_email(user, verification_code, expires_at)
|
||||
|
||||
@staticmethod
|
||||
def _send_deletion_verification_email(user: User, verification_code: str, expires_at: timezone.datetime) -> None:
|
||||
"""Send verification email for account deletion."""
|
||||
try:
|
||||
context = {
|
||||
"user": user,
|
||||
"verification_code": verification_code,
|
||||
"expires_at": expires_at,
|
||||
"site_name": "ThrillWiki",
|
||||
"site_url": getattr(settings, "SITE_URL", "https://thrillwiki.com"),
|
||||
'user': user,
|
||||
'verification_code': verification_code,
|
||||
'expires_at': expires_at,
|
||||
'site_name': 'ThrillWiki',
|
||||
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
|
||||
}
|
||||
|
||||
subject = "ThrillWiki: Confirm Account Deletion"
|
||||
html_message = render_to_string("emails/account_deletion_verification.html", context)
|
||||
plain_message = render_to_string("emails/account_deletion_verification.txt", context)
|
||||
subject = 'ThrillWiki: Confirm Account Deletion'
|
||||
html_message = render_to_string(
|
||||
'emails/account_deletion_verification.html', context)
|
||||
plain_message = render_to_string(
|
||||
'emails/account_deletion_verification.txt', context)
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
@@ -332,5 +304,6 @@ class UserDeletionService:
|
||||
logger.info(f"Deletion verification email sent to {user.email}")
|
||||
|
||||
except Exception as e:
|
||||
capture_and_log(e, f'Send deletion verification email to {user.email}', source='service')
|
||||
logger.error(
|
||||
f"Failed to send deletion verification email to {user.email}: {str(e)}")
|
||||
raise
|
||||
169
apps/accounts/signals.py
Normal file
169
apps/accounts/signals.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
from django.contrib.auth.models import Group
|
||||
from django.db import transaction
|
||||
from django.core.files import File
|
||||
from django.core.files.temp import NamedTemporaryFile
|
||||
import requests
|
||||
from .models import User, UserProfile
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_user_profile(sender, instance, created, **kwargs):
|
||||
"""Create UserProfile for new users - unified signal handler"""
|
||||
if created:
|
||||
try:
|
||||
# Use get_or_create to prevent duplicates
|
||||
profile, profile_created = UserProfile.objects.get_or_create(user=instance)
|
||||
|
||||
if profile_created:
|
||||
# If user has a social account with avatar, download it
|
||||
try:
|
||||
social_account = instance.socialaccount_set.first()
|
||||
if social_account:
|
||||
extra_data = social_account.extra_data
|
||||
avatar_url = None
|
||||
|
||||
if social_account.provider == "google":
|
||||
avatar_url = extra_data.get("picture")
|
||||
elif social_account.provider == "discord":
|
||||
avatar = extra_data.get("avatar")
|
||||
discord_id = extra_data.get("id")
|
||||
if avatar:
|
||||
avatar_url = f"https://cdn.discordapp.com/avatars/{discord_id}/{avatar}.png"
|
||||
|
||||
if avatar_url:
|
||||
response = requests.get(avatar_url, timeout=60)
|
||||
if response.status_code == 200:
|
||||
img_temp = NamedTemporaryFile(delete=True)
|
||||
img_temp.write(response.content)
|
||||
img_temp.flush()
|
||||
|
||||
file_name = f"avatar_{instance.username}.png"
|
||||
profile.avatar.save(file_name, File(img_temp), save=True)
|
||||
except Exception as e:
|
||||
print(f"Error downloading avatar for user {instance.username}: {str(e)}")
|
||||
except Exception as e:
|
||||
print(f"Error creating profile for user {instance.username}: {str(e)}")
|
||||
|
||||
|
||||
@receiver(pre_save, sender=User)
|
||||
def sync_user_role_with_groups(sender, instance, **kwargs):
|
||||
"""Sync user role with Django groups"""
|
||||
if instance.pk: # Only for existing users
|
||||
try:
|
||||
old_instance = User.objects.get(pk=instance.pk)
|
||||
if old_instance.role != instance.role:
|
||||
# Role has changed, update groups
|
||||
with transaction.atomic():
|
||||
# Remove from old role group if exists
|
||||
if old_instance.role != "USER":
|
||||
old_group = Group.objects.filter(name=old_instance.role).first()
|
||||
if old_group:
|
||||
instance.groups.remove(old_group)
|
||||
|
||||
# Add to new role group
|
||||
if instance.role != "USER":
|
||||
new_group, _ = Group.objects.get_or_create(name=instance.role)
|
||||
instance.groups.add(new_group)
|
||||
|
||||
# Special handling for superuser role
|
||||
if instance.role == "SUPERUSER":
|
||||
instance.is_superuser = True
|
||||
instance.is_staff = True
|
||||
elif old_instance.role == "SUPERUSER":
|
||||
# If removing superuser role, remove superuser
|
||||
# status
|
||||
instance.is_superuser = False
|
||||
if instance.role not in [
|
||||
"ADMIN",
|
||||
"MODERATOR",
|
||||
]:
|
||||
instance.is_staff = False
|
||||
|
||||
# Handle staff status for admin and moderator roles
|
||||
if instance.role in [
|
||||
"ADMIN",
|
||||
"MODERATOR",
|
||||
]:
|
||||
instance.is_staff = True
|
||||
elif old_instance.role in [
|
||||
"ADMIN",
|
||||
"MODERATOR",
|
||||
]:
|
||||
# If removing admin/moderator role, remove staff
|
||||
# status
|
||||
if instance.role not in ["SUPERUSER"]:
|
||||
instance.is_staff = False
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error syncing role with groups for user {instance.username}: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def create_default_groups():
|
||||
"""
|
||||
Create default groups with appropriate permissions.
|
||||
Call this in a migration or management command.
|
||||
"""
|
||||
try:
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
# Create Moderator group
|
||||
moderator_group, _ = Group.objects.get_or_create(name="MODERATOR")
|
||||
moderator_permissions = [
|
||||
# Review moderation permissions
|
||||
"change_review",
|
||||
"delete_review",
|
||||
"change_reviewreport",
|
||||
"delete_reviewreport",
|
||||
# Edit moderation permissions
|
||||
"change_parkedit",
|
||||
"delete_parkedit",
|
||||
"change_rideedit",
|
||||
"delete_rideedit",
|
||||
"change_companyedit",
|
||||
"delete_companyedit",
|
||||
"change_manufactureredit",
|
||||
"delete_manufactureredit",
|
||||
]
|
||||
|
||||
# Create Admin group
|
||||
admin_group, _ = Group.objects.get_or_create(name="ADMIN")
|
||||
admin_permissions = moderator_permissions + [
|
||||
# User management permissions
|
||||
"change_user",
|
||||
"delete_user",
|
||||
# Content management permissions
|
||||
"add_park",
|
||||
"change_park",
|
||||
"delete_park",
|
||||
"add_ride",
|
||||
"change_ride",
|
||||
"delete_ride",
|
||||
"add_company",
|
||||
"change_company",
|
||||
"delete_company",
|
||||
"add_manufacturer",
|
||||
"change_manufacturer",
|
||||
"delete_manufacturer",
|
||||
]
|
||||
|
||||
# Assign permissions to groups
|
||||
for codename in moderator_permissions:
|
||||
try:
|
||||
perm = Permission.objects.get(codename=codename)
|
||||
moderator_group.permissions.add(perm)
|
||||
except Permission.DoesNotExist:
|
||||
print(f"Permission not found: {codename}")
|
||||
|
||||
for codename in admin_permissions:
|
||||
try:
|
||||
perm = Permission.objects.get(codename=codename)
|
||||
admin_group.permissions.add(perm)
|
||||
except Permission.DoesNotExist:
|
||||
print(f"Permission not found: {codename}")
|
||||
except Exception as e:
|
||||
print(f"Error creating default groups: {str(e)}")
|
||||
@@ -1,9 +1,7 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
from .models import User, UserProfile
|
||||
from .signals import create_default_groups
|
||||
|
||||
@@ -111,12 +109,18 @@ class SignalsTestCase(TestCase):
|
||||
|
||||
create_default_groups()
|
||||
|
||||
moderator_group = Group.objects.get(name=User.Roles.MODERATOR)
|
||||
moderator_group = Group.objects.get(name="MODERATOR")
|
||||
self.assertIsNotNone(moderator_group)
|
||||
self.assertTrue(moderator_group.permissions.filter(codename="change_review").exists())
|
||||
self.assertFalse(moderator_group.permissions.filter(codename="change_user").exists())
|
||||
self.assertTrue(
|
||||
moderator_group.permissions.filter(codename="change_review").exists()
|
||||
)
|
||||
self.assertFalse(
|
||||
moderator_group.permissions.filter(codename="change_user").exists()
|
||||
)
|
||||
|
||||
admin_group = Group.objects.get(name=User.Roles.ADMIN)
|
||||
admin_group = Group.objects.get(name="ADMIN")
|
||||
self.assertIsNotNone(admin_group)
|
||||
self.assertTrue(admin_group.permissions.filter(codename="change_review").exists())
|
||||
self.assertTrue(
|
||||
admin_group.permissions.filter(codename="change_review").exists()
|
||||
)
|
||||
self.assertTrue(admin_group.permissions.filter(codename="change_user").exists())
|
||||
@@ -2,11 +2,10 @@
|
||||
Tests for user deletion while preserving submissions.
|
||||
"""
|
||||
|
||||
from django.db import transaction
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.accounts.models import User, UserProfile
|
||||
from django.db import transaction
|
||||
from apps.accounts.services import UserDeletionService
|
||||
from apps.accounts.models import User, UserProfile
|
||||
|
||||
|
||||
class UserDeletionServiceTest(TestCase):
|
||||
@@ -14,8 +13,10 @@ class UserDeletionServiceTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
# Create test users (signals auto-create UserProfile)
|
||||
self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123")
|
||||
# Create test users
|
||||
self.user = User.objects.create_user(
|
||||
username="testuser", email="test@example.com", password="testpass123"
|
||||
)
|
||||
|
||||
self.admin_user = User.objects.create_user(
|
||||
username="admin",
|
||||
@@ -24,14 +25,14 @@ class UserDeletionServiceTest(TestCase):
|
||||
is_superuser=True,
|
||||
)
|
||||
|
||||
# Update auto-created profiles (signals already created them)
|
||||
self.user.profile.display_name = "Test User"
|
||||
self.user.profile.bio = "Test bio"
|
||||
self.user.profile.save()
|
||||
# Create user profiles
|
||||
UserProfile.objects.create(
|
||||
user=self.user, display_name="Test User", bio="Test bio"
|
||||
)
|
||||
|
||||
self.admin_user.profile.display_name = "Admin User"
|
||||
self.admin_user.profile.bio = "Admin bio"
|
||||
self.admin_user.profile.save()
|
||||
UserProfile.objects.create(
|
||||
user=self.admin_user, display_name="Admin User", bio="Admin bio"
|
||||
)
|
||||
|
||||
def test_get_or_create_deleted_user(self):
|
||||
"""Test that deleted user placeholder is created correctly."""
|
||||
@@ -41,9 +42,11 @@ class UserDeletionServiceTest(TestCase):
|
||||
self.assertEqual(deleted_user.email, "deleted@thrillwiki.com")
|
||||
self.assertFalse(deleted_user.is_active)
|
||||
self.assertTrue(deleted_user.is_banned)
|
||||
self.assertEqual(deleted_user.role, "USER")
|
||||
|
||||
# Check profile was created (by signal, defaults display_name to username)
|
||||
# Check profile was created
|
||||
self.assertTrue(hasattr(deleted_user, "profile"))
|
||||
self.assertEqual(deleted_user.profile.display_name, "Deleted User")
|
||||
|
||||
def test_get_or_create_deleted_user_idempotent(self):
|
||||
"""Test that calling get_or_create_deleted_user multiple times returns same user."""
|
||||
@@ -73,7 +76,7 @@ class UserDeletionServiceTest(TestCase):
|
||||
can_delete, reason = UserDeletionService.can_delete_user(deleted_user)
|
||||
|
||||
self.assertFalse(can_delete)
|
||||
self.assertEqual(reason, "Cannot delete the deleted user placeholder account")
|
||||
self.assertEqual(reason, "Cannot delete the system deleted user placeholder")
|
||||
|
||||
def test_delete_user_preserve_submissions_no_submissions(self):
|
||||
"""Test deleting user with no submissions."""
|
||||
@@ -104,7 +107,9 @@ class UserDeletionServiceTest(TestCase):
|
||||
with self.assertRaises(ValueError) as context:
|
||||
UserDeletionService.delete_user_preserve_submissions(deleted_user)
|
||||
|
||||
self.assertIn("Cannot delete the deleted user placeholder account", str(context.exception))
|
||||
self.assertIn(
|
||||
"Cannot delete the system deleted user placeholder", str(context.exception)
|
||||
)
|
||||
|
||||
def test_delete_user_with_submissions_transfers_correctly(self):
|
||||
"""Test that user submissions are transferred to deleted user placeholder."""
|
||||
@@ -135,12 +140,13 @@ class UserDeletionServiceTest(TestCase):
|
||||
original_user_count = User.objects.count()
|
||||
|
||||
# Mock a failure during the deletion process
|
||||
with self.assertRaises(Exception), transaction.atomic(): # noqa: B017
|
||||
# Start the deletion process
|
||||
UserDeletionService.get_or_create_deleted_user()
|
||||
with self.assertRaises(Exception):
|
||||
with transaction.atomic():
|
||||
# Start the deletion process
|
||||
UserDeletionService.get_or_create_deleted_user()
|
||||
|
||||
# Simulate an error
|
||||
raise Exception("Simulated error during deletion")
|
||||
# Simulate an error
|
||||
raise Exception("Simulated error during deletion")
|
||||
|
||||
# Verify user count hasn't changed
|
||||
self.assertEqual(User.objects.count(), original_user_count)
|
||||
@@ -1,7 +1,6 @@
|
||||
from allauth.account.views import LogoutView
|
||||
from django.contrib.auth import views as auth_views
|
||||
from django.urls import path
|
||||
|
||||
from django.contrib.auth import views as auth_views
|
||||
from allauth.account.views import LogoutView
|
||||
from . import views
|
||||
|
||||
app_name = "accounts"
|
||||
@@ -1,44 +1,38 @@
|
||||
import logging
|
||||
import re
|
||||
from contextlib import suppress
|
||||
from datetime import timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
from allauth.account.views import LoginView, SignupView
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model, login
|
||||
from django.views.generic import DetailView, TemplateView
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib import messages
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.contrib.sites.models import Site
|
||||
from django.contrib.sites.requests import RequestSite
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from django.db.models import QuerySet
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template.loader import render_to_string
|
||||
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.views.generic import DetailView, TemplateView
|
||||
from django_forwardemail.services import EmailService
|
||||
from django_htmx.http import HttpResponseClientRefresh
|
||||
|
||||
from django.contrib.auth import login
|
||||
from django.core.files.uploadedfile import UploadedFile
|
||||
from apps.accounts.models import (
|
||||
EmailVerification,
|
||||
PasswordReset,
|
||||
User,
|
||||
PasswordReset,
|
||||
TopList,
|
||||
EmailVerification,
|
||||
UserProfile,
|
||||
)
|
||||
from apps.core.logging import log_security_event
|
||||
from apps.lists.models import UserList
|
||||
from django_forwardemail.services import EmailService
|
||||
from apps.parks.models import ParkReview
|
||||
from apps.rides.models import RideReview
|
||||
|
||||
from allauth.account.views import LoginView, SignupView
|
||||
from .mixins import TurnstileMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from typing import Dict, Any, Optional, Union, cast
|
||||
from django_htmx.http import HttpResponseClientRefresh
|
||||
from contextlib import suppress
|
||||
import re
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
@@ -52,26 +46,13 @@ class CustomLoginView(TurnstileMixin, LoginView):
|
||||
return self.form_invalid(form)
|
||||
|
||||
response = super().form_valid(form)
|
||||
user = self.request.user
|
||||
log_security_event(
|
||||
logger,
|
||||
event_type="user_login",
|
||||
message=f"User {user.username} logged in successfully",
|
||||
severity="low",
|
||||
context={"user_id": user.id, "username": user.username},
|
||||
request=self.request,
|
||||
return (
|
||||
HttpResponseClientRefresh()
|
||||
if getattr(self.request, "htmx", False)
|
||||
else response
|
||||
)
|
||||
return HttpResponseClientRefresh() if getattr(self.request, "htmx", False) else response
|
||||
|
||||
def form_invalid(self, form):
|
||||
log_security_event(
|
||||
logger,
|
||||
event_type="login_failed",
|
||||
message="Failed login attempt",
|
||||
severity="medium",
|
||||
context={"username": form.data.get("login", "unknown")},
|
||||
request=self.request,
|
||||
)
|
||||
if getattr(self.request, "htmx", False):
|
||||
return render(
|
||||
self.request,
|
||||
@@ -99,20 +80,11 @@ class CustomSignupView(TurnstileMixin, SignupView):
|
||||
return self.form_invalid(form)
|
||||
|
||||
response = super().form_valid(form)
|
||||
user = self.user
|
||||
log_security_event(
|
||||
logger,
|
||||
event_type="user_signup",
|
||||
message=f"New user registered: {user.username}",
|
||||
severity="low",
|
||||
context={
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"email": user.email,
|
||||
},
|
||||
request=self.request,
|
||||
return (
|
||||
HttpResponseClientRefresh()
|
||||
if getattr(self.request, "htmx", False)
|
||||
else response
|
||||
)
|
||||
return HttpResponseClientRefresh() if getattr(self.request, "htmx", False) else response
|
||||
|
||||
def form_invalid(self, form):
|
||||
if getattr(self.request, "htmx", False):
|
||||
@@ -177,7 +149,7 @@ class ProfileView(DetailView):
|
||||
def get_queryset(self) -> QuerySet[User]:
|
||||
return User.objects.select_related("profile")
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = cast(User, self.get_object())
|
||||
|
||||
@@ -201,9 +173,9 @@ class ProfileView(DetailView):
|
||||
.order_by("-created_at")[:5]
|
||||
)
|
||||
|
||||
def _get_user_top_lists(self, user: User) -> QuerySet[UserList]:
|
||||
def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
|
||||
return (
|
||||
UserList.objects.filter(user=user)
|
||||
TopList.objects.filter(user=user)
|
||||
.select_related("user", "user__profile")
|
||||
.prefetch_related("items")
|
||||
.order_by("-created_at")[:5]
|
||||
@@ -213,7 +185,7 @@ class ProfileView(DetailView):
|
||||
class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "accounts/settings.html"
|
||||
|
||||
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
|
||||
def get_context_data(self, **kwargs: Any) -> Dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["user"] = self.request.user
|
||||
return context
|
||||
@@ -225,22 +197,12 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
if display_name := request.POST.get("display_name"):
|
||||
profile.display_name = display_name
|
||||
|
||||
if unit_system := request.POST.get("unit_system"):
|
||||
profile.unit_system = unit_system
|
||||
|
||||
if location := request.POST.get("location"):
|
||||
profile.location = location
|
||||
|
||||
if "avatar" in request.FILES:
|
||||
avatar_file = cast(UploadedFile, request.FILES["avatar"])
|
||||
profile.avatar.save(avatar_file.name, avatar_file, save=False)
|
||||
profile.save()
|
||||
|
||||
user.save()
|
||||
logger.info(
|
||||
f"User {user.username} updated their profile",
|
||||
extra={"user_id": user.id, "username": user.username},
|
||||
)
|
||||
messages.success(request, "Profile updated successfully")
|
||||
|
||||
def _validate_password(self, password: str) -> bool:
|
||||
@@ -252,7 +214,9 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
and bool(re.search(r"[0-9]", password))
|
||||
)
|
||||
|
||||
def _send_password_change_confirmation(self, request: HttpRequest, user: User) -> None:
|
||||
def _send_password_change_confirmation(
|
||||
self, request: HttpRequest, user: User
|
||||
) -> None:
|
||||
"""Send password change confirmation email."""
|
||||
site = get_current_site(request)
|
||||
context = {
|
||||
@@ -260,7 +224,9 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
"site_name": site.name,
|
||||
}
|
||||
|
||||
email_html = render_to_string("accounts/email/password_change_confirmation.html", context)
|
||||
email_html = render_to_string(
|
||||
"accounts/email/password_change_confirmation.html", context
|
||||
)
|
||||
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
@@ -270,7 +236,9 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
html=email_html,
|
||||
)
|
||||
|
||||
def _handle_password_change(self, request: HttpRequest) -> HttpResponseRedirect | None:
|
||||
def _handle_password_change(
|
||||
self, request: HttpRequest
|
||||
) -> Optional[HttpResponseRedirect]:
|
||||
user = cast(User, request.user)
|
||||
old_password = request.POST.get("old_password", "")
|
||||
new_password = request.POST.get("new_password", "")
|
||||
@@ -294,15 +262,6 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
log_security_event(
|
||||
logger,
|
||||
event_type="password_changed",
|
||||
message=f"User {user.username} changed their password",
|
||||
severity="medium",
|
||||
context={"user_id": user.id, "username": user.username},
|
||||
request=request,
|
||||
)
|
||||
|
||||
self._send_password_change_confirmation(request, user)
|
||||
messages.success(
|
||||
request,
|
||||
@@ -313,7 +272,9 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
||||
def _handle_email_change(self, request: HttpRequest) -> None:
|
||||
if new_email := request.POST.get("new_email"):
|
||||
self._send_email_verification(request, new_email)
|
||||
messages.success(request, "Verification email sent to your new email address")
|
||||
messages.success(
|
||||
request, "Verification email sent to your new email address"
|
||||
)
|
||||
else:
|
||||
messages.error(request, "New email is required")
|
||||
|
||||
@@ -369,7 +330,9 @@ def create_password_reset_token(user: User) -> str:
|
||||
return token
|
||||
|
||||
|
||||
def send_password_reset_email(user: User, site: Site | RequestSite, token: str) -> None:
|
||||
def send_password_reset_email(
|
||||
user: User, site: Union[Site, RequestSite], token: str
|
||||
) -> None:
|
||||
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
|
||||
context = {
|
||||
"user": user,
|
||||
@@ -400,14 +363,6 @@ def request_password_reset(request: HttpRequest) -> HttpResponse:
|
||||
token = create_password_reset_token(user)
|
||||
site = get_current_site(request)
|
||||
send_password_reset_email(user, site, token)
|
||||
log_security_event(
|
||||
logger,
|
||||
event_type="password_reset_requested",
|
||||
message=f"Password reset requested for {email}",
|
||||
severity="medium",
|
||||
context={"email": email},
|
||||
request=request,
|
||||
)
|
||||
|
||||
messages.success(request, "Password reset email sent")
|
||||
return redirect("account_login")
|
||||
@@ -418,7 +373,7 @@ def handle_password_reset(
|
||||
user: User,
|
||||
new_password: str,
|
||||
reset: PasswordReset,
|
||||
site: Site | RequestSite,
|
||||
site: Union[Site, RequestSite],
|
||||
) -> None:
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
@@ -426,25 +381,20 @@ def handle_password_reset(
|
||||
reset.used = True
|
||||
reset.save()
|
||||
|
||||
log_security_event(
|
||||
logger,
|
||||
event_type="password_reset_complete",
|
||||
message=f"Password reset completed for user {user.username}",
|
||||
severity="medium",
|
||||
context={"user_id": user.id, "username": user.username},
|
||||
request=request,
|
||||
)
|
||||
|
||||
send_password_reset_confirmation(user, site)
|
||||
messages.success(request, "Password reset successfully")
|
||||
|
||||
|
||||
def send_password_reset_confirmation(user: User, site: Site | RequestSite) -> None:
|
||||
def send_password_reset_confirmation(
|
||||
user: User, site: Union[Site, RequestSite]
|
||||
) -> None:
|
||||
context = {
|
||||
"user": user,
|
||||
"site_name": site.name,
|
||||
}
|
||||
email_html = render_to_string("accounts/email/password_reset_complete.html", context)
|
||||
email_html = render_to_string(
|
||||
"accounts/email/password_reset_complete.html", context
|
||||
)
|
||||
|
||||
EmailService.send_email(
|
||||
to=user.email,
|
||||
@@ -457,7 +407,9 @@ def send_password_reset_confirmation(user: User, site: Site | RequestSite) -> No
|
||||
|
||||
def reset_password(request: HttpRequest, token: str) -> HttpResponse:
|
||||
try:
|
||||
reset = PasswordReset.objects.select_related("user").get(token=token, expires_at__gt=timezone.now(), used=False)
|
||||
reset = PasswordReset.objects.select_related("user").get(
|
||||
token=token, expires_at__gt=timezone.now(), used=False
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
if new_password := request.POST.get("new_password"):
|
||||
@@ -57,10 +57,8 @@ def run_migrations_online() -> None:
|
||||
# Import SQLAlchemy lazily so environments without it (e.g. static analyzers)
|
||||
# don't fail at module import time.
|
||||
try:
|
||||
from sqlalchemy import (
|
||||
engine_from_config, # type: ignore
|
||||
pool, # type: ignore
|
||||
)
|
||||
from sqlalchemy import engine_from_config # type: ignore
|
||||
from sqlalchemy import pool # type: ignore
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"SQLAlchemy is required to run online Alembic migrations. "
|
||||
@@ -6,8 +6,8 @@ Create Date: 2025-06-17 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa # type: ignore
|
||||
from alembic import op # type: ignore
|
||||
import sqlalchemy as sa # type: ignore
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "20250617"
|
||||
@@ -117,7 +117,9 @@ def upgrade() -> None:
|
||||
sa.Column("status", sa.String(length=50), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=False),
|
||||
sa.Column("parent_id", sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["parent_id"], ["progress_entries.id"], ondelete="SET NULL"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["parent_id"], ["progress_entries.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_table(
|
||||
@@ -9,4 +9,4 @@ system status, and other foundational features.
|
||||
from .choices import core_choices
|
||||
|
||||
# Ensure choices are registered on app startup
|
||||
__all__ = ["core_choices"]
|
||||
__all__ = ['core_choices']
|
||||
30
apps/core/admin.py
Normal file
30
apps/core/admin.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from .models import SlugHistory
|
||||
|
||||
|
||||
@admin.register(SlugHistory)
|
||||
class SlugHistoryAdmin(admin.ModelAdmin):
|
||||
list_display = ["content_object_link", "old_slug", "created_at"]
|
||||
list_filter = ["content_type", "created_at"]
|
||||
search_fields = ["old_slug", "object_id"]
|
||||
readonly_fields = ["content_type", "object_id", "old_slug", "created_at"]
|
||||
date_hierarchy = "created_at"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
@admin.display(description="Object")
|
||||
def content_object_link(self, obj):
|
||||
"""Create a link to the related object's admin page"""
|
||||
try:
|
||||
url = obj.content_object.get_absolute_url()
|
||||
return format_html('<a href="{}">{}</a>', url, str(obj.content_object))
|
||||
except (AttributeError, ValueError):
|
||||
return str(obj.content_object)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
"""Disable manual creation of slug history records"""
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
"""Disable editing of slug history records"""
|
||||
return False
|
||||
@@ -1,16 +1,17 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import pghistory
|
||||
from django.db import models
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models import Count
|
||||
from django.utils import timezone
|
||||
from django.db.models import Count
|
||||
from datetime import timedelta
|
||||
import pghistory
|
||||
|
||||
|
||||
@pghistory.track()
|
||||
class PageView(models.Model):
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, related_name="page_views")
|
||||
content_type = models.ForeignKey(
|
||||
ContentType, on_delete=models.CASCADE, related_name="page_views"
|
||||
)
|
||||
object_id = models.PositiveIntegerField()
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
@@ -62,7 +63,9 @@ class PageView(models.Model):
|
||||
return model_class.objects.none()
|
||||
|
||||
@classmethod
|
||||
def get_views_growth(cls, content_type, object_id, current_period_hours, previous_period_hours):
|
||||
def get_views_growth(
|
||||
cls, content_type, object_id, current_period_hours, previous_period_hours
|
||||
):
|
||||
"""Get view growth statistics between two time periods.
|
||||
|
||||
Args:
|
||||
@@ -98,7 +101,9 @@ class PageView(models.Model):
|
||||
if previous_views == 0:
|
||||
growth_percentage = current_views * 100 if current_views > 0 else 0
|
||||
else:
|
||||
growth_percentage = ((current_views - previous_views) / previous_views) * 100
|
||||
growth_percentage = (
|
||||
(current_views - previous_views) / previous_views
|
||||
) * 100
|
||||
|
||||
return current_views, previous_views, growth_percentage
|
||||
|
||||
@@ -115,4 +120,6 @@ class PageView(models.Model):
|
||||
int: Total view count
|
||||
"""
|
||||
cutoff = timezone.now() - timedelta(hours=hours)
|
||||
return cls.objects.filter(content_type=content_type, object_id=object_id, timestamp__gte=cutoff).count()
|
||||
return cls.objects.filter(
|
||||
content_type=content_type, object_id=object_id, timestamp__gte=cutoff
|
||||
).count()
|
||||
@@ -3,27 +3,21 @@ Custom exception handling for ThrillWiki API.
|
||||
Provides standardized error responses following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from django.http import Http404
|
||||
from django.core.exceptions import (
|
||||
PermissionDenied,
|
||||
)
|
||||
from django.core.exceptions import (
|
||||
ValidationError as DjangoValidationError,
|
||||
)
|
||||
from django.http import Http404
|
||||
from rest_framework import status
|
||||
from rest_framework.exceptions import (
|
||||
NotFound,
|
||||
)
|
||||
from rest_framework.exceptions import (
|
||||
PermissionDenied as DRFPermissionDenied,
|
||||
)
|
||||
from rest_framework.exceptions import (
|
||||
ValidationError as DRFValidationError,
|
||||
)
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import exception_handler
|
||||
from rest_framework.exceptions import (
|
||||
ValidationError as DRFValidationError,
|
||||
NotFound,
|
||||
PermissionDenied as DRFPermissionDenied,
|
||||
)
|
||||
|
||||
from ..exceptions import ThrillWikiException
|
||||
from ..logging import get_logger, log_exception
|
||||
@@ -31,7 +25,9 @@ from ..logging import get_logger, log_exception
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def custom_exception_handler(exc: Exception, context: dict[str, Any]) -> Response | None:
|
||||
def custom_exception_handler(
|
||||
exc: Exception, context: Dict[str, Any]
|
||||
) -> Optional[Response]:
|
||||
"""
|
||||
Custom exception handler for DRF that provides standardized error responses.
|
||||
|
||||
@@ -170,7 +166,9 @@ def custom_exception_handler(exc: Exception, context: dict[str, Any]) -> Respons
|
||||
request=request,
|
||||
)
|
||||
|
||||
response = Response(custom_response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
response = Response(
|
||||
custom_response_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -211,7 +209,7 @@ def _get_error_message(exc: Exception, response_data: Any) -> str:
|
||||
return str(exc) if str(exc) else "An error occurred"
|
||||
|
||||
|
||||
def _get_error_details(exc: Exception, response_data: Any) -> dict[str, Any] | None:
|
||||
def _get_error_details(exc: Exception, response_data: Any) -> Optional[Dict[str, Any]]:
|
||||
"""Extract detailed error information for debugging."""
|
||||
if isinstance(response_data, dict) and len(response_data) > 1:
|
||||
return response_data
|
||||
@@ -226,11 +224,14 @@ def _get_error_details(exc: Exception, response_data: Any) -> dict[str, Any] | N
|
||||
|
||||
def _format_django_validation_errors(
|
||||
exc: DjangoValidationError,
|
||||
) -> dict[str, Any]:
|
||||
) -> Dict[str, Any]:
|
||||
"""Format Django ValidationError for API response."""
|
||||
if hasattr(exc, "error_dict"):
|
||||
# Field-specific errors
|
||||
return {field: [str(error) for error in errors] for field, errors in exc.error_dict.items()}
|
||||
return {
|
||||
field: [str(error) for error in errors]
|
||||
for field, errors in exc.error_dict.items()
|
||||
}
|
||||
elif hasattr(exc, "error_list"):
|
||||
# Non-field errors
|
||||
return {"non_field_errors": [str(error) for error in exc.error_list]}
|
||||
@@ -2,11 +2,10 @@
|
||||
Common mixins for API views following Django styleguide patterns.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from rest_framework import status
|
||||
from typing import Dict, Any, Optional, Type
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
# Constants for error messages
|
||||
_MISSING_INPUT_SERIALIZER_MSG = "Subclasses must set input_serializer class attribute"
|
||||
@@ -21,17 +20,17 @@ class ApiMixin:
|
||||
|
||||
# Expose expected attributes so static type checkers know they exist on subclasses.
|
||||
# Subclasses or other bases (e.g. DRF GenericAPIView) will actually provide these.
|
||||
input_serializer: type[Any] | None = None
|
||||
output_serializer: type[Any] | None = None
|
||||
input_serializer: Optional[Type[Any]] = None
|
||||
output_serializer: Optional[Type[Any]] = None
|
||||
|
||||
def create_response(
|
||||
self,
|
||||
*,
|
||||
data: Any = None,
|
||||
message: str | None = None,
|
||||
message: Optional[str] = None,
|
||||
status_code: int = status.HTTP_200_OK,
|
||||
pagination: dict[str, Any] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
pagination: Optional[Dict[str, Any]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Response:
|
||||
"""
|
||||
Create standardized API response.
|
||||
@@ -67,8 +66,8 @@ class ApiMixin:
|
||||
*,
|
||||
message: str,
|
||||
status_code: int = status.HTTP_400_BAD_REQUEST,
|
||||
error_code: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
error_code: Optional[str] = None,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> Response:
|
||||
"""
|
||||
Create standardized error response.
|
||||
@@ -83,7 +82,7 @@ class ApiMixin:
|
||||
Standardized error Response object
|
||||
"""
|
||||
# explicitly allow any-shaped values in the error_data dict
|
||||
error_data: dict[str, Any] = {
|
||||
error_data: Dict[str, Any] = {
|
||||
"code": error_code or "GENERIC_ERROR",
|
||||
"message": message,
|
||||
}
|
||||
@@ -103,11 +102,15 @@ class ApiMixin:
|
||||
# These will raise if not implemented; they also inform static analyzers about their existence.
|
||||
def paginate_queryset(self, queryset):
|
||||
"""Override / implement in subclass or provided base if pagination is needed."""
|
||||
raise NotImplementedError("Subclasses must implement paginate_queryset to enable pagination")
|
||||
raise NotImplementedError(
|
||||
"Subclasses must implement paginate_queryset to enable pagination"
|
||||
)
|
||||
|
||||
def get_paginated_response(self, data):
|
||||
"""Override / implement in subclass or provided base to return paginated responses."""
|
||||
raise NotImplementedError("Subclasses must implement get_paginated_response to enable pagination")
|
||||
raise NotImplementedError(
|
||||
"Subclasses must implement get_paginated_response to enable pagination"
|
||||
)
|
||||
|
||||
def get_object(self):
|
||||
"""Default placeholder; subclasses should implement this."""
|
||||
@@ -164,7 +167,9 @@ class UpdateApiMixin(ApiMixin):
|
||||
def update(self, _request: Request, *_args, **_kwargs) -> Response:
|
||||
"""Handle PUT/PATCH requests for updating resources."""
|
||||
instance = self.get_object()
|
||||
serializer = self.get_input_serializer(data=_request.data, partial=_kwargs.get("partial", False))
|
||||
serializer = self.get_input_serializer(
|
||||
data=_request.data, partial=_kwargs.get("partial", False)
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# Update the object using the service layer
|
||||
@@ -223,7 +228,9 @@ class ListApiMixin(ApiMixin):
|
||||
Override this method to use selector patterns.
|
||||
Should call selector functions, not access model managers directly.
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement get_queryset using selectors")
|
||||
raise NotImplementedError(
|
||||
"Subclasses must implement get_queryset using selectors"
|
||||
)
|
||||
|
||||
def get_output_serializer(self, *args, **kwargs):
|
||||
"""Get the output serializer for response."""
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ListsConfig(AppConfig):
|
||||
class CoreConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.lists"
|
||||
name = "apps.core"
|
||||
@@ -12,22 +12,21 @@ Key Components:
|
||||
- RichChoiceSerializer: DRF serializer for API responses
|
||||
"""
|
||||
|
||||
from .base import ChoiceCategory, ChoiceGroup, RichChoice
|
||||
from .fields import RichChoiceField
|
||||
from .base import RichChoice, ChoiceCategory, ChoiceGroup
|
||||
from .registry import ChoiceRegistry, register_choices
|
||||
from .serializers import RichChoiceOptionSerializer, RichChoiceSerializer, RichChoiceSerializerField
|
||||
from .utils import get_choice_display, validate_choice_value
|
||||
from .fields import RichChoiceField
|
||||
from .serializers import RichChoiceSerializer, RichChoiceOptionSerializer
|
||||
from .utils import validate_choice_value, get_choice_display
|
||||
|
||||
__all__ = [
|
||||
"RichChoice",
|
||||
"ChoiceCategory",
|
||||
"ChoiceGroup",
|
||||
"ChoiceRegistry",
|
||||
"register_choices",
|
||||
"RichChoiceField",
|
||||
"RichChoiceSerializer",
|
||||
"RichChoiceSerializerField",
|
||||
"RichChoiceOptionSerializer",
|
||||
"validate_choice_value",
|
||||
"get_choice_display",
|
||||
'RichChoice',
|
||||
'ChoiceCategory',
|
||||
'ChoiceGroup',
|
||||
'ChoiceRegistry',
|
||||
'register_choices',
|
||||
'RichChoiceField',
|
||||
'RichChoiceSerializer',
|
||||
'RichChoiceOptionSerializer',
|
||||
'validate_choice_value',
|
||||
'get_choice_display',
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user