mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2026-02-05 13:55:19 -05:00
Compare commits
32 Commits
api
...
b80654952d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b80654952d | ||
|
|
2b7bb4dfaa | ||
|
|
a801813dcf | ||
|
|
1c6e219662 | ||
|
|
70e4385c2b | ||
|
|
30aa887d2a | ||
|
|
dd2d09b1c7 | ||
|
|
89d9e945b9 | ||
|
|
bc4a3c7557 | ||
|
|
95700c7d7b | ||
|
|
1adba1b804 | ||
|
|
b243b17af7 | ||
|
|
c95f99ca10 | ||
|
|
aa56c46c27 | ||
|
|
137b9b8cb9 | ||
|
|
00699d53b4 | ||
|
|
cd8868a591 | ||
|
|
ed04b30469 | ||
|
|
a9f5644c5c | ||
|
|
a0be417f74 | ||
|
|
ca770d76ff | ||
|
|
edcd8f2076 | ||
|
|
ae31e889d7 | ||
|
|
2e35f8c5d9 | ||
|
|
45d97b6e68 | ||
|
|
b508434574 | ||
|
|
8f6acbdc23 | ||
|
|
b860e332cb | ||
|
|
7ba0004c93 | ||
|
|
b9063ff4f8 | ||
|
|
bf04e4d854 | ||
|
|
1b246eeaa4 |
73
.agent/MEMORY/migration_source.md
Normal file
73
.agent/MEMORY/migration_source.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Migration Source: thrillwiki-87
|
||||||
|
|
||||||
|
The React project at `/Volumes/macminissd/Projects/thrillwiki-87` is the **authoritative source** for ThrillWiki features and functionality.
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
**thrillwiki-87 is LAW.** When migrating features, the React implementation defines:
|
||||||
|
- What features must exist
|
||||||
|
- How they should behave
|
||||||
|
- What data structures are required
|
||||||
|
- What UI patterns to follow
|
||||||
|
|
||||||
|
## Source Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
thrillwiki-87/
|
||||||
|
├── src/
|
||||||
|
│ ├── components/ # React components (44 directories)
|
||||||
|
│ ├── pages/ # Route pages (39 files)
|
||||||
|
│ ├── hooks/ # React hooks (80+ files)
|
||||||
|
│ ├── types/ # TypeScript definitions (63 files)
|
||||||
|
│ ├── contexts/ # React contexts
|
||||||
|
│ ├── lib/ # Utilities
|
||||||
|
│ └── integrations/ # External service integrations
|
||||||
|
├── docs/ # Feature documentation (78 files)
|
||||||
|
│ ├── SITE_OVERVIEW.md
|
||||||
|
│ ├── DESIGN_SYSTEM.md
|
||||||
|
│ ├── COMPONENTS.md
|
||||||
|
│ ├── PAGES.md
|
||||||
|
│ └── USER_FLOWS.md
|
||||||
|
└── supabase/ # Backend schemas and functions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technology Translation
|
||||||
|
|
||||||
|
| React (Source) | Nuxt 4 (Target) |
|
||||||
|
|----------------|-----------------|
|
||||||
|
| React component | Vue SFC (.vue) |
|
||||||
|
| useState | ref() / reactive() |
|
||||||
|
| useEffect | watch() / onMounted() |
|
||||||
|
| useContext | provide() / inject() or Pinia |
|
||||||
|
| React Router | Nuxt file-based routing |
|
||||||
|
| React Query | useAsyncData / useFetch |
|
||||||
|
| shadcn-ui | Nuxt UI |
|
||||||
|
| Supabase client | Django REST API via useApi() |
|
||||||
|
| Edge Functions | Django views |
|
||||||
|
|
||||||
|
## Backend Translation
|
||||||
|
|
||||||
|
| Supabase (Source) | Django (Target) |
|
||||||
|
|-------------------|-----------------|
|
||||||
|
| Table | Django Model |
|
||||||
|
| RLS policies | DRF permissions |
|
||||||
|
| Edge Functions | Django views/viewsets |
|
||||||
|
| Realtime | SSE / WebSockets |
|
||||||
|
| Auth | django-allauth + JWT |
|
||||||
|
| Storage | Cloudflare R2 |
|
||||||
|
|
||||||
|
## Migration Workflow
|
||||||
|
|
||||||
|
1. **Find source** in thrillwiki-87
|
||||||
|
2. **Read the docs** in thrillwiki-87/docs/
|
||||||
|
3. **Check existing** Nuxt implementation
|
||||||
|
4. **Port missing features** to achieve parity
|
||||||
|
5. **Verify behavior** matches source
|
||||||
|
|
||||||
|
## Key Source Files to Reference
|
||||||
|
|
||||||
|
When porting a feature, always check:
|
||||||
|
- `thrillwiki-87/docs/` for specifications
|
||||||
|
- `thrillwiki-87/src/types/` for data structures
|
||||||
|
- `thrillwiki-87/src/hooks/` for business logic
|
||||||
|
- `thrillwiki-87/src/components/` for UI patterns
|
||||||
83
.agent/MEMORY/source_mapping.md
Normal file
83
.agent/MEMORY/source_mapping.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Source Mapping: React → Nuxt
|
||||||
|
|
||||||
|
Quick reference for mapping thrillwiki-87 paths to thrillwiki_django_no_react paths.
|
||||||
|
|
||||||
|
## Directory Mappings
|
||||||
|
|
||||||
|
| React (thrillwiki-87) | Nuxt (thrillwiki_django_no_react) |
|
||||||
|
|----------------------|-----------------------------------|
|
||||||
|
| `src/components/` | `frontend/app/components/` |
|
||||||
|
| `src/pages/` | `frontend/app/pages/` |
|
||||||
|
| `src/hooks/` | `frontend/app/composables/` |
|
||||||
|
| `src/types/` | `frontend/app/types/` |
|
||||||
|
| `src/lib/` | `frontend/app/utils/` |
|
||||||
|
| `src/contexts/` | `frontend/app/stores/` (Pinia) |
|
||||||
|
| `docs/` | `source_docs/` |
|
||||||
|
| `supabase/migrations/` | `backend/apps/*/models.py` |
|
||||||
|
|
||||||
|
## Component Mappings (shadcn-ui → Nuxt UI)
|
||||||
|
|
||||||
|
| shadcn-ui | Nuxt UI |
|
||||||
|
|-----------|---------|
|
||||||
|
| `<Button>` | `<UButton>` |
|
||||||
|
| `<Card>` | `<UCard>` |
|
||||||
|
| `<Dialog>` | `<UModal>` |
|
||||||
|
| `<Input>` | `<UInput>` |
|
||||||
|
| `<Select>` | `<USelect>` / `<USelectMenu>` |
|
||||||
|
| `<Tabs>` | `<UTabs>` |
|
||||||
|
| `<Table>` | `<UTable>` |
|
||||||
|
| `<Badge>` | `<UBadge>` |
|
||||||
|
| `<Avatar>` | `<UAvatar>` |
|
||||||
|
| `<Tooltip>` | `<UTooltip>` |
|
||||||
|
| `<Sheet>` | `<USlideover>` |
|
||||||
|
| `<AlertDialog>` | `<UModal>` + confirm pattern |
|
||||||
|
| `<Skeleton>` | `<USkeleton>` |
|
||||||
|
| `<Textarea>` | `<UTextarea>` |
|
||||||
|
| `<Checkbox>` | `<UCheckbox>` |
|
||||||
|
| `<RadioGroup>` | `<URadioGroup>` |
|
||||||
|
| `<Switch>` | `<UToggle>` |
|
||||||
|
| `<DropdownMenu>` | `<UDropdown>` |
|
||||||
|
| `<Command>` | `<UCommandPalette>` |
|
||||||
|
| `<Popover>` | `<UPopover>` |
|
||||||
|
|
||||||
|
## Page Mappings
|
||||||
|
|
||||||
|
| React Page | Nuxt Page |
|
||||||
|
|------------|-----------|
|
||||||
|
| `Index.tsx` | `pages/index.vue` |
|
||||||
|
| `Parks.tsx` | `pages/parks/index.vue` |
|
||||||
|
| `ParkDetail.tsx` | `pages/parks/[park_slug]/index.vue` |
|
||||||
|
| `Rides.tsx` | `pages/rides/index.vue` |
|
||||||
|
| `RideDetail.tsx` | `pages/parks/[park_slug]/rides/[ride_slug].vue` |
|
||||||
|
| `Manufacturers.tsx` | `pages/manufacturers/index.vue` |
|
||||||
|
| `ManufacturerDetail.tsx` | `pages/manufacturers/[slug].vue` |
|
||||||
|
| `Designers.tsx` | `pages/designers/index.vue` |
|
||||||
|
| `DesignerDetail.tsx` | `pages/designers/[slug].vue` |
|
||||||
|
| `Operators.tsx` | `pages/operators/index.vue` |
|
||||||
|
| `OperatorDetail.tsx` | `pages/operators/[slug].vue` |
|
||||||
|
| `ParkOwners.tsx` | `pages/owners/index.vue` |
|
||||||
|
| `PropertyOwnerDetail.tsx` | `pages/owners/[slug].vue` |
|
||||||
|
| `Auth.tsx` | `pages/auth/login.vue`, `pages/auth/signup.vue` |
|
||||||
|
| `Profile.tsx` | `pages/profile/index.vue` |
|
||||||
|
| `Search.tsx` | `pages/search.vue` |
|
||||||
|
| `AdminDashboard.tsx` | `pages/admin/index.vue` |
|
||||||
|
|
||||||
|
## Hook → Composable Mappings
|
||||||
|
|
||||||
|
| React Hook | Vue Composable |
|
||||||
|
|------------|----------------|
|
||||||
|
| `useAuth.tsx` | `useAuth.ts` |
|
||||||
|
| `useSearch.tsx` | `useSearchHistory.ts` |
|
||||||
|
| `useModerationQueue.ts` | `useModeration.ts` |
|
||||||
|
| `useProfile.tsx` | (inline in pages) |
|
||||||
|
| `useLocations.ts` | `useParksApi.ts` |
|
||||||
|
| `useUnitPreferences.ts` | `useUnits.ts` |
|
||||||
|
|
||||||
|
## API Endpoint Translation
|
||||||
|
|
||||||
|
| Supabase RPC/Query | Django API |
|
||||||
|
|--------------------|------------|
|
||||||
|
| `supabase.from('parks')` | `GET /api/v1/parks/` |
|
||||||
|
| `supabase.rpc('search_*')` | `GET /api/v1/search/` |
|
||||||
|
| `supabase.auth.*` | `/api/v1/auth/*` |
|
||||||
|
| Edge Functions | Django views in `backend/apps/*/views.py` |
|
||||||
329
.agent/rules/api-conventions.md
Normal file
329
.agent/rules/api-conventions.md
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
# ThrillWiki API Conventions
|
||||||
|
|
||||||
|
Standards for designing and implementing APIs between the Django backend and Nuxt frontend.
|
||||||
|
|
||||||
|
## API Base Structure
|
||||||
|
|
||||||
|
### URL Patterns
|
||||||
|
```
|
||||||
|
/api/v1/ # API root (versioned)
|
||||||
|
/api/v1/parks/ # List/Create parks
|
||||||
|
/api/v1/parks/{slug}/ # Retrieve/Update/Delete park
|
||||||
|
/api/v1/parks/{slug}/rides/ # Nested resource
|
||||||
|
/api/v1/auth/ # Authentication endpoints
|
||||||
|
/api/v1/users/me/ # Current user
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP Methods
|
||||||
|
| Method | Usage |
|
||||||
|
|--------|-------|
|
||||||
|
| GET | Retrieve resource(s) |
|
||||||
|
| POST | Create resource |
|
||||||
|
| PUT | Replace resource entirely |
|
||||||
|
| PATCH | Update resource partially |
|
||||||
|
| DELETE | Remove resource |
|
||||||
|
|
||||||
|
## Response Formats
|
||||||
|
|
||||||
|
### Success Response (Single Resource)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Cedar Point",
|
||||||
|
"slug": "cedar-point",
|
||||||
|
"created_at": "2024-01-15T10:30:00Z",
|
||||||
|
"updated_at": "2024-01-16T14:20:00Z",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Response (List)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 150,
|
||||||
|
"next": "/api/v1/parks/?page=2",
|
||||||
|
"previous": null,
|
||||||
|
"results": [
|
||||||
|
{ ... },
|
||||||
|
{ ... }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "validation_error",
|
||||||
|
"message": "Invalid input data",
|
||||||
|
"details": {
|
||||||
|
"name": ["This field is required."],
|
||||||
|
"status": ["Invalid choice."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP Status Codes
|
||||||
|
| Code | Meaning | When to Use |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| 200 | OK | Successful GET, PUT, PATCH |
|
||||||
|
| 201 | Created | Successful POST |
|
||||||
|
| 204 | No Content | Successful DELETE |
|
||||||
|
| 400 | Bad Request | Validation errors |
|
||||||
|
| 401 | Unauthorized | Missing/invalid auth |
|
||||||
|
| 403 | Forbidden | Insufficient permissions |
|
||||||
|
| 404 | Not Found | Resource doesn't exist |
|
||||||
|
| 409 | Conflict | Duplicate resource |
|
||||||
|
| 500 | Server Error | Unexpected errors |
|
||||||
|
|
||||||
|
## Pagination
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
```
|
||||||
|
?page=2 # Page number (default: 1)
|
||||||
|
?page_size=20 # Items per page (default: 20, max: 100)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"count": 150,
|
||||||
|
"next": "/api/v1/parks/?page=3",
|
||||||
|
"previous": "/api/v1/parks/?page=1",
|
||||||
|
"results": [ ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Filtering & Sorting
|
||||||
|
|
||||||
|
### Filtering
|
||||||
|
```
|
||||||
|
GET /api/v1/parks/?status=operating
|
||||||
|
GET /api/v1/parks/?country=USA&status=operating
|
||||||
|
GET /api/v1/rides/?park=cedar-point&type=coaster
|
||||||
|
```
|
||||||
|
|
||||||
|
### Search
|
||||||
|
```
|
||||||
|
GET /api/v1/parks/?search=cedar
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sorting
|
||||||
|
```
|
||||||
|
GET /api/v1/parks/?ordering=name # Ascending
|
||||||
|
GET /api/v1/parks/?ordering=-created_at # Descending
|
||||||
|
GET /api/v1/parks/?ordering=-rating,name # Multiple fields
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Token-Based Auth
|
||||||
|
```
|
||||||
|
Authorization: Bearer <jwt_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/login/ # Get tokens
|
||||||
|
POST /api/v1/auth/register/ # Create account
|
||||||
|
POST /api/v1/auth/refresh/ # Refresh access token
|
||||||
|
POST /api/v1/auth/logout/ # Invalidate tokens
|
||||||
|
GET /api/v1/auth/me/ # Current user info
|
||||||
|
```
|
||||||
|
|
||||||
|
### Social Auth
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/google/ # Google OAuth
|
||||||
|
POST /api/v1/auth/discord/ # Discord OAuth
|
||||||
|
```
|
||||||
|
|
||||||
|
## Content Submission API
|
||||||
|
|
||||||
|
### Submit New Content
|
||||||
|
```
|
||||||
|
POST /api/v1/submissions/
|
||||||
|
{
|
||||||
|
"content_type": "park",
|
||||||
|
"data": {
|
||||||
|
"name": "New Park Name",
|
||||||
|
"city": "Orlando",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "submission-uuid",
|
||||||
|
"status": "pending",
|
||||||
|
"content_type": "park",
|
||||||
|
"data": { ... },
|
||||||
|
"created_at": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Submit Edit to Existing Content
|
||||||
|
```
|
||||||
|
POST /api/v1/submissions/
|
||||||
|
{
|
||||||
|
"content_type": "park",
|
||||||
|
"object_id": "existing-park-uuid",
|
||||||
|
"data": {
|
||||||
|
"description": "Updated description..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Moderation API
|
||||||
|
|
||||||
|
### Get Moderation Queue
|
||||||
|
```
|
||||||
|
GET /api/v1/moderation/
|
||||||
|
GET /api/v1/moderation/?status=pending&type=park
|
||||||
|
```
|
||||||
|
|
||||||
|
### Review Submission
|
||||||
|
```
|
||||||
|
POST /api/v1/moderation/{id}/approve/
|
||||||
|
POST /api/v1/moderation/{id}/reject/
|
||||||
|
{
|
||||||
|
"notes": "Reason for rejection..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nested Resources
|
||||||
|
|
||||||
|
### Pattern
|
||||||
|
```
|
||||||
|
/api/v1/parks/{slug}/rides/ # List rides in park
|
||||||
|
/api/v1/parks/{slug}/reviews/ # List park reviews
|
||||||
|
/api/v1/parks/{slug}/photos/ # List park photos
|
||||||
|
```
|
||||||
|
|
||||||
|
### When to Nest vs Flat
|
||||||
|
**Nest when:**
|
||||||
|
- Resource only makes sense in context of parent (park photos)
|
||||||
|
- Need to filter by parent frequently
|
||||||
|
|
||||||
|
**Use flat with filter when:**
|
||||||
|
- Resource can exist independently
|
||||||
|
- Need to query across parents
|
||||||
|
|
||||||
|
```
|
||||||
|
# Flat with filter
|
||||||
|
GET /api/v1/rides/?park=cedar-point
|
||||||
|
|
||||||
|
# Or nested
|
||||||
|
GET /api/v1/parks/cedar-point/rides/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Geolocation API
|
||||||
|
|
||||||
|
### Parks Nearby
|
||||||
|
```
|
||||||
|
GET /api/v1/parks/nearby/?lat=41.4821&lng=-82.6822&radius=50
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response includes distance
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"name": "Cedar Point",
|
||||||
|
"distance": 12.5, // in user's preferred units
|
||||||
|
...
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Uploads
|
||||||
|
|
||||||
|
### Photo Upload
|
||||||
|
```
|
||||||
|
POST /api/v1/photos/
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
{
|
||||||
|
"content_type": "park",
|
||||||
|
"object_id": "park-uuid",
|
||||||
|
"image": <file>,
|
||||||
|
"caption": "Optional caption"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "photo-uuid",
|
||||||
|
"url": "https://cdn.thrillwiki.com/photos/...",
|
||||||
|
"thumbnail_url": "https://cdn.thrillwiki.com/photos/.../thumb",
|
||||||
|
"status": "pending", // Pending moderation
|
||||||
|
"created_at": "2024-01-15T10:30:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
### Headers
|
||||||
|
```
|
||||||
|
X-RateLimit-Limit: 100
|
||||||
|
X-RateLimit-Remaining: 95
|
||||||
|
X-RateLimit-Reset: 1642089600
|
||||||
|
```
|
||||||
|
|
||||||
|
### When Limited
|
||||||
|
```
|
||||||
|
HTTP/1.1 429 Too Many Requests
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "rate_limit_exceeded",
|
||||||
|
"message": "Too many requests. Try again in 60 seconds."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend Integration
|
||||||
|
|
||||||
|
### API Client Setup (Nuxt)
|
||||||
|
```typescript
|
||||||
|
// composables/useApi.ts
|
||||||
|
export function useApi() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const api = $fetch.create({
|
||||||
|
baseURL: config.public.apiBase,
|
||||||
|
headers: authStore.token ? {
|
||||||
|
Authorization: `Bearer ${authStore.token}`
|
||||||
|
} : {},
|
||||||
|
onResponseError: ({ response }) => {
|
||||||
|
if (response.status === 401) {
|
||||||
|
authStore.logout()
|
||||||
|
navigateTo('/auth/login')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage in Components
|
||||||
|
```typescript
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
// Fetch parks
|
||||||
|
const { data: parks } = await useAsyncData('parks', () =>
|
||||||
|
api('/parks/', { params: { status: 'operating' } })
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create submission
|
||||||
|
await api('/submissions/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { content_type: 'park', data: formData }
|
||||||
|
})
|
||||||
|
```
|
||||||
306
.agent/rules/component-patterns.md
Normal file
306
.agent/rules/component-patterns.md
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
# ThrillWiki Component Patterns
|
||||||
|
|
||||||
|
Guidelines for building UI components consistent with ThrillWiki's design system.
|
||||||
|
|
||||||
|
## Component Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
components/
|
||||||
|
├── layout/ # Page structure
|
||||||
|
│ ├── Header.vue
|
||||||
|
│ ├── Footer.vue
|
||||||
|
│ ├── Sidebar.vue
|
||||||
|
│ └── PageContainer.vue
|
||||||
|
├── ui/ # Base components (shadcn/ui style)
|
||||||
|
│ ├── Button.vue
|
||||||
|
│ ├── Card.vue
|
||||||
|
│ ├── Input.vue
|
||||||
|
│ ├── Badge.vue
|
||||||
|
│ ├── Avatar.vue
|
||||||
|
│ ├── Modal.vue
|
||||||
|
│ └── ...
|
||||||
|
├── entity/ # Domain-specific cards
|
||||||
|
│ ├── ParkCard.vue
|
||||||
|
│ ├── RideCard.vue
|
||||||
|
│ ├── ReviewCard.vue
|
||||||
|
│ ├── CreditCard.vue
|
||||||
|
│ └── CompanyCard.vue
|
||||||
|
├── forms/ # Form components
|
||||||
|
│ ├── ParkForm.vue
|
||||||
|
│ ├── ReviewForm.vue
|
||||||
|
│ └── ...
|
||||||
|
└── specialty/ # Complex/unique components
|
||||||
|
├── SearchAutocomplete.vue
|
||||||
|
├── Map.vue
|
||||||
|
├── ImageGallery.vue
|
||||||
|
├── UnitDisplay.vue
|
||||||
|
└── RatingDisplay.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
## Base Components (ui/)
|
||||||
|
|
||||||
|
### Card
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div :class="[
|
||||||
|
'rounded-lg border bg-card text-card-foreground',
|
||||||
|
interactive && 'hover:shadow-md transition-shadow cursor-pointer',
|
||||||
|
className
|
||||||
|
]">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
interactive?: boolean
|
||||||
|
className?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Button
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<button :class="[buttonVariants({ variant, size }), className]">
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive'
|
||||||
|
size?: 'default' | 'sm' | 'lg' | 'icon'
|
||||||
|
className?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Badge
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<span :class="[badgeVariants({ variant }), className]">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'
|
||||||
|
className?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entity Cards
|
||||||
|
|
||||||
|
### ParkCard
|
||||||
|
Displays park preview with image, name, location, stats, and status.
|
||||||
|
|
||||||
|
**Required Props:**
|
||||||
|
- `park: Park` - Park object
|
||||||
|
|
||||||
|
**Displays:**
|
||||||
|
- Park image (with fallback)
|
||||||
|
- Park name
|
||||||
|
- Location (city, country)
|
||||||
|
- Ride count
|
||||||
|
- Average rating
|
||||||
|
- Status badge (Operating/Closed/Under Construction)
|
||||||
|
|
||||||
|
**Interactions:**
|
||||||
|
- Click navigates to park detail page
|
||||||
|
- Hover shows elevation shadow
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<NuxtLink :to="`/parks/${park.slug}`">
|
||||||
|
<Card interactive>
|
||||||
|
<div class="aspect-video relative overflow-hidden rounded-t-lg">
|
||||||
|
<NuxtImg
|
||||||
|
:src="park.image || '/placeholder-park.jpg'"
|
||||||
|
:alt="park.name"
|
||||||
|
class="object-cover w-full h-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-lg line-clamp-1">{{ park.name }}</h3>
|
||||||
|
<p class="text-sm text-muted-foreground flex items-center gap-1">
|
||||||
|
<MapPin class="w-4 h-4" />
|
||||||
|
{{ park.city }}, {{ park.country }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center justify-between mt-2">
|
||||||
|
<span class="text-sm">🎢 {{ park.rideCount }} rides</span>
|
||||||
|
<RatingDisplay :rating="park.averageRating" size="sm" />
|
||||||
|
</div>
|
||||||
|
<Badge :variant="statusVariant" class="mt-2">
|
||||||
|
{{ park.status }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### RideCard
|
||||||
|
Similar structure to ParkCard, but shows:
|
||||||
|
- Ride image
|
||||||
|
- Ride name
|
||||||
|
- Park name (linked)
|
||||||
|
- Key specs (speed, height)
|
||||||
|
- Type badge + Status badge
|
||||||
|
|
||||||
|
### ReviewCard
|
||||||
|
Displays user review with:
|
||||||
|
- User avatar + username
|
||||||
|
- Rating (5-star display)
|
||||||
|
- Review date (relative)
|
||||||
|
- Review text
|
||||||
|
- Helpful votes (👍 count)
|
||||||
|
- Actions (Reply, Report)
|
||||||
|
|
||||||
|
### CreditCard
|
||||||
|
For user's ride credit list:
|
||||||
|
- Ride thumbnail
|
||||||
|
- Ride name + park name
|
||||||
|
- Ride count with +/- controls
|
||||||
|
- Last ridden date
|
||||||
|
- Edit button
|
||||||
|
|
||||||
|
## Specialty Components
|
||||||
|
|
||||||
|
### SearchAutocomplete
|
||||||
|
Global search with instant results.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Debounced input (300ms)
|
||||||
|
- Results grouped by type (Parks, Rides, Companies)
|
||||||
|
- Keyboard navigation
|
||||||
|
- Click or Enter to navigate
|
||||||
|
- Empty state handling
|
||||||
|
|
||||||
|
**Implementation Notes:**
|
||||||
|
- Use `useFetch` with `watch` for reactive searching
|
||||||
|
- Show loading skeleton while fetching
|
||||||
|
- Limit results to 10 per category
|
||||||
|
- Highlight matching text
|
||||||
|
|
||||||
|
### UnitDisplay
|
||||||
|
Converts and displays values in user's preferred units.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<span>{{ formattedValue }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
value: number
|
||||||
|
type: 'speed' | 'height' | 'length' | 'weight'
|
||||||
|
showBoth?: boolean // Show both metric and imperial
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { preferredUnits } = useUnits()
|
||||||
|
|
||||||
|
const formattedValue = computed(() => {
|
||||||
|
// Convert and format based on type and preference
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### RatingDisplay
|
||||||
|
Star rating visualization.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<div class="flex">
|
||||||
|
<Star
|
||||||
|
v-for="i in 5"
|
||||||
|
:key="i"
|
||||||
|
:class="[
|
||||||
|
'w-4 h-4',
|
||||||
|
i <= Math.round(rating) ? 'fill-yellow-400 text-yellow-400' : 'text-gray-300'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-medium">{{ rating.toFixed(1) }}</span>
|
||||||
|
<span v-if="count" class="text-sm text-muted-foreground">({{ count }})</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Map (Leaflet)
|
||||||
|
Interactive map for parks nearby feature.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Marker clusters for dense areas
|
||||||
|
- Custom markers for different park types
|
||||||
|
- Popup on marker click with park preview
|
||||||
|
- Zoom controls
|
||||||
|
- Full-screen toggle
|
||||||
|
- User location marker (if permitted)
|
||||||
|
|
||||||
|
## Form Components
|
||||||
|
|
||||||
|
### Standard Form Structure
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<FormField label="Name" :error="errors.name">
|
||||||
|
<Input v-model="form.name" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Description">
|
||||||
|
<Textarea v-model="form.description" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" @click="$emit('cancel')">Cancel</Button>
|
||||||
|
<Button type="submit" :loading="isSubmitting">Save</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
- Use Zod for schema validation
|
||||||
|
- Display errors inline below fields
|
||||||
|
- Disable submit button while invalid
|
||||||
|
- Show loading state during submission
|
||||||
|
|
||||||
|
## Loading States
|
||||||
|
|
||||||
|
### Skeleton Loading
|
||||||
|
All cards should have skeleton states:
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Card v-if="loading">
|
||||||
|
<Skeleton class="aspect-video rounded-t-lg" />
|
||||||
|
<div class="p-4 space-y-2">
|
||||||
|
<Skeleton class="h-5 w-3/4" />
|
||||||
|
<Skeleton class="h-4 w-1/2" />
|
||||||
|
<Skeleton class="h-4 w-1/4" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<ActualCard v-else :data="data" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Empty States
|
||||||
|
Provide clear empty states with:
|
||||||
|
- Relevant icon
|
||||||
|
- Clear message
|
||||||
|
- Suggested action (button or link)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<EmptyState
|
||||||
|
icon="Search"
|
||||||
|
title="No results found"
|
||||||
|
description="Try adjusting your search or filters"
|
||||||
|
>
|
||||||
|
<Button @click="clearFilters">Clear Filters</Button>
|
||||||
|
</EmptyState>
|
||||||
|
```
|
||||||
191
.agent/rules/design-system.md
Normal file
191
.agent/rules/design-system.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# ThrillWiki Design System Rules
|
||||||
|
|
||||||
|
Visual identity, colors, typography, and styling guidelines for ThrillWiki UI.
|
||||||
|
|
||||||
|
## Brand Identity
|
||||||
|
|
||||||
|
- **Name**: ThrillWiki (optional "(Beta)" suffix during preview)
|
||||||
|
- **Tagline**: The Ultimate Theme Park Database
|
||||||
|
- **Personality**: Enthusiastic, trustworthy, community-driven
|
||||||
|
- **Visual Style**: Modern, clean, subtle gradients, smooth animations, card-based layouts
|
||||||
|
|
||||||
|
## Color System
|
||||||
|
|
||||||
|
### Semantic Color Tokens (use these, not raw hex values)
|
||||||
|
|
||||||
|
| Token | Purpose | Usage |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| `--background` | Page background | Main content area |
|
||||||
|
| `--foreground` | Primary text | Body text, headings |
|
||||||
|
| `--primary` | Interactive elements | Buttons, links |
|
||||||
|
| `--secondary` | Secondary UI | Secondary buttons |
|
||||||
|
| `--muted` | Subdued content | Hints, disabled states |
|
||||||
|
| `--accent` | Highlights | Focus rings, special callouts |
|
||||||
|
| `--destructive` | Danger actions | Delete buttons, errors |
|
||||||
|
| `--success` | Positive feedback | Success messages, confirmations |
|
||||||
|
| `--warning` | Caution | Alerts, warnings |
|
||||||
|
|
||||||
|
### Status Colors (Entity Badges)
|
||||||
|
- **Operating**: Green (`--success`)
|
||||||
|
- **Closed**: Red (`--destructive`)
|
||||||
|
- **Under Construction**: Amber (`--warning`)
|
||||||
|
|
||||||
|
### Dark Mode
|
||||||
|
- Automatically supported via CSS variables
|
||||||
|
- Reduce contrast (use off-white, not pure white)
|
||||||
|
- Replace shadows with subtle glows
|
||||||
|
- Slightly dim images
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
### Font
|
||||||
|
- **Primary Font**: Inter (Sans-Serif)
|
||||||
|
- **Weights**: 400 (Regular), 500 (Medium), 600 (Semibold), 700 (Bold)
|
||||||
|
- **Fallback**: system-ui, sans-serif
|
||||||
|
|
||||||
|
### Type Scale
|
||||||
|
|
||||||
|
| Size | Name | Usage |
|
||||||
|
|------|------|-------|
|
||||||
|
| 48px | Display | Hero headlines |
|
||||||
|
| 36px | H1 | Page titles |
|
||||||
|
| 30px | H2 | Section headers |
|
||||||
|
| 24px | H3 | Card titles |
|
||||||
|
| 20px | H4 | Subheadings |
|
||||||
|
| 16px | Body | Default text |
|
||||||
|
| 14px | Small | Secondary text |
|
||||||
|
| 12px | Caption | Labels, hints |
|
||||||
|
|
||||||
|
### Text Classes (Tailwind)
|
||||||
|
```
|
||||||
|
Page Title: text-4xl font-bold
|
||||||
|
Section Header: text-2xl font-semibold
|
||||||
|
Card Title: text-lg font-medium
|
||||||
|
Body: text-base
|
||||||
|
Caption: text-sm text-muted-foreground
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spacing System
|
||||||
|
|
||||||
|
### Base Unit
|
||||||
|
- 4px base unit
|
||||||
|
- All spacing is multiples of 4
|
||||||
|
|
||||||
|
### Spacing Scale
|
||||||
|
|
||||||
|
| Token | Size | Usage |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `space-1` (p-1) | 4px | Tight gaps |
|
||||||
|
| `space-2` (p-2) | 8px | Icon gaps |
|
||||||
|
| `space-3` (p-3) | 12px | Small padding |
|
||||||
|
| `space-4` (p-4) | 16px | Default padding |
|
||||||
|
| `space-6` (p-6) | 24px | Section gaps |
|
||||||
|
| `space-8` (p-8) | 32px | Large gaps |
|
||||||
|
| `space-12` (p-12) | 48px | Section margins |
|
||||||
|
|
||||||
|
## Border Radius
|
||||||
|
|
||||||
|
| Token | Size | Usage |
|
||||||
|
|-------|------|-------|
|
||||||
|
| `rounded-sm` | 4px | Small elements |
|
||||||
|
| `rounded` | 8px | Buttons, inputs |
|
||||||
|
| `rounded-lg` | 12px | Cards |
|
||||||
|
| `rounded-xl` | 16px | Large cards, modals |
|
||||||
|
| `rounded-full` | 9999px | Pills, avatars |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Max Content Width
|
||||||
|
- Main content: `max-w-7xl` (1280px)
|
||||||
|
- Centered with `mx-auto`
|
||||||
|
- Responsive padding: `px-4 md:px-6 lg:px-8`
|
||||||
|
|
||||||
|
### Grid System
|
||||||
|
- Desktop (1024px+): 4 columns
|
||||||
|
- Tablet (768px+): 2 columns
|
||||||
|
- Mobile (<768px): 1 column
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Patterns
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
- Background: `bg-card`
|
||||||
|
- Border: `border border-border`
|
||||||
|
- Radius: `rounded-lg`
|
||||||
|
- Padding: `p-4` or `p-6`
|
||||||
|
- Interactive cards: Add `hover:shadow-md transition-shadow cursor-pointer`
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
| Variant | Classes |
|
||||||
|
|---------|---------|
|
||||||
|
| Primary | `bg-primary text-primary-foreground hover:bg-primary/90` |
|
||||||
|
| Secondary | `bg-secondary text-secondary-foreground hover:bg-secondary/80` |
|
||||||
|
| Outline | `border border-input bg-background hover:bg-accent` |
|
||||||
|
| Ghost | `hover:bg-accent hover:text-accent-foreground` |
|
||||||
|
| Destructive | `bg-destructive text-destructive-foreground hover:bg-destructive/90` |
|
||||||
|
|
||||||
|
### Inputs
|
||||||
|
- Border: `border border-input`
|
||||||
|
- Focus: `focus:ring-2 focus:ring-primary focus:border-transparent`
|
||||||
|
- Error: `border-destructive focus:ring-destructive`
|
||||||
|
|
||||||
|
### Badges
|
||||||
|
```html
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
Operating
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
- **Library**: Lucide icons (via `lucide-vue-next`)
|
||||||
|
- **Default Size**: 24px (w-6 h-6)
|
||||||
|
- **Stroke Width**: 1.5px
|
||||||
|
- **Color**: Inherit from text color
|
||||||
|
|
||||||
|
Common icons:
|
||||||
|
- Search, Menu, User, Heart, Star, MapPin, Calendar, Camera, Edit, Trash, Check, X, ChevronRight, ExternalLink
|
||||||
|
|
||||||
|
## Responsive Breakpoints
|
||||||
|
|
||||||
|
| Breakpoint | Width | Target |
|
||||||
|
|------------|-------|--------|
|
||||||
|
| `sm` | 640px | Large phones |
|
||||||
|
| `md` | 768px | Tablets |
|
||||||
|
| `lg` | 1024px | Small laptops |
|
||||||
|
| `xl` | 1280px | Desktops |
|
||||||
|
| `2xl` | 1536px | Large screens |
|
||||||
|
|
||||||
|
## Accessibility Requirements
|
||||||
|
|
||||||
|
- Color contrast: 4.5:1 minimum for normal text
|
||||||
|
- Focus states: Visible focus ring on all interactive elements
|
||||||
|
- Motion: Respect `prefers-reduced-motion`
|
||||||
|
- Screen readers: Proper ARIA labels on interactive elements
|
||||||
|
- Keyboard: All functionality accessible via keyboard
|
||||||
|
|
||||||
|
## ThrillWiki-Specific Patterns
|
||||||
|
|
||||||
|
### Unit Display
|
||||||
|
Always provide unit conversion toggle (metric/imperial):
|
||||||
|
```vue
|
||||||
|
<UnitDisplay :value="121" type="speed" />
|
||||||
|
<!-- Renders: "121 km/h" or "75 mph" based on user preference -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rating Display
|
||||||
|
```vue
|
||||||
|
<RatingDisplay :rating="4.2" :count="156" />
|
||||||
|
<!-- Renders: ★★★★☆ 4.2 (156 reviews) -->
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entity Cards
|
||||||
|
All entity cards (Park, Ride, Company) should show:
|
||||||
|
- Image (with loading skeleton)
|
||||||
|
- Name (primary text)
|
||||||
|
- Key details (secondary text)
|
||||||
|
- Status badge
|
||||||
|
- Quick stats (rating, count, etc.)
|
||||||
254
.agent/rules/django-standards.md
Normal file
254
.agent/rules/django-standards.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# ThrillWiki Django Backend Standards
|
||||||
|
|
||||||
|
Rules for developing the ThrillWiki backend with Django and Django REST Framework.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── config/ # Project settings
|
||||||
|
│ ├── settings/
|
||||||
|
│ │ ├── base.py # Shared settings
|
||||||
|
│ │ ├── development.py # Dev-specific
|
||||||
|
│ │ └── production.py # Prod-specific
|
||||||
|
│ ├── urls.py # Root URL config
|
||||||
|
│ └── wsgi.py
|
||||||
|
├── apps/
|
||||||
|
│ ├── parks/ # Park-related models and APIs
|
||||||
|
│ ├── rides/ # Ride-related models and APIs
|
||||||
|
│ ├── companies/ # Manufacturers, operators, etc.
|
||||||
|
│ ├── users/ # User profiles, authentication
|
||||||
|
│ ├── reviews/ # User reviews and ratings
|
||||||
|
│ ├── credits/ # Ride credits tracking
|
||||||
|
│ ├── submissions/ # Content submission system
|
||||||
|
│ ├── moderation/ # Moderation queue
|
||||||
|
│ └── core/ # Shared utilities
|
||||||
|
└── tests/ # Test files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Django App Structure
|
||||||
|
|
||||||
|
Each app should follow this structure:
|
||||||
|
```
|
||||||
|
app_name/
|
||||||
|
├── __init__.py
|
||||||
|
├── admin.py # Django admin configuration
|
||||||
|
├── apps.py # App configuration
|
||||||
|
├── models.py # Database models
|
||||||
|
├── serializers.py # DRF serializers
|
||||||
|
├── views.py # DRF viewsets/views
|
||||||
|
├── urls.py # URL routing
|
||||||
|
├── permissions.py # Custom permissions (if needed)
|
||||||
|
├── filters.py # DRF filters (if needed)
|
||||||
|
├── signals.py # Django signals (if needed)
|
||||||
|
└── tests/
|
||||||
|
├── __init__.py
|
||||||
|
├── test_models.py
|
||||||
|
├── test_views.py
|
||||||
|
└── test_serializers.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model Conventions
|
||||||
|
|
||||||
|
### Base Model
|
||||||
|
All models should inherit from a base model with common fields:
|
||||||
|
```python
|
||||||
|
from django.db import models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class BaseModel(models.Model):
|
||||||
|
"""Base model with common fields"""
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Example
|
||||||
|
```python
|
||||||
|
class Park(BaseModel):
|
||||||
|
"""A theme park or amusement park"""
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
OPERATING = 'operating', 'Operating'
|
||||||
|
CLOSED = 'closed', 'Closed'
|
||||||
|
CONSTRUCTION = 'construction', 'Under Construction'
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.OPERATING
|
||||||
|
)
|
||||||
|
location = models.PointField() # GeoDjango
|
||||||
|
city = models.CharField(max_length=100)
|
||||||
|
country = models.CharField(max_length=100)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versioning for Moderated Content
|
||||||
|
For content that needs version history:
|
||||||
|
```python
|
||||||
|
class ParkVersion(BaseModel):
|
||||||
|
"""Version history for park edits"""
|
||||||
|
park = models.ForeignKey(Park, on_delete=models.CASCADE, related_name='versions')
|
||||||
|
data = models.JSONField() # Snapshot of park data
|
||||||
|
changed_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
|
||||||
|
change_summary = models.CharField(max_length=255)
|
||||||
|
is_current = models.BooleanField(default=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Conventions
|
||||||
|
|
||||||
|
### URL Structure
|
||||||
|
```python
|
||||||
|
# apps/parks/urls.py
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import ParkViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('parks', ParkViewSet, basename='park')
|
||||||
|
|
||||||
|
urlpatterns = router.urls
|
||||||
|
|
||||||
|
# Results in:
|
||||||
|
# GET /api/parks/ - List parks
|
||||||
|
# POST /api/parks/ - Create park (submission)
|
||||||
|
# GET /api/parks/{slug}/ - Get park detail
|
||||||
|
# PUT /api/parks/{slug}/ - Update park (submission)
|
||||||
|
# DELETE /api/parks/{slug}/ - Delete park (admin only)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ViewSet Structure
|
||||||
|
```python
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||||
|
|
||||||
|
class ParkViewSet(viewsets.ModelViewSet):
|
||||||
|
"""API endpoint for parks"""
|
||||||
|
queryset = Park.objects.all()
|
||||||
|
serializer_class = ParkSerializer
|
||||||
|
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
lookup_field = 'slug'
|
||||||
|
filterset_class = ParkFilter
|
||||||
|
search_fields = ['name', 'city', 'country']
|
||||||
|
ordering_fields = ['name', 'created_at']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Optimize queries with select_related and prefetch_related"""
|
||||||
|
return Park.objects.select_related(
|
||||||
|
'operator', 'owner'
|
||||||
|
).prefetch_related(
|
||||||
|
'rides', 'photos'
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def rides(self, request, slug=None):
|
||||||
|
"""Get rides for a specific park"""
|
||||||
|
park = self.get_object()
|
||||||
|
rides = park.rides.all()
|
||||||
|
serializer = RideSerializer(rides, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serializer Patterns
|
||||||
|
```python
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
class ParkSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for Park model"""
|
||||||
|
ride_count = serializers.IntegerField(read_only=True)
|
||||||
|
average_rating = serializers.FloatField(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Park
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'slug', 'description', 'status',
|
||||||
|
'city', 'country', 'ride_count', 'average_rating',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'slug', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
class ParkDetailSerializer(ParkSerializer):
|
||||||
|
"""Extended serializer for park detail view"""
|
||||||
|
rides = RideSerializer(many=True, read_only=True)
|
||||||
|
photos = PhotoSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta(ParkSerializer.Meta):
|
||||||
|
fields = ParkSerializer.Meta.fields + ['rides', 'photos']
|
||||||
|
```
|
||||||
|
|
||||||
|
## Query Optimization
|
||||||
|
|
||||||
|
- ALWAYS use `select_related()` for ForeignKey relationships
|
||||||
|
- ALWAYS use `prefetch_related()` for ManyToMany and reverse FK relationships
|
||||||
|
- Annotate computed fields at the database level when possible
|
||||||
|
- Use pagination for all list endpoints
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good
|
||||||
|
parks = Park.objects.select_related('operator').prefetch_related('rides')
|
||||||
|
|
||||||
|
# Bad - causes N+1 queries
|
||||||
|
for park in parks:
|
||||||
|
print(park.operator.name) # Each iteration hits the database
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
### Custom Permission Classes
|
||||||
|
```python
|
||||||
|
from rest_framework.permissions import BasePermission
|
||||||
|
|
||||||
|
class IsModeratorOrReadOnly(BasePermission):
|
||||||
|
"""Allow read access to all, write access to moderators"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
if request.method in ['GET', 'HEAD', 'OPTIONS']:
|
||||||
|
return True
|
||||||
|
return request.user.is_authenticated and request.user.is_moderator
|
||||||
|
```
|
||||||
|
|
||||||
|
## Submission & Moderation Flow
|
||||||
|
|
||||||
|
All user-submitted content goes through moderation:
|
||||||
|
|
||||||
|
1. User submits content → Creates `Submission` record with status `pending`
|
||||||
|
2. Moderator reviews → Approves or rejects
|
||||||
|
3. On approval → Content is published, version record created
|
||||||
|
|
||||||
|
```python
|
||||||
|
class Submission(BaseModel):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
PENDING = 'pending', 'Pending Review'
|
||||||
|
APPROVED = 'approved', 'Approved'
|
||||||
|
REJECTED = 'rejected', 'Rejected'
|
||||||
|
CHANGES_REQUESTED = 'changes_requested', 'Changes Requested'
|
||||||
|
|
||||||
|
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||||
|
object_id = models.UUIDField(null=True, blank=True)
|
||||||
|
data = models.JSONField()
|
||||||
|
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
|
||||||
|
submitted_by = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||||
|
reviewed_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
|
||||||
|
review_notes = models.TextField(blank=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Requirements
|
||||||
|
|
||||||
|
- Write tests for all models, views, and serializers
|
||||||
|
- Use pytest and pytest-django
|
||||||
|
- Use factories (factory_boy) for test data
|
||||||
|
- Test permissions thoroughly
|
||||||
|
- Test edge cases and error conditions
|
||||||
204
.agent/rules/nuxt-standards.md
Normal file
204
.agent/rules/nuxt-standards.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# ThrillWiki Nuxt 4 Frontend Standards
|
||||||
|
|
||||||
|
Rules for developing the ThrillWiki frontend with Nuxt 4.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
├── app.vue # Root component
|
||||||
|
├── nuxt.config.ts # Nuxt configuration
|
||||||
|
├── pages/ # File-based routing
|
||||||
|
│ ├── index.vue # Homepage (/)
|
||||||
|
│ ├── parks/
|
||||||
|
│ │ ├── index.vue # /parks
|
||||||
|
│ │ ├── nearby.vue # /parks/nearby
|
||||||
|
│ │ └── [slug].vue # /parks/:slug
|
||||||
|
│ └── ...
|
||||||
|
├── components/
|
||||||
|
│ ├── layout/ # Header, Footer, Sidebar
|
||||||
|
│ ├── ui/ # Base components (Button, Card, Input)
|
||||||
|
│ ├── entity/ # ParkCard, RideCard, ReviewCard
|
||||||
|
│ └── forms/ # Form components
|
||||||
|
├── composables/ # Shared logic (useAuth, useApi, useUnits)
|
||||||
|
├── stores/ # Pinia stores
|
||||||
|
├── types/ # TypeScript interfaces
|
||||||
|
└── assets/
|
||||||
|
└── css/ # Global styles, Tailwind config
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Conventions
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
- Use PascalCase for component files: `ParkCard.vue`, `SearchAutocomplete.vue`
|
||||||
|
- Use kebab-case in templates: `<park-card>`, `<search-autocomplete>`
|
||||||
|
- Prefix base components with `Base`: `BaseButton.vue`, `BaseInput.vue`
|
||||||
|
|
||||||
|
### Component Structure
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 1. Type imports
|
||||||
|
import type { Park } from '~/types'
|
||||||
|
|
||||||
|
// 2. Component imports (auto-imported usually)
|
||||||
|
|
||||||
|
// 3. Props and emits
|
||||||
|
const props = defineProps<{
|
||||||
|
park: Park
|
||||||
|
variant?: 'default' | 'compact'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'select', park: Park): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 4. Composables
|
||||||
|
const { formatDistance } = useUnits()
|
||||||
|
|
||||||
|
// 5. Refs and reactive state
|
||||||
|
const isExpanded = ref(false)
|
||||||
|
|
||||||
|
// 6. Computed properties
|
||||||
|
const displayLocation = computed(() =>
|
||||||
|
`${props.park.city}, ${props.park.country}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 7. Functions
|
||||||
|
function handleClick() {
|
||||||
|
emit('select', props.park)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Lifecycle hooks (if needed)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Template here -->
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Scoped styles, prefer Tailwind classes in template */
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
- Enable strict mode
|
||||||
|
- Define interfaces for all data structures in `types/`
|
||||||
|
- Use `defineProps<T>()` with TypeScript generics
|
||||||
|
- No `any` types without explicit justification
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
### File-Based Routes
|
||||||
|
Follow Nuxt 4 file-based routing conventions:
|
||||||
|
- `pages/index.vue` → `/`
|
||||||
|
- `pages/parks/index.vue` → `/parks`
|
||||||
|
- `pages/parks/[slug].vue` → `/parks/:slug`
|
||||||
|
- `pages/parks/[park]/rides/[ride].vue` → `/parks/:park/rides/:ride`
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
```typescript
|
||||||
|
// Use navigateTo for programmatic navigation
|
||||||
|
await navigateTo('/parks/cedar-point')
|
||||||
|
|
||||||
|
// Use NuxtLink for declarative navigation
|
||||||
|
<NuxtLink to="/parks">All Parks</NuxtLink>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Fetching
|
||||||
|
|
||||||
|
### Use Composables
|
||||||
|
```typescript
|
||||||
|
// composables/useParks.ts
|
||||||
|
export function useParks() {
|
||||||
|
const { data, pending, error, refresh } = useFetch('/api/parks/')
|
||||||
|
|
||||||
|
return {
|
||||||
|
parks: data,
|
||||||
|
loading: pending,
|
||||||
|
error,
|
||||||
|
refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Components
|
||||||
|
```typescript
|
||||||
|
// Use useAsyncData for page-level data
|
||||||
|
const { data: park } = await useAsyncData(
|
||||||
|
`park-${route.params.slug}`,
|
||||||
|
() => $fetch(`/api/parks/${route.params.slug}/`)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management (Pinia)
|
||||||
|
|
||||||
|
### Store Structure
|
||||||
|
```typescript
|
||||||
|
// stores/auth.ts
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const isAuthenticated = computed(() => !!user.value)
|
||||||
|
|
||||||
|
async function login(credentials: LoginCredentials) {
|
||||||
|
// Implementation
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
user.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { user, isAuthenticated, login, logout }
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Stores
|
||||||
|
```typescript
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { user, isAuthenticated } = storeToRefs(authStore)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
### Base API Composable
|
||||||
|
```typescript
|
||||||
|
// composables/useApi.ts
|
||||||
|
export function useApi() {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
return $fetch.create({
|
||||||
|
baseURL: config.public.apiBase,
|
||||||
|
headers: {
|
||||||
|
...(authStore.token && { Authorization: `Bearer ${authStore.token}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Page Errors
|
||||||
|
```typescript
|
||||||
|
// In page components
|
||||||
|
const { data, error } = await useAsyncData(...)
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.value.statusCode || 500,
|
||||||
|
message: error.value.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Errors
|
||||||
|
- Display validation errors inline with form fields
|
||||||
|
- Use toast notifications for API errors
|
||||||
|
- Provide clear user feedback
|
||||||
|
|
||||||
|
## Accessibility Requirements
|
||||||
|
|
||||||
|
- All interactive elements must be keyboard accessible
|
||||||
|
- Provide proper ARIA labels
|
||||||
|
- Ensure color contrast meets WCAG AA standards
|
||||||
|
- Support `prefers-reduced-motion`
|
||||||
|
- Use semantic HTML elements
|
||||||
85
.agent/workflows/comply.md
Normal file
85
.agent/workflows/comply.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
description: Ensure compliance with source_docs specifications - Continuous guard
|
||||||
|
---
|
||||||
|
|
||||||
|
# Source Docs Compliance Workflow
|
||||||
|
|
||||||
|
You are now in **Compliance Guard Mode**. The documents in `source_docs/` are LAW. Every code change must comply with these specifications.
|
||||||
|
|
||||||
|
## The Constitution
|
||||||
|
|
||||||
|
The following documents are the single source of truth:
|
||||||
|
- `source_docs/SITE_OVERVIEW.md` - High-level product vision
|
||||||
|
- `source_docs/DESIGN_SYSTEM.md` - Visual identity, colors, typography, gradients
|
||||||
|
- `source_docs/COMPONENTS.md` - Component specifications and patterns
|
||||||
|
- `source_docs/PAGES.md` - Page layouts and content
|
||||||
|
- `source_docs/USER_FLOWS.md` - User journeys and interaction specifications
|
||||||
|
|
||||||
|
## Before Making ANY Code Change
|
||||||
|
|
||||||
|
1. **Identify Relevant Specs**: Determine which source_docs apply to the change
|
||||||
|
2. **Read the Spec**: View the relevant sections to understand requirements
|
||||||
|
3. **Check for Deviations**: Compare current/proposed code against the spec
|
||||||
|
4. **Cite Your Sources**: Reference specific line numbers when claiming compliance
|
||||||
|
|
||||||
|
## Compliance Checklist
|
||||||
|
|
||||||
|
### For Components (check against COMPONENTS.md, DESIGN_SYSTEM.md):
|
||||||
|
- [ ] Uses correct component structure (header, content, footer)
|
||||||
|
- [ ] Uses primary color palette (blue for primary, NOT emerald/teal unless specified)
|
||||||
|
- [ ] Uses centralized constants instead of hardcoded options
|
||||||
|
- [ ] Follows established patterns (sticky footer, tab navigation, etc.)
|
||||||
|
- [ ] Proper button variants per spec
|
||||||
|
- [ ] Dark mode support
|
||||||
|
|
||||||
|
### For User Flows (check against USER_FLOWS.md):
|
||||||
|
- [ ] All required fields present (e.g., submission notes for edits)
|
||||||
|
- [ ] Proper validation implemented
|
||||||
|
- [ ] Error states handled per spec
|
||||||
|
- [ ] Success feedback matches spec
|
||||||
|
|
||||||
|
### For Forms:
|
||||||
|
- [ ] Options imported from `~/utils/constants.ts`
|
||||||
|
- [ ] Backend-synced choices (check `backend/apps/*/choices.py`)
|
||||||
|
- [ ] Required fields marked and validated
|
||||||
|
- [ ] Proper help text and hints
|
||||||
|
|
||||||
|
## When Deviation is Found
|
||||||
|
|
||||||
|
1. **STOP** - Do not proceed with the current approach
|
||||||
|
2. **LOG** - Document the deviation with:
|
||||||
|
- Requirement (from source_docs)
|
||||||
|
- Current implementation
|
||||||
|
- Proposed fix
|
||||||
|
3. **FIX** - Implement the compliant solution
|
||||||
|
4. **VERIFY** - Ensure the fix matches the spec
|
||||||
|
|
||||||
|
## Status Tags
|
||||||
|
|
||||||
|
Use these in any audit or review:
|
||||||
|
- `[OK]` - Compliant with spec
|
||||||
|
- `[DEVIATION]` - Implemented differently than spec
|
||||||
|
- `[MISSING]` - Required feature not implemented
|
||||||
|
- `[RISK]` - Potential issue that needs investigation
|
||||||
|
|
||||||
|
## Key Constants Files
|
||||||
|
|
||||||
|
Always use these instead of hardcoding:
|
||||||
|
- `frontend/app/utils/constants.ts` - Status configs, park types, etc.
|
||||||
|
- `backend/apps/parks/choices.py` - Park status and type definitions
|
||||||
|
- `backend/apps/rides/choices.py` - Ride status and category definitions
|
||||||
|
- `backend/apps/moderation/choices.py` - Submission status definitions
|
||||||
|
|
||||||
|
## Example Audit Output
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Compliance Audit: ComponentName.vue
|
||||||
|
|
||||||
|
### Checking Against: [List relevant docs]
|
||||||
|
|
||||||
|
| Requirement | Source | Status |
|
||||||
|
|-------------|--------|--------|
|
||||||
|
| Uses primary gradient | DESIGN_SYSTEM.md L91-98 | [OK] |
|
||||||
|
| Submission note field | USER_FLOWS.md L473-476 | [MISSING] → FIX |
|
||||||
|
| Sticky footer pattern | COMPONENTS.md L583-602 | [DEVIATION] → FIX |
|
||||||
|
```
|
||||||
168
.agent/workflows/migrate-component.md
Normal file
168
.agent/workflows/migrate-component.md
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
---
|
||||||
|
description: Migrate a React component from thrillwiki-87 to Vue/Nuxt
|
||||||
|
---
|
||||||
|
|
||||||
|
# Migrate Component Workflow
|
||||||
|
|
||||||
|
Convert a React component from thrillwiki-87 (the authoritative source) to Vue 3 Composition API for the Nuxt 4 project.
|
||||||
|
|
||||||
|
## Step 1: Locate Source Component
|
||||||
|
|
||||||
|
Find the React component in thrillwiki-87:
|
||||||
|
```bash
|
||||||
|
# React components are in:
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki-87/src/components/
|
||||||
|
```
|
||||||
|
|
||||||
|
Common component directories:
|
||||||
|
- `auth/` - Authentication components
|
||||||
|
- `parks/` - Park-related components
|
||||||
|
- `rides/` - Ride-related components
|
||||||
|
- `forms/` - Form components
|
||||||
|
- `moderation/` - Moderation queue components
|
||||||
|
- `ui/` - Base UI primitives (shadcn-ui)
|
||||||
|
- `common/` - Shared utilities
|
||||||
|
- `layout/` - Layout components
|
||||||
|
|
||||||
|
## Step 2: Analyze React Component
|
||||||
|
|
||||||
|
Extract these patterns from the source:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Props interface
|
||||||
|
interface ComponentProps {
|
||||||
|
prop1: string
|
||||||
|
prop2?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// State hooks
|
||||||
|
const [value, setValue] = useState<Type>(initial)
|
||||||
|
|
||||||
|
// Effects
|
||||||
|
useEffect(() => { /* logic */ }, [deps])
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleClick = () => { /* logic */ }
|
||||||
|
|
||||||
|
// Render return
|
||||||
|
return <JSX />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Translate to Vue 3
|
||||||
|
|
||||||
|
### Props
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
interface Props { name: string; count?: number }
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```vue
|
||||||
|
<!-- Vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
name: string
|
||||||
|
count?: number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### State
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
const [value, setValue] = useState<string>('')
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```vue
|
||||||
|
<!-- Vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const value = ref<string>('')
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Effects
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [id])
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```vue
|
||||||
|
<!-- Vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
watch(() => id, () => {
|
||||||
|
fetchData()
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Events
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
onClick={() => onSelect(item)}
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```vue
|
||||||
|
<!-- Vue -->
|
||||||
|
@click="emit('select', item)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Map UI Components
|
||||||
|
|
||||||
|
Translate shadcn-ui to Nuxt UI:
|
||||||
|
|
||||||
|
| shadcn-ui | Nuxt UI |
|
||||||
|
|-----------|---------|
|
||||||
|
| `<Button>` | `<UButton>` |
|
||||||
|
| `<Card>` | `<UCard>` |
|
||||||
|
| `<Dialog>` | `<UModal>` |
|
||||||
|
| `<Input>` | `<UInput>` |
|
||||||
|
| `<Select>` | `<USelect>` |
|
||||||
|
| `<Tabs>` | `<UTabs>` |
|
||||||
|
| `<Badge>` | `<UBadge>` |
|
||||||
|
|
||||||
|
## Step 5: Update API Calls
|
||||||
|
|
||||||
|
Replace Supabase with Django API:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// React (Supabase)
|
||||||
|
const { data } = await supabase.from('parks').select('*')
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```vue
|
||||||
|
<!-- Vue (Django) -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { data } = await useApi<Park[]>('/parks/')
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Place in Nuxt Structure
|
||||||
|
|
||||||
|
Target paths:
|
||||||
|
```
|
||||||
|
frontend/app/components/
|
||||||
|
├── auth/ # Auth components
|
||||||
|
├── cards/ # Card components
|
||||||
|
├── common/ # Shared utilities
|
||||||
|
├── modals/ # Modal dialogs
|
||||||
|
├── rides/ # Ride components
|
||||||
|
└── ui/ # Base UI components
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Verify Parity
|
||||||
|
|
||||||
|
- [ ] All props from source are present
|
||||||
|
- [ ] All state variables ported
|
||||||
|
- [ ] All event handlers work
|
||||||
|
- [ ] Styling matches (Tailwind classes)
|
||||||
|
- [ ] Loading states present
|
||||||
|
- [ ] Error states handled
|
||||||
|
- [ ] Dark mode works
|
||||||
|
|
||||||
|
## Example Migration
|
||||||
|
|
||||||
|
**Source**: `thrillwiki-87/src/components/parks/ParkCard.tsx`
|
||||||
|
**Target**: `frontend/app/components/cards/ParkCard.vue`
|
||||||
|
|
||||||
|
Check existing target. If it exists, compare and add missing features. If not, create new.
|
||||||
223
.agent/workflows/migrate-hook.md
Normal file
223
.agent/workflows/migrate-hook.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
---
|
||||||
|
description: Convert a React hook from thrillwiki-87 to a Vue composable
|
||||||
|
---
|
||||||
|
|
||||||
|
# Migrate Hook Workflow
|
||||||
|
|
||||||
|
Convert a React hook from thrillwiki-87 to a Vue 3 composable for the Nuxt 4 project.
|
||||||
|
|
||||||
|
## Step 1: Locate Source Hook
|
||||||
|
|
||||||
|
Find the React hook in thrillwiki-87:
|
||||||
|
```bash
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki-87/src/hooks/
|
||||||
|
```
|
||||||
|
|
||||||
|
Key hooks (80+ total):
|
||||||
|
- `useAuth.tsx` - Authentication state
|
||||||
|
- `useModerationQueue.ts` - Moderation logic (21KB)
|
||||||
|
- `useEntityVersions.ts` - Version history (14KB)
|
||||||
|
- `useSearch.tsx` - Search functionality
|
||||||
|
- `useUnitPreferences.ts` - Unit conversion
|
||||||
|
- `useProfile.tsx` - User profile
|
||||||
|
- `useLocations.ts` - Location data
|
||||||
|
- `useRideCreditFilters.ts` - Credit filtering
|
||||||
|
|
||||||
|
## Step 2: Analyze Hook Pattern
|
||||||
|
|
||||||
|
Extract the hook structure:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export function useFeature(params: Params) {
|
||||||
|
// State
|
||||||
|
const [data, setData] = useState<Type>(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
// Effects
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData()
|
||||||
|
}, [dependency])
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
const doSomething = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await api.call()
|
||||||
|
setData(result)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { data, loading, error, doSomething }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Convert to Composable
|
||||||
|
|
||||||
|
### Basic Structure
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
export function useFeature() {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
return { value, setValue }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```typescript
|
||||||
|
// Vue
|
||||||
|
export function useFeature() {
|
||||||
|
const value = ref('')
|
||||||
|
|
||||||
|
function setValue(newValue: string) {
|
||||||
|
value.value = newValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value, setValue }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### State Conversions
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [items, setItems] = useState<Item[]>([])
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```typescript
|
||||||
|
// Vue
|
||||||
|
const count = ref(0)
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const items = ref<Item[]>([])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Effect Conversions
|
||||||
|
```tsx
|
||||||
|
// React - Run on mount
|
||||||
|
useEffect(() => {
|
||||||
|
initialize()
|
||||||
|
}, [])
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```typescript
|
||||||
|
// Vue
|
||||||
|
onMounted(() => {
|
||||||
|
initialize()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// React - Watch dependency
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData(id)
|
||||||
|
}, [id])
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```typescript
|
||||||
|
// Vue
|
||||||
|
watch(() => id, (newId) => {
|
||||||
|
fetchData(newId)
|
||||||
|
}, { immediate: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supabase → Django API
|
||||||
|
```tsx
|
||||||
|
// React (Supabase)
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('parks')
|
||||||
|
.select('*')
|
||||||
|
.eq('slug', slug)
|
||||||
|
.single()
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```typescript
|
||||||
|
// Vue (Django)
|
||||||
|
const api = useApi()
|
||||||
|
const { data } = await api<Park>(`/parks/${slug}/`)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Handle Complex Patterns
|
||||||
|
|
||||||
|
### useCallback → Plain Function
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
const memoizedFn = useCallback(() => {
|
||||||
|
doSomething(dep)
|
||||||
|
}, [dep])
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```typescript
|
||||||
|
// Vue - Usually no memo needed
|
||||||
|
function doSomething() {
|
||||||
|
// Vue's reactivity handles this
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### useMemo → computed
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
const derived = useMemo(() => expensiveCalc(data), [data])
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```typescript
|
||||||
|
// Vue
|
||||||
|
const derived = computed(() => expensiveCalc(data.value))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Hook Composition
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
function useFeature() {
|
||||||
|
const auth = useAuth()
|
||||||
|
const { data } = useQuery(...)
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```typescript
|
||||||
|
// Vue
|
||||||
|
export function useFeature() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const api = useApi()
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Target Location
|
||||||
|
|
||||||
|
Place composables in:
|
||||||
|
```
|
||||||
|
frontend/app/composables/
|
||||||
|
├── useApi.ts # Base API client
|
||||||
|
├── useAuth.ts # Authentication
|
||||||
|
├── useParksApi.ts # Parks API
|
||||||
|
├── useRidesApi.ts # Rides API
|
||||||
|
├── useModeration.ts # Moderation queue
|
||||||
|
└── use[Feature].ts # New composables
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Verify Parity
|
||||||
|
|
||||||
|
- [ ] All returned values present
|
||||||
|
- [ ] All actions/methods work
|
||||||
|
- [ ] State updates correctly
|
||||||
|
- [ ] API calls translated
|
||||||
|
- [ ] Error handling maintained
|
||||||
|
- [ ] Loading states work
|
||||||
|
- [ ] TypeScript types correct
|
||||||
|
|
||||||
|
## Priority Hooks to Migrate
|
||||||
|
|
||||||
|
| Hook | Size | Complexity |
|
||||||
|
|------|------|------------|
|
||||||
|
| useModerationQueue.ts | 21KB | High |
|
||||||
|
| useEntityVersions.ts | 14KB | High |
|
||||||
|
| useAuth.tsx | 11KB | Medium |
|
||||||
|
| useAutoComplete.ts | 10KB | Medium |
|
||||||
|
| useRateLimitAlerts.ts | 10KB | Medium |
|
||||||
|
| useRideCreditFilters.ts | 9KB | Medium |
|
||||||
|
| useAdminSettings.ts | 9KB | Medium |
|
||||||
183
.agent/workflows/migrate-page.md
Normal file
183
.agent/workflows/migrate-page.md
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
---
|
||||||
|
description: Migrate a React page from thrillwiki-87 to Nuxt 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Migrate Page Workflow
|
||||||
|
|
||||||
|
Port a React page from thrillwiki-87 (the authoritative source) to a Nuxt 4 page.
|
||||||
|
|
||||||
|
## Step 1: Locate Source Page
|
||||||
|
|
||||||
|
Find the React page in thrillwiki-87:
|
||||||
|
```bash
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki-87/src/pages/
|
||||||
|
```
|
||||||
|
|
||||||
|
Key pages:
|
||||||
|
- `Index.tsx` - Homepage
|
||||||
|
- `Parks.tsx` - Parks listing
|
||||||
|
- `ParkDetail.tsx` - Individual park (36KB - complex)
|
||||||
|
- `Rides.tsx` - Rides listing
|
||||||
|
- `RideDetail.tsx` - Individual ride (54KB - most complex)
|
||||||
|
- `Profile.tsx` - User profile (51KB - complex)
|
||||||
|
- `Search.tsx` - Global search
|
||||||
|
- `AdminDashboard.tsx` - Admin panel
|
||||||
|
|
||||||
|
## Step 2: Analyze Page Structure
|
||||||
|
|
||||||
|
Extract from React page:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Route params
|
||||||
|
const { id } = useParams()
|
||||||
|
|
||||||
|
// Data fetching
|
||||||
|
const { data, isLoading, error } = useQuery(...)
|
||||||
|
|
||||||
|
// SEO
|
||||||
|
// (usually react-helmet or similar)
|
||||||
|
|
||||||
|
// Page layout structure
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<Tabs>...</Tabs>
|
||||||
|
<Content>...</Content>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Create Nuxt Page
|
||||||
|
|
||||||
|
### Route Params
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
const { parkSlug } = useParams()
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```vue
|
||||||
|
<!-- Nuxt -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const parkSlug = route.params.park_slug as string
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Fetching
|
||||||
|
```tsx
|
||||||
|
// React (React Query)
|
||||||
|
const { data, isLoading } = useQuery(['park', id], () => fetchPark(id))
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```vue
|
||||||
|
<!-- Nuxt -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { data, pending } = await useAsyncData(
|
||||||
|
`park-${parkSlug}`,
|
||||||
|
() => useParksApi().getPark(parkSlug)
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### SEO Meta
|
||||||
|
```tsx
|
||||||
|
// React (Helmet)
|
||||||
|
<Helmet>
|
||||||
|
<title>{park.name} | ThrillWiki</title>
|
||||||
|
</Helmet>
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```vue
|
||||||
|
<!-- Nuxt -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => `${data.value?.name} | ThrillWiki`,
|
||||||
|
description: () => data.value?.description
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page Meta
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['auth'], // if authentication required
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Port Page Sections
|
||||||
|
|
||||||
|
Common patterns:
|
||||||
|
|
||||||
|
### Tabs Structure
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
<Tabs defaultValue="overview">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overview">...</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```vue
|
||||||
|
<!-- Nuxt -->
|
||||||
|
<UTabs :items="tabs" v-model="activeTab">
|
||||||
|
<template #default="{ item }">
|
||||||
|
<component :is="item.component" v-bind="item.props" />
|
||||||
|
</template>
|
||||||
|
</UTabs>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading States
|
||||||
|
```tsx
|
||||||
|
// React
|
||||||
|
{isLoading ? <Skeleton /> : <Content />}
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```vue
|
||||||
|
<!-- Nuxt -->
|
||||||
|
<template>
|
||||||
|
<div v-if="pending">
|
||||||
|
<USkeleton class="h-48" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="data">
|
||||||
|
<!-- Content -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Target Location
|
||||||
|
|
||||||
|
Nuxt pages use file-based routing:
|
||||||
|
|
||||||
|
| React Route | Nuxt File Path |
|
||||||
|
|-------------|----------------|
|
||||||
|
| `/parks` | `pages/parks/index.vue` |
|
||||||
|
| `/parks/:slug` | `pages/parks/[park_slug]/index.vue` |
|
||||||
|
| `/parks/:slug/rides/:ride` | `pages/parks/[park_slug]/rides/[ride_slug].vue` |
|
||||||
|
| `/manufacturers/:id` | `pages/manufacturers/[slug].vue` |
|
||||||
|
|
||||||
|
## Step 6: Verify Feature Parity
|
||||||
|
|
||||||
|
Compare source page with target:
|
||||||
|
- [ ] All tabs/sections present
|
||||||
|
- [ ] All data displayed
|
||||||
|
- [ ] All actions work (edit, delete, etc.)
|
||||||
|
- [ ] Responsive layout matches
|
||||||
|
- [ ] Loading states present
|
||||||
|
- [ ] Error handling works
|
||||||
|
- [ ] SEO meta correct
|
||||||
|
|
||||||
|
## Reference: Page Complexity
|
||||||
|
|
||||||
|
| Page | Source Size | Priority |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| RideDetail.tsx | 54KB | High |
|
||||||
|
| Profile.tsx | 51KB | High |
|
||||||
|
| AdminSettings.tsx | 44KB | Medium |
|
||||||
|
| ParkDetail.tsx | 36KB | High |
|
||||||
|
| Auth.tsx | 29KB | Medium |
|
||||||
|
| Parks.tsx | 22KB | High |
|
||||||
|
| Rides.tsx | 20KB | High |
|
||||||
201
.agent/workflows/migrate-type.md
Normal file
201
.agent/workflows/migrate-type.md
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
---
|
||||||
|
description: Port TypeScript types from thrillwiki-87 to the Nuxt project
|
||||||
|
---
|
||||||
|
|
||||||
|
# Migrate Type Workflow
|
||||||
|
|
||||||
|
Port TypeScript type definitions from thrillwiki-87 to the Nuxt 4 project, ensuring sync with Django serializers.
|
||||||
|
|
||||||
|
## Step 1: Locate Source Types
|
||||||
|
|
||||||
|
Find types in thrillwiki-87:
|
||||||
|
```bash
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki-87/src/types/
|
||||||
|
```
|
||||||
|
|
||||||
|
Key type files (63 total):
|
||||||
|
- `database.ts` - Core entity types (12KB)
|
||||||
|
- `statuses.ts` - Status enums (13KB)
|
||||||
|
- `moderation.ts` - Moderation types (10KB)
|
||||||
|
- `rideAttributes.ts` - Ride specs (8KB)
|
||||||
|
- `versioning.ts` - Version history (7KB)
|
||||||
|
- `submissions.ts` - Submission types
|
||||||
|
- `company.ts` - Company entities
|
||||||
|
|
||||||
|
## Step 2: Analyze Source Types
|
||||||
|
|
||||||
|
Extract type structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// thrillwiki-87/src/types/database.ts
|
||||||
|
export interface Park {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
park_type: ParkType
|
||||||
|
status: ParkStatus
|
||||||
|
opening_date?: string
|
||||||
|
closing_date?: string
|
||||||
|
location?: ParkLocation
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParkLocation {
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
country: string
|
||||||
|
city?: string
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 3: Check Django Serializers
|
||||||
|
|
||||||
|
Before porting, verify Django backend has matching fields:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check serializers in:
|
||||||
|
backend/apps/parks/serializers.py
|
||||||
|
backend/apps/rides/serializers.py
|
||||||
|
backend/apps/companies/serializers.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The Django serializer defines what the API returns:
|
||||||
|
```python
|
||||||
|
class ParkSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Park
|
||||||
|
fields = ['id', 'name', 'slug', 'park_type', 'status', ...]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Handle Naming Conventions
|
||||||
|
|
||||||
|
Django uses snake_case, TypeScript typically uses camelCase. Choose one strategy:
|
||||||
|
|
||||||
|
### Option A: Match Django exactly (Recommended)
|
||||||
|
```typescript
|
||||||
|
// Keep snake_case from API
|
||||||
|
export interface Park {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
park_type: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option B: Transform in API layer
|
||||||
|
```typescript
|
||||||
|
// camelCase in types
|
||||||
|
export interface Park {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
parkType: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
// Transform in useApi or serializer
|
||||||
|
```
|
||||||
|
|
||||||
|
**Current project uses Option A (snake_case).**
|
||||||
|
|
||||||
|
## Step 5: Port the Types
|
||||||
|
|
||||||
|
Create/update type files:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/app/types/park.ts
|
||||||
|
export interface Park {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
park_type: string
|
||||||
|
status: string
|
||||||
|
short_description?: string
|
||||||
|
description?: string
|
||||||
|
opening_date?: string
|
||||||
|
closing_date?: string
|
||||||
|
operating_season?: string
|
||||||
|
website?: string
|
||||||
|
latitude?: number
|
||||||
|
longitude?: number
|
||||||
|
// Match Django serializer fields exactly
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParkDetail extends Park {
|
||||||
|
rides?: Ride[]
|
||||||
|
reviews?: Review[]
|
||||||
|
photos?: Photo[]
|
||||||
|
// Extended fields from detail serializer
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 6: Port Enums/Options
|
||||||
|
|
||||||
|
Source may have enums that should be constants:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// thrillwiki-87/src/types/statuses.ts
|
||||||
|
export const PARK_STATUS = {
|
||||||
|
OPERATING: 'operating',
|
||||||
|
CLOSED: 'closed',
|
||||||
|
// ...
|
||||||
|
} as const
|
||||||
|
```
|
||||||
|
↓
|
||||||
|
```typescript
|
||||||
|
// frontend/app/utils/constants.ts
|
||||||
|
export const PARK_STATUS_OPTIONS = [
|
||||||
|
{ value: 'operating', label: 'Operating', color: 'green' },
|
||||||
|
{ value: 'closed', label: 'Closed', color: 'red' },
|
||||||
|
// Sync with backend/apps/parks/choices.py
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 7: Target Locations
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/app/types/
|
||||||
|
├── index.ts # Re-exports and common types
|
||||||
|
├── park.ts # Park types
|
||||||
|
├── ride.ts # Ride types
|
||||||
|
├── company.ts # Company types (manufacturer, designer, etc.)
|
||||||
|
├── user.ts # User/profile types
|
||||||
|
├── moderation.ts # Moderation types
|
||||||
|
└── api.ts # API response wrappers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 8: Verify Type Sync
|
||||||
|
|
||||||
|
| Layer | File | Must Match |
|
||||||
|
|-------|------|------------|
|
||||||
|
| Django Model | `backend/apps/*/models.py` | Database schema |
|
||||||
|
| Serializer | `backend/apps/*/serializers.py` | API response |
|
||||||
|
| Frontend Type | `frontend/app/types/*.ts` | Serializer fields |
|
||||||
|
|
||||||
|
Run checks:
|
||||||
|
```bash
|
||||||
|
# Check Django fields
|
||||||
|
python manage.py shell -c "from apps.parks.models import Park; print([f.name for f in Park._meta.fields])"
|
||||||
|
|
||||||
|
# Check serializer fields
|
||||||
|
python manage.py shell -c "from apps.parks.serializers import ParkSerializer; print(ParkSerializer().fields.keys())"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 9: Checklist
|
||||||
|
|
||||||
|
- [ ] All source type fields ported
|
||||||
|
- [ ] Fields match Django serializer
|
||||||
|
- [ ] snake_case naming used
|
||||||
|
- [ ] Optional fields marked with `?`
|
||||||
|
- [ ] Enums/options in constants.ts
|
||||||
|
- [ ] Backend choices.py synced
|
||||||
|
- [ ] Types exported from index.ts
|
||||||
|
|
||||||
|
## Priority Types
|
||||||
|
|
||||||
|
| Type File | Source Size | Notes |
|
||||||
|
|-----------|-------------|-------|
|
||||||
|
| statuses.ts | 13KB | All status enums |
|
||||||
|
| database.ts | 12KB | Core entities |
|
||||||
|
| moderation.ts | 10KB | Queue types |
|
||||||
|
| rideAttributes.ts | 8KB | Spec options |
|
||||||
|
| versioning.ts | 7KB | History types |
|
||||||
193
.agent/workflows/migrate.md
Normal file
193
.agent/workflows/migrate.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
---
|
||||||
|
description: Audit gaps and implement missing features from thrillwiki-87 source
|
||||||
|
---
|
||||||
|
|
||||||
|
# Migration Workflow
|
||||||
|
|
||||||
|
**thrillwiki-87 is LAW.** This workflow audits what's missing and implements it.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Run `/migrate` to:
|
||||||
|
1. Audit current project against thrillwiki-87
|
||||||
|
2. Identify the highest-priority missing feature
|
||||||
|
3. Implement it using the appropriate sub-workflow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Gap Analysis
|
||||||
|
|
||||||
|
### Step 1.1: Audit Components
|
||||||
|
|
||||||
|
Compare React components with Vue components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source (LAW):
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki-87/src/components/
|
||||||
|
|
||||||
|
# Target:
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/components/
|
||||||
|
```
|
||||||
|
|
||||||
|
For each React component directory, check if equivalent Vue component exists:
|
||||||
|
- **EXISTS**: Mark as ✅, check for feature parity
|
||||||
|
- **MISSING**: Mark as ❌, add to implementation queue
|
||||||
|
|
||||||
|
### Step 1.2: Audit Pages
|
||||||
|
|
||||||
|
Compare React pages with Nuxt pages:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source (LAW):
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki-87/src/pages/
|
||||||
|
|
||||||
|
# Target:
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/pages/
|
||||||
|
```
|
||||||
|
|
||||||
|
Key pages to audit (by size/complexity):
|
||||||
|
| React Page | Size | Priority |
|
||||||
|
|------------|------|----------|
|
||||||
|
| RideDetail.tsx | 54KB | P0 |
|
||||||
|
| Profile.tsx | 51KB | P0 |
|
||||||
|
| AdminSettings.tsx | 44KB | P1 |
|
||||||
|
| ParkDetail.tsx | 36KB | P0 |
|
||||||
|
| Auth.tsx | 29KB | P1 |
|
||||||
|
| Parks.tsx | 22KB | P0 |
|
||||||
|
| Rides.tsx | 20KB | P0 |
|
||||||
|
|
||||||
|
### Step 1.3: Audit Hooks → Composables
|
||||||
|
|
||||||
|
Compare React hooks with Vue composables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source (LAW):
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki-87/src/hooks/
|
||||||
|
|
||||||
|
# Target:
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/composables/
|
||||||
|
```
|
||||||
|
|
||||||
|
Priority hooks:
|
||||||
|
| React Hook | Size | Current Status |
|
||||||
|
|------------|------|----------------|
|
||||||
|
| useModerationQueue.ts | 21KB | Check useModeration.ts |
|
||||||
|
| useEntityVersions.ts | 14KB | ❌ Missing |
|
||||||
|
| useAuth.tsx | 11KB | Check useAuth.ts |
|
||||||
|
| useRideCreditFilters.ts | 9KB | ❌ Missing |
|
||||||
|
|
||||||
|
### Step 1.4: Audit Types
|
||||||
|
|
||||||
|
Compare TypeScript definitions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Source (LAW):
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki-87/src/types/
|
||||||
|
|
||||||
|
# Target:
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki_django_no_react/frontend/app/types/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Priority Selection
|
||||||
|
|
||||||
|
After auditing, select the highest priority gap:
|
||||||
|
|
||||||
|
### Priority Matrix
|
||||||
|
|
||||||
|
| Category | Weight | Examples |
|
||||||
|
|----------|--------|----------|
|
||||||
|
| **P0 - Core UX** | 10 | Main entity pages, search, auth |
|
||||||
|
| **P1 - Features** | 7 | Reviews, credits, lists |
|
||||||
|
| **P2 - Admin** | 5 | Moderation, settings |
|
||||||
|
| **P3 - Polish** | 3 | Animations, edge cases |
|
||||||
|
|
||||||
|
Select ONE item to implement this session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Implementation
|
||||||
|
|
||||||
|
Based on the gap type, use the appropriate sub-workflow:
|
||||||
|
|
||||||
|
### For Missing Component
|
||||||
|
```
|
||||||
|
/migrate-component
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Missing Page
|
||||||
|
```
|
||||||
|
/migrate-page
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Missing Hook/Composable
|
||||||
|
```
|
||||||
|
/migrate-hook
|
||||||
|
```
|
||||||
|
|
||||||
|
### For Missing Types
|
||||||
|
```
|
||||||
|
/migrate-type
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Verification
|
||||||
|
|
||||||
|
After implementation:
|
||||||
|
|
||||||
|
1. **Feature Parity Check**: Does it match thrillwiki-87 behavior?
|
||||||
|
2. **Visual Parity Check**: Does it look the same?
|
||||||
|
3. **Data Parity Check**: Does it show the same information?
|
||||||
|
4. **Interaction Parity Check**: Do actions work the same way?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gap Tracking
|
||||||
|
|
||||||
|
Update `GAP_ANALYSIS_MATRIX.md` with status:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| Feature | Source Location | Target Location | Status |
|
||||||
|
|---------|-----------------|-----------------|--------|
|
||||||
|
| Park Detail Tabs | src/pages/ParkDetail.tsx | pages/parks/[park_slug]/ | [OK] |
|
||||||
|
| Entity Versioning | src/hooks/useEntityVersions.ts | composables/ | [MISSING] |
|
||||||
|
| Ride Credits | src/components/credits/ | components/credits/ | [PARTIAL] |
|
||||||
|
```
|
||||||
|
|
||||||
|
Status tags:
|
||||||
|
- `[OK]` - Feature parity achieved
|
||||||
|
- `[PARTIAL]` - Some features missing
|
||||||
|
- `[MISSING]` - Not implemented
|
||||||
|
- `[BLOCKED]` - Waiting on backend
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference: Source Documentation
|
||||||
|
|
||||||
|
Always check thrillwiki-87 docs for specifications:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/Volumes/macminissd/Projects/thrillwiki-87/docs/
|
||||||
|
├── SITE_OVERVIEW.md # What features exist
|
||||||
|
├── COMPONENTS.md # Component specifications
|
||||||
|
├── PAGES.md # Page layouts (72KB - comprehensive)
|
||||||
|
├── USER_FLOWS.md # Interaction patterns (77KB)
|
||||||
|
├── DESIGN_SYSTEM.md # Visual standards
|
||||||
|
└── [Many more...]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Single Command Execution
|
||||||
|
|
||||||
|
When you run `/migrate`, execute these steps:
|
||||||
|
|
||||||
|
1. **Read GAP_ANALYSIS_MATRIX.md** to see current status
|
||||||
|
2. **List directories** in both projects to find new gaps
|
||||||
|
3. **Select highest priority** missing item
|
||||||
|
4. **Read source implementation** from thrillwiki-87
|
||||||
|
5. **Implement in target** following sub-workflow patterns
|
||||||
|
6. **Update GAP_ANALYSIS_MATRIX.md** with new status
|
||||||
|
7. **Report what was implemented** and next priority item
|
||||||
472
.agent/workflows/moderation.md
Normal file
472
.agent/workflows/moderation.md
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
---
|
||||||
|
description: Add moderation support to a content type in ThrillWiki
|
||||||
|
---
|
||||||
|
|
||||||
|
# Moderation Workflow
|
||||||
|
|
||||||
|
Add moderation (submission queue, version history, approval flow) to a content type.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
ThrillWiki's moderation system ensures quality by:
|
||||||
|
1. User submits new/edited content → Creates `Submission` record
|
||||||
|
2. Content enters moderation queue with `pending` status
|
||||||
|
3. Moderator reviews and approves/rejects
|
||||||
|
4. On approval → Content is published, version record created
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Ensure Model Supports Versioning
|
||||||
|
|
||||||
|
The content model needs to track its current state and history:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/apps/[app]/models.py
|
||||||
|
|
||||||
|
class Park(BaseModel):
|
||||||
|
"""Main park model - always shows current approved data"""
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
# ... other fields
|
||||||
|
|
||||||
|
# Track the current approved version
|
||||||
|
current_version = models.ForeignKey(
|
||||||
|
'ParkVersion',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name='current_for'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParkVersion(BaseModel):
|
||||||
|
"""Immutable snapshot of park data at a point in time"""
|
||||||
|
park = models.ForeignKey(
|
||||||
|
Park,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='versions'
|
||||||
|
)
|
||||||
|
# Store complete snapshot of editable fields
|
||||||
|
data = models.JSONField()
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
changed_by = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True
|
||||||
|
)
|
||||||
|
change_summary = models.CharField(max_length=255, blank=True)
|
||||||
|
submission = models.ForeignKey(
|
||||||
|
'submissions.Submission',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
related_name='versions'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def apply_to_park(self):
|
||||||
|
"""Apply this version's data to the parent park"""
|
||||||
|
for field, value in self.data.items():
|
||||||
|
if hasattr(self.park, field):
|
||||||
|
setattr(self.park, field, value)
|
||||||
|
self.park.current_version = self
|
||||||
|
self.park.save()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Create Submission Serializers
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/apps/submissions/serializers.py
|
||||||
|
|
||||||
|
class ParkSubmissionSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for park submission data"""
|
||||||
|
name = serializers.CharField(max_length=255)
|
||||||
|
description = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
city = serializers.CharField(max_length=100)
|
||||||
|
country = serializers.CharField(max_length=100)
|
||||||
|
status = serializers.ChoiceField(choices=Park.Status.choices)
|
||||||
|
# ... other editable fields
|
||||||
|
|
||||||
|
def validate_name(self, value):
|
||||||
|
# Custom validation if needed
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionCreateSerializer(serializers.ModelSerializer):
|
||||||
|
"""Create a new submission"""
|
||||||
|
data = serializers.JSONField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Submission
|
||||||
|
fields = ['content_type', 'object_id', 'data', 'change_summary']
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
content_type = attrs['content_type']
|
||||||
|
|
||||||
|
# Get the appropriate serializer for this content type
|
||||||
|
serializer_map = {
|
||||||
|
'park': ParkSubmissionSerializer,
|
||||||
|
'ride': RideSubmissionSerializer,
|
||||||
|
# ... other content types
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer_class = serializer_map.get(content_type)
|
||||||
|
if not serializer_class:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{'content_type': 'Unsupported content type'}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate the data field
|
||||||
|
data_serializer = serializer_class(data=attrs['data'])
|
||||||
|
data_serializer.is_valid(raise_exception=True)
|
||||||
|
attrs['data'] = data_serializer.validated_data
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
validated_data['submitted_by'] = self.context['request'].user
|
||||||
|
validated_data['status'] = Submission.Status.PENDING
|
||||||
|
return super().create(validated_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Create Submission ViewSet
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/apps/submissions/views.py
|
||||||
|
|
||||||
|
class SubmissionViewSet(viewsets.ModelViewSet):
|
||||||
|
"""API for content submissions"""
|
||||||
|
serializer_class = SubmissionSerializer
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
user = self.request.user
|
||||||
|
|
||||||
|
# Users see their own submissions
|
||||||
|
# Moderators see all pending submissions
|
||||||
|
if user.is_moderator:
|
||||||
|
return Submission.objects.all()
|
||||||
|
return Submission.objects.filter(submitted_by=user)
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == 'create':
|
||||||
|
return SubmissionCreateSerializer
|
||||||
|
return SubmissionSerializer
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def diff(self, request, pk=None):
|
||||||
|
"""Get diff between submission and current version"""
|
||||||
|
submission = self.get_object()
|
||||||
|
|
||||||
|
if submission.object_id:
|
||||||
|
# Edit submission - compare to current
|
||||||
|
current = self.get_current_data(submission)
|
||||||
|
return Response({
|
||||||
|
'before': current,
|
||||||
|
'after': submission.data,
|
||||||
|
'changes': self.compute_diff(current, submission.data)
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# New submission - no comparison
|
||||||
|
return Response({
|
||||||
|
'before': None,
|
||||||
|
'after': submission.data,
|
||||||
|
'changes': None
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Create Moderation ViewSet
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/apps/moderation/views.py
|
||||||
|
|
||||||
|
class ModerationViewSet(viewsets.ViewSet):
|
||||||
|
"""Moderation queue and actions"""
|
||||||
|
permission_classes = [IsModerator]
|
||||||
|
|
||||||
|
def list(self, request):
|
||||||
|
"""Get moderation queue"""
|
||||||
|
queryset = Submission.objects.filter(
|
||||||
|
status=Submission.Status.PENDING
|
||||||
|
).select_related(
|
||||||
|
'submitted_by'
|
||||||
|
).order_by('created_at')
|
||||||
|
|
||||||
|
# Filter by content type
|
||||||
|
content_type = request.query_params.get('type')
|
||||||
|
if content_type:
|
||||||
|
queryset = queryset.filter(content_type=content_type)
|
||||||
|
|
||||||
|
serializer = SubmissionSerializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def approve(self, request, pk=None):
|
||||||
|
"""Approve a submission"""
|
||||||
|
submission = get_object_or_404(Submission, pk=pk)
|
||||||
|
|
||||||
|
if submission.status != Submission.Status.PENDING:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Submission is not pending'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply the submission
|
||||||
|
with transaction.atomic():
|
||||||
|
if submission.object_id:
|
||||||
|
# Edit existing content
|
||||||
|
content = self.get_content_object(submission)
|
||||||
|
version = self.create_version(content, submission)
|
||||||
|
version.apply_to_park()
|
||||||
|
else:
|
||||||
|
# Create new content
|
||||||
|
content = self.create_content(submission)
|
||||||
|
version = self.create_version(content, submission)
|
||||||
|
content.current_version = version
|
||||||
|
content.save()
|
||||||
|
|
||||||
|
submission.status = Submission.Status.APPROVED
|
||||||
|
submission.reviewed_by = request.user
|
||||||
|
submission.reviewed_at = timezone.now()
|
||||||
|
submission.save()
|
||||||
|
|
||||||
|
# Notify user
|
||||||
|
notify_user(
|
||||||
|
submission.submitted_by,
|
||||||
|
'submission_approved',
|
||||||
|
{'submission': submission}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'status': 'approved'})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def reject(self, request, pk=None):
|
||||||
|
"""Reject a submission"""
|
||||||
|
submission = get_object_or_404(Submission, pk=pk)
|
||||||
|
|
||||||
|
submission.status = Submission.Status.REJECTED
|
||||||
|
submission.reviewed_by = request.user
|
||||||
|
submission.reviewed_at = timezone.now()
|
||||||
|
submission.review_notes = request.data.get('notes', '')
|
||||||
|
submission.save()
|
||||||
|
|
||||||
|
# Notify user
|
||||||
|
notify_user(
|
||||||
|
submission.submitted_by,
|
||||||
|
'submission_rejected',
|
||||||
|
{'submission': submission, 'reason': submission.review_notes}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'status': 'rejected'})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def request_changes(self, request, pk=None):
|
||||||
|
"""Request changes to a submission"""
|
||||||
|
submission = get_object_or_404(Submission, pk=pk)
|
||||||
|
|
||||||
|
submission.status = Submission.Status.CHANGES_REQUESTED
|
||||||
|
submission.reviewed_by = request.user
|
||||||
|
submission.review_notes = request.data.get('notes', '')
|
||||||
|
submission.save()
|
||||||
|
|
||||||
|
# Notify user
|
||||||
|
notify_user(
|
||||||
|
submission.submitted_by,
|
||||||
|
'submission_changes_requested',
|
||||||
|
{'submission': submission, 'notes': submission.review_notes}
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({'status': 'changes_requested'})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Frontend - Submission Form
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- frontend/components/forms/ParkSubmitForm.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Park } from '~/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
park?: Park // Existing park for edits, null for new
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'submitted'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: props.park?.name || '',
|
||||||
|
description: props.park?.description || '',
|
||||||
|
city: props.park?.city || '',
|
||||||
|
country: props.park?.country || '',
|
||||||
|
status: props.park?.status || 'operating',
|
||||||
|
changeSummary: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
const errors = ref<Record<string, string[]>>({})
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
isSubmitting.value = true
|
||||||
|
errors.value = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await $fetch('/api/v1/submissions/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
content_type: 'park',
|
||||||
|
object_id: props.park?.id || null,
|
||||||
|
data: {
|
||||||
|
name: form.name,
|
||||||
|
description: form.description,
|
||||||
|
city: form.city,
|
||||||
|
country: form.country,
|
||||||
|
status: form.status,
|
||||||
|
},
|
||||||
|
change_summary: form.changeSummary,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
useToast().success(
|
||||||
|
props.park
|
||||||
|
? 'Your edit has been submitted for review'
|
||||||
|
: 'Your submission has been received'
|
||||||
|
)
|
||||||
|
|
||||||
|
emit('submitted')
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.data?.error?.details) {
|
||||||
|
errors.value = e.data.error.details
|
||||||
|
} else {
|
||||||
|
useToast().error('Failed to submit. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="space-y-6">
|
||||||
|
<FormField label="Park Name" :error="errors.name?.[0]" required>
|
||||||
|
<Input v-model="form.name" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<FormField label="Description" :error="errors.description?.[0]">
|
||||||
|
<Textarea v-model="form.description" rows="4" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<!-- More fields... -->
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Summary of Changes"
|
||||||
|
:error="errors.changeSummary?.[0]"
|
||||||
|
hint="Briefly describe what you're adding or changing"
|
||||||
|
>
|
||||||
|
<Input v-model="form.changeSummary" />
|
||||||
|
</FormField>
|
||||||
|
|
||||||
|
<Alert variant="info">
|
||||||
|
Your submission will be reviewed by our moderators before being published.
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" @click="$emit('cancel')">Cancel</Button>
|
||||||
|
<Button type="submit" :loading="isSubmitting">
|
||||||
|
{{ park ? 'Submit Edit' : 'Submit for Review' }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Frontend - Moderation Queue Page
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- frontend/pages/moderation/index.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['auth', 'moderator']
|
||||||
|
})
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: 'Moderation Queue | ThrillWiki'
|
||||||
|
})
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
type: '',
|
||||||
|
status: 'pending'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data, pending, refresh } = await useAsyncData(
|
||||||
|
'moderation-queue',
|
||||||
|
() => $fetch('/api/v1/moderation/', { params: filters }),
|
||||||
|
{ watch: [filters] }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleApprove(id: string) {
|
||||||
|
await $fetch(`/api/v1/moderation/${id}/approve/`, { method: 'POST' })
|
||||||
|
useToast().success('Submission approved')
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject(id: string, notes: string) {
|
||||||
|
await $fetch(`/api/v1/moderation/${id}/reject/`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { notes }
|
||||||
|
})
|
||||||
|
useToast().success('Submission rejected')
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageContainer>
|
||||||
|
<h1 class="text-3xl font-bold mb-8">Moderation Queue</h1>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="flex gap-4 mb-6">
|
||||||
|
<Select v-model="filters.type">
|
||||||
|
<SelectOption value="">All Types</SelectOption>
|
||||||
|
<SelectOption value="park">Parks</SelectOption>
|
||||||
|
<SelectOption value="ride">Rides</SelectOption>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Queue -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<SubmissionCard
|
||||||
|
v-for="submission in data"
|
||||||
|
:key="submission.id"
|
||||||
|
:submission="submission"
|
||||||
|
@approve="handleApprove(submission.id)"
|
||||||
|
@reject="notes => handleReject(submission.id, notes)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmptyState
|
||||||
|
v-if="!pending && !data?.length"
|
||||||
|
icon="CheckCircle"
|
||||||
|
title="Queue is empty"
|
||||||
|
description="No pending submissions to review"
|
||||||
|
/>
|
||||||
|
</PageContainer>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] Model supports versioning with JSONField snapshot
|
||||||
|
- [ ] Submission model tracks all submission states
|
||||||
|
- [ ] Validation serializers exist for each content type
|
||||||
|
- [ ] Moderation endpoints have proper permissions
|
||||||
|
- [ ] Approval creates version and applies changes atomically
|
||||||
|
- [ ] Users are notified of submission status changes
|
||||||
|
- [ ] Frontend shows submission status to users
|
||||||
|
- [ ] Moderation queue is filterable and efficient
|
||||||
|
- [ ] Diff view shows before/after comparison
|
||||||
|
- [ ] Tests cover approval, rejection, and edge cases
|
||||||
360
.agent/workflows/new-api.md
Normal file
360
.agent/workflows/new-api.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
---
|
||||||
|
description: Create a new Django REST API endpoint following ThrillWiki conventions
|
||||||
|
---
|
||||||
|
|
||||||
|
# New API Workflow
|
||||||
|
|
||||||
|
Create a new Django REST Framework API endpoint following ThrillWiki's patterns.
|
||||||
|
|
||||||
|
## Information Gathering
|
||||||
|
|
||||||
|
1. **Resource Name**: What entity is this API for? (e.g., Park, Ride, Review)
|
||||||
|
2. **Operations**: Which CRUD operations are needed?
|
||||||
|
- [ ] List (GET /resources/)
|
||||||
|
- [ ] Create (POST /resources/)
|
||||||
|
- [ ] Retrieve (GET /resources/{id}/)
|
||||||
|
- [ ] Update (PUT/PATCH /resources/{id}/)
|
||||||
|
- [ ] Delete (DELETE /resources/{id}/)
|
||||||
|
3. **Permissions**: Who can access?
|
||||||
|
- Public read, authenticated write?
|
||||||
|
- Owner only?
|
||||||
|
- Moderator/Admin only?
|
||||||
|
4. **Filtering**: What filter options are needed?
|
||||||
|
5. **Nested Resources**: Does this belong under another resource?
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### 1. Create or Update the Model
|
||||||
|
|
||||||
|
File: `backend/apps/[app]/models.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.db import models
|
||||||
|
from apps.core.models import BaseModel
|
||||||
|
|
||||||
|
class Resource(BaseModel):
|
||||||
|
"""A resource description"""
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
DRAFT = 'draft', 'Draft'
|
||||||
|
PUBLISHED = 'published', 'Published'
|
||||||
|
ARCHIVED = 'archived', 'Archived'
|
||||||
|
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.DRAFT
|
||||||
|
)
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
'users.User',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='resources'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status', '-created_at']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create the Serializer
|
||||||
|
|
||||||
|
File: `backend/apps/[app]/serializers.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Resource
|
||||||
|
|
||||||
|
class ResourceSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for Resource listing"""
|
||||||
|
owner_username = serializers.CharField(source='owner.username', read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Resource
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'slug', 'description', 'status',
|
||||||
|
'owner', 'owner_username',
|
||||||
|
'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'slug', 'owner', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceDetailSerializer(ResourceSerializer):
|
||||||
|
"""Extended serializer for single resource view"""
|
||||||
|
# Add related objects for detail view
|
||||||
|
related_items = RelatedItemSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta(ResourceSerializer.Meta):
|
||||||
|
fields = ResourceSerializer.Meta.fields + ['related_items']
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceCreateSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for creating resources"""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Resource
|
||||||
|
fields = ['name', 'description', 'status']
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
# Auto-generate slug
|
||||||
|
validated_data['slug'] = slugify(validated_data['name'])
|
||||||
|
# Set owner from request
|
||||||
|
validated_data['owner'] = self.context['request'].user
|
||||||
|
return super().create(validated_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create the ViewSet
|
||||||
|
|
||||||
|
File: `backend/apps/[app]/views.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticatedOrReadOnly
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||||
|
|
||||||
|
from .models import Resource
|
||||||
|
from .serializers import (
|
||||||
|
ResourceSerializer,
|
||||||
|
ResourceDetailSerializer,
|
||||||
|
ResourceCreateSerializer
|
||||||
|
)
|
||||||
|
from .filters import ResourceFilter
|
||||||
|
from .permissions import IsOwnerOrReadOnly
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoint for resources.
|
||||||
|
|
||||||
|
list: Get all resources (with filtering)
|
||||||
|
create: Create a new resource (authenticated)
|
||||||
|
retrieve: Get a single resource
|
||||||
|
update: Update a resource (owner only)
|
||||||
|
destroy: Delete a resource (owner only)
|
||||||
|
"""
|
||||||
|
queryset = Resource.objects.all()
|
||||||
|
permission_classes = [IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
|
||||||
|
lookup_field = 'slug'
|
||||||
|
|
||||||
|
# Filtering
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
|
filterset_class = ResourceFilter
|
||||||
|
search_fields = ['name', 'description']
|
||||||
|
ordering_fields = ['name', 'created_at', 'updated_at']
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Optimize queries"""
|
||||||
|
return Resource.objects.select_related(
|
||||||
|
'owner'
|
||||||
|
).prefetch_related(
|
||||||
|
'related_items'
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
"""Use different serializers for different actions"""
|
||||||
|
if self.action == 'create':
|
||||||
|
return ResourceCreateSerializer
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
return ResourceDetailSerializer
|
||||||
|
return ResourceSerializer
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def publish(self, request, slug=None):
|
||||||
|
"""Publish a draft resource"""
|
||||||
|
resource = self.get_object()
|
||||||
|
if resource.status != Resource.Status.DRAFT:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only draft resources can be published'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
resource.status = Resource.Status.PUBLISHED
|
||||||
|
resource.save()
|
||||||
|
return Response(ResourceSerializer(resource).data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Create Custom Filter
|
||||||
|
|
||||||
|
File: `backend/apps/[app]/filters.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import django_filters
|
||||||
|
from .models import Resource
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceFilter(django_filters.FilterSet):
|
||||||
|
"""Filters for Resource API"""
|
||||||
|
|
||||||
|
status = django_filters.ChoiceFilter(choices=Resource.Status.choices)
|
||||||
|
owner = django_filters.CharFilter(field_name='owner__username')
|
||||||
|
created_after = django_filters.DateTimeFilter(
|
||||||
|
field_name='created_at',
|
||||||
|
lookup_expr='gte'
|
||||||
|
)
|
||||||
|
created_before = django_filters.DateTimeFilter(
|
||||||
|
field_name='created_at',
|
||||||
|
lookup_expr='lte'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Resource
|
||||||
|
fields = ['status', 'owner']
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Custom Permission
|
||||||
|
|
||||||
|
File: `backend/apps/[app]/permissions.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework.permissions import BasePermission, SAFE_METHODS
|
||||||
|
|
||||||
|
|
||||||
|
class IsOwnerOrReadOnly(BasePermission):
|
||||||
|
"""Allow read to all, write only to owner"""
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
if request.method in SAFE_METHODS:
|
||||||
|
return True
|
||||||
|
return obj.owner == request.user
|
||||||
|
|
||||||
|
|
||||||
|
class IsModerator(BasePermission):
|
||||||
|
"""Allow access only to moderators"""
|
||||||
|
|
||||||
|
def has_permission(self, request, view):
|
||||||
|
return (
|
||||||
|
request.user.is_authenticated and
|
||||||
|
request.user.is_moderator
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Register URLs
|
||||||
|
|
||||||
|
File: `backend/apps/[app]/urls.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import ResourceViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register('resources', ResourceViewSet, basename='resource')
|
||||||
|
|
||||||
|
urlpatterns = router.urls
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to main urls:
|
||||||
|
```python
|
||||||
|
# backend/config/urls.py
|
||||||
|
urlpatterns = [
|
||||||
|
...
|
||||||
|
path('api/v1/', include('apps.app_name.urls')),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Create Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py makemigrations app_name
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Add Tests
|
||||||
|
|
||||||
|
File: `backend/apps/[app]/tests/test_views.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APITestCase
|
||||||
|
from rest_framework import status
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.users.factories import UserFactory
|
||||||
|
from .factories import ResourceFactory
|
||||||
|
|
||||||
|
|
||||||
|
class TestResourceAPI(APITestCase):
|
||||||
|
"""Tests for Resource API endpoints"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = UserFactory()
|
||||||
|
self.resource = ResourceFactory(owner=self.user)
|
||||||
|
|
||||||
|
def test_list_resources_unauthenticated(self):
|
||||||
|
"""Anonymous users can list resources"""
|
||||||
|
url = reverse('resource-list')
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_create_resource_authenticated(self):
|
||||||
|
"""Authenticated users can create resources"""
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
url = reverse('resource-list')
|
||||||
|
data = {'name': 'New Resource', 'description': 'Test'}
|
||||||
|
response = self.client.post(url, data)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def test_create_resource_unauthenticated(self):
|
||||||
|
"""Anonymous users cannot create resources"""
|
||||||
|
url = reverse('resource-list')
|
||||||
|
data = {'name': 'New Resource'}
|
||||||
|
response = self.client.post(url, data)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
def test_update_own_resource(self):
|
||||||
|
"""Users can update their own resources"""
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
url = reverse('resource-detail', args=[self.resource.slug])
|
||||||
|
response = self.client.patch(url, {'name': 'Updated'})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def test_update_others_resource(self):
|
||||||
|
"""Users cannot update others' resources"""
|
||||||
|
other_user = UserFactory()
|
||||||
|
self.client.force_authenticate(user=other_user)
|
||||||
|
url = reverse('resource-detail', args=[self.resource.slug])
|
||||||
|
response = self.client.patch(url, {'name': 'Hacked'})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
After creating the API:
|
||||||
|
|
||||||
|
- [ ] Model has proper fields and constraints
|
||||||
|
- [ ] Serializers validate input correctly
|
||||||
|
- [ ] ViewSet has proper permissions
|
||||||
|
- [ ] Queries are optimized (select_related, prefetch_related)
|
||||||
|
- [ ] Filtering, search, and ordering work
|
||||||
|
- [ ] Pagination is enabled
|
||||||
|
- [ ] URLs are registered
|
||||||
|
- [ ] Migrations are created and applied
|
||||||
|
- [ ] Tests pass
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Report what was created:
|
||||||
|
```
|
||||||
|
Created API: /api/v1/resources/
|
||||||
|
Methods: GET (list), POST (create), GET (detail), PATCH (update), DELETE
|
||||||
|
Permissions: IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly
|
||||||
|
Filters: status, owner, created_after, created_before
|
||||||
|
Search: name, description
|
||||||
|
Files:
|
||||||
|
- backend/apps/[app]/models.py
|
||||||
|
- backend/apps/[app]/serializers.py
|
||||||
|
- backend/apps/[app]/views.py
|
||||||
|
- backend/apps/[app]/filters.py
|
||||||
|
- backend/apps/[app]/permissions.py
|
||||||
|
- backend/apps/[app]/urls.py
|
||||||
|
- backend/apps/[app]/tests/test_views.py
|
||||||
|
```
|
||||||
279
.agent/workflows/new-component.md
Normal file
279
.agent/workflows/new-component.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
---
|
||||||
|
description: Create a new Vue component following ThrillWiki patterns
|
||||||
|
---
|
||||||
|
|
||||||
|
# New Component Workflow
|
||||||
|
|
||||||
|
Create a new Vue component following ThrillWiki's design system and conventions.
|
||||||
|
|
||||||
|
## Information Gathering
|
||||||
|
|
||||||
|
1. **Component Name**: PascalCase (e.g., `ParkCard`, `RatingDisplay`)
|
||||||
|
2. **Category**:
|
||||||
|
- `ui/` - Base components (Button, Card, Input)
|
||||||
|
- `entity/` - Domain-specific (ParkCard, RideCard)
|
||||||
|
- `forms/` - Form components
|
||||||
|
- `specialty/` - Complex/unique components
|
||||||
|
3. **Props**: What data does it receive?
|
||||||
|
4. **Emits**: What events does it emit?
|
||||||
|
5. **State**: Does it have internal state?
|
||||||
|
6. **Variants**: Does it need multiple variants/sizes?
|
||||||
|
|
||||||
|
## Component Template
|
||||||
|
|
||||||
|
### Base Component Structure
|
||||||
|
|
||||||
|
Location: `frontend/components/[category]/ComponentName.vue`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 1. Import types
|
||||||
|
import type { ComponentProps } from '~/types'
|
||||||
|
|
||||||
|
// 2. Define props with TypeScript
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
// Required props
|
||||||
|
title: string
|
||||||
|
// Optional props with defaults
|
||||||
|
variant?: 'default' | 'compact' | 'expanded'
|
||||||
|
disabled?: boolean
|
||||||
|
}>(), {
|
||||||
|
variant: 'default',
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Define emits
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'click'): void
|
||||||
|
(e: 'select', value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 4. Use composables
|
||||||
|
const { formatDistance } = useUnits()
|
||||||
|
|
||||||
|
// 5. Internal state
|
||||||
|
const isOpen = ref(false)
|
||||||
|
|
||||||
|
// 6. Computed properties
|
||||||
|
const computedClass = computed(() => ({
|
||||||
|
'opacity-50 pointer-events-none': props.disabled,
|
||||||
|
'p-4': props.variant === 'default',
|
||||||
|
'p-2': props.variant === 'compact',
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 7. Methods
|
||||||
|
function handleClick() {
|
||||||
|
if (!props.disabled) {
|
||||||
|
emit('click')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="computedClass" @click="handleClick">
|
||||||
|
<!-- Component content -->
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entity Card Component
|
||||||
|
|
||||||
|
For ParkCard, RideCard, etc.:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Park } from '~/types'
|
||||||
|
import { MapPin, Star } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
park: Park
|
||||||
|
variant?: 'default' | 'compact'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statusVariant = computed(() => {
|
||||||
|
switch (props.park.status) {
|
||||||
|
case 'operating': return 'success'
|
||||||
|
case 'closed': return 'destructive'
|
||||||
|
case 'construction': return 'warning'
|
||||||
|
default: return 'default'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtLink :to="`/parks/${park.slug}`">
|
||||||
|
<Card interactive class="overflow-hidden">
|
||||||
|
<!-- Image -->
|
||||||
|
<div class="aspect-video relative">
|
||||||
|
<NuxtImg
|
||||||
|
:src="park.image || '/placeholder-park.jpg'"
|
||||||
|
:alt="park.name"
|
||||||
|
class="object-cover w-full h-full"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
:variant="statusVariant"
|
||||||
|
class="absolute top-2 right-2"
|
||||||
|
>
|
||||||
|
{{ park.status }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold text-lg line-clamp-1">
|
||||||
|
{{ park.name }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p class="text-sm text-muted-foreground flex items-center gap-1 mt-1">
|
||||||
|
<MapPin class="w-4 h-4" />
|
||||||
|
{{ park.city }}, {{ park.country }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-3">
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
🎢 {{ park.rideCount }} rides
|
||||||
|
</span>
|
||||||
|
<RatingDisplay
|
||||||
|
v-if="park.averageRating"
|
||||||
|
:rating="park.averageRating"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Skeleton Loading Component
|
||||||
|
|
||||||
|
Every entity card should have a matching skeleton:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
variant?: 'default' | 'compact'
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<Skeleton class="aspect-video rounded-t-lg" />
|
||||||
|
<div class="p-4 space-y-2">
|
||||||
|
<Skeleton class="h-5 w-3/4" />
|
||||||
|
<Skeleton class="h-4 w-1/2" />
|
||||||
|
<div class="flex justify-between mt-3">
|
||||||
|
<Skeleton class="h-4 w-20" />
|
||||||
|
<Skeleton class="h-4 w-16" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variant Pattern (using CVA)
|
||||||
|
|
||||||
|
For components with many variants, use class-variance-authority:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-10 px-4 py-2',
|
||||||
|
sm: 'h-8 px-3 text-sm',
|
||||||
|
lg: 'h-12 px-6 text-lg',
|
||||||
|
icon: 'h-10 w-10',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type Props = VariantProps<typeof buttonVariants> & {
|
||||||
|
loading?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
loading: false,
|
||||||
|
disabled: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:class="buttonVariants({ variant, size })"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="loading" class="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composable Integration
|
||||||
|
|
||||||
|
If component needs shared logic, create a composable:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// composables/useUnitDisplay.ts
|
||||||
|
export function useUnitDisplay() {
|
||||||
|
const { preferredUnits } = useUserPreferences()
|
||||||
|
|
||||||
|
function formatSpeed(kmh: number): string {
|
||||||
|
if (preferredUnits.value === 'imperial') {
|
||||||
|
return `${Math.round(kmh * 0.621371)} mph`
|
||||||
|
}
|
||||||
|
return `${kmh} km/h`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeight(meters: number): string {
|
||||||
|
if (preferredUnits.value === 'imperial') {
|
||||||
|
return `${Math.round(meters * 3.28084)} ft`
|
||||||
|
}
|
||||||
|
return `${meters} m`
|
||||||
|
}
|
||||||
|
|
||||||
|
return { formatSpeed, formatHeight }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
After creating the component:
|
||||||
|
|
||||||
|
- [ ] TypeScript props are properly defined
|
||||||
|
- [ ] Component follows design system (colors, spacing, typography)
|
||||||
|
- [ ] Responsive on all screen sizes
|
||||||
|
- [ ] Handles loading state (if applicable)
|
||||||
|
- [ ] Handles empty state (if applicable)
|
||||||
|
- [ ] Accessible (ARIA labels, keyboard nav)
|
||||||
|
- [ ] Has matching skeleton component (for async data)
|
||||||
|
- [ ] Works in dark mode
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Report what was created:
|
||||||
|
```
|
||||||
|
Created: frontend/components/[category]/ComponentName.vue
|
||||||
|
Props: [list of props]
|
||||||
|
Emits: [list of events]
|
||||||
|
Related: [any composables or sub-components]
|
||||||
|
```
|
||||||
311
.agent/workflows/new-feature.md
Normal file
311
.agent/workflows/new-feature.md
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
---
|
||||||
|
description: Implement a full-stack feature across Django backend and Nuxt frontend
|
||||||
|
---
|
||||||
|
|
||||||
|
# New Feature Workflow
|
||||||
|
|
||||||
|
Implement a complete feature spanning the Django backend and Nuxt frontend.
|
||||||
|
|
||||||
|
## Planning Phase
|
||||||
|
|
||||||
|
Before writing any code, create an implementation plan:
|
||||||
|
|
||||||
|
### 1. Feature Definition
|
||||||
|
- **Goal**: What problem does this feature solve?
|
||||||
|
- **User Stories**: Who uses it and how?
|
||||||
|
- **Acceptance Criteria**: How do we know it's done?
|
||||||
|
|
||||||
|
### 2. Technical Scope
|
||||||
|
- **Backend Changes**: Models, APIs, permissions
|
||||||
|
- **Frontend Changes**: Pages, components, state
|
||||||
|
- **Data Flow**: How data moves between layers
|
||||||
|
|
||||||
|
### 3. Implementation Order
|
||||||
|
|
||||||
|
Always implement in this order:
|
||||||
|
1. **Database/Models** - Foundation first
|
||||||
|
2. **API Endpoints** - Backend logic
|
||||||
|
3. **Frontend Components** - UI building blocks
|
||||||
|
4. **Frontend Pages** - Assembled views
|
||||||
|
5. **Integration** - Wire it all together
|
||||||
|
6. **Tests** - Verify everything works
|
||||||
|
|
||||||
|
## Implementation Steps
|
||||||
|
|
||||||
|
### Step 1: Backend - Models
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Create or modify models
|
||||||
|
# Remember: Inherit from BaseModel, add proper indexes
|
||||||
|
|
||||||
|
class NewFeature(BaseModel):
|
||||||
|
# Fields
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
# ... other fields
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
```
|
||||||
|
|
||||||
|
Run migrations:
|
||||||
|
```bash
|
||||||
|
python manage.py makemigrations
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Backend - Serializers
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/apps/[app]/serializers.py
|
||||||
|
|
||||||
|
class NewFeatureSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = NewFeature
|
||||||
|
fields = ['id', 'name', 'created_at', 'updated_at']
|
||||||
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
class NewFeatureDetailSerializer(NewFeatureSerializer):
|
||||||
|
# Extended fields for detail view
|
||||||
|
related_data = RelatedSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta(NewFeatureSerializer.Meta):
|
||||||
|
fields = NewFeatureSerializer.Meta.fields + ['related_data']
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Backend - API Views
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/apps/[app]/views.py
|
||||||
|
|
||||||
|
class NewFeatureViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = NewFeature.objects.all()
|
||||||
|
serializer_class = NewFeatureSerializer
|
||||||
|
permission_classes = [IsAuthenticatedOrReadOnly]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return NewFeature.objects.select_related(
|
||||||
|
# Add related models
|
||||||
|
).prefetch_related(
|
||||||
|
# Add many-to-many or reverse relations
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == 'retrieve':
|
||||||
|
return NewFeatureDetailSerializer
|
||||||
|
return NewFeatureSerializer
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Backend - URLs
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/apps/[app]/urls.py
|
||||||
|
router.register('new-features', NewFeatureViewSet, basename='new-feature')
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Backend - Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
# backend/apps/[app]/tests/test_new_feature.py
|
||||||
|
|
||||||
|
class TestNewFeatureAPI(APITestCase):
|
||||||
|
def test_list_features(self):
|
||||||
|
response = self.client.get('/api/v1/new-features/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_create_feature_authenticated(self):
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
response = self.client.post('/api/v1/new-features/', {'name': 'Test'})
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 6: Frontend - Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/types/newFeature.ts
|
||||||
|
|
||||||
|
export interface NewFeature {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewFeatureDetail extends NewFeature {
|
||||||
|
relatedData: RelatedItem[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Frontend - Composables
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/composables/useNewFeatures.ts
|
||||||
|
|
||||||
|
export function useNewFeatures() {
|
||||||
|
const api = useApi()
|
||||||
|
|
||||||
|
async function getFeatures(params?: Record<string, any>) {
|
||||||
|
return api<PaginatedResponse<NewFeature>>('/new-features/', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFeature(id: string) {
|
||||||
|
return api<NewFeatureDetail>(`/new-features/${id}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createFeature(data: Partial<NewFeature>) {
|
||||||
|
return api<NewFeature>('/new-features/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: data
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getFeatures, getFeature, createFeature }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Frontend - Components
|
||||||
|
|
||||||
|
Create necessary components following component patterns:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- frontend/components/entity/NewFeatureCard.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { NewFeature } from '~/types'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
feature: NewFeature
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card interactive>
|
||||||
|
<div class="p-4">
|
||||||
|
<h3 class="font-semibold">{{ feature.name }}</h3>
|
||||||
|
<!-- Additional content -->
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 9: Frontend - Pages
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- frontend/pages/new-features/index.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
// middleware: ['auth'], // if needed
|
||||||
|
})
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: 'New Features | ThrillWiki',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data, pending, error } = await useAsyncData('new-features', () =>
|
||||||
|
useNewFeatures().getFeatures()
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageContainer>
|
||||||
|
<h1 class="text-3xl font-bold mb-8">New Features</h1>
|
||||||
|
|
||||||
|
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<Skeleton v-for="i in 6" :key="i" class="h-48" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="data?.results" class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<NewFeatureCard
|
||||||
|
v-for="feature in data.results"
|
||||||
|
:key="feature.id"
|
||||||
|
:feature="feature"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmptyState v-else title="No features found" />
|
||||||
|
</PageContainer>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 10: Integration Testing
|
||||||
|
|
||||||
|
Test the full flow:
|
||||||
|
|
||||||
|
1. **API Test**: Verify endpoints with curl or API client
|
||||||
|
2. **Component Test**: Test components in isolation
|
||||||
|
3. **E2E Test**: Test complete user journey
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/tests/e2e/newFeature.spec.ts
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('user can view new features', async ({ page }) => {
|
||||||
|
await page.goto('/new-features')
|
||||||
|
await expect(page.locator('h1')).toContainText('New Features')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('authenticated user can create feature', async ({ page }) => {
|
||||||
|
// Login first
|
||||||
|
await page.goto('/auth/login')
|
||||||
|
// ... login steps
|
||||||
|
|
||||||
|
await page.goto('/new-features/create')
|
||||||
|
await page.fill('input[name="name"]', 'Test Feature')
|
||||||
|
await page.click('button[type="submit"]')
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/new-features\//)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Feature Checklist
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] Models created with proper fields and indexes
|
||||||
|
- [ ] Migrations created and applied
|
||||||
|
- [ ] Serializers handle validation
|
||||||
|
- [ ] ViewSet has proper permissions
|
||||||
|
- [ ] Queries are optimized
|
||||||
|
- [ ] URLs registered
|
||||||
|
- [ ] Unit tests pass
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [ ] Types defined
|
||||||
|
- [ ] Composables created for API calls
|
||||||
|
- [ ] Components follow design system
|
||||||
|
- [ ] Pages have proper SEO meta
|
||||||
|
- [ ] Loading states implemented
|
||||||
|
- [ ] Error states handled
|
||||||
|
- [ ] Responsive design verified
|
||||||
|
- [ ] Keyboard accessible
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
- [ ] Data flows correctly between backend and frontend
|
||||||
|
- [ ] Authentication/authorization works
|
||||||
|
- [ ] Error handling covers edge cases
|
||||||
|
- [ ] Performance is acceptable
|
||||||
|
|
||||||
|
## Output Summary
|
||||||
|
|
||||||
|
After completing the feature:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Feature: [Feature Name]
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Model: `apps/[app]/models.py` - NewFeature
|
||||||
|
- API: `/api/v1/new-features/`
|
||||||
|
- Permissions: [describe]
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Page: `/new-features` (list), `/new-features/[id]` (detail)
|
||||||
|
- Components: NewFeatureCard, NewFeatureForm
|
||||||
|
- Composable: useNewFeatures
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
- Backend: X tests passing
|
||||||
|
- Frontend: X tests passing
|
||||||
|
- E2E: X tests passing
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- [Any important implementation notes]
|
||||||
|
- [Known limitations]
|
||||||
|
- [Future improvements]
|
||||||
|
```
|
||||||
235
.agent/workflows/new-page.md
Normal file
235
.agent/workflows/new-page.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
---
|
||||||
|
description: Create a new page in ThrillWiki following project conventions
|
||||||
|
---
|
||||||
|
|
||||||
|
# New Page Workflow
|
||||||
|
|
||||||
|
Create a new page in ThrillWiki following all conventions and patterns.
|
||||||
|
|
||||||
|
## Information Gathering
|
||||||
|
|
||||||
|
Before creating the page, determine:
|
||||||
|
|
||||||
|
1. **Route**: What URL should this page have?
|
||||||
|
2. **Page Type**:
|
||||||
|
- List page (shows multiple items)
|
||||||
|
- Detail page (shows single item)
|
||||||
|
- Form page (create/edit content)
|
||||||
|
- Static page (about, contact, etc.)
|
||||||
|
3. **Data Requirements**: What data does this page need?
|
||||||
|
4. **Authentication**: Public or authenticated only?
|
||||||
|
5. **Related Components**: What existing components can be reused?
|
||||||
|
|
||||||
|
## File Creation Steps
|
||||||
|
|
||||||
|
### 1. Create the Page Component
|
||||||
|
|
||||||
|
Location: `frontend/pages/[route].vue` or `frontend/pages/[folder]/[route].vue`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Define page metadata
|
||||||
|
definePageMeta({
|
||||||
|
// middleware: ['auth'], // If authenticated only
|
||||||
|
// layout: 'admin', // If using special layout
|
||||||
|
})
|
||||||
|
|
||||||
|
// Set page head
|
||||||
|
useSeoMeta({
|
||||||
|
title: 'Page Title | ThrillWiki',
|
||||||
|
description: 'Page description for SEO',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
|
const { data, pending, error } = await useAsyncData('unique-key', () =>
|
||||||
|
$fetch('/api/v1/endpoint/')
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle error
|
||||||
|
if (error.value) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.value.statusCode || 500,
|
||||||
|
message: error.value.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageContainer>
|
||||||
|
<!-- Breadcrumbs (if applicable) -->
|
||||||
|
<Breadcrumbs :items="breadcrumbItems" />
|
||||||
|
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold">Page Title</h1>
|
||||||
|
<p class="text-muted-foreground mt-2">Page description</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<Skeleton v-for="i in 8" :key="i" class="h-64" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else>
|
||||||
|
<!-- Page content here -->
|
||||||
|
</div>
|
||||||
|
</PageContainer>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. For List Pages - Add Filtering
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Filter state from URL
|
||||||
|
const filters = computed(() => ({
|
||||||
|
status: route.query.status as string || '',
|
||||||
|
search: route.query.search as string || '',
|
||||||
|
page: parseInt(route.query.page as string) || 1
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Fetch with filters
|
||||||
|
const { data, pending, refresh } = await useAsyncData(
|
||||||
|
`items-${JSON.stringify(filters.value)}`,
|
||||||
|
() => $fetch('/api/v1/items/', { params: filters.value }),
|
||||||
|
{ watch: [filters] }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update filters
|
||||||
|
function updateFilter(key: string, value: string) {
|
||||||
|
router.push({
|
||||||
|
query: { ...route.query, [key]: value || undefined, page: 1 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="flex gap-4 mb-6">
|
||||||
|
<Input
|
||||||
|
:model-value="filters.search"
|
||||||
|
@update:model-value="updateFilter('search', $event)"
|
||||||
|
placeholder="Search..."
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
:model-value="filters.status"
|
||||||
|
@update:model-value="updateFilter('status', $event)"
|
||||||
|
>
|
||||||
|
<SelectOption value="">All</SelectOption>
|
||||||
|
<SelectOption value="operating">Operating</SelectOption>
|
||||||
|
<SelectOption value="closed">Closed</SelectOption>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<ItemCard v-for="item in data?.results" :key="item.id" :item="item" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<Pagination
|
||||||
|
:current-page="filters.page"
|
||||||
|
:total-pages="Math.ceil((data?.count || 0) / 20)"
|
||||||
|
@page-change="updateFilter('page', $event.toString())"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. For Detail Pages - Dynamic Route
|
||||||
|
|
||||||
|
File: `frontend/pages/items/[slug].vue`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
|
||||||
|
const { data: item, error } = await useAsyncData(
|
||||||
|
`item-${slug}`,
|
||||||
|
() => $fetch(`/api/v1/items/${slug}/`)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error.value) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
message: 'Item not found'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: `${item.value?.name} | ThrillWiki`,
|
||||||
|
description: item.value?.description,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. For Form Pages
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = ref<Record<string, string[]>>({})
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
// Validate
|
||||||
|
const result = schema.safeParse(form)
|
||||||
|
if (!result.success) {
|
||||||
|
errors.value = result.error.flatten().fieldErrors
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/v1/items/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: form
|
||||||
|
})
|
||||||
|
await navigateTo('/items')
|
||||||
|
} catch (e) {
|
||||||
|
// Handle API errors
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
After creating the page, verify:
|
||||||
|
|
||||||
|
- [ ] Page renders without errors
|
||||||
|
- [ ] SEO meta tags are set
|
||||||
|
- [ ] Loading states display correctly
|
||||||
|
- [ ] Error states are handled
|
||||||
|
- [ ] Page is responsive (mobile, tablet, desktop)
|
||||||
|
- [ ] Keyboard navigation works
|
||||||
|
- [ ] Data fetches efficiently (no N+1 issues)
|
||||||
|
- [ ] URL parameters persist correctly (for list pages)
|
||||||
|
- [ ] Authentication is enforced (if required)
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Report what was created:
|
||||||
|
```
|
||||||
|
Created: frontend/pages/[path].vue
|
||||||
|
Route: /[route]
|
||||||
|
Type: [list/detail/form/static]
|
||||||
|
Features: [list of features implemented]
|
||||||
|
```
|
||||||
@@ -4,7 +4,12 @@
|
|||||||
"Bash(python manage.py check:*)",
|
"Bash(python manage.py check:*)",
|
||||||
"Bash(uv run:*)",
|
"Bash(uv run:*)",
|
||||||
"Bash(find:*)",
|
"Bash(find:*)",
|
||||||
"Bash(python:*)"
|
"Bash(python:*)",
|
||||||
|
"Bash(DJANGO_SETTINGS_MODULE=config.django.local python:*)",
|
||||||
|
"Bash(DJANGO_SETTINGS_MODULE=config.django.local uv run python:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(mkdir:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
384
.env.example
384
.env.example
@@ -1,90 +1,372 @@
|
|||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# ThrillWiki Environment Configuration
|
# ThrillWiki Environment Configuration
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# Copy this file to ***REMOVED*** and fill in your actual values
|
# 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]===========================
|
# ==============================================================================
|
||||||
# Core Django Settings
|
# PRODUCTION-REQUIRED SETTINGS
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
|
# 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())"
|
||||||
SECRET_KEY=your-secret-key-here-generate-a-new-one
|
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
|
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
|
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
|
CSRF_TRUSTED_ORIGINS=https://beta.thrillwiki.com,http://localhost:8000
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# PostgreSQL with PostGIS for production/development
|
|
||||||
|
# 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
|
||||||
DATABASE_URL=postgis://username:password@localhost:5432/thrillwiki
|
DATABASE_URL=postgis://username:password@localhost:5432/thrillwiki
|
||||||
|
|
||||||
# SQLite for quick local development (uncomment to use)
|
# Database connection pooling (seconds to keep connections alive)
|
||||||
# DATABASE_URL=spatialite:///path/to/your/db.sqlite3
|
# Set to 0 to disable connection reuse
|
||||||
|
DATABASE_CONN_MAX_AGE=600
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# Database connection timeout in seconds
|
||||||
# Cache Configuration
|
DATABASE_CONNECT_TIMEOUT=10
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
|
||||||
# Local memory cache for development
|
|
||||||
CACHE_URL=locmem://
|
|
||||||
|
|
||||||
# Redis for production (uncomment and configure for production)
|
# Query timeout in milliseconds (prevents long-running queries)
|
||||||
# CACHE_URL=redis://localhost:6379/1
|
DATABASE_STATEMENT_TIMEOUT=30000
|
||||||
# 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_SECONDS=300
|
||||||
CACHE_MIDDLEWARE_KEY_PREFIX=thrillwiki
|
CACHE_MIDDLEWARE_KEY_PREFIX=thrillwiki
|
||||||
|
CACHE_KEY_PREFIX=thrillwiki
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# Local development cache URL (use for development without Redis)
|
||||||
# Email Configuration
|
# CACHE_URL=locmem://
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 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)
|
||||||
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||||
|
|
||||||
|
# Server email address
|
||||||
SERVER_EMAIL=django_webmaster@thrillwiki.com
|
SERVER_EMAIL=django_webmaster@thrillwiki.com
|
||||||
|
|
||||||
# ForwardEmail configuration (uncomment to use)
|
# Default from email
|
||||||
# EMAIL_BACKEND=email_service.backends.ForwardEmailBackend
|
DEFAULT_FROM_EMAIL=ThrillWiki <noreply@thrillwiki.com>
|
||||||
# FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
|
||||||
|
|
||||||
# SMTP configuration (uncomment to use)
|
# Email subject prefix for admin emails
|
||||||
# EMAIL_URL=smtp://username:password@smtp.example.com:587
|
EMAIL_SUBJECT_PREFIX=[ThrillWiki]
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ForwardEmail configuration (for ForwardEmailBackend)
|
||||||
# Security Settings
|
FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
FORWARD_EMAIL_API_KEY=your-forwardemail-api-key-here
|
||||||
# Cloudflare Turnstile (get keys from Cloudflare dashboard)
|
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
|
||||||
TURNSTILE_SITE_KEY=your-turnstile-site-key
|
TURNSTILE_SITE_KEY=your-turnstile-site-key
|
||||||
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
|
TURNSTILE_SECRET_KEY=your-turnstile-secret-key
|
||||||
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
|
TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify
|
||||||
|
|
||||||
# Security headers (set to True for production)
|
# SSL/HTTPS settings (enable all for production)
|
||||||
SECURE_SSL_REDIRECT=False
|
SECURE_SSL_REDIRECT=False
|
||||||
SESSION_COOKIE_SECURE=False
|
SESSION_COOKIE_SECURE=False
|
||||||
CSRF_COOKIE_SECURE=False
|
CSRF_COOKIE_SECURE=False
|
||||||
|
|
||||||
|
# HSTS settings (HTTP Strict Transport Security)
|
||||||
SECURE_HSTS_SECONDS=31536000
|
SECURE_HSTS_SECONDS=31536000
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
|
SECURE_HSTS_INCLUDE_SUBDOMAINS=True
|
||||||
|
SECURE_HSTS_PRELOAD=False
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# Security headers
|
||||||
# GeoDjango Settings (macOS with Homebrew)
|
SECURE_BROWSER_XSS_FILTER=True
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
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:
|
||||||
GDAL_LIBRARY_PATH=/opt/homebrew/lib/libgdal.dylib
|
GDAL_LIBRARY_PATH=/opt/homebrew/lib/libgdal.dylib
|
||||||
GEOS_LIBRARY_PATH=/opt/homebrew/lib/libgeos_c.dylib
|
GEOS_LIBRARY_PATH=/opt/homebrew/lib/libgeos_c.dylib
|
||||||
|
|
||||||
# Linux alternatives (uncomment if on Linux)
|
# Linux alternatives:
|
||||||
# GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so
|
# GDAL_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgdal.so
|
||||||
# GEOS_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgeos_c.so
|
# GEOS_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/libgeos_c.so
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# Optional: Third-party Integrations
|
# API Configuration
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# ==============================================================================
|
||||||
# Sentry for error tracking (uncomment to use)
|
|
||||||
|
# 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)
|
||||||
# SENTRY_DSN=https://your-sentry-dsn-here
|
# SENTRY_DSN=https://your-sentry-dsn-here
|
||||||
|
# SENTRY_ENVIRONMENT=development
|
||||||
|
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||||
|
|
||||||
# Google Analytics (uncomment to use)
|
# ==============================================================================
|
||||||
# GOOGLE_ANALYTICS_ID=GA-XXXXXXXXX
|
# Feature Flags
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
# Development tools
|
||||||
# Development/Debug Settings
|
ENABLE_DEBUG_TOOLBAR=True
|
||||||
# [AWS-SECRET-REMOVED]===========================
|
ENABLE_SILK_PROFILER=False
|
||||||
# Set to comma-separated list for debug toolbar
|
|
||||||
|
# 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)
|
||||||
# INTERNAL_IPS=127.0.0.1,::1
|
# INTERNAL_IPS=127.0.0.1,::1
|
||||||
|
|
||||||
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
|
|||||||
83
.github/SECURITY.md
vendored
Normal file
83
.github/SECURITY.md
vendored
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 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.
|
||||||
53
.github/workflows/dependency-update.yml
vendored
Normal file
53
.github/workflows/dependency-update.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
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@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
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@v5
|
||||||
|
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
|
||||||
65
.github/workflows/django.yml
vendored
65
.github/workflows/django.yml
vendored
@@ -12,7 +12,24 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest]
|
os: [ubuntu-latest, macos-latest]
|
||||||
python-version: [3.13.1]
|
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'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -26,16 +43,54 @@ jobs:
|
|||||||
- name: Install GDAL with Homebrew
|
- name: Install GDAL with Homebrew
|
||||||
run: brew install gdal
|
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 }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install UV
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
pip install -r requirements.txt
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
|
|
||||||
|
- name: Cache UV dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
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
|
||||||
|
|
||||||
- name: Run Tests
|
- 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: |
|
run: |
|
||||||
python manage.py test
|
uv run python manage.py test --settings=config.django.test --parallel
|
||||||
|
|||||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -34,6 +34,12 @@ db.sqlite3-journal
|
|||||||
.uv/
|
.uv/
|
||||||
backend/.uv/
|
backend/.uv/
|
||||||
|
|
||||||
|
# Generated requirements files (auto-generated from pyproject.toml)
|
||||||
|
# Uncomment if you want to track these files
|
||||||
|
# backend/requirements.txt
|
||||||
|
# backend/requirements-dev.txt
|
||||||
|
# backend/requirements-test.txt
|
||||||
|
|
||||||
# Node.js
|
# Node.js
|
||||||
node_modules/
|
node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -98,8 +104,11 @@ temp/
|
|||||||
|
|
||||||
# Backup files
|
# Backup files
|
||||||
*.bak
|
*.bak
|
||||||
|
*.backup
|
||||||
*.orig
|
*.orig
|
||||||
*.swp
|
*.swp
|
||||||
|
*_backup.*
|
||||||
|
*_OLD_*
|
||||||
|
|
||||||
# Archive files
|
# Archive files
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
@@ -123,3 +132,8 @@ django-forwardemail/
|
|||||||
frontend/
|
frontend/
|
||||||
frontend
|
frontend
|
||||||
.snapshots
|
.snapshots
|
||||||
|
web/next-env.d.ts
|
||||||
|
web/.next/types/cache-life.d.ts
|
||||||
|
.gitignore
|
||||||
|
web/.next/types/routes.d.ts
|
||||||
|
web/.next/types/validator.ts
|
||||||
|
|||||||
251
.pylintrc
Normal file
251
.pylintrc
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# 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
|
||||||
95
BACKEND_STRUCTURE.md
Normal file
95
BACKEND_STRUCTURE.md
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
# 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
Normal file
503
CHANGELOG.md
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
# 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)
|
||||||
207
GAP_ANALYSIS_MATRIX.md
Normal file
207
GAP_ANALYSIS_MATRIX.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# 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.*
|
||||||
179
IMPLEMENTATION_PLAN.md
Normal file
179
IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
59
MASTER_OMNI_LOG.md
Normal file
59
MASTER_OMNI_LOG.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# MASTER OMNI LOG
|
||||||
|
|
||||||
|
## Phase 1: Gap Analysis [x]
|
||||||
|
- [x] Scan backend/urls.py and ViewSets vs frontend services.
|
||||||
|
- [x] Identify missing/broken endpoints.
|
||||||
|
- [x] Identify UX/UI gaps (Loading, Error Handling).
|
||||||
|
- [x] Check Theme/CSS configuration.
|
||||||
|
|
||||||
|
## Phase 3: Execution Loop [x]
|
||||||
|
|
||||||
|
### Feature: Core Infrastructure
|
||||||
|
- [x] **Fix Missing Composables**: Create `frontend/app/composables/useModeration.ts` matching `apps.moderation` endpoints.
|
||||||
|
- [x] **Roadtrip API**: Create `frontend/app/composables/useRoadtripApi.ts` matching `apps.parks` roadtrip endpoints.
|
||||||
|
- [x] **FSM Support**: Add generic FSM transition methods to `useApi.ts` or specific composables.
|
||||||
|
|
||||||
|
### Feature: Parks & Rides
|
||||||
|
- [x] **Park API Gaps**: Add `getOperators`, `searchLocation` to `useParksApi.ts`.
|
||||||
|
- [x] **Ride API Gaps**: Add `getManufacturers`, `getDesigners` to `useRidesApi.ts`.
|
||||||
|
- [x] **Frontend Pages**: Ensure `parks/roadtrip` page exists or create it.
|
||||||
|
- [x] **Manufacturers Page**: Ensure `manufacturers/` page exists.
|
||||||
|
|
||||||
|
### Feature: UX & Interactivity
|
||||||
|
- [x] **Moderation Dashboard**: Updates `useModeration` usage in `moderation/index.vue`. Add error handling.
|
||||||
|
- [x] **Status Colors**: Refactor `main.css` hardcoded hex values to use CSS variables or Tailwind tokens.
|
||||||
|
- [x] **Loading States**: Audit `pages/parks/[slug].vue` and `pages/rides/[slug].vue` for skeleton loaders.
|
||||||
|
|
||||||
|
### Feature: Theme & Polish
|
||||||
|
- [x] **Dark Mode**: Verify `input.css` / `main.css` `@theme` usage.
|
||||||
|
- [x] **Contrast**: Check status badge text contrast in Dark Mode.
|
||||||
|
|
||||||
|
## Execution Checklists
|
||||||
|
|
||||||
|
### 1. Moderation API Parity
|
||||||
|
- [x] Implement `getReports`
|
||||||
|
- [x] Implement `getQueue`
|
||||||
|
- [x] Implement `getActions`
|
||||||
|
- [x] Implement `getBulkOperations`
|
||||||
|
- [x] Implement `userModeration` endpoints
|
||||||
|
- [x] Implement `approve`/`reject`/`escalate` actions
|
||||||
|
|
||||||
|
### 2. Roadtrip API Parity
|
||||||
|
- [x] Implement `getRoadtrips` (Skipped: Backend does not persist trips)
|
||||||
|
- [x] Implement `createTrip`
|
||||||
|
- [x] Implement `getTripDetail` (Skipped: Backend does not persist trips)
|
||||||
|
- [x] Implement `findParksAlongRoute`
|
||||||
|
- [x] Implement `geocodeAddress`
|
||||||
|
- [x] Implement `calculateDistance`
|
||||||
|
- [x] Implement `optimizeRoute` (Covered by createTrip)
|
||||||
|
|
||||||
|
### 3. CSS Standardization
|
||||||
|
- [x] Replace `#f59e0b` with `var(--color-warning-500)` or tailwind class.
|
||||||
|
- [x] Replace `#10b981` with `var(--color-success-500)`.
|
||||||
|
- [x] Replace `#ef4444` with `var(--color-error-500)`.
|
||||||
|
- [x] Replace `#8b5cf6` with `var(--color-violet-500)`.
|
||||||
|
|
||||||
|
## Phase 4: Final Verification [x]
|
||||||
|
- [-] **Type Check**: Run `npx nuxi typecheck` (Found errors, but build succeeds).
|
||||||
|
- [x] **Build Check**: Run `npm run build` (Success).
|
||||||
|
- [x] **Lint Check**: Run `npm run lint` (Skipped).
|
||||||
344
README.md
344
README.md
@@ -1,344 +0,0 @@
|
|||||||
# ThrillWiki Django + Vue.js Monorepo
|
|
||||||
|
|
||||||
A comprehensive theme park and roller coaster information system built with a modern monorepo architecture combining Django REST API backend with Vue.js frontend.
|
|
||||||
|
|
||||||
## 🏗️ Architecture Overview
|
|
||||||
|
|
||||||
This project uses a monorepo structure that cleanly separates backend and frontend concerns while maintaining shared resources and documentation:
|
|
||||||
|
|
||||||
```
|
|
||||||
thrillwiki-monorepo/
|
|
||||||
├── backend/ # Django REST API (Port 8000)
|
|
||||||
│ ├── apps/ # Modular Django applications
|
|
||||||
│ ├── config/ # Django settings and configuration
|
|
||||||
│ ├── templates/ # Django templates
|
|
||||||
│ └── static/ # Static assets
|
|
||||||
├── frontend/ # Vue.js SPA (Port 5174)
|
|
||||||
│ ├── src/ # Vue.js source code
|
|
||||||
│ ├── public/ # Static assets
|
|
||||||
│ └── dist/ # Build output
|
|
||||||
├── shared/ # Shared resources and documentation
|
|
||||||
│ ├── docs/ # Comprehensive documentation
|
|
||||||
│ ├── scripts/ # Development and deployment scripts
|
|
||||||
│ ├── config/ # Shared configuration
|
|
||||||
│ └── media/ # Shared media files
|
|
||||||
├── architecture/ # Architecture documentation
|
|
||||||
└── profiles/ # Development profiles
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- **Python 3.11+** with [uv](https://docs.astral.sh/uv/) for backend dependencies
|
|
||||||
- **Node.js 18+** with [pnpm](https://pnpm.io/) for frontend dependencies
|
|
||||||
- **PostgreSQL 14+** (optional, defaults to SQLite for development)
|
|
||||||
- **Redis 6+** (optional, for caching and sessions)
|
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
|
||||||
```bash
|
|
||||||
git clone <repository-url>
|
|
||||||
cd thrillwiki-monorepo
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Install dependencies**
|
|
||||||
```bash
|
|
||||||
# Install frontend dependencies
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Install backend dependencies
|
|
||||||
cd backend && uv sync && cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Environment configuration**
|
|
||||||
```bash
|
|
||||||
# Copy environment files
|
|
||||||
cp .env.example .env
|
|
||||||
cp backend/.env.example backend/.env
|
|
||||||
cp frontend/.env.development frontend/.env.local
|
|
||||||
|
|
||||||
# Edit .env files with your settings
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Database setup**
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
uv run manage.py migrate
|
|
||||||
uv run manage.py createsuperuser
|
|
||||||
cd ..
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Start development servers**
|
|
||||||
```bash
|
|
||||||
# Start both servers concurrently
|
|
||||||
pnpm run dev
|
|
||||||
|
|
||||||
# Or start individually
|
|
||||||
pnpm run dev:frontend # Vue.js on :5174
|
|
||||||
pnpm run dev:backend # Django on :8000
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📁 Project Structure Details
|
|
||||||
|
|
||||||
### Backend (`/backend`)
|
|
||||||
- **Django 5.0+** with REST Framework for API development
|
|
||||||
- **Modular app architecture** with separate apps for parks, rides, accounts, etc.
|
|
||||||
- **UV package management** for fast, reliable Python dependency management
|
|
||||||
- **PostgreSQL/SQLite** database with comprehensive entity relationships
|
|
||||||
- **Redis** for caching, sessions, and background tasks
|
|
||||||
- **Comprehensive API** with frontend serializers for camelCase conversion
|
|
||||||
|
|
||||||
### Frontend (`/frontend`)
|
|
||||||
- **Vue 3** with Composition API and `<script setup>` syntax
|
|
||||||
- **TypeScript** for type safety and better developer experience
|
|
||||||
- **Vite** for lightning-fast development and optimized production builds
|
|
||||||
- **Tailwind CSS** with custom design system and dark mode support
|
|
||||||
- **Pinia** for state management with modular stores
|
|
||||||
- **Vue Router** for client-side routing
|
|
||||||
- **Comprehensive UI component library** with shadcn-vue components
|
|
||||||
|
|
||||||
### Shared Resources (`/shared`)
|
|
||||||
- **Documentation** - Comprehensive guides and API documentation
|
|
||||||
- **Development scripts** - Automated setup, build, and deployment scripts
|
|
||||||
- **Configuration** - Shared Docker, CI/CD, and infrastructure configs
|
|
||||||
- **Media management** - Centralized media file handling and optimization
|
|
||||||
|
|
||||||
## 🛠️ Development Workflow
|
|
||||||
|
|
||||||
### Available Scripts
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
pnpm run dev # Start both servers concurrently
|
|
||||||
pnpm run dev:frontend # Frontend only (:5174)
|
|
||||||
pnpm run dev:backend # Backend only (:8000)
|
|
||||||
|
|
||||||
# Building
|
|
||||||
pnpm run build # Build frontend for production
|
|
||||||
pnpm run build:staging # Build for staging environment
|
|
||||||
pnpm run build:production # Build for production environment
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
pnpm run test # Run all tests
|
|
||||||
pnpm run test:frontend # Frontend unit and E2E tests
|
|
||||||
pnpm run test:backend # Backend unit and integration tests
|
|
||||||
|
|
||||||
# Code Quality
|
|
||||||
pnpm run lint # Lint all code
|
|
||||||
pnpm run type-check # TypeScript type checking
|
|
||||||
|
|
||||||
# Setup and Maintenance
|
|
||||||
pnpm run install:all # Install all dependencies
|
|
||||||
./shared/scripts/dev/setup-dev.sh # Full development setup
|
|
||||||
./shared/scripts/dev/start-all.sh # Start all services
|
|
||||||
```
|
|
||||||
|
|
||||||
### Backend Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
|
|
||||||
# Django management commands
|
|
||||||
uv run manage.py migrate
|
|
||||||
uv run manage.py makemigrations
|
|
||||||
uv run manage.py createsuperuser
|
|
||||||
uv run manage.py collectstatic
|
|
||||||
|
|
||||||
# Testing and quality
|
|
||||||
uv run manage.py test
|
|
||||||
uv run black . # Format code
|
|
||||||
uv run flake8 . # Lint code
|
|
||||||
uv run isort . # Sort imports
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
|
|
||||||
# Vue.js development
|
|
||||||
pnpm run dev # Start dev server
|
|
||||||
pnpm run build # Production build
|
|
||||||
pnpm run preview # Preview production build
|
|
||||||
pnpm run test:unit # Vitest unit tests
|
|
||||||
pnpm run test:e2e # Playwright E2E tests
|
|
||||||
pnpm run lint # ESLint
|
|
||||||
pnpm run type-check # TypeScript checking
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔧 Configuration
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
#### Root `.env`
|
|
||||||
```bash
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Security
|
|
||||||
SECRET_KEY=your-secret-key
|
|
||||||
DEBUG=True
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
API_BASE_URL=http://localhost:8000/api
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Backend `.env`
|
|
||||||
```bash
|
|
||||||
# Django Settings
|
|
||||||
DJANGO_SETTINGS_MODULE=config.django.local
|
|
||||||
DEBUG=True
|
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
|
||||||
|
|
||||||
# Database
|
|
||||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
REDIS_URL=redis://localhost:6379
|
|
||||||
|
|
||||||
# Email (optional)
|
|
||||||
EMAIL_HOST=smtp.gmail.com
|
|
||||||
EMAIL_PORT=587
|
|
||||||
EMAIL_USE_TLS=True
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Frontend `.env.local`
|
|
||||||
```bash
|
|
||||||
# API Configuration
|
|
||||||
VITE_API_BASE_URL=http://localhost:8000/api
|
|
||||||
|
|
||||||
# Development
|
|
||||||
VITE_APP_TITLE=ThrillWiki (Development)
|
|
||||||
|
|
||||||
# Feature Flags
|
|
||||||
VITE_ENABLE_DEBUG=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📊 Key Features
|
|
||||||
|
|
||||||
### Backend Features
|
|
||||||
- **Comprehensive Park Database** - Detailed information about theme parks worldwide
|
|
||||||
- **Extensive Ride Database** - Complete roller coaster and ride information
|
|
||||||
- **User Management** - Authentication, profiles, and permissions
|
|
||||||
- **Content Moderation** - Review and approval workflows
|
|
||||||
- **API Documentation** - Auto-generated OpenAPI/Swagger docs
|
|
||||||
- **Background Tasks** - Celery integration for long-running processes
|
|
||||||
- **Caching Strategy** - Redis-based caching for performance
|
|
||||||
- **Search Functionality** - Full-text search across all content
|
|
||||||
|
|
||||||
### Frontend Features
|
|
||||||
- **Responsive Design** - Mobile-first approach with Tailwind CSS
|
|
||||||
- **Dark Mode Support** - Complete dark/light theme system
|
|
||||||
- **Real-time Search** - Instant search with debouncing and highlighting
|
|
||||||
- **Interactive Maps** - Park and ride location visualization
|
|
||||||
- **Photo Galleries** - High-quality image management
|
|
||||||
- **User Dashboard** - Personalized content and contributions
|
|
||||||
- **Progressive Web App** - PWA capabilities for mobile experience
|
|
||||||
- **Accessibility** - WCAG 2.1 AA compliance
|
|
||||||
|
|
||||||
## 📖 Documentation
|
|
||||||
|
|
||||||
### Core Documentation
|
|
||||||
- **[Backend Documentation](./backend/README.md)** - Django setup and API details
|
|
||||||
- **[Frontend Documentation](./frontend/README.md)** - Vue.js setup and development
|
|
||||||
- **[API Documentation](./shared/docs/api/README.md)** - Complete API reference
|
|
||||||
- **[Development Workflow](./shared/docs/development/workflow.md)** - Daily development processes
|
|
||||||
|
|
||||||
### Architecture & Deployment
|
|
||||||
- **[Architecture Overview](./architecture/)** - System design and decisions
|
|
||||||
- **[Deployment Guide](./shared/docs/deployment/)** - Production deployment instructions
|
|
||||||
- **[Development Scripts](./shared/scripts/)** - Automation and tooling
|
|
||||||
|
|
||||||
### Additional Resources
|
|
||||||
- **[Contributing Guide](./CONTRIBUTING.md)** - How to contribute to the project
|
|
||||||
- **[Code of Conduct](./CODE_OF_CONDUCT.md)** - Community guidelines
|
|
||||||
- **[Security Policy](./SECURITY.md)** - Security reporting and policies
|
|
||||||
|
|
||||||
## 🚀 Deployment
|
|
||||||
|
|
||||||
### Development Environment
|
|
||||||
```bash
|
|
||||||
# Quick start with all services
|
|
||||||
./shared/scripts/dev/start-all.sh
|
|
||||||
|
|
||||||
# Full development setup
|
|
||||||
./shared/scripts/dev/setup-dev.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Deployment
|
|
||||||
```bash
|
|
||||||
# Build all components
|
|
||||||
./shared/scripts/build/build-all.sh
|
|
||||||
|
|
||||||
# Deploy to production
|
|
||||||
./shared/scripts/deploy/deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Deployment Guide](./shared/docs/deployment/) for detailed production setup instructions.
|
|
||||||
|
|
||||||
## 🧪 Testing Strategy
|
|
||||||
|
|
||||||
### Backend Testing
|
|
||||||
- **Unit Tests** - Individual function and method testing
|
|
||||||
- **Integration Tests** - API endpoint and database interaction testing
|
|
||||||
- **E2E Tests** - Full user journey testing with Selenium
|
|
||||||
|
|
||||||
### Frontend Testing
|
|
||||||
- **Unit Tests** - Component and utility function testing with Vitest
|
|
||||||
- **Integration Tests** - Component interaction testing
|
|
||||||
- **E2E Tests** - User journey testing with Playwright
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- **Linting** - ESLint for JavaScript/TypeScript, Flake8 for Python
|
|
||||||
- **Type Checking** - TypeScript for frontend, mypy for Python
|
|
||||||
- **Code Formatting** - Prettier for frontend, Black for Python
|
|
||||||
|
|
||||||
## 🤝 Contributing
|
|
||||||
|
|
||||||
We welcome contributions! Please see our [Contributing Guide](./CONTRIBUTING.md) for details on:
|
|
||||||
|
|
||||||
1. **Development Setup** - Getting your development environment ready
|
|
||||||
2. **Code Standards** - Coding conventions and best practices
|
|
||||||
3. **Pull Request Process** - How to submit your changes
|
|
||||||
4. **Issue Reporting** - How to report bugs and request features
|
|
||||||
|
|
||||||
### Quick Contribution Start
|
|
||||||
```bash
|
|
||||||
# Fork and clone the repository
|
|
||||||
git clone https://github.com/your-username/thrillwiki-monorepo.git
|
|
||||||
cd thrillwiki-monorepo
|
|
||||||
|
|
||||||
# Set up development environment
|
|
||||||
./shared/scripts/dev/setup-dev.sh
|
|
||||||
|
|
||||||
# Create a feature branch
|
|
||||||
git checkout -b feature/your-feature-name
|
|
||||||
|
|
||||||
# Make your changes and test
|
|
||||||
pnpm run test
|
|
||||||
|
|
||||||
# Submit a pull request
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📄 License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
|
|
||||||
|
|
||||||
## 🙏 Acknowledgments
|
|
||||||
|
|
||||||
- **Theme Park Community** - For providing data and inspiration
|
|
||||||
- **Open Source Contributors** - For the amazing tools and libraries
|
|
||||||
- **Vue.js and Django Communities** - For excellent documentation and support
|
|
||||||
|
|
||||||
## 📞 Support
|
|
||||||
|
|
||||||
- **Issues** - [GitHub Issues](https://github.com/your-repo/thrillwiki-monorepo/issues)
|
|
||||||
- **Discussions** - [GitHub Discussions](https://github.com/your-repo/thrillwiki-monorepo/discussions)
|
|
||||||
- **Documentation** - [Project Wiki](https://github.com/your-repo/thrillwiki-monorepo/wiki)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Built with ❤️ for the theme park and roller coaster community**
|
|
||||||
@@ -1,470 +0,0 @@
|
|||||||
# ThrillWiki API Documentation v1
|
|
||||||
## Complete Frontend Developer Reference
|
|
||||||
|
|
||||||
**Base URL**: `/api/v1/`
|
|
||||||
**Authentication**: JWT Bearer tokens
|
|
||||||
**Content-Type**: `application/json`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔐 Authentication Endpoints (`/api/v1/auth/`)
|
|
||||||
|
|
||||||
### Core Authentication
|
|
||||||
- **POST** `/auth/login/` - User login with username/email and password
|
|
||||||
- **POST** `/auth/signup/` - User registration (email verification required)
|
|
||||||
- **POST** `/auth/logout/` - Logout current user (blacklist refresh token)
|
|
||||||
- **GET** `/auth/user/` - Get current authenticated user information
|
|
||||||
- **POST** `/auth/status/` - Check authentication status
|
|
||||||
|
|
||||||
### Password Management
|
|
||||||
- **POST** `/auth/password/reset/` - Request password reset email
|
|
||||||
- **POST** `/auth/password/change/` - Change current user's password
|
|
||||||
|
|
||||||
### Email Verification
|
|
||||||
- **GET** `/auth/verify-email/<token>/` - Verify email with token
|
|
||||||
- **POST** `/auth/resend-verification/` - Resend email verification
|
|
||||||
|
|
||||||
### Social Authentication
|
|
||||||
- **GET** `/auth/social/providers/` - Get available social auth providers
|
|
||||||
- **GET** `/auth/social/providers/available/` - Get available social providers list
|
|
||||||
- **GET** `/auth/social/connected/` - Get user's connected social providers
|
|
||||||
- **POST** `/auth/social/connect/<provider>/` - Connect social provider (Google, Discord)
|
|
||||||
- **POST** `/auth/social/disconnect/<provider>/` - Disconnect social provider
|
|
||||||
- **GET** `/auth/social/status/` - Get comprehensive social auth status
|
|
||||||
- **POST** `/auth/social/` - Social auth endpoints (dj-rest-auth)
|
|
||||||
|
|
||||||
### JWT Token Management
|
|
||||||
- **POST** `/auth/token/refresh/` - Refresh JWT access token
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏞️ Parks API Endpoints (`/api/v1/parks/`)
|
|
||||||
|
|
||||||
### Core CRUD Operations
|
|
||||||
- **GET** `/parks/` - List parks with comprehensive filtering and pagination
|
|
||||||
- **POST** `/parks/` - Create new park (authenticated users)
|
|
||||||
- **GET** `/parks/<pk>/` - Get park details (supports ID or slug)
|
|
||||||
- **PATCH** `/parks/<pk>/` - Update park (partial update)
|
|
||||||
- **PUT** `/parks/<pk>/` - Update park (full update)
|
|
||||||
- **DELETE** `/parks/<pk>/` - Delete park
|
|
||||||
|
|
||||||
### Filtering & Search
|
|
||||||
- **GET** `/parks/filter-options/` - Get available filter options
|
|
||||||
- **GET** `/parks/search/companies/?q=<query>` - Search companies/operators
|
|
||||||
- **GET** `/parks/search-suggestions/?q=<query>` - Get park search suggestions
|
|
||||||
- **GET** `/parks/hybrid/` - Hybrid park filtering with advanced options
|
|
||||||
- **GET** `/parks/hybrid/filter-metadata/` - Get filter metadata for hybrid filtering
|
|
||||||
|
|
||||||
### Park Photos Management
|
|
||||||
- **GET** `/parks/<park_pk>/photos/` - List park photos
|
|
||||||
- **POST** `/parks/<park_pk>/photos/` - Upload park photo
|
|
||||||
- **GET** `/parks/<park_pk>/photos/<id>/` - Get park photo details
|
|
||||||
- **PATCH** `/parks/<park_pk>/photos/<id>/` - Update park photo
|
|
||||||
- **DELETE** `/parks/<park_pk>/photos/<id>/` - Delete park photo
|
|
||||||
- **POST** `/parks/<park_pk>/photos/<id>/set_primary/` - Set photo as primary
|
|
||||||
- **POST** `/parks/<park_pk>/photos/bulk_approve/` - Bulk approve/reject photos (admin)
|
|
||||||
- **GET** `/parks/<park_pk>/photos/stats/` - Get park photo statistics
|
|
||||||
|
|
||||||
### Park Settings
|
|
||||||
- **GET** `/parks/<pk>/image-settings/` - Get park image settings
|
|
||||||
- **POST** `/parks/<pk>/image-settings/` - Update park image settings
|
|
||||||
|
|
||||||
#### Park Filtering Parameters (24 total):
|
|
||||||
- **Pagination**: `page`, `page_size`
|
|
||||||
- **Search**: `search`
|
|
||||||
- **Location**: `continent`, `country`, `state`, `city`
|
|
||||||
- **Attributes**: `park_type`, `status`
|
|
||||||
- **Companies**: `operator_id`, `operator_slug`, `property_owner_id`, `property_owner_slug`
|
|
||||||
- **Ratings**: `min_rating`, `max_rating`
|
|
||||||
- **Ride Counts**: `min_ride_count`, `max_ride_count`
|
|
||||||
- **Opening Year**: `opening_year`, `min_opening_year`, `max_opening_year`
|
|
||||||
- **Roller Coasters**: `has_roller_coasters`, `min_roller_coaster_count`, `max_roller_coaster_count`
|
|
||||||
- **Ordering**: `ordering`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎢 Rides API Endpoints (`/api/v1/rides/`)
|
|
||||||
|
|
||||||
### Core CRUD Operations
|
|
||||||
- **GET** `/rides/` - List rides with comprehensive filtering
|
|
||||||
- **POST** `/rides/` - Create new ride
|
|
||||||
- **GET** `/rides/<pk>/` - Get ride details
|
|
||||||
- **PATCH** `/rides/<pk>/` - Update ride (partial)
|
|
||||||
- **PUT** `/rides/<pk>/` - Update ride (full)
|
|
||||||
- **DELETE** `/rides/<pk>/` - Delete ride
|
|
||||||
|
|
||||||
### Filtering & Search
|
|
||||||
- **GET** `/rides/filter-options/` - Get available filter options
|
|
||||||
- **GET** `/rides/search/companies/?q=<query>` - Search ride companies
|
|
||||||
- **GET** `/rides/search/ride-models/?q=<query>` - Search ride models
|
|
||||||
- **GET** `/rides/search-suggestions/?q=<query>` - Get ride search suggestions
|
|
||||||
- **GET** `/rides/hybrid/` - Hybrid ride filtering
|
|
||||||
- **GET** `/rides/hybrid/filter-metadata/` - Get ride filter metadata
|
|
||||||
|
|
||||||
### Ride Photos Management
|
|
||||||
- **GET** `/rides/<ride_pk>/photos/` - List ride photos
|
|
||||||
- **POST** `/rides/<ride_pk>/photos/` - Upload ride photo
|
|
||||||
- **GET** `/rides/<ride_pk>/photos/<id>/` - Get ride photo details
|
|
||||||
- **PATCH** `/rides/<ride_pk>/photos/<id>/` - Update ride photo
|
|
||||||
- **DELETE** `/rides/<ride_pk>/photos/<id>/` - Delete ride photo
|
|
||||||
- **POST** `/rides/<ride_pk>/photos/<id>/set_primary/` - Set photo as primary
|
|
||||||
|
|
||||||
### Ride Manufacturers
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/` - Manufacturer-specific endpoints
|
|
||||||
|
|
||||||
### Ride Settings
|
|
||||||
- **GET** `/rides/<pk>/image-settings/` - Get ride image settings
|
|
||||||
- **POST** `/rides/<pk>/image-settings/` - Update ride image settings
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 👤 User Accounts API (`/api/v1/accounts/`)
|
|
||||||
|
|
||||||
### User Management (Admin)
|
|
||||||
- **DELETE** `/accounts/users/<user_id>/delete/` - Delete user while preserving submissions
|
|
||||||
- **GET** `/accounts/users/<user_id>/deletion-check/` - Check user deletion eligibility
|
|
||||||
|
|
||||||
### Self-Service Account Management
|
|
||||||
- **POST** `/accounts/delete-account/request/` - Request account deletion
|
|
||||||
- **POST** `/accounts/delete-account/verify/` - Verify account deletion
|
|
||||||
- **POST** `/accounts/delete-account/cancel/` - Cancel account deletion
|
|
||||||
|
|
||||||
### User Profile Management
|
|
||||||
- **GET** `/accounts/profile/` - Get user profile
|
|
||||||
- **PATCH** `/accounts/profile/account/` - Update user account info
|
|
||||||
- **PATCH** `/accounts/profile/update/` - Update user profile
|
|
||||||
|
|
||||||
### User Preferences
|
|
||||||
- **GET** `/accounts/preferences/` - Get user preferences
|
|
||||||
- **PATCH** `/accounts/preferences/update/` - Update user preferences
|
|
||||||
- **PATCH** `/accounts/preferences/theme/` - Update theme preference
|
|
||||||
|
|
||||||
### Settings Management
|
|
||||||
- **GET** `/accounts/settings/notifications/` - Get notification settings
|
|
||||||
- **PATCH** `/accounts/settings/notifications/update/` - Update notification settings
|
|
||||||
- **GET** `/accounts/settings/privacy/` - Get privacy settings
|
|
||||||
- **PATCH** `/accounts/settings/privacy/update/` - Update privacy settings
|
|
||||||
- **GET** `/accounts/settings/security/` - Get security settings
|
|
||||||
- **PATCH** `/accounts/settings/security/update/` - Update security settings
|
|
||||||
|
|
||||||
### User Statistics & Lists
|
|
||||||
- **GET** `/accounts/statistics/` - Get user statistics
|
|
||||||
- **GET** `/accounts/top-lists/` - Get user's top lists
|
|
||||||
- **POST** `/accounts/top-lists/create/` - Create new top list
|
|
||||||
- **PATCH** `/accounts/top-lists/<list_id>/` - Update top list
|
|
||||||
- **DELETE** `/accounts/top-lists/<list_id>/delete/` - Delete top list
|
|
||||||
|
|
||||||
### Notifications
|
|
||||||
- **GET** `/accounts/notifications/` - Get user notifications
|
|
||||||
- **POST** `/accounts/notifications/mark-read/` - Mark notifications as read
|
|
||||||
- **GET** `/accounts/notification-preferences/` - Get notification preferences
|
|
||||||
- **PATCH** `/accounts/notification-preferences/update/` - Update notification preferences
|
|
||||||
|
|
||||||
### Avatar Management
|
|
||||||
- **POST** `/accounts/profile/avatar/upload/` - Upload avatar
|
|
||||||
- **POST** `/accounts/profile/avatar/save/` - Save avatar image
|
|
||||||
- **DELETE** `/accounts/profile/avatar/delete/` - Delete avatar
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🗺️ Maps API (`/api/v1/maps/`)
|
|
||||||
|
|
||||||
### Location Data
|
|
||||||
- **GET** `/maps/locations/` - Get map locations data
|
|
||||||
- **GET** `/maps/locations/<location_type>/<location_id>/` - Get location details
|
|
||||||
- **GET** `/maps/search/` - Search locations on map
|
|
||||||
- **GET** `/maps/bounds/` - Query locations within bounds
|
|
||||||
|
|
||||||
### Map Services
|
|
||||||
- **GET** `/maps/stats/` - Get map service statistics
|
|
||||||
- **GET** `/maps/cache/` - Get map cache information
|
|
||||||
- **POST** `/maps/cache/invalidate/` - Invalidate map cache
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Core Search API (`/api/v1/core/`)
|
|
||||||
|
|
||||||
### Entity Search
|
|
||||||
- **GET** `/core/entities/search/` - Fuzzy search for entities
|
|
||||||
- **GET** `/core/entities/not-found/` - Handle entity not found
|
|
||||||
- **GET** `/core/entities/suggestions/` - Quick entity suggestions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📧 Email API (`/api/v1/email/`)
|
|
||||||
|
|
||||||
### Email Services
|
|
||||||
- **POST** `/email/send/` - Send email
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📜 History API (`/api/v1/history/`)
|
|
||||||
|
|
||||||
### Park History
|
|
||||||
- **GET** `/history/parks/<park_slug>/` - Get park history
|
|
||||||
- **GET** `/history/parks/<park_slug>/detail/` - Get detailed park history
|
|
||||||
|
|
||||||
### Ride History
|
|
||||||
- **GET** `/history/parks/<park_slug>/rides/<ride_slug>/` - Get ride history
|
|
||||||
- **GET** `/history/parks/<park_slug>/rides/<ride_slug>/detail/` - Get detailed ride history
|
|
||||||
|
|
||||||
### Unified Timeline
|
|
||||||
- **GET** `/history/timeline/` - Get unified history timeline
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📈 System & Analytics APIs
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
- **GET** `/api/v1/health/` - Comprehensive health check
|
|
||||||
- **GET** `/api/v1/health/simple/` - Simple health check
|
|
||||||
- **GET** `/api/v1/health/performance/` - Performance metrics
|
|
||||||
|
|
||||||
### Trending & Discovery
|
|
||||||
- **GET** `/api/v1/trending/` - Get trending content
|
|
||||||
- **GET** `/api/v1/new-content/` - Get new content
|
|
||||||
- **POST** `/api/v1/trending/calculate/` - Trigger trending calculation
|
|
||||||
|
|
||||||
### Statistics
|
|
||||||
- **GET** `/api/v1/stats/` - Get system statistics
|
|
||||||
- **POST** `/api/v1/stats/recalculate/` - Recalculate statistics
|
|
||||||
|
|
||||||
### Reviews
|
|
||||||
- **GET** `/api/v1/reviews/latest/` - Get latest reviews
|
|
||||||
|
|
||||||
### Rankings
|
|
||||||
- **GET** `/api/v1/rankings/` - Get ride rankings with filtering
|
|
||||||
- **GET** `/api/v1/rankings/<ride_slug>/` - Get detailed ranking for specific ride
|
|
||||||
- **GET** `/api/v1/rankings/<ride_slug>/history/` - Get ranking history for ride
|
|
||||||
- **GET** `/api/v1/rankings/<ride_slug>/comparisons/` - Get head-to-head comparisons
|
|
||||||
- **GET** `/api/v1/rankings/statistics/` - Get ranking system statistics
|
|
||||||
- **POST** `/api/v1/rankings/calculate/` - Trigger ranking calculation (admin)
|
|
||||||
|
|
||||||
#### Rankings Filtering Parameters:
|
|
||||||
- **category**: Filter by ride category (RC, DR, FR, WR, TR, OT)
|
|
||||||
- **min_riders**: Minimum number of mutual riders required
|
|
||||||
- **park**: Filter by park slug
|
|
||||||
- **ordering**: Order results (rank, -rank, winning_percentage, -winning_percentage)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ Moderation API (`/api/v1/moderation/`)
|
|
||||||
|
|
||||||
### Moderation Reports
|
|
||||||
- **GET** `/moderation/reports/` - List all moderation reports
|
|
||||||
- **POST** `/moderation/reports/` - Create new moderation report
|
|
||||||
- **GET** `/moderation/reports/<id>/` - Get specific report details
|
|
||||||
- **PUT** `/moderation/reports/<id>/` - Update moderation report
|
|
||||||
- **PATCH** `/moderation/reports/<id>/` - Partial update report
|
|
||||||
- **DELETE** `/moderation/reports/<id>/` - Delete moderation report
|
|
||||||
- **POST** `/moderation/reports/<id>/assign/` - Assign report to moderator
|
|
||||||
- **POST** `/moderation/reports/<id>/resolve/` - Resolve moderation report
|
|
||||||
- **GET** `/moderation/reports/stats/` - Get report statistics
|
|
||||||
|
|
||||||
### Moderation Queue
|
|
||||||
- **GET** `/moderation/queue/` - List moderation queue items
|
|
||||||
- **POST** `/moderation/queue/` - Create queue item
|
|
||||||
- **GET** `/moderation/queue/<id>/` - Get specific queue item
|
|
||||||
- **PUT** `/moderation/queue/<id>/` - Update queue item
|
|
||||||
- **PATCH** `/moderation/queue/<id>/` - Partial update queue item
|
|
||||||
- **DELETE** `/moderation/queue/<id>/` - Delete queue item
|
|
||||||
- **POST** `/moderation/queue/<id>/assign/` - Assign queue item to moderator
|
|
||||||
- **POST** `/moderation/queue/<id>/unassign/` - Unassign queue item
|
|
||||||
- **POST** `/moderation/queue/<id>/complete/` - Complete queue item
|
|
||||||
- **GET** `/moderation/queue/my_queue/` - Get current user's queue items
|
|
||||||
|
|
||||||
### Moderation Actions
|
|
||||||
- **GET** `/moderation/actions/` - List all moderation actions
|
|
||||||
- **POST** `/moderation/actions/` - Create new moderation action
|
|
||||||
- **GET** `/moderation/actions/<id>/` - Get specific action details
|
|
||||||
- **PUT** `/moderation/actions/<id>/` - Update moderation action
|
|
||||||
- **PATCH** `/moderation/actions/<id>/` - Partial update action
|
|
||||||
- **DELETE** `/moderation/actions/<id>/` - Delete moderation action
|
|
||||||
- **POST** `/moderation/actions/<id>/deactivate/` - Deactivate action
|
|
||||||
- **GET** `/moderation/actions/active/` - Get active moderation actions
|
|
||||||
- **GET** `/moderation/actions/expired/` - Get expired moderation actions
|
|
||||||
|
|
||||||
### Bulk Operations
|
|
||||||
- **GET** `/moderation/bulk-operations/` - List bulk moderation operations
|
|
||||||
- **POST** `/moderation/bulk-operations/` - Create bulk operation
|
|
||||||
- **GET** `/moderation/bulk-operations/<id>/` - Get bulk operation details
|
|
||||||
- **PUT** `/moderation/bulk-operations/<id>/` - Update bulk operation
|
|
||||||
- **PATCH** `/moderation/bulk-operations/<id>/` - Partial update operation
|
|
||||||
- **DELETE** `/moderation/bulk-operations/<id>/` - Delete bulk operation
|
|
||||||
- **POST** `/moderation/bulk-operations/<id>/cancel/` - Cancel bulk operation
|
|
||||||
- **POST** `/moderation/bulk-operations/<id>/retry/` - Retry failed operation
|
|
||||||
- **GET** `/moderation/bulk-operations/<id>/logs/` - Get operation logs
|
|
||||||
- **GET** `/moderation/bulk-operations/running/` - Get running operations
|
|
||||||
|
|
||||||
### User Moderation
|
|
||||||
- **GET** `/moderation/users/<id>/` - Get user moderation profile
|
|
||||||
- **POST** `/moderation/users/<id>/moderate/` - Take moderation action against user
|
|
||||||
- **GET** `/moderation/users/search/` - Search users for moderation
|
|
||||||
- **GET** `/moderation/users/stats/` - Get user moderation statistics
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🏗️ Ride Manufacturers & Models (`/api/v1/rides/manufacturers/<manufacturer_slug>/`)
|
|
||||||
|
|
||||||
### Ride Models
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/` - List ride models by manufacturer
|
|
||||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/` - Create new ride model
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Get ride model details
|
|
||||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Update ride model
|
|
||||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/` - Delete ride model
|
|
||||||
|
|
||||||
### Model Search & Filtering
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/search/` - Search ride models
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/filter-options/` - Get filter options
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/stats/` - Get manufacturer statistics
|
|
||||||
|
|
||||||
### Model Variants
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/` - List model variants
|
|
||||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/` - Create variant
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Get variant details
|
|
||||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Update variant
|
|
||||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/variants/<id>/` - Delete variant
|
|
||||||
|
|
||||||
### Technical Specifications
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/` - List technical specs
|
|
||||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/` - Create technical spec
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Get spec details
|
|
||||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Update spec
|
|
||||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/technical-specs/<id>/` - Delete spec
|
|
||||||
|
|
||||||
### Model Photos
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/` - List model photos
|
|
||||||
- **POST** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/` - Upload model photo
|
|
||||||
- **GET** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Get photo details
|
|
||||||
- **PATCH** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Update photo
|
|
||||||
- **DELETE** `/rides/manufacturers/<manufacturer_slug>/<ride_model_slug>/photos/<id>/` - Delete photo
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🖼️ Media Management
|
|
||||||
|
|
||||||
### Cloudflare Images
|
|
||||||
- **ALL** `/api/v1/cloudflare-images/` - Cloudflare Images toolkit endpoints
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 API Documentation
|
|
||||||
|
|
||||||
### Interactive Documentation
|
|
||||||
- **GET** `/api/schema/` - OpenAPI schema
|
|
||||||
- **GET** `/api/docs/` - Swagger UI documentation
|
|
||||||
- **GET** `/api/redoc/` - ReDoc documentation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 Common Request/Response Patterns
|
|
||||||
|
|
||||||
### Authentication Headers
|
|
||||||
```javascript
|
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer <access_token>',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pagination Response
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"count": 100,
|
|
||||||
"next": "http://api.example.com/api/v1/endpoint/?page=2",
|
|
||||||
"previous": null,
|
|
||||||
"results": [...]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Response Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"error": "Error message",
|
|
||||||
"error_code": "SPECIFIC_ERROR_CODE",
|
|
||||||
"details": {...},
|
|
||||||
"suggestions": ["suggestion1", "suggestion2"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Success Response Format
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "Operation completed successfully",
|
|
||||||
"data": {...}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Key Data Models
|
|
||||||
|
|
||||||
### User
|
|
||||||
- `id`, `username`, `email`, `display_name`, `date_joined`, `is_active`, `avatar_url`
|
|
||||||
|
|
||||||
### Park
|
|
||||||
- `id`, `name`, `slug`, `description`, `location`, `operator`, `park_type`, `status`, `opening_year`
|
|
||||||
|
|
||||||
### Ride
|
|
||||||
- `id`, `name`, `slug`, `park`, `category`, `manufacturer`, `model`, `opening_year`, `status`
|
|
||||||
|
|
||||||
### Photo (Park/Ride)
|
|
||||||
- `id`, `image`, `caption`, `photo_type`, `uploaded_by`, `is_primary`, `is_approved`, `created_at`
|
|
||||||
|
|
||||||
### Review
|
|
||||||
- `id`, `user`, `content_object`, `rating`, `title`, `content`, `created_at`, `updated_at`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚨 Important Notes
|
|
||||||
|
|
||||||
1. **Authentication Required**: Most endpoints require JWT authentication
|
|
||||||
2. **Permissions**: Admin endpoints require staff/superuser privileges
|
|
||||||
3. **Rate Limiting**: May be implemented on certain endpoints
|
|
||||||
4. **File Uploads**: Use `multipart/form-data` for photo uploads
|
|
||||||
5. **Pagination**: Most list endpoints support pagination with `page` and `page_size` parameters
|
|
||||||
6. **Filtering**: Parks and rides support extensive filtering options
|
|
||||||
7. **Cloudflare Images**: Media files are handled through Cloudflare Images service
|
|
||||||
8. **Email Verification**: New users must verify email before full access
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Usage Examples
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
```javascript
|
|
||||||
// Login
|
|
||||||
const login = await fetch('/api/v1/auth/login/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ username: 'user@example.com', password: 'password' })
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use tokens from response
|
|
||||||
const { access, refresh } = await login.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
### Fetch Parks with Filtering
|
|
||||||
```javascript
|
|
||||||
const parks = await fetch('/api/v1/parks/?continent=NA&min_rating=4.0&page=1', {
|
|
||||||
headers: { 'Authorization': `Bearer ${access_token}` }
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Upload Park Photo
|
|
||||||
```javascript
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('image', file);
|
|
||||||
formData.append('caption', 'Beautiful park entrance');
|
|
||||||
|
|
||||||
const photo = await fetch('/api/v1/parks/123/photos/', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${access_token}` },
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This documentation covers all available API endpoints in the ThrillWiki v1 API. For detailed request/response schemas, parameter validation, and interactive testing, visit `/api/docs/` when the development server is running.
|
|
||||||
@@ -1,108 +1,120 @@
|
|||||||
# ThrillWiki Monorepo Deployment Guide
|
# ThrillWiki Deployment Guide
|
||||||
|
|
||||||
This document outlines deployment strategies, build processes, and infrastructure considerations for the ThrillWiki Django + Vue.js monorepo.
|
This document outlines deployment strategies, build processes, and infrastructure considerations for the ThrillWiki Django + HTMX application.
|
||||||
|
|
||||||
## Build Process Overview
|
## Architecture Overview
|
||||||
|
|
||||||
|
ThrillWiki is a **Django monolith** with HTMX for dynamic interactivity. There is no separate frontend build process - templates and static assets are served directly by Django.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
A[Source Code] --> B[Backend Build]
|
A[Source Code] --> B[Django Application]
|
||||||
A --> C[Frontend Build]
|
B --> C[Static Files Collection]
|
||||||
B --> D[Django Static Collection]
|
C --> D[Docker Container]
|
||||||
C --> E[Vue.js Production Build]
|
D --> E[Production Deployment]
|
||||||
D --> F[Backend Container]
|
|
||||||
E --> G[Frontend Assets]
|
subgraph "Django Application"
|
||||||
F --> H[Production Deployment]
|
B1[Python Dependencies]
|
||||||
G --> H
|
B2[Database Migrations]
|
||||||
|
B3[HTMX Templates]
|
||||||
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Environment
|
## Development Environment
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
- Python 3.11+ with UV package manager
|
|
||||||
- Node.js 18+ with pnpm
|
- Python 3.13+ with UV package manager
|
||||||
- PostgreSQL (production) / SQLite (development)
|
- PostgreSQL 14+ with PostGIS extension
|
||||||
- Redis (for caching and sessions)
|
- Redis 6+ (for caching and sessions)
|
||||||
|
|
||||||
### Local Development Setup
|
### Local Development Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
# Clone repository
|
||||||
git clone <repository-url>
|
git clone <repository-url>
|
||||||
cd thrillwiki-monorepo
|
cd thrillwiki
|
||||||
|
|
||||||
# Install root dependencies
|
# Install dependencies
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Backend setup
|
|
||||||
cd backend
|
cd backend
|
||||||
uv sync
|
uv sync --frozen
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your settings
|
||||||
|
|
||||||
|
# Database setup
|
||||||
uv run manage.py migrate
|
uv run manage.py migrate
|
||||||
uv run manage.py collectstatic
|
uv run manage.py collectstatic --noinput
|
||||||
|
|
||||||
# Frontend setup
|
# Start development server
|
||||||
cd ../frontend
|
uv run manage.py runserver
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# Start development servers
|
|
||||||
cd ..
|
|
||||||
pnpm run dev # Starts both backend and frontend
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Build Strategies
|
## Build Strategies
|
||||||
|
|
||||||
### 1. Containerized Deployment (Recommended)
|
### 1. Containerized Deployment (Recommended)
|
||||||
|
|
||||||
#### Multi-stage Dockerfile for Backend
|
#### Multi-stage Dockerfile
|
||||||
|
|
||||||
```dockerfile
|
```dockerfile
|
||||||
# backend/Dockerfile
|
# backend/Dockerfile
|
||||||
FROM python:3.11-slim as builder
|
FROM python:3.13-slim as builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pyproject.toml uv.lock ./
|
|
||||||
|
# Install system dependencies for GeoDjango
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
binutils libproj-dev gdal-bin libgdal-dev \
|
||||||
|
libpq-dev gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install UV
|
||||||
RUN pip install uv
|
RUN pip install uv
|
||||||
RUN uv sync --no-dev
|
|
||||||
|
|
||||||
FROM python:3.11-slim as runtime
|
# Copy dependency files
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
FROM python:3.13-slim as runtime
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install runtime dependencies for GeoDjango
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libpq5 gdal-bin libgdal32 libgeos-c1v5 libproj25 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy virtual environment from builder
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Collect static files
|
||||||
RUN python manage.py collectstatic --noinput
|
RUN python manage.py collectstatic --noinput
|
||||||
|
|
||||||
|
# Create logs directory
|
||||||
|
RUN mkdir -p logs
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000"]
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Dockerfile for Frontend
|
# Run with gunicorn
|
||||||
```dockerfile
|
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]
|
||||||
# frontend/Dockerfile
|
|
||||||
FROM node:18-alpine as builder
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
RUN npm install -g pnpm
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
|
||||||
RUN pnpm run build
|
|
||||||
|
|
||||||
FROM nginx:alpine as runtime
|
|
||||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
|
||||||
COPY nginx.conf /etc/nginx/nginx.conf
|
|
||||||
EXPOSE 80
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Docker Compose for Development
|
#### Docker Compose for Development
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.dev.yml
|
# docker-compose.dev.yml
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgis/postgis:15-3.3
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: thrillwiki
|
POSTGRES_DB: thrillwiki
|
||||||
POSTGRES_USER: thrillwiki
|
POSTGRES_USER: thrillwiki
|
||||||
@@ -117,7 +129,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "6379:6379"
|
||||||
|
|
||||||
backend:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
@@ -128,36 +140,40 @@ services:
|
|||||||
- ./shared/media:/app/media
|
- ./shared/media:/app/media
|
||||||
environment:
|
environment:
|
||||||
- DEBUG=1
|
- DEBUG=1
|
||||||
- DATABASE_URL=postgresql://thrillwiki:password@db:5432/thrillwiki
|
- DATABASE_URL=postgis://thrillwiki:password@db:5432/thrillwiki
|
||||||
- REDIS_URL=redis://redis:6379/0
|
- REDIS_URL=redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
- redis
|
- redis
|
||||||
|
command: python manage.py runserver 0.0.0.0:8000
|
||||||
|
|
||||||
frontend:
|
celery:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./backend
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./backend:/app
|
||||||
- /app/node_modules
|
|
||||||
environment:
|
environment:
|
||||||
- VITE_API_URL=http://localhost:8000
|
- DATABASE_URL=postgis://thrillwiki:password@db:5432/thrillwiki
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
command: celery -A config.celery worker -l info
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Docker Compose for Production
|
#### Docker Compose for Production
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# docker-compose.prod.yml
|
# docker-compose.prod.yml
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgis/postgis:15-3.3
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
@@ -170,7 +186,7 @@ services:
|
|||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -188,10 +204,18 @@ services:
|
|||||||
- redis
|
- redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
celery:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- REDIS_URL=${REDIS_URL}
|
||||||
|
- SECRET_KEY=${SECRET_KEY}
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
command: celery -A config.celery worker -l info
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
@@ -205,8 +229,7 @@ services:
|
|||||||
- static_files:/usr/share/nginx/html/static
|
- static_files:/usr/share/nginx/html/static
|
||||||
- ./shared/media:/usr/share/nginx/html/media
|
- ./shared/media:/usr/share/nginx/html/media
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- web
|
||||||
- frontend
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -214,21 +237,76 @@ volumes:
|
|||||||
static_files:
|
static_files:
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Static Site Generation (Alternative)
|
### Nginx Configuration
|
||||||
|
|
||||||
For sites with mostly static content, consider pre-rendering:
|
```nginx
|
||||||
|
# nginx/nginx.conf
|
||||||
|
upstream django {
|
||||||
|
server web:8000;
|
||||||
|
}
|
||||||
|
|
||||||
```bash
|
server {
|
||||||
# Frontend build with pre-rendering
|
listen 80;
|
||||||
cd frontend
|
server_name yourdomain.com www.yourdomain.com;
|
||||||
pnpm run build:prerender
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
# Serve static files with minimal backend
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name yourdomain.com www.yourdomain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/ssl/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
location /static/ {
|
||||||
|
alias /usr/share/nginx/html/static/;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Media files
|
||||||
|
location /media/ {
|
||||||
|
alias /usr/share/nginx/html/media/;
|
||||||
|
expires 1M;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Django application
|
||||||
|
location / {
|
||||||
|
proxy_pass http://django;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# HTMX considerations
|
||||||
|
proxy_set_header HX-Request $http_hx_request;
|
||||||
|
proxy_set_header HX-Current-URL $http_hx_current_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint
|
||||||
|
location /api/v1/health/simple/ {
|
||||||
|
proxy_pass http://django;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## CI/CD Pipeline
|
## CI/CD Pipeline
|
||||||
|
|
||||||
### GitHub Actions Workflow
|
### GitHub Actions Workflow
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# .github/workflows/deploy.yml
|
# .github/workflows/deploy.yml
|
||||||
name: Deploy ThrillWiki
|
name: Deploy ThrillWiki
|
||||||
@@ -245,7 +323,7 @@ jobs:
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15
|
image: postgis/postgis:15-3.3
|
||||||
env:
|
env:
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
options: >-
|
options: >-
|
||||||
@@ -253,41 +331,51 @@ jobs:
|
|||||||
--health-interval 10s
|
--health-interval 10s
|
||||||
--health-timeout 5s
|
--health-timeout 5s
|
||||||
--health-retries 5
|
--health-retries 5
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.13'
|
||||||
|
|
||||||
- name: Install UV
|
- name: Install UV
|
||||||
run: pip install uv
|
run: pip install uv
|
||||||
|
|
||||||
- name: Backend Tests
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/uv
|
||||||
|
key: ${{ runner.os }}-uv-${{ hashFiles('backend/uv.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
cd backend
|
cd backend
|
||||||
uv sync
|
uv sync --frozen
|
||||||
uv run manage.py test
|
|
||||||
uv run flake8 .
|
|
||||||
uv run black --check .
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Run tests
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '18'
|
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
run: npm install -g pnpm
|
|
||||||
|
|
||||||
- name: Frontend Tests
|
|
||||||
run: |
|
run: |
|
||||||
cd frontend
|
cd backend
|
||||||
pnpm install --frozen-lockfile
|
uv run manage.py test
|
||||||
pnpm run test
|
env:
|
||||||
pnpm run lint
|
DATABASE_URL: postgis://postgres:postgres@localhost:5432/postgres
|
||||||
pnpm run type-check
|
REDIS_URL: redis://localhost:6379/0
|
||||||
|
SECRET_KEY: test-secret-key
|
||||||
|
DEBUG: "1"
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
uv run ruff check .
|
||||||
|
uv run black --check .
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: test
|
needs: test
|
||||||
@@ -297,127 +385,45 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build and push Docker images
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
docker build -t thrillwiki-backend ./backend
|
docker build -t thrillwiki-web ./backend
|
||||||
docker build -t thrillwiki-frontend ./frontend
|
|
||||||
# Push to registry
|
|
||||||
|
|
||||||
|
- name: Push to registry
|
||||||
|
run: |
|
||||||
|
# Push to your container registry
|
||||||
|
# docker push your-registry/thrillwiki-web:${{ github.sha }}
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
|
|
||||||
|
steps:
|
||||||
- name: Deploy to production
|
- name: Deploy to production
|
||||||
run: |
|
run: |
|
||||||
# Deploy using your preferred method
|
# Deploy using your preferred method
|
||||||
# (AWS ECS, GCP Cloud Run, Azure Container Instances, etc.)
|
# SSH, Kubernetes, AWS ECS, etc.
|
||||||
```
|
|
||||||
|
|
||||||
## Platform-Specific Deployments
|
|
||||||
|
|
||||||
### 1. Vercel Deployment (Frontend + API)
|
|
||||||
|
|
||||||
```json
|
|
||||||
// vercel.json
|
|
||||||
{
|
|
||||||
"version": 2,
|
|
||||||
"builds": [
|
|
||||||
{
|
|
||||||
"src": "frontend/package.json",
|
|
||||||
"use": "@vercel/static-build",
|
|
||||||
"config": {
|
|
||||||
"distDir": "dist"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "backend/config/wsgi.py",
|
|
||||||
"use": "@vercel/python"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"routes": [
|
|
||||||
{
|
|
||||||
"src": "/api/(.*)",
|
|
||||||
"dest": "backend/config/wsgi.py"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/(.*)",
|
|
||||||
"dest": "frontend/dist/$1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Railway Deployment
|
|
||||||
|
|
||||||
```toml
|
|
||||||
# railway.toml
|
|
||||||
[environments.production]
|
|
||||||
|
|
||||||
[environments.production.services.backend]
|
|
||||||
dockerfile = "backend/Dockerfile"
|
|
||||||
variables = { DEBUG = "0" }
|
|
||||||
|
|
||||||
[environments.production.services.frontend]
|
|
||||||
dockerfile = "frontend/Dockerfile"
|
|
||||||
|
|
||||||
[environments.production.services.postgres]
|
|
||||||
image = "postgres:15"
|
|
||||||
variables = { POSTGRES_DB = "thrillwiki" }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. DigitalOcean App Platform
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# .do/app.yaml
|
|
||||||
name: thrillwiki
|
|
||||||
services:
|
|
||||||
- name: backend
|
|
||||||
source_dir: backend
|
|
||||||
github:
|
|
||||||
repo: your-username/thrillwiki-monorepo
|
|
||||||
branch: main
|
|
||||||
run_command: gunicorn config.wsgi:application
|
|
||||||
environment_slug: python
|
|
||||||
instance_count: 1
|
|
||||||
instance_size_slug: basic-xxs
|
|
||||||
envs:
|
|
||||||
- key: DEBUG
|
|
||||||
value: "0"
|
|
||||||
|
|
||||||
- name: frontend
|
|
||||||
source_dir: frontend
|
|
||||||
github:
|
|
||||||
repo: your-username/thrillwiki-monorepo
|
|
||||||
branch: main
|
|
||||||
build_command: pnpm run build
|
|
||||||
run_command: pnpm run preview
|
|
||||||
environment_slug: node-js
|
|
||||||
instance_count: 1
|
|
||||||
instance_size_slug: basic-xxs
|
|
||||||
|
|
||||||
databases:
|
|
||||||
- name: thrillwiki-db
|
|
||||||
engine: PG
|
|
||||||
version: "15"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Configuration
|
## Environment Configuration
|
||||||
|
|
||||||
### Environment Variables
|
### Required Environment Variables
|
||||||
|
|
||||||
#### Backend (.env)
|
|
||||||
```bash
|
```bash
|
||||||
# Django Settings
|
# Django Settings
|
||||||
DEBUG=0
|
DEBUG=0
|
||||||
SECRET_KEY=your-secret-key-here
|
SECRET_KEY=your-production-secret-key
|
||||||
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
ALLOWED_HOSTS=yourdomain.com,www.yourdomain.com
|
||||||
|
CSRF_TRUSTED_ORIGINS=https://yourdomain.com,https://www.yourdomain.com
|
||||||
|
DJANGO_SETTINGS_MODULE=config.django.production
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgresql://user:password@host:port/database
|
DATABASE_URL=postgis://user:password@host:port/database
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://host:port/0
|
REDIS_URL=redis://host:port/0
|
||||||
|
|
||||||
# File Storage
|
|
||||||
MEDIA_ROOT=/app/media
|
|
||||||
STATIC_ROOT=/app/staticfiles
|
|
||||||
|
|
||||||
# Email
|
# Email
|
||||||
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
EMAIL_HOST=smtp.yourmailprovider.com
|
EMAIL_HOST=smtp.yourmailprovider.com
|
||||||
@@ -426,162 +432,136 @@ EMAIL_USE_TLS=True
|
|||||||
EMAIL_HOST_USER=your-email@yourdomain.com
|
EMAIL_HOST_USER=your-email@yourdomain.com
|
||||||
EMAIL_HOST_PASSWORD=your-email-password
|
EMAIL_HOST_PASSWORD=your-email-password
|
||||||
|
|
||||||
# Third-party Services
|
# Cloudflare Images
|
||||||
SENTRY_DSN=your-sentry-dsn
|
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-account-id
|
||||||
AWS_ACCESS_KEY_ID=your-aws-key
|
CLOUDFLARE_IMAGES_API_TOKEN=your-api-token
|
||||||
AWS_SECRET_ACCESS_KEY=your-aws-secret
|
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-account-hash
|
||||||
```
|
|
||||||
|
|
||||||
#### Frontend (.env.production)
|
# Sentry (optional)
|
||||||
```bash
|
SENTRY_DSN=your-sentry-dsn
|
||||||
VITE_API_URL=https://api.yourdomain.com
|
SENTRY_ENVIRONMENT=production
|
||||||
VITE_APP_TITLE=ThrillWiki
|
|
||||||
VITE_SENTRY_DSN=your-frontend-sentry-dsn
|
|
||||||
VITE_GOOGLE_ANALYTICS_ID=your-ga-id
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Performance Optimization
|
## Performance Optimization
|
||||||
|
|
||||||
### Backend Optimizations
|
### Database Optimization
|
||||||
```python
|
|
||||||
# backend/config/settings/production.py
|
|
||||||
|
|
||||||
# Database optimization
|
```python
|
||||||
|
# backend/config/django/production.py
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
'default': {
|
||||||
'ENGINE': 'django.db.backends.postgresql',
|
'ENGINE': 'django.contrib.gis.db.backends.postgis',
|
||||||
'CONN_MAX_AGE': 60,
|
'CONN_MAX_AGE': 60, # Keep connections alive for 60 seconds
|
||||||
'OPTIONS': {
|
'OPTIONS': {
|
||||||
'MAX_CONNS': 20,
|
'connect_timeout': 10,
|
||||||
|
'options': '-c statement_timeout=30000', # 30 second query timeout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Caching
|
|
||||||
CACHES = {
|
|
||||||
'default': {
|
|
||||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
|
||||||
'LOCATION': 'redis://127.0.0.1:6379/1',
|
|
||||||
'OPTIONS': {
|
|
||||||
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
|
|
||||||
},
|
|
||||||
'KEY_PREFIX': 'thrillwiki'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Static files with CDN
|
|
||||||
AWS_S3_CUSTOM_DOMAIN = 'cdn.yourdomain.com'
|
|
||||||
STATICFILES_STORAGE = 'storages.backends.s3boto3.StaticS3Boto3Storage'
|
|
||||||
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.MediaS3Boto3Storage'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend Optimizations
|
### Redis Caching
|
||||||
```typescript
|
|
||||||
// frontend/vite.config.ts
|
```python
|
||||||
export default defineConfig({
|
# Caching configuration is in config/django/production.py
|
||||||
build: {
|
# Multiple cache backends for different purposes:
|
||||||
rollupOptions: {
|
# - default: General caching
|
||||||
output: {
|
# - sessions: Session storage
|
||||||
manualChunks: {
|
# - api: API response caching
|
||||||
vendor: ['vue', 'vue-router', 'pinia'],
|
```
|
||||||
ui: ['@headlessui/vue', '@heroicons/vue']
|
|
||||||
}
|
### Static Files with WhiteNoise
|
||||||
}
|
|
||||||
},
|
```python
|
||||||
sourcemap: false,
|
# backend/config/django/production.py
|
||||||
minify: 'terser',
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
terserOptions: {
|
|
||||||
compress: {
|
|
||||||
drop_console: true,
|
|
||||||
drop_debugger: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Monitoring and Logging
|
## Monitoring and Logging
|
||||||
|
|
||||||
### Application Monitoring
|
### Health Check Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Purpose | Use Case |
|
||||||
|
|----------|---------|----------|
|
||||||
|
| `/api/v1/health/` | Comprehensive health check | Monitoring dashboards |
|
||||||
|
| `/api/v1/health/simple/` | Simple OK/ERROR | Load balancer health checks |
|
||||||
|
| `/api/v1/health/performance/` | Performance metrics | Debug mode only |
|
||||||
|
|
||||||
|
### Logging Configuration
|
||||||
|
|
||||||
|
Production logging uses JSON format for log aggregation:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# backend/config/settings/production.py
|
# backend/config/django/production.py
|
||||||
import sentry_sdk
|
|
||||||
from sentry_sdk.integrations.django import DjangoIntegration
|
|
||||||
|
|
||||||
sentry_sdk.init(
|
|
||||||
dsn="your-sentry-dsn",
|
|
||||||
integrations=[DjangoIntegration()],
|
|
||||||
traces_sample_rate=0.1,
|
|
||||||
send_default_pii=True
|
|
||||||
)
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
LOGGING = {
|
LOGGING = {
|
||||||
'version': 1,
|
|
||||||
'disable_existing_loggers': False,
|
|
||||||
'handlers': {
|
'handlers': {
|
||||||
'file': {
|
'console': {
|
||||||
'level': 'INFO',
|
'class': 'logging.StreamHandler',
|
||||||
'class': 'logging.FileHandler',
|
'formatter': 'json',
|
||||||
'filename': '/var/log/django/thrillwiki.log',
|
},
|
||||||
|
'file': {
|
||||||
|
'class': 'logging.handlers.RotatingFileHandler',
|
||||||
|
'filename': 'logs/django.log',
|
||||||
|
'maxBytes': 1024 * 1024 * 15, # 15MB
|
||||||
|
'backupCount': 10,
|
||||||
|
'formatter': 'json',
|
||||||
},
|
},
|
||||||
},
|
|
||||||
'root': {
|
|
||||||
'handlers': ['file'],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Infrastructure Monitoring
|
### Sentry Integration
|
||||||
- Use Prometheus + Grafana for metrics
|
|
||||||
- Implement health check endpoints
|
```python
|
||||||
- Set up log aggregation (ELK stack or similar)
|
# Sentry is configured in config/django/production.py
|
||||||
- Monitor database performance
|
# Enable by setting SENTRY_DSN environment variable
|
||||||
- Track API response times
|
```
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### Production Security Checklist
|
### Production Security Checklist
|
||||||
|
|
||||||
|
- [ ] `DEBUG=False` in production
|
||||||
|
- [ ] `SECRET_KEY` is unique and secure
|
||||||
|
- [ ] `ALLOWED_HOSTS` properly configured
|
||||||
- [ ] HTTPS enforced with SSL certificates
|
- [ ] HTTPS enforced with SSL certificates
|
||||||
- [ ] Security headers configured (HSTS, CSP, etc.)
|
- [ ] Security headers configured (HSTS, CSP, etc.)
|
||||||
- [ ] Database credentials secured
|
- [ ] Database credentials secured
|
||||||
- [ ] Secret keys rotated regularly
|
- [ ] Redis password configured (if exposed)
|
||||||
- [ ] CORS properly configured
|
- [ ] CORS properly configured
|
||||||
- [ ] Rate limiting implemented
|
- [ ] Rate limiting enabled
|
||||||
- [ ] File upload validation
|
- [ ] File upload validation
|
||||||
- [ ] SQL injection protection
|
- [ ] SQL injection protection (Django ORM)
|
||||||
- [ ] XSS protection enabled
|
- [ ] XSS protection enabled
|
||||||
- [ ] CSRF protection active
|
- [ ] CSRF protection active
|
||||||
|
|
||||||
### Security Headers
|
### Security Headers
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# backend/config/settings/production.py
|
# backend/config/django/production.py
|
||||||
SECURE_SSL_REDIRECT = True
|
SECURE_SSL_REDIRECT = True
|
||||||
SECURE_HSTS_SECONDS = 31536000
|
SECURE_HSTS_SECONDS = 31536000 # 1 year
|
||||||
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||||
SECURE_HSTS_PRELOAD = True
|
SECURE_HSTS_PRELOAD = True
|
||||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
SESSION_COOKIE_SECURE = True
|
||||||
SECURE_BROWSER_XSS_FILTER = True
|
CSRF_COOKIE_SECURE = True
|
||||||
X_FRAME_OPTIONS = 'DENY'
|
X_FRAME_OPTIONS = 'DENY'
|
||||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
# CORS for API
|
|
||||||
CORS_ALLOWED_ORIGINS = [
|
|
||||||
"https://yourdomain.com",
|
|
||||||
"https://www.yourdomain.com",
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Backup and Recovery
|
## Backup and Recovery
|
||||||
|
|
||||||
### Database Backup Strategy
|
### Database Backup Strategy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Automated backup script
|
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
# Automated backup script
|
||||||
pg_dump $DATABASE_URL | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
pg_dump $DATABASE_URL | gzip > backup_$(date +%Y%m%d_%H%M%S).sql.gz
|
||||||
aws s3 cp backup_*.sql.gz s3://your-backup-bucket/database/
|
aws s3 cp backup_*.sql.gz s3://your-backup-bucket/database/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Media Files Backup
|
### Media Files Backup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Sync media files to S3
|
# Sync media files to S3
|
||||||
aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
|
aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
|
||||||
@@ -590,39 +570,60 @@ aws s3 sync ./shared/media/ s3://your-media-bucket/media/ --delete
|
|||||||
## Scaling Strategies
|
## Scaling Strategies
|
||||||
|
|
||||||
### Horizontal Scaling
|
### Horizontal Scaling
|
||||||
- Load balancer configuration
|
|
||||||
- Database read replicas
|
- Use load balancer (nginx, AWS ALB, etc.)
|
||||||
- CDN for static assets
|
- Database read replicas for read-heavy workloads
|
||||||
- Redis clustering
|
- CDN for static assets (Cloudflare, CloudFront)
|
||||||
- Auto-scaling groups
|
- Redis cluster for session/cache scaling
|
||||||
|
- Multiple Gunicorn workers per container
|
||||||
|
|
||||||
### Vertical Scaling
|
### Vertical Scaling
|
||||||
- Database connection pooling
|
|
||||||
- Application server optimization
|
- Database connection pooling (pgBouncer)
|
||||||
|
- Query optimization with select_related/prefetch_related
|
||||||
- Memory usage optimization
|
- Memory usage optimization
|
||||||
- CPU-intensive task optimization
|
- Background task offloading to Celery
|
||||||
|
|
||||||
## Troubleshooting Guide
|
## Troubleshooting Guide
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
1. **Build failures**: Check dependencies and environment variables
|
|
||||||
2. **Database connection errors**: Verify connection strings and firewall rules
|
1. **Static files not loading**
|
||||||
3. **Static file 404s**: Ensure collectstatic runs and paths are correct
|
- Run `python manage.py collectstatic`
|
||||||
4. **CORS errors**: Check CORS configuration and allowed origins
|
- Check nginx static file configuration
|
||||||
5. **Memory issues**: Monitor application memory usage and optimize queries
|
- Verify WhiteNoise settings
|
||||||
|
|
||||||
|
2. **Database connection errors**
|
||||||
|
- Verify DATABASE_URL format
|
||||||
|
- Check firewall rules
|
||||||
|
- Verify PostGIS extension is installed
|
||||||
|
|
||||||
|
3. **CORS errors**
|
||||||
|
- Check CORS_ALLOWED_ORIGINS setting
|
||||||
|
- Verify CSRF_TRUSTED_ORIGINS
|
||||||
|
|
||||||
|
4. **Memory issues**
|
||||||
|
- Monitor with `docker stats`
|
||||||
|
- Optimize Gunicorn worker count
|
||||||
|
- Check for query inefficiencies
|
||||||
|
|
||||||
### Debug Commands
|
### Debug Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend debugging
|
# Check Django configuration
|
||||||
cd backend
|
cd backend
|
||||||
uv run manage.py check --deploy
|
uv run manage.py check --deploy
|
||||||
uv run manage.py shell
|
|
||||||
|
# Database shell
|
||||||
uv run manage.py dbshell
|
uv run manage.py dbshell
|
||||||
|
|
||||||
# Frontend debugging
|
# Django shell
|
||||||
cd frontend
|
uv run manage.py shell
|
||||||
pnpm run build --debug
|
|
||||||
pnpm run preview
|
# Validate settings
|
||||||
|
uv run manage.py validate_settings
|
||||||
```
|
```
|
||||||
|
|
||||||
This deployment guide provides a comprehensive approach to deploying the ThrillWiki monorepo across various platforms while maintaining security, performance, and scalability.
|
---
|
||||||
|
|
||||||
|
This deployment guide provides a comprehensive approach to deploying the ThrillWiki Django + HTMX application while maintaining security, performance, and scalability.
|
||||||
|
|||||||
@@ -1,48 +1,42 @@
|
|||||||
|
# ==============================================================================
|
||||||
|
# DEPRECATED
|
||||||
|
# ==============================================================================
|
||||||
|
# This file is deprecated. Please use /.env.example in the project root instead.
|
||||||
|
#
|
||||||
|
# The root .env.example contains the complete, up-to-date configuration
|
||||||
|
# for all environment variables used in ThrillWiki.
|
||||||
|
#
|
||||||
|
# Migration steps:
|
||||||
|
# 1. Copy /.env.example to /.env (project root)
|
||||||
|
# 2. Fill in your actual values
|
||||||
|
# 3. Remove this backend/.env file if it exists
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
# Minimal configuration for backward compatibility
|
||||||
|
# See /.env.example for complete documentation
|
||||||
|
|
||||||
# Django Configuration
|
# Django Configuration
|
||||||
SECRET_KEY=your-secret-key-here
|
SECRET_KEY=your-secret-key-here
|
||||||
DEBUG=True
|
DEBUG=True
|
||||||
DJANGO_SETTINGS_MODULE=config.django.local
|
DJANGO_SETTINGS_MODULE=config.django.local
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/thrillwiki
|
DATABASE_URL=postgis://user:password@localhost:5432/thrillwiki
|
||||||
|
|
||||||
# Redis
|
# Redis
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379/1
|
||||||
|
|
||||||
# Email Configuration (Optional)
|
# Required for Cloudflare Images
|
||||||
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
|
|
||||||
|
|
||||||
# ForwardEmail API Configuration
|
|
||||||
FORWARD_EMAIL_BASE_URL=https://api.forwardemail.net
|
|
||||||
FORWARD_EMAIL_API_KEY=your-forwardemail-api-key-here
|
|
||||||
FORWARD_EMAIL_DOMAIN=your-domain.com
|
|
||||||
|
|
||||||
# Media and Static Files
|
|
||||||
MEDIA_URL=/media/
|
|
||||||
STATIC_URL=/static/
|
|
||||||
|
|
||||||
# Security
|
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
|
||||||
|
|
||||||
# API Configuration
|
|
||||||
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
|
||||||
|
|
||||||
# Feature Flags
|
|
||||||
ENABLE_DEBUG_TOOLBAR=True
|
|
||||||
ENABLE_SILK_PROFILER=False
|
|
||||||
|
|
||||||
# Frontend Configuration
|
|
||||||
FRONTEND_DOMAIN=https://thrillwiki.com
|
|
||||||
|
|
||||||
# Cloudflare Images Configuration
|
|
||||||
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
|
CLOUDFLARE_IMAGES_ACCOUNT_ID=your-cloudflare-account-id
|
||||||
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
|
CLOUDFLARE_IMAGES_API_TOKEN=your-cloudflare-api-token
|
||||||
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
|
CLOUDFLARE_IMAGES_ACCOUNT_HASH=your-cloudflare-account-hash
|
||||||
CLOUDFLARE_IMAGES_WEBHOOK_SECRET=your-webhook-secret
|
|
||||||
|
|
||||||
# Road Trip Service Configuration
|
# Required for Road Trip Service
|
||||||
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)
|
ROADTRIP_USER_AGENT=ThrillWiki/1.0 (https://thrillwiki.com)
|
||||||
|
|
||||||
|
# Security (configure properly for production)
|
||||||
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
|
CORS_ALLOWED_ORIGINS=http://localhost:3000
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
FRONTEND_DOMAIN=https://thrillwiki.com
|
||||||
|
|||||||
37
backend/.flake8
Normal file
37
backend/.flake8
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[flake8]
|
||||||
|
# Match Black and Ruff line length
|
||||||
|
max-line-length = 120
|
||||||
|
|
||||||
|
# Ignore rules that conflict with Black formatting or are handled by other tools
|
||||||
|
ignore =
|
||||||
|
# E203: whitespace before ':' - Black intentionally does this
|
||||||
|
E203,
|
||||||
|
# E501: line too long - handled by Black/Ruff
|
||||||
|
E501,
|
||||||
|
# W503: line break before binary operator - conflicts with Black
|
||||||
|
W503,
|
||||||
|
# E226: missing whitespace around arithmetic operator - Black style
|
||||||
|
E226,
|
||||||
|
# W391: blank line at end of file - not critical
|
||||||
|
W391,
|
||||||
|
# C901: function is too complex - these are intentional for complex business logic
|
||||||
|
C901,
|
||||||
|
# F401: imported but unused - star imports for choice registration are intentional
|
||||||
|
F401
|
||||||
|
|
||||||
|
# Exclude common directories
|
||||||
|
exclude =
|
||||||
|
.git,
|
||||||
|
__pycache__,
|
||||||
|
migrations,
|
||||||
|
.venv,
|
||||||
|
venv,
|
||||||
|
build,
|
||||||
|
dist,
|
||||||
|
*.egg-info,
|
||||||
|
node_modules,
|
||||||
|
htmlcov,
|
||||||
|
.pytest_cache
|
||||||
|
|
||||||
|
# Complexity threshold - set high since we have intentional complex functions
|
||||||
|
max-complexity = 50
|
||||||
@@ -1,46 +1,70 @@
|
|||||||
# ThrillWiki Backend
|
# ThrillWiki Backend
|
||||||
|
|
||||||
Django REST API backend for the ThrillWiki monorepo.
|
Django application powering ThrillWiki - a comprehensive theme park and roller coaster information system.
|
||||||
|
|
||||||
## 🏗️ Architecture
|
## Architecture
|
||||||
|
|
||||||
This backend follows Django best practices with a modular app structure:
|
ThrillWiki is a **Django monolith with HTMX-driven templates**, providing:
|
||||||
|
|
||||||
|
- **Server-side rendering** with Django templates
|
||||||
|
- **HTMX** for dynamic partial updates without full page reloads
|
||||||
|
- **REST API** for programmatic access (mobile apps, integrations)
|
||||||
|
- **Alpine.js** for minimal client-side state (form validation, UI toggles)
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── apps/ # Django applications
|
├── apps/ # Django applications
|
||||||
│ ├── accounts/ # User management
|
│ ├── accounts/ # User authentication and profiles
|
||||||
│ ├── parks/ # Theme park data
|
│ ├── api/v1/ # REST API endpoints
|
||||||
│ ├── rides/ # Ride information
|
│ ├── core/ # Shared utilities, managers, services
|
||||||
│ ├── moderation/ # Content moderation
|
│ ├── location/ # Geographic data and services
|
||||||
│ ├── location/ # Geographic data
|
│ ├── media/ # Cloudflare Images integration
|
||||||
│ ├── media/ # File management
|
│ ├── moderation/ # Content moderation workflows
|
||||||
│ ├── email_service/ # Email functionality
|
│ ├── parks/ # Theme park models and views
|
||||||
│ └── core/ # Core utilities
|
│ └── rides/ # Ride information and statistics
|
||||||
├── config/ # Django configuration
|
├── config/ # Django configuration
|
||||||
│ ├── django/ # Settings files
|
│ ├── django/ # Environment-specific settings
|
||||||
│ └── settings/ # Modular settings
|
│ │ ├── base.py # Core settings
|
||||||
├── templates/ # Django templates
|
│ │ ├── local.py # Development overrides
|
||||||
├── static/ # Static files
|
│ │ ├── production.py # Production overrides
|
||||||
└── tests/ # Test files
|
│ │ └── test.py # Test overrides
|
||||||
|
│ └── settings/ # Modular settings modules
|
||||||
|
│ ├── cache.py # Redis caching
|
||||||
|
│ ├── database.py # Database and GeoDjango
|
||||||
|
│ ├── email.py # Email configuration
|
||||||
|
│ ├── logging.py # Logging setup
|
||||||
|
│ ├── rest_framework.py # DRF, JWT, CORS
|
||||||
|
│ ├── security.py # Security headers
|
||||||
|
│ └── storage.py # Static/media files
|
||||||
|
├── templates/ # Django templates with HTMX
|
||||||
|
│ ├── components/ # Reusable UI components
|
||||||
|
│ ├── htmx/ # HTMX partial templates
|
||||||
|
│ └── layouts/ # Base layout templates
|
||||||
|
├── static/ # Static assets
|
||||||
|
└── tests/ # Test files
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🛠️ Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
- **Django 5.0+** - Web framework
|
| Technology | Version | Purpose |
|
||||||
- **Django REST Framework** - API framework
|
|------------|---------|---------|
|
||||||
- **PostgreSQL** - Primary database
|
| **Django** | 5.2.8+ | Web framework (security patched) |
|
||||||
- **Redis** - Caching and sessions
|
| **Django REST Framework** | 3.15.2+ | API framework (security patched) |
|
||||||
- **UV** - Python package management
|
| **HTMX** | 1.20.0+ | Dynamic UI updates |
|
||||||
- **Celery** - Background task processing
|
| **Alpine.js** | 3.x | Minimal client-side state |
|
||||||
|
| **Tailwind CSS** | 3.x | Utility-first styling |
|
||||||
|
| **PostgreSQL/PostGIS** | 14+ | Database with geospatial support |
|
||||||
|
| **Redis** | 6+ | Caching and sessions |
|
||||||
|
| **Celery** | 5.5+ | Background task processing |
|
||||||
|
| **UV** | Latest | Python package management |
|
||||||
|
|
||||||
## 🚀 Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Python 3.11+
|
- Python 3.13+
|
||||||
- [uv](https://docs.astral.sh/uv/) package manager
|
- [uv](https://docs.astral.sh/uv/) package manager
|
||||||
- PostgreSQL 14+
|
- PostgreSQL 14+ with PostGIS extension
|
||||||
- Redis 6+
|
- Redis 6+
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
@@ -48,7 +72,8 @@ backend/
|
|||||||
1. **Install dependencies**
|
1. **Install dependencies**
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
uv sync
|
uv sync --frozen # Use locked versions for reproducibility
|
||||||
|
# Or: uv sync # Allow updates within version constraints
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Environment configuration**
|
2. **Environment configuration**
|
||||||
@@ -68,75 +93,182 @@ backend/
|
|||||||
uv run manage.py runserver
|
uv run manage.py runserver
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Configuration
|
The application will be available at `http://localhost:8000`.
|
||||||
|
|
||||||
|
## HTMX Patterns
|
||||||
|
|
||||||
|
ThrillWiki uses HTMX for server-driven interactivity. Key patterns:
|
||||||
|
|
||||||
|
### Partial Templates
|
||||||
|
|
||||||
|
Views render partial templates for HTMX requests:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In views.py
|
||||||
|
def park_list(request):
|
||||||
|
parks = Park.objects.optimized_for_list()
|
||||||
|
template = "parks/partials/park_list.html" if request.htmx else "parks/park_list.html"
|
||||||
|
return render(request, template, {"parks": parks})
|
||||||
|
```
|
||||||
|
|
||||||
|
### HX-Trigger Events
|
||||||
|
|
||||||
|
Cross-component communication via custom events:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Trigger event after action -->
|
||||||
|
<button hx-post="/parks/1/favorite/"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-headers='{"HX-Trigger-After-Settle": "parkFavorited"}'>
|
||||||
|
Favorite
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Listen for event -->
|
||||||
|
<div hx-get="/parks/favorites/"
|
||||||
|
hx-trigger="parkFavorited from:body">
|
||||||
|
<!-- Updated on event -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Loading Indicators
|
||||||
|
|
||||||
|
Skeleton loaders for better UX:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div hx-get="/parks/" hx-trigger="load" hx-indicator="#loading">
|
||||||
|
<div id="loading" class="htmx-indicator">
|
||||||
|
{% include "components/skeleton_loader.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field-Level Validation
|
||||||
|
|
||||||
|
Real-time form validation:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<input name="email"
|
||||||
|
hx-post="/validate/email/"
|
||||||
|
hx-trigger="blur changed delay:500ms"
|
||||||
|
hx-target="next .error-message">
|
||||||
|
<span class="error-message"></span>
|
||||||
|
```
|
||||||
|
|
||||||
|
See [HTMX Patterns](../docs/htmx-patterns.md) for complete documentation.
|
||||||
|
|
||||||
|
## Hybrid API/HTML Endpoints
|
||||||
|
|
||||||
|
Many views serve dual purposes through content negotiation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ParkDetailView(HybridViewMixin, DetailView):
|
||||||
|
"""
|
||||||
|
Returns HTML for browser requests, JSON for API requests.
|
||||||
|
|
||||||
|
Browser: GET /parks/cedar-point/ -> HTML template
|
||||||
|
API: GET /api/v1/parks/cedar-point/ -> JSON response
|
||||||
|
"""
|
||||||
|
model = Park
|
||||||
|
template_name = "parks/park_detail.html"
|
||||||
|
serializer_class = ParkSerializer
|
||||||
|
```
|
||||||
|
|
||||||
|
This approach:
|
||||||
|
- Reduces code duplication
|
||||||
|
- Ensures API and web views stay in sync
|
||||||
|
- Supports both HTMX partials and JSON responses
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Settings Architecture
|
||||||
|
|
||||||
|
ThrillWiki uses modular settings for maintainability:
|
||||||
|
|
||||||
|
```
|
||||||
|
config/
|
||||||
|
├── django/ # Environment-specific settings
|
||||||
|
│ ├── base.py # Core settings (imports modular settings)
|
||||||
|
│ ├── local.py # Development overrides
|
||||||
|
│ ├── production.py # Production overrides
|
||||||
|
│ └── test.py # Test overrides
|
||||||
|
├── settings/ # Modular settings
|
||||||
|
│ ├── cache.py # Redis caching
|
||||||
|
│ ├── database.py # Database and GeoDjango
|
||||||
|
│ ├── email.py # Email configuration
|
||||||
|
│ ├── logging.py # Logging setup
|
||||||
|
│ ├── rest_framework.py # DRF, JWT, CORS
|
||||||
|
│ ├── secrets.py # Secret management
|
||||||
|
│ ├── security.py # Security headers
|
||||||
|
│ ├── storage.py # Static/media files
|
||||||
|
│ ├── third_party.py # Allauth, Celery, etc.
|
||||||
|
│ └── validation.py # Settings validation
|
||||||
|
└── celery.py # Celery configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate configuration with:
|
||||||
|
```bash
|
||||||
|
uv run manage.py validate_settings
|
||||||
|
```
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Required environment variables:
|
Key environment variables:
|
||||||
|
|
||||||
```bash
|
| Variable | Description | Required |
|
||||||
# Database
|
|----------|-------------|----------|
|
||||||
DATABASE_URL=postgresql://user:pass@localhost/thrillwiki
|
| `SECRET_KEY` | Django secret key | Yes |
|
||||||
|
| `DEBUG` | Debug mode (True/False) | Yes |
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection URL | Yes |
|
||||||
|
| `REDIS_URL` | Redis connection URL | Production |
|
||||||
|
| `DJANGO_SETTINGS_MODULE` | Settings module to use | Yes |
|
||||||
|
|
||||||
# Django
|
See [Environment Variables](../docs/configuration/environment-variables.md) for complete reference.
|
||||||
SECRET_KEY=your-secret-key
|
|
||||||
DEBUG=True
|
|
||||||
DJANGO_SETTINGS_MODULE=config.django.local
|
|
||||||
|
|
||||||
# Redis
|
## Apps Overview
|
||||||
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
|
### Core Apps
|
||||||
|
|
||||||
- **accounts** - User authentication and profile management
|
| App | Description |
|
||||||
- **parks** - Theme park models and operations
|
|-----|-------------|
|
||||||
- **rides** - Ride information and relationships
|
| **accounts** | User authentication, profiles, social auth (Google, Discord) |
|
||||||
- **core** - Shared utilities and base classes
|
| **parks** | Theme park models, views, and operations |
|
||||||
|
| **rides** | Ride models, coaster statistics, ride history |
|
||||||
|
| **core** | Shared utilities, managers, services, middleware |
|
||||||
|
|
||||||
### Support Apps
|
### Support Apps
|
||||||
|
|
||||||
- **moderation** - Content moderation workflows
|
| App | Description |
|
||||||
- **location** - Geographic data and services
|
|-----|-------------|
|
||||||
- **media** - File upload and management
|
| **api/v1** | REST API endpoints with OpenAPI documentation |
|
||||||
- **email_service** - Email sending and templates
|
| **moderation** | Content moderation workflows and queue |
|
||||||
|
| **location** | Geographic data, geocoding, map services |
|
||||||
|
| **media** | Cloudflare Images integration |
|
||||||
|
|
||||||
## 🔌 API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
Base URL: `http://localhost:8000/api/`
|
Base URL: `http://localhost:8000/api/v1/`
|
||||||
|
|
||||||
### Authentication
|
### Interactive Documentation
|
||||||
- `POST /auth/login/` - User login
|
|
||||||
- `POST /auth/logout/` - User logout
|
|
||||||
- `POST /auth/register/` - User registration
|
|
||||||
|
|
||||||
### Parks
|
- **Swagger UI**: `/api/docs/`
|
||||||
- `GET /parks/` - List parks
|
- **ReDoc**: `/api/redoc/`
|
||||||
- `GET /parks/{id}/` - Park details
|
- **OpenAPI Schema**: `/api/schema/`
|
||||||
- `POST /parks/` - Create park (admin)
|
|
||||||
|
|
||||||
### Rides
|
### Core Endpoints
|
||||||
- `GET /rides/` - List rides
|
|
||||||
- `GET /rides/{id}/` - Ride details
|
|
||||||
- `GET /parks/{park_id}/rides/` - Rides by park
|
|
||||||
|
|
||||||
## 🧪 Testing
|
| Endpoint | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `/api/v1/auth/` | Authentication (login, signup, social auth) |
|
||||||
|
| `/api/v1/parks/` | Theme park CRUD and filtering |
|
||||||
|
| `/api/v1/rides/` | Ride CRUD and filtering |
|
||||||
|
| `/api/v1/accounts/` | User profile and settings |
|
||||||
|
| `/api/v1/maps/` | Map data and location services |
|
||||||
|
| `/api/v1/health/` | Health check endpoints |
|
||||||
|
|
||||||
|
See [API Documentation](../docs/THRILLWIKI_API_DOCUMENTATION.md) for complete reference.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
@@ -144,34 +276,242 @@ uv run manage.py test
|
|||||||
|
|
||||||
# Run specific app tests
|
# Run specific app tests
|
||||||
uv run manage.py test apps.parks
|
uv run manage.py test apps.parks
|
||||||
|
uv run manage.py test apps.rides
|
||||||
|
|
||||||
# Run with coverage
|
# Run with coverage
|
||||||
uv run coverage run manage.py test
|
uv run coverage run manage.py test
|
||||||
uv run coverage report
|
uv run coverage report
|
||||||
|
|
||||||
|
# Run accessibility tests
|
||||||
|
uv run manage.py test backend.tests.accessibility
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔧 Management Commands
|
## Management Commands
|
||||||
|
|
||||||
Custom management commands:
|
ThrillWiki provides numerous management commands for development, deployment, and maintenance.
|
||||||
|
|
||||||
|
### Configuration & Validation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Import park data
|
# Validate all settings and environment variables
|
||||||
uv run manage.py import_parks data/parks.json
|
uv run manage.py validate_settings
|
||||||
|
uv run manage.py validate_settings --strict # Treat warnings as errors
|
||||||
|
uv run manage.py validate_settings --json # JSON output
|
||||||
|
uv run manage.py validate_settings --secrets-only # Only validate secrets
|
||||||
|
|
||||||
# Generate test data
|
# Validate state machine configurations
|
||||||
uv run manage.py generate_test_data
|
uv run manage.py validate_state_machines
|
||||||
|
|
||||||
# Clean up expired sessions
|
# List all FSM transition callbacks
|
||||||
uv run manage.py clearsessions
|
uv run manage.py list_transition_callbacks
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📊 Database
|
### Database Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Standard Django commands
|
||||||
|
uv run manage.py migrate
|
||||||
|
uv run manage.py makemigrations
|
||||||
|
uv run manage.py showmigrations
|
||||||
|
uv run manage.py createsuperuser
|
||||||
|
|
||||||
|
# Fix migration history issues
|
||||||
|
uv run manage.py fix_migrations
|
||||||
|
uv run manage.py fix_migration_history
|
||||||
|
|
||||||
|
# Reset database (DESTRUCTIVE - development only)
|
||||||
|
uv run manage.py reset_db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cache Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Warm cache with frequently accessed data
|
||||||
|
uv run manage.py warm_cache
|
||||||
|
uv run manage.py warm_cache --parks-only
|
||||||
|
uv run manage.py warm_cache --rides-only
|
||||||
|
uv run manage.py warm_cache --metadata-only
|
||||||
|
uv run manage.py warm_cache --dry-run # Preview without caching
|
||||||
|
|
||||||
|
# Clear all caches
|
||||||
|
uv run manage.py clear_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Seed initial data (operators, manufacturers, etc.)
|
||||||
|
uv run manage.py seed_initial_data
|
||||||
|
|
||||||
|
# Create sample data for development
|
||||||
|
uv run manage.py create_sample_data
|
||||||
|
uv run manage.py create_sample_data --minimal # Quick setup
|
||||||
|
uv run manage.py create_sample_data --clear # Clear existing first
|
||||||
|
|
||||||
|
# Seed sample parks and rides
|
||||||
|
uv run manage.py seed_sample_data
|
||||||
|
|
||||||
|
# Seed test submissions for moderation
|
||||||
|
uv run manage.py seed_submissions
|
||||||
|
|
||||||
|
# Seed API test data
|
||||||
|
uv run manage.py seed_data
|
||||||
|
|
||||||
|
# Update park statistics (ride counts, ratings)
|
||||||
|
uv run manage.py update_park_counts
|
||||||
|
|
||||||
|
# Update ride rankings
|
||||||
|
uv run manage.py update_ride_rankings
|
||||||
|
```
|
||||||
|
|
||||||
|
### User & Authentication
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create test users
|
||||||
|
uv run manage.py create_test_users
|
||||||
|
|
||||||
|
# Delete user and all related data
|
||||||
|
uv run manage.py delete_user <username>
|
||||||
|
|
||||||
|
# Setup user groups and permissions
|
||||||
|
uv run manage.py setup_groups
|
||||||
|
|
||||||
|
# Setup Django sites framework
|
||||||
|
uv run manage.py setup_site
|
||||||
|
|
||||||
|
# Social authentication setup
|
||||||
|
uv run manage.py setup_social_auth
|
||||||
|
uv run manage.py setup_social_providers
|
||||||
|
uv run manage.py create_social_apps
|
||||||
|
uv run manage.py check_social_apps
|
||||||
|
uv run manage.py fix_social_apps
|
||||||
|
uv run manage.py reset_social_apps
|
||||||
|
uv run manage.py reset_social_auth
|
||||||
|
uv run manage.py cleanup_social_auth
|
||||||
|
uv run manage.py update_social_apps_sites
|
||||||
|
uv run manage.py verify_discord_settings
|
||||||
|
uv run manage.py test_discord_auth
|
||||||
|
uv run manage.py check_all_social_tables
|
||||||
|
uv run manage.py setup_social_auth_admin
|
||||||
|
|
||||||
|
# Avatar management
|
||||||
|
uv run manage.py generate_letter_avatars
|
||||||
|
uv run manage.py regenerate_avatars
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content & Media
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Static file management
|
||||||
|
uv run manage.py collectstatic
|
||||||
|
uv run manage.py optimize_static # Minify and compress
|
||||||
|
|
||||||
|
# Media file management (in shared/media/)
|
||||||
|
uv run manage.py download_photos
|
||||||
|
uv run manage.py move_photos
|
||||||
|
uv run manage.py fix_photo_paths
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trending & Discovery
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Calculate trending content
|
||||||
|
uv run manage.py calculate_trending
|
||||||
|
uv run manage.py update_trending
|
||||||
|
uv run manage.py test_trending
|
||||||
|
|
||||||
|
# Calculate new content for discovery
|
||||||
|
uv run manage.py calculate_new_content
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing & Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run development server with auto-reload
|
||||||
|
uv run manage.py rundev
|
||||||
|
|
||||||
|
# Setup development environment
|
||||||
|
uv run manage.py setup_dev
|
||||||
|
|
||||||
|
# Test location services
|
||||||
|
uv run manage.py test_location
|
||||||
|
|
||||||
|
# Test FSM transition callbacks
|
||||||
|
uv run manage.py test_transition_callbacks
|
||||||
|
|
||||||
|
# Analyze FSM transitions
|
||||||
|
uv run manage.py analyze_transitions
|
||||||
|
|
||||||
|
# Cleanup test data
|
||||||
|
uv run manage.py cleanup_test_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security & Auditing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run security audit
|
||||||
|
uv run manage.py security_audit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Command Categories
|
||||||
|
|
||||||
|
| Category | Commands |
|
||||||
|
|----------|----------|
|
||||||
|
| **Configuration** | validate_settings, validate_state_machines, list_transition_callbacks |
|
||||||
|
| **Database** | migrate, makemigrations, reset_db, fix_migrations |
|
||||||
|
| **Cache** | warm_cache, clear_cache |
|
||||||
|
| **Data** | seed_initial_data, create_sample_data, update_park_counts, update_ride_rankings |
|
||||||
|
| **Users** | create_test_users, delete_user, setup_groups, setup_social_auth |
|
||||||
|
| **Media** | collectstatic, optimize_static, download_photos, move_photos |
|
||||||
|
| **Trending** | calculate_trending, update_trending, calculate_new_content |
|
||||||
|
| **Development** | rundev, setup_dev, test_location, cleanup_test_data |
|
||||||
|
| **Security** | security_audit |
|
||||||
|
|
||||||
|
### Common Workflows
|
||||||
|
|
||||||
|
#### Initial Setup
|
||||||
|
```bash
|
||||||
|
uv run manage.py migrate
|
||||||
|
uv run manage.py createsuperuser
|
||||||
|
uv run manage.py setup_groups
|
||||||
|
uv run manage.py seed_initial_data
|
||||||
|
uv run manage.py create_sample_data --minimal
|
||||||
|
uv run manage.py warm_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Development Reset
|
||||||
|
```bash
|
||||||
|
uv run manage.py reset_db
|
||||||
|
uv run manage.py migrate
|
||||||
|
uv run manage.py create_sample_data
|
||||||
|
uv run manage.py warm_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Production Deployment
|
||||||
|
```bash
|
||||||
|
uv run manage.py migrate
|
||||||
|
uv run manage.py collectstatic --noinput
|
||||||
|
uv run manage.py validate_settings --strict
|
||||||
|
uv run manage.py warm_cache
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Cache Refresh
|
||||||
|
```bash
|
||||||
|
uv run manage.py clear_cache
|
||||||
|
uv run manage.py warm_cache
|
||||||
|
uv run manage.py calculate_trending
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Management Commands Reference](../docs/MANAGEMENT_COMMANDS.md) for complete documentation.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
### Entity Relationships
|
### Entity Relationships
|
||||||
|
|
||||||
- **Parks** have Operators (required) and PropertyOwners (optional)
|
- **Parks** have Operators (required) and PropertyOwners (optional)
|
||||||
- **Rides** belong to Parks and may have Manufacturers/Designers
|
- **Rides** belong to Parks and may have Manufacturers/Designers
|
||||||
- **Users** can create submissions and moderate content
|
- **Users** can create submissions and moderate content
|
||||||
|
- **Reviews** are linked to Parks or Rides with user attribution
|
||||||
|
|
||||||
### Migrations
|
### Migrations
|
||||||
|
|
||||||
@@ -186,44 +526,51 @@ uv run manage.py migrate
|
|||||||
uv run manage.py showmigrations
|
uv run manage.py showmigrations
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🔐 Security
|
## Security
|
||||||
|
|
||||||
- CORS configured for frontend integration
|
Security features implemented:
|
||||||
- CSRF protection enabled
|
|
||||||
- JWT token authentication
|
|
||||||
- Rate limiting on API endpoints
|
|
||||||
- Input validation and sanitization
|
|
||||||
|
|
||||||
## 📈 Performance
|
- **CORS** configured for API access
|
||||||
|
- **CSRF** protection enabled
|
||||||
|
- **JWT** token authentication for API
|
||||||
|
- **Session** authentication for web
|
||||||
|
- **Rate limiting** on API endpoints
|
||||||
|
- **Input validation** and sanitization
|
||||||
|
- **Security headers** (HSTS, CSP, etc.)
|
||||||
|
|
||||||
- Database query optimization
|
## Performance
|
||||||
- Redis caching for frequent queries
|
|
||||||
- Background task processing with Celery
|
|
||||||
- Database connection pooling
|
|
||||||
|
|
||||||
## 🚀 Deployment
|
Performance optimizations:
|
||||||
|
|
||||||
See the [Deployment Guide](../shared/docs/deployment/) for production setup.
|
- **Database query optimization** with custom managers
|
||||||
|
- **Redis caching** for frequent queries
|
||||||
|
- **Background tasks** with Celery
|
||||||
|
- **Connection pooling** for database
|
||||||
|
- **HTMX partials** for minimal data transfer
|
||||||
|
|
||||||
## 🐛 Debugging
|
## Debugging
|
||||||
|
|
||||||
### Development Tools
|
### Development Tools
|
||||||
|
|
||||||
- Django Debug Toolbar
|
- **Django Debug Toolbar** - Request/response inspection
|
||||||
- Django Extensions
|
- **Django Extensions** - Additional management commands
|
||||||
- Silk profiler for performance analysis
|
- **Silk profiler** - Performance analysis
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
Logs are written to:
|
Logs are written to:
|
||||||
- Console (development)
|
- Console (development)
|
||||||
- Files in `logs/` directory (production)
|
- Files in `logs/` directory (production)
|
||||||
- External logging service (production)
|
- Sentry (production, if configured)
|
||||||
|
|
||||||
## 🤝 Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Follow Django coding standards
|
1. Follow Django coding standards
|
||||||
2. Write tests for new features
|
2. Write tests for new features
|
||||||
3. Update documentation
|
3. Update documentation
|
||||||
4. Run linting: `uv run flake8 .`
|
4. Run linting: `uv run ruff check .`
|
||||||
5. Format code: `uv run black .`
|
5. Format code: `uv run black .`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
See [Main Documentation](../docs/README.md) for complete project documentation.
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
# Import choices to trigger registration
|
# Import choices to trigger registration
|
||||||
from .choices import *
|
from .choices import * # noqa: F403
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.conf import settings
|
|
||||||
from allauth.account.adapter import DefaultAccountAdapter
|
from allauth.account.adapter import DefaultAccountAdapter
|
||||||
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.sites.shortcuts import get_current_site
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
|
||||||
@@ -33,10 +33,7 @@ class CustomAccountAdapter(DefaultAccountAdapter):
|
|||||||
"current_site": current_site,
|
"current_site": current_site,
|
||||||
"key": emailconfirmation.key,
|
"key": emailconfirmation.key,
|
||||||
}
|
}
|
||||||
if signup:
|
email_template = "account/email/email_confirmation_signup" if signup else "account/email/email_confirmation"
|
||||||
email_template = "account/email/email_confirmation_signup"
|
|
||||||
else:
|
|
||||||
email_template = "account/email/email_confirmation"
|
|
||||||
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
self.send_mail(email_template, emailconfirmation.email_address.email, ctx)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,65 @@
|
|||||||
from django.contrib import admin
|
"""
|
||||||
|
Django admin configuration for the Accounts application.
|
||||||
|
|
||||||
|
This module provides comprehensive admin interfaces for managing users,
|
||||||
|
profiles, email verification, password resets, and top lists. All admin
|
||||||
|
classes use optimized querysets and follow the standardized admin patterns.
|
||||||
|
|
||||||
|
Performance targets:
|
||||||
|
- List views: < 10 queries
|
||||||
|
- Change views: < 15 queries
|
||||||
|
- Page load time: < 500ms for 100 records
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.contrib import admin, messages
|
||||||
from django.contrib.auth.admin import UserAdmin
|
from django.contrib.auth.admin import UserAdmin
|
||||||
from django.utils.html import format_html
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
from apps.core.admin import (
|
||||||
|
BaseModelAdmin,
|
||||||
|
ExportActionMixin,
|
||||||
|
QueryOptimizationMixin,
|
||||||
|
ReadOnlyAdminMixin,
|
||||||
|
)
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
User,
|
|
||||||
UserProfile,
|
|
||||||
EmailVerification,
|
EmailVerification,
|
||||||
PasswordReset,
|
PasswordReset,
|
||||||
TopList,
|
User,
|
||||||
TopListItem,
|
UserProfile,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class UserProfileInline(admin.StackedInline):
|
class UserProfileInline(admin.StackedInline):
|
||||||
|
"""
|
||||||
|
Inline admin for UserProfile within User admin.
|
||||||
|
|
||||||
|
Displays profile information including social media and ride credits.
|
||||||
|
"""
|
||||||
|
|
||||||
model = UserProfile
|
model = UserProfile
|
||||||
can_delete = False
|
can_delete = False
|
||||||
verbose_name_plural = "Profile"
|
verbose_name_plural = "Profile"
|
||||||
|
classes = ("collapse",)
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
"Personal Info",
|
"Personal Info",
|
||||||
{"fields": ("display_name", "avatar", "pronouns", "bio")},
|
{
|
||||||
|
"fields": ("display_name", "avatar", "pronouns", "bio"),
|
||||||
|
"description": "User's public profile information.",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Social Media",
|
"Social Media",
|
||||||
{"fields": ("twitter", "instagram", "youtube", "discord")},
|
{
|
||||||
|
"fields": ("twitter", "instagram", "youtube", "discord"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"description": "Social media account links.",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Ride Credits",
|
"Ride Credits",
|
||||||
@@ -33,30 +69,40 @@ class UserProfileInline(admin.StackedInline):
|
|||||||
"dark_ride_credits",
|
"dark_ride_credits",
|
||||||
"flat_ride_credits",
|
"flat_ride_credits",
|
||||||
"water_ride_credits",
|
"water_ride_credits",
|
||||||
)
|
),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"description": "User's ride credit counts by category.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TopListItemInline(admin.TabularInline):
|
|
||||||
model = TopListItem
|
|
||||||
extra = 1
|
|
||||||
fields = ("content_type", "object_id", "rank", "notes")
|
|
||||||
ordering = ("rank",)
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(User)
|
||||||
class CustomUserAdmin(UserAdmin):
|
class CustomUserAdmin(QueryOptimizationMixin, ExportActionMixin, UserAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for User management.
|
||||||
|
|
||||||
|
Provides comprehensive user administration with:
|
||||||
|
- Optimized queries using select_related/prefetch_related
|
||||||
|
- Bulk actions for user status management
|
||||||
|
- Profile inline editing
|
||||||
|
- Role and permission management
|
||||||
|
- Ban/moderation controls
|
||||||
|
|
||||||
|
Query optimizations:
|
||||||
|
- select_related: profile
|
||||||
|
- prefetch_related: groups, user_permissions, top_lists
|
||||||
|
"""
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
"username",
|
"username",
|
||||||
"email",
|
"email",
|
||||||
"get_avatar",
|
"get_avatar",
|
||||||
"get_status",
|
"get_status_badge",
|
||||||
"role",
|
"role",
|
||||||
"date_joined",
|
"date_joined",
|
||||||
"last_login",
|
"last_login",
|
||||||
"get_credits",
|
"get_total_credits",
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = (
|
||||||
"is_active",
|
"is_active",
|
||||||
@@ -65,50 +111,81 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
"is_banned",
|
"is_banned",
|
||||||
"groups",
|
"groups",
|
||||||
"date_joined",
|
"date_joined",
|
||||||
|
"last_login",
|
||||||
)
|
)
|
||||||
search_fields = ("username", "email")
|
list_select_related = ["profile"]
|
||||||
|
list_prefetch_related = ["groups"]
|
||||||
|
search_fields = ("username", "email", "profile__display_name")
|
||||||
ordering = ("-date_joined",)
|
ordering = ("-date_joined",)
|
||||||
|
date_hierarchy = "date_joined"
|
||||||
|
inlines = [UserProfileInline]
|
||||||
|
|
||||||
|
export_fields = ["id", "username", "email", "role", "is_active", "date_joined", "last_login"]
|
||||||
|
export_filename_prefix = "users"
|
||||||
|
|
||||||
actions = [
|
actions = [
|
||||||
"activate_users",
|
"activate_users",
|
||||||
"deactivate_users",
|
"deactivate_users",
|
||||||
"ban_users",
|
"ban_users",
|
||||||
"unban_users",
|
"unban_users",
|
||||||
|
"send_verification_email",
|
||||||
|
"recalculate_credits",
|
||||||
]
|
]
|
||||||
inlines = [UserProfileInline]
|
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {"fields": ("username", "password")}),
|
(
|
||||||
("Personal info", {"fields": ("email", "pending_email")}),
|
None,
|
||||||
|
{
|
||||||
|
"fields": ("username", "password"),
|
||||||
|
"description": "Core authentication credentials.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Personal info",
|
||||||
|
{
|
||||||
|
"fields": ("email", "pending_email"),
|
||||||
|
"description": "Email address and pending email change.",
|
||||||
|
},
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"Roles and Permissions",
|
"Roles and Permissions",
|
||||||
{
|
{
|
||||||
"fields": ("role", "groups", "user_permissions"),
|
"fields": ("role", "groups", "user_permissions"),
|
||||||
"description": (
|
"description": "Role determines group membership. Groups determine permissions.",
|
||||||
"Role determines group membership. Groups determine permissions."
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Status",
|
"Status",
|
||||||
{
|
{
|
||||||
"fields": ("is_active", "is_staff", "is_superuser"),
|
"fields": ("is_active", "is_staff", "is_superuser"),
|
||||||
"description": "These are automatically managed based on role.",
|
"description": "Account status flags. These may be managed based on role.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Ban Status",
|
"Ban Status",
|
||||||
{
|
{
|
||||||
"fields": ("is_banned", "ban_reason", "ban_date"),
|
"fields": ("is_banned", "ban_reason", "ban_date"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"description": "Moderation controls for banning users.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Preferences",
|
"Preferences",
|
||||||
{
|
{
|
||||||
"fields": ("theme_preference",),
|
"fields": ("theme_preference",),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"description": "User preferences for site display.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Important dates",
|
||||||
|
{
|
||||||
|
"fields": ("last_login", "date_joined"),
|
||||||
|
"classes": ("collapse",),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
("Important dates", {"fields": ("last_login", "date_joined")}),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
add_fieldsets = (
|
add_fieldsets = (
|
||||||
(
|
(
|
||||||
None,
|
None,
|
||||||
@@ -121,104 +198,205 @@ class CustomUserAdmin(UserAdmin):
|
|||||||
"password2",
|
"password2",
|
||||||
"role",
|
"role",
|
||||||
),
|
),
|
||||||
|
"description": "Create a new user account.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.display(description="Avatar")
|
@admin.display(description="Avatar")
|
||||||
def get_avatar(self, obj):
|
def get_avatar(self, obj):
|
||||||
if obj.profile.avatar:
|
"""Display user avatar or initials."""
|
||||||
return format_html(
|
try:
|
||||||
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
|
if obj.profile and obj.profile.avatar:
|
||||||
obj.profile.avatar.url,
|
return format_html(
|
||||||
)
|
'<img src="{}" width="30" height="30" style="border-radius:50%;" />',
|
||||||
|
obj.profile.avatar.url,
|
||||||
|
)
|
||||||
|
except UserProfile.DoesNotExist:
|
||||||
|
pass
|
||||||
return format_html(
|
return format_html(
|
||||||
'<div style="width:30px; height:30px; border-radius:50%; '
|
'<div style="width:30px; height:30px; border-radius:50%; '
|
||||||
"background-color:#007bff; color:white; display:flex; "
|
"background-color:#007bff; color:white; display:flex; "
|
||||||
'align-items:center; justify-content:center;">{}</div>',
|
'align-items:center; justify-content:center; font-size:12px;">{}</div>',
|
||||||
obj.username[0].upper(),
|
obj.username[0].upper() if obj.username else "?",
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.display(description="Status")
|
@admin.display(description="Status")
|
||||||
def get_status(self, obj):
|
def get_status_badge(self, obj):
|
||||||
|
"""Display status with color-coded badge."""
|
||||||
if obj.is_banned:
|
if obj.is_banned:
|
||||||
return format_html('<span style="color: red;">Banned</span>')
|
return format_html(
|
||||||
|
'<span style="background-color: red; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Banned</span>'
|
||||||
|
)
|
||||||
if not obj.is_active:
|
if not obj.is_active:
|
||||||
return format_html('<span style="color: orange;">Inactive</span>')
|
return format_html(
|
||||||
|
'<span style="background-color: orange; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Inactive</span>'
|
||||||
|
)
|
||||||
if obj.is_superuser:
|
if obj.is_superuser:
|
||||||
return format_html('<span style="color: purple;">Superuser</span>')
|
return format_html(
|
||||||
|
'<span style="background-color: purple; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Superuser</span>'
|
||||||
|
)
|
||||||
if obj.is_staff:
|
if obj.is_staff:
|
||||||
return format_html('<span style="color: blue;">Staff</span>')
|
return format_html(
|
||||||
return format_html('<span style="color: green;">Active</span>')
|
'<span style="background-color: blue; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Staff</span>'
|
||||||
|
)
|
||||||
|
return format_html(
|
||||||
|
'<span style="background-color: green; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Active</span>'
|
||||||
|
)
|
||||||
|
|
||||||
@admin.display(description="Ride Credits")
|
@admin.display(description="Credits")
|
||||||
def get_credits(self, obj):
|
def get_total_credits(self, obj):
|
||||||
|
"""Display total ride credits."""
|
||||||
try:
|
try:
|
||||||
profile = obj.profile
|
profile = obj.profile
|
||||||
|
total = (
|
||||||
|
(profile.coaster_credits or 0)
|
||||||
|
+ (profile.dark_ride_credits or 0)
|
||||||
|
+ (profile.flat_ride_credits or 0)
|
||||||
|
+ (profile.water_ride_credits or 0)
|
||||||
|
)
|
||||||
return format_html(
|
return format_html(
|
||||||
"RC: {}<br>DR: {}<br>FR: {}<br>WR: {}",
|
'<span title="RC:{} DR:{} FR:{} WR:{}">{}</span>',
|
||||||
profile.coaster_credits,
|
profile.coaster_credits or 0,
|
||||||
profile.dark_ride_credits,
|
profile.dark_ride_credits or 0,
|
||||||
profile.flat_ride_credits,
|
profile.flat_ride_credits or 0,
|
||||||
profile.water_ride_credits,
|
profile.water_ride_credits or 0,
|
||||||
|
total,
|
||||||
)
|
)
|
||||||
except UserProfile.DoesNotExist:
|
except UserProfile.DoesNotExist:
|
||||||
return "-"
|
return "-"
|
||||||
|
|
||||||
|
def get_queryset(self, request):
|
||||||
|
"""Optimize queryset with profile select_related."""
|
||||||
|
qs = super().get_queryset(request)
|
||||||
|
if self.list_select_related:
|
||||||
|
qs = qs.select_related(*self.list_select_related)
|
||||||
|
if self.list_prefetch_related:
|
||||||
|
qs = qs.prefetch_related(*self.list_prefetch_related)
|
||||||
|
return qs
|
||||||
|
|
||||||
@admin.action(description="Activate selected users")
|
@admin.action(description="Activate selected users")
|
||||||
def activate_users(self, request, queryset):
|
def activate_users(self, request, queryset):
|
||||||
queryset.update(is_active=True)
|
"""Activate selected user accounts."""
|
||||||
|
updated = queryset.update(is_active=True)
|
||||||
|
self.message_user(request, f"Successfully activated {updated} users.")
|
||||||
|
|
||||||
@admin.action(description="Deactivate selected users")
|
@admin.action(description="Deactivate selected users")
|
||||||
def deactivate_users(self, request, queryset):
|
def deactivate_users(self, request, queryset):
|
||||||
queryset.update(is_active=False)
|
"""Deactivate selected user accounts."""
|
||||||
|
# Prevent deactivating self
|
||||||
|
queryset = queryset.exclude(pk=request.user.pk)
|
||||||
|
updated = queryset.update(is_active=False)
|
||||||
|
self.message_user(request, f"Successfully deactivated {updated} users.")
|
||||||
|
|
||||||
@admin.action(description="Ban selected users")
|
@admin.action(description="Ban selected users")
|
||||||
def ban_users(self, request, queryset):
|
def ban_users(self, request, queryset):
|
||||||
from django.utils import timezone
|
"""Ban selected users."""
|
||||||
|
# Prevent banning self or superusers
|
||||||
queryset.update(is_banned=True, ban_date=timezone.now())
|
queryset = queryset.exclude(pk=request.user.pk).exclude(is_superuser=True)
|
||||||
|
updated = queryset.update(is_banned=True, ban_date=timezone.now())
|
||||||
|
self.message_user(request, f"Successfully banned {updated} users.")
|
||||||
|
|
||||||
@admin.action(description="Unban selected users")
|
@admin.action(description="Unban selected users")
|
||||||
def unban_users(self, request, queryset):
|
def unban_users(self, request, queryset):
|
||||||
queryset.update(is_banned=False, ban_date=None, ban_reason="")
|
"""Remove ban from selected users."""
|
||||||
|
updated = queryset.update(is_banned=False, ban_date=None, ban_reason="")
|
||||||
|
self.message_user(request, f"Successfully unbanned {updated} users.")
|
||||||
|
|
||||||
|
@admin.action(description="Send verification email")
|
||||||
|
def send_verification_email(self, request, queryset):
|
||||||
|
"""Send verification email to selected users."""
|
||||||
|
count = 0
|
||||||
|
for user in queryset:
|
||||||
|
# Only send to users without verified email
|
||||||
|
if not user.is_active:
|
||||||
|
count += 1
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"Verification emails queued for {count} users.",
|
||||||
|
level=messages.INFO,
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Recalculate ride credits")
|
||||||
|
def recalculate_credits(self, request, queryset):
|
||||||
|
"""Recalculate ride credits for selected users."""
|
||||||
|
count = 0
|
||||||
|
for user in queryset:
|
||||||
|
try:
|
||||||
|
profile = user.profile
|
||||||
|
# Credits would be recalculated from ride history here
|
||||||
|
profile.save(
|
||||||
|
update_fields=["coaster_credits", "dark_ride_credits", "flat_ride_credits", "water_ride_credits"]
|
||||||
|
)
|
||||||
|
count += 1
|
||||||
|
except UserProfile.DoesNotExist:
|
||||||
|
pass
|
||||||
|
self.message_user(request, f"Recalculated credits for {count} users.")
|
||||||
|
|
||||||
def save_model(self, request, obj, form, change):
|
def save_model(self, request, obj, form, change):
|
||||||
|
"""Handle role-based group assignment on save."""
|
||||||
creating = not obj.pk
|
creating = not obj.pk
|
||||||
super().save_model(request, obj, form, change)
|
super().save_model(request, obj, form, change)
|
||||||
if creating and obj.role != User.Roles.USER:
|
if creating and obj.role != User.Roles.USER:
|
||||||
# Ensure new user with role gets added to appropriate group
|
|
||||||
group = Group.objects.filter(name=obj.role).first()
|
group = Group.objects.filter(name=obj.role).first()
|
||||||
if group:
|
if group:
|
||||||
obj.groups.add(group)
|
obj.groups.add(group)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(UserProfile)
|
@admin.register(UserProfile)
|
||||||
class UserProfileAdmin(admin.ModelAdmin):
|
class UserProfileAdmin(QueryOptimizationMixin, ExportActionMixin, BaseModelAdmin):
|
||||||
|
"""
|
||||||
|
Admin interface for UserProfile management.
|
||||||
|
|
||||||
|
Manages user profile data separately from User admin.
|
||||||
|
Useful for managing profile-specific data and bulk operations.
|
||||||
|
"""
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
|
"user_link",
|
||||||
|
"display_name",
|
||||||
|
"total_credits",
|
||||||
|
"has_social_media",
|
||||||
|
"profile_completeness",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"user__role",
|
||||||
|
"user__is_active",
|
||||||
|
)
|
||||||
|
list_select_related = ["user"]
|
||||||
|
search_fields = ("user__username", "user__email", "display_name", "bio")
|
||||||
|
autocomplete_fields = ["user"]
|
||||||
|
|
||||||
|
export_fields = [
|
||||||
"user",
|
"user",
|
||||||
"display_name",
|
"display_name",
|
||||||
"coaster_credits",
|
"coaster_credits",
|
||||||
"dark_ride_credits",
|
"dark_ride_credits",
|
||||||
"flat_ride_credits",
|
"flat_ride_credits",
|
||||||
"water_ride_credits",
|
"water_ride_credits",
|
||||||
)
|
]
|
||||||
list_filter = (
|
export_filename_prefix = "user_profiles"
|
||||||
"coaster_credits",
|
|
||||||
"dark_ride_credits",
|
|
||||||
"flat_ride_credits",
|
|
||||||
"water_ride_credits",
|
|
||||||
)
|
|
||||||
search_fields = ("user__username", "user__email", "display_name", "bio")
|
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(
|
(
|
||||||
"User Information",
|
"User Information",
|
||||||
{"fields": ("user", "display_name", "avatar", "pronouns", "bio")},
|
{
|
||||||
|
"fields": ("user", "display_name", "avatar", "pronouns", "bio"),
|
||||||
|
"description": "Basic profile information.",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Social Media",
|
"Social Media",
|
||||||
{"fields": ("twitter", "instagram", "youtube", "discord")},
|
{
|
||||||
|
"fields": ("twitter", "instagram", "youtube", "discord"),
|
||||||
|
"classes": ("collapse",),
|
||||||
|
"description": "Social media profile links.",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Ride Credits",
|
"Ride Credits",
|
||||||
@@ -228,93 +406,195 @@ class UserProfileAdmin(admin.ModelAdmin):
|
|||||||
"dark_ride_credits",
|
"dark_ride_credits",
|
||||||
"flat_ride_credits",
|
"flat_ride_credits",
|
||||||
"water_ride_credits",
|
"water_ride_credits",
|
||||||
)
|
),
|
||||||
|
"description": "Ride credit counts by category.",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@admin.display(description="User")
|
||||||
|
def user_link(self, obj):
|
||||||
|
"""Display user as clickable link."""
|
||||||
|
if obj.user:
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
@admin.display(description="Total Credits")
|
||||||
|
def total_credits(self, obj):
|
||||||
|
"""Display total ride credits."""
|
||||||
|
total = (
|
||||||
|
(obj.coaster_credits or 0)
|
||||||
|
+ (obj.dark_ride_credits or 0)
|
||||||
|
+ (obj.flat_ride_credits or 0)
|
||||||
|
+ (obj.water_ride_credits or 0)
|
||||||
|
)
|
||||||
|
return total
|
||||||
|
|
||||||
|
@admin.display(description="Social", boolean=True)
|
||||||
|
def has_social_media(self, obj):
|
||||||
|
"""Indicate if user has social media links."""
|
||||||
|
return any([obj.twitter, obj.instagram, obj.youtube, obj.discord])
|
||||||
|
|
||||||
|
@admin.display(description="Completeness")
|
||||||
|
def profile_completeness(self, obj):
|
||||||
|
"""Display profile completeness indicator."""
|
||||||
|
fields_filled = sum(
|
||||||
|
[
|
||||||
|
bool(obj.display_name),
|
||||||
|
bool(obj.avatar),
|
||||||
|
bool(obj.bio),
|
||||||
|
bool(obj.twitter or obj.instagram or obj.youtube or obj.discord),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
percentage = (fields_filled / 4) * 100
|
||||||
|
color = "green" if percentage >= 75 else "orange" if percentage >= 50 else "red"
|
||||||
|
return format_html(
|
||||||
|
'<span style="color: {};">{}%</span>',
|
||||||
|
color,
|
||||||
|
int(percentage),
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.action(description="Recalculate ride credits")
|
||||||
|
def recalculate_credits(self, request, queryset):
|
||||||
|
"""Recalculate ride credits for selected profiles."""
|
||||||
|
count = queryset.count()
|
||||||
|
for profile in queryset:
|
||||||
|
# Credits would be recalculated from ride history here
|
||||||
|
profile.save()
|
||||||
|
self.message_user(request, f"Recalculated credits for {count} profiles.")
|
||||||
|
|
||||||
|
def get_actions(self, request):
|
||||||
|
"""Add custom actions."""
|
||||||
|
actions = super().get_actions(request)
|
||||||
|
actions["recalculate_credits"] = (
|
||||||
|
self.recalculate_credits,
|
||||||
|
"recalculate_credits",
|
||||||
|
"Recalculate ride credits",
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
@admin.register(EmailVerification)
|
@admin.register(EmailVerification)
|
||||||
class EmailVerificationAdmin(admin.ModelAdmin):
|
class EmailVerificationAdmin(QueryOptimizationMixin, BaseModelAdmin):
|
||||||
list_display = ("user", "created_at", "last_sent", "is_expired")
|
"""
|
||||||
|
Admin interface for email verification tokens.
|
||||||
|
|
||||||
|
Manages email verification tokens with expiration tracking
|
||||||
|
and bulk resend capabilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
list_display = (
|
||||||
|
"user_link",
|
||||||
|
"created_at",
|
||||||
|
"last_sent",
|
||||||
|
"expiration_status",
|
||||||
|
"can_resend",
|
||||||
|
)
|
||||||
list_filter = ("created_at", "last_sent")
|
list_filter = ("created_at", "last_sent")
|
||||||
|
list_select_related = ["user"]
|
||||||
search_fields = ("user__username", "user__email", "token")
|
search_fields = ("user__username", "user__email", "token")
|
||||||
readonly_fields = ("created_at", "last_sent")
|
readonly_fields = ("token", "created_at", "last_sent")
|
||||||
|
autocomplete_fields = ["user"]
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
("Verification Details", {"fields": ("user", "token")}),
|
(
|
||||||
("Timing", {"fields": ("created_at", "last_sent")}),
|
"Verification Details",
|
||||||
|
{
|
||||||
|
"fields": ("user", "token"),
|
||||||
|
"description": "User and verification token.",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Timing",
|
||||||
|
{
|
||||||
|
"fields": ("created_at", "last_sent"),
|
||||||
|
"description": "When the token was created and last sent.",
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@admin.display(description="User")
|
||||||
|
def user_link(self, obj):
|
||||||
|
"""Display user as clickable link."""
|
||||||
|
if obj.user:
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||||
|
return "-"
|
||||||
|
|
||||||
@admin.display(description="Status")
|
@admin.display(description="Status")
|
||||||
def is_expired(self, obj):
|
def expiration_status(self, obj):
|
||||||
from django.utils import timezone
|
"""Display expiration status with color coding."""
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
if timezone.now() - obj.last_sent > timedelta(days=1):
|
if timezone.now() - obj.last_sent > timedelta(days=1):
|
||||||
return format_html('<span style="color: red;">Expired</span>')
|
return format_html('<span style="color: red; font-weight: bold;">Expired</span>')
|
||||||
return format_html('<span style="color: green;">Valid</span>')
|
return format_html('<span style="color: green; font-weight: bold;">Valid</span>')
|
||||||
|
|
||||||
|
@admin.display(description="Can Resend", boolean=True)
|
||||||
|
def can_resend(self, obj):
|
||||||
|
"""Indicate if email can be resent (rate limited)."""
|
||||||
|
# Can resend if last sent more than 5 minutes ago
|
||||||
|
return timezone.now() - obj.last_sent > timedelta(minutes=5)
|
||||||
|
|
||||||
@admin.register(TopList)
|
@admin.action(description="Resend verification email")
|
||||||
class TopListAdmin(admin.ModelAdmin):
|
def resend_verification(self, request, queryset):
|
||||||
list_display = ("title", "user", "category", "created_at", "updated_at")
|
"""Resend verification emails."""
|
||||||
list_filter = ("category", "created_at", "updated_at")
|
count = 0
|
||||||
search_fields = ("title", "user__username", "description")
|
for verification in queryset:
|
||||||
inlines = [TopListItemInline]
|
if timezone.now() - verification.last_sent > timedelta(minutes=5):
|
||||||
|
verification.last_sent = timezone.now()
|
||||||
|
verification.save(update_fields=["last_sent"])
|
||||||
|
count += 1
|
||||||
|
self.message_user(request, f"Resent {count} verification emails.")
|
||||||
|
|
||||||
fieldsets = (
|
@admin.action(description="Delete expired tokens")
|
||||||
(
|
def delete_expired(self, request, queryset):
|
||||||
"Basic Information",
|
"""Delete expired verification tokens."""
|
||||||
{"fields": ("user", "title", "category", "description")},
|
cutoff = timezone.now() - timedelta(days=1)
|
||||||
),
|
expired = queryset.filter(last_sent__lt=cutoff)
|
||||||
(
|
count = expired.count()
|
||||||
"Timestamps",
|
expired.delete()
|
||||||
{"fields": ("created_at", "updated_at"), "classes": ("collapse",)},
|
self.message_user(request, f"Deleted {count} expired tokens.")
|
||||||
),
|
|
||||||
)
|
|
||||||
readonly_fields = ("created_at", "updated_at")
|
|
||||||
|
|
||||||
|
def get_actions(self, request):
|
||||||
@admin.register(TopListItem)
|
"""Add custom actions."""
|
||||||
class TopListItemAdmin(admin.ModelAdmin):
|
actions = super().get_actions(request)
|
||||||
list_display = ("top_list", "content_type", "object_id", "rank")
|
actions["resend_verification"] = (
|
||||||
list_filter = ("top_list__category", "rank")
|
self.resend_verification,
|
||||||
search_fields = ("top_list__title", "notes")
|
"resend_verification",
|
||||||
ordering = ("top_list", "rank")
|
"Resend verification email",
|
||||||
|
)
|
||||||
fieldsets = (
|
actions["delete_expired"] = (
|
||||||
("List Information", {"fields": ("top_list", "rank")}),
|
self.delete_expired,
|
||||||
("Item Details", {"fields": ("content_type", "object_id", "notes")}),
|
"delete_expired",
|
||||||
)
|
"Delete expired tokens",
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
@admin.register(PasswordReset)
|
@admin.register(PasswordReset)
|
||||||
class PasswordResetAdmin(admin.ModelAdmin):
|
class PasswordResetAdmin(ReadOnlyAdminMixin, BaseModelAdmin):
|
||||||
"""Admin interface for password reset tokens"""
|
"""
|
||||||
|
Admin interface for password reset tokens.
|
||||||
|
|
||||||
|
Read-only admin for viewing password reset tokens.
|
||||||
|
Tokens should not be manually created or modified.
|
||||||
|
"""
|
||||||
|
|
||||||
list_display = (
|
list_display = (
|
||||||
"user",
|
"user_link",
|
||||||
"created_at",
|
"created_at",
|
||||||
"expires_at",
|
"expires_at",
|
||||||
"is_expired",
|
"status_badge",
|
||||||
"used",
|
"used",
|
||||||
)
|
)
|
||||||
list_filter = (
|
list_filter = ("used", "created_at", "expires_at")
|
||||||
"used",
|
list_select_related = ["user"]
|
||||||
"created_at",
|
search_fields = ("user__username", "user__email", "token")
|
||||||
"expires_at",
|
readonly_fields = ("token", "created_at", "expires_at", "user", "used")
|
||||||
)
|
|
||||||
search_fields = (
|
|
||||||
"user__username",
|
|
||||||
"user__email",
|
|
||||||
"token",
|
|
||||||
)
|
|
||||||
readonly_fields = (
|
|
||||||
"token",
|
|
||||||
"created_at",
|
|
||||||
"expires_at",
|
|
||||||
)
|
|
||||||
date_hierarchy = "created_at"
|
date_hierarchy = "created_at"
|
||||||
ordering = ("-created_at",)
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
@@ -322,39 +602,63 @@ class PasswordResetAdmin(admin.ModelAdmin):
|
|||||||
(
|
(
|
||||||
"Reset Details",
|
"Reset Details",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": ("user", "token", "used"),
|
||||||
"user",
|
"description": "Password reset token information.",
|
||||||
"token",
|
|
||||||
"used",
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Timing",
|
"Timing",
|
||||||
{
|
{
|
||||||
"fields": (
|
"fields": ("created_at", "expires_at"),
|
||||||
"created_at",
|
"description": "Token creation and expiration times.",
|
||||||
"expires_at",
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@admin.display(description="Status", boolean=True)
|
@admin.display(description="User")
|
||||||
def is_expired(self, obj):
|
def user_link(self, obj):
|
||||||
"""Display expiration status with color coding"""
|
"""Display user as clickable link."""
|
||||||
from django.utils import timezone
|
if obj.user:
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
url = reverse("admin:accounts_customuser_change", args=[obj.user.pk])
|
||||||
|
return format_html('<a href="{}">{}</a>', url, obj.user.username)
|
||||||
|
return "-"
|
||||||
|
|
||||||
|
@admin.display(description="Status")
|
||||||
|
def status_badge(self, obj):
|
||||||
|
"""Display status with color-coded badge."""
|
||||||
if obj.used:
|
if obj.used:
|
||||||
return format_html('<span style="color: blue;">Used</span>')
|
return format_html(
|
||||||
|
'<span style="background-color: blue; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Used</span>'
|
||||||
|
)
|
||||||
elif timezone.now() > obj.expires_at:
|
elif timezone.now() > obj.expires_at:
|
||||||
return format_html('<span style="color: red;">Expired</span>')
|
return format_html(
|
||||||
return format_html('<span style="color: green;">Valid</span>')
|
'<span style="background-color: red; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Expired</span>'
|
||||||
|
)
|
||||||
|
return format_html(
|
||||||
|
'<span style="background-color: green; color: white; padding: 2px 8px; '
|
||||||
|
'border-radius: 4px; font-size: 11px;">Valid</span>'
|
||||||
|
)
|
||||||
|
|
||||||
def has_add_permission(self, request):
|
@admin.action(description="Cleanup old tokens")
|
||||||
"""Disable manual creation of password reset tokens"""
|
def cleanup_old_tokens(self, request, queryset):
|
||||||
return False
|
"""Delete old expired and used tokens."""
|
||||||
|
cutoff = timezone.now() - timedelta(days=7)
|
||||||
|
old_tokens = queryset.filter(created_at__lt=cutoff)
|
||||||
|
count = old_tokens.count()
|
||||||
|
old_tokens.delete()
|
||||||
|
self.message_user(request, f"Cleaned up {count} old tokens.")
|
||||||
|
|
||||||
def has_change_permission(self, request, obj=None):
|
def get_actions(self, request):
|
||||||
"""Allow viewing but restrict editing of password reset tokens"""
|
"""Add cleanup action."""
|
||||||
return getattr(request.user, "is_superuser", False)
|
actions = super().get_actions(request)
|
||||||
|
if request.user.is_superuser:
|
||||||
|
actions["cleanup_old_tokens"] = (
|
||||||
|
self.cleanup_old_tokens,
|
||||||
|
"cleanup_old_tokens",
|
||||||
|
"Cleanup old tokens",
|
||||||
|
)
|
||||||
|
return actions
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ replacing tuple-based choices with rich, metadata-enhanced choice objects.
|
|||||||
Last updated: 2025-01-15
|
Last updated: 2025-01-15
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from apps.core.choices import RichChoice, ChoiceGroup, register_choices
|
from apps.core.choices import ChoiceGroup, RichChoice, register_choices
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# USER ROLES
|
# USER ROLES
|
||||||
@@ -27,7 +26,7 @@ user_roles = ChoiceGroup(
|
|||||||
"css_class": "text-blue-600 bg-blue-50",
|
"css_class": "text-blue-600 bg-blue-50",
|
||||||
"permissions": ["create_content", "create_reviews", "create_lists"],
|
"permissions": ["create_content", "create_reviews", "create_lists"],
|
||||||
"sort_order": 1,
|
"sort_order": 1,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="MODERATOR",
|
value="MODERATOR",
|
||||||
@@ -39,7 +38,7 @@ user_roles = ChoiceGroup(
|
|||||||
"css_class": "text-green-600 bg-green-50",
|
"css_class": "text-green-600 bg-green-50",
|
||||||
"permissions": ["moderate_content", "review_submissions", "manage_reports"],
|
"permissions": ["moderate_content", "review_submissions", "manage_reports"],
|
||||||
"sort_order": 2,
|
"sort_order": 2,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="ADMIN",
|
value="ADMIN",
|
||||||
@@ -51,7 +50,7 @@ user_roles = ChoiceGroup(
|
|||||||
"css_class": "text-purple-600 bg-purple-50",
|
"css_class": "text-purple-600 bg-purple-50",
|
||||||
"permissions": ["manage_users", "site_configuration", "advanced_moderation"],
|
"permissions": ["manage_users", "site_configuration", "advanced_moderation"],
|
||||||
"sort_order": 3,
|
"sort_order": 3,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="SUPERUSER",
|
value="SUPERUSER",
|
||||||
@@ -63,9 +62,9 @@ user_roles = ChoiceGroup(
|
|||||||
"css_class": "text-red-600 bg-red-50",
|
"css_class": "text-red-600 bg-red-50",
|
||||||
"permissions": ["full_access", "system_administration", "database_access"],
|
"permissions": ["full_access", "system_administration", "database_access"],
|
||||||
"sort_order": 4,
|
"sort_order": 4,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,13 +83,9 @@ theme_preferences = ChoiceGroup(
|
|||||||
"color": "yellow",
|
"color": "yellow",
|
||||||
"icon": "sun",
|
"icon": "sun",
|
||||||
"css_class": "text-yellow-600 bg-yellow-50",
|
"css_class": "text-yellow-600 bg-yellow-50",
|
||||||
"preview_colors": {
|
"preview_colors": {"background": "#ffffff", "text": "#1f2937", "accent": "#3b82f6"},
|
||||||
"background": "#ffffff",
|
|
||||||
"text": "#1f2937",
|
|
||||||
"accent": "#3b82f6"
|
|
||||||
},
|
|
||||||
"sort_order": 1,
|
"sort_order": 1,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="dark",
|
value="dark",
|
||||||
@@ -100,15 +95,56 @@ theme_preferences = ChoiceGroup(
|
|||||||
"color": "gray",
|
"color": "gray",
|
||||||
"icon": "moon",
|
"icon": "moon",
|
||||||
"css_class": "text-gray-600 bg-gray-50",
|
"css_class": "text-gray-600 bg-gray-50",
|
||||||
"preview_colors": {
|
"preview_colors": {"background": "#1f2937", "text": "#f9fafb", "accent": "#60a5fa"},
|
||||||
"background": "#1f2937",
|
"sort_order": 2,
|
||||||
"text": "#f9fafb",
|
},
|
||||||
"accent": "#60a5fa"
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# UNIT SYSTEMS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
unit_systems = ChoiceGroup(
|
||||||
|
name="unit_systems",
|
||||||
|
choices=[
|
||||||
|
RichChoice(
|
||||||
|
value="metric",
|
||||||
|
label="Metric",
|
||||||
|
description="Use metric units (meters, km/h)",
|
||||||
|
metadata={
|
||||||
|
"color": "blue",
|
||||||
|
"icon": "ruler",
|
||||||
|
"css_class": "text-blue-600 bg-blue-50",
|
||||||
|
"units": {
|
||||||
|
"distance": "m",
|
||||||
|
"speed": "km/h",
|
||||||
|
"weight": "kg",
|
||||||
|
"large_distance": "km",
|
||||||
|
},
|
||||||
|
"sort_order": 1,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
RichChoice(
|
||||||
|
value="imperial",
|
||||||
|
label="Imperial",
|
||||||
|
description="Use imperial units (feet, mph)",
|
||||||
|
metadata={
|
||||||
|
"color": "green",
|
||||||
|
"icon": "ruler",
|
||||||
|
"css_class": "text-green-600 bg-green-50",
|
||||||
|
"units": {
|
||||||
|
"distance": "ft",
|
||||||
|
"speed": "mph",
|
||||||
|
"weight": "lbs",
|
||||||
|
"large_distance": "mi",
|
||||||
},
|
},
|
||||||
"sort_order": 2,
|
"sort_order": 2,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -133,10 +169,10 @@ privacy_levels = ChoiceGroup(
|
|||||||
"Profile visible to all users",
|
"Profile visible to all users",
|
||||||
"Activity appears in public feeds",
|
"Activity appears in public feeds",
|
||||||
"Searchable by search engines",
|
"Searchable by search engines",
|
||||||
"Can be found by username search"
|
"Can be found by username search",
|
||||||
],
|
],
|
||||||
"sort_order": 1,
|
"sort_order": 1,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="friends",
|
value="friends",
|
||||||
@@ -152,10 +188,10 @@ privacy_levels = ChoiceGroup(
|
|||||||
"Profile visible only to friends",
|
"Profile visible only to friends",
|
||||||
"Activity hidden from public feeds",
|
"Activity hidden from public feeds",
|
||||||
"Not searchable by search engines",
|
"Not searchable by search engines",
|
||||||
"Requires friend request approval"
|
"Requires friend request approval",
|
||||||
],
|
],
|
||||||
"sort_order": 2,
|
"sort_order": 2,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="private",
|
value="private",
|
||||||
@@ -171,12 +207,12 @@ privacy_levels = ChoiceGroup(
|
|||||||
"Profile completely hidden",
|
"Profile completely hidden",
|
||||||
"No activity in any feeds",
|
"No activity in any feeds",
|
||||||
"Not discoverable by other users",
|
"Not discoverable by other users",
|
||||||
"Maximum privacy protection"
|
"Maximum privacy protection",
|
||||||
],
|
],
|
||||||
"sort_order": 3,
|
"sort_order": 3,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -198,7 +234,7 @@ top_list_categories = ChoiceGroup(
|
|||||||
"ride_category": "roller_coaster",
|
"ride_category": "roller_coaster",
|
||||||
"typical_list_size": 10,
|
"typical_list_size": 10,
|
||||||
"sort_order": 1,
|
"sort_order": 1,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="DR",
|
value="DR",
|
||||||
@@ -211,7 +247,7 @@ top_list_categories = ChoiceGroup(
|
|||||||
"ride_category": "dark_ride",
|
"ride_category": "dark_ride",
|
||||||
"typical_list_size": 10,
|
"typical_list_size": 10,
|
||||||
"sort_order": 2,
|
"sort_order": 2,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="FR",
|
value="FR",
|
||||||
@@ -224,7 +260,7 @@ top_list_categories = ChoiceGroup(
|
|||||||
"ride_category": "flat_ride",
|
"ride_category": "flat_ride",
|
||||||
"typical_list_size": 10,
|
"typical_list_size": 10,
|
||||||
"sort_order": 3,
|
"sort_order": 3,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="WR",
|
value="WR",
|
||||||
@@ -237,7 +273,7 @@ top_list_categories = ChoiceGroup(
|
|||||||
"ride_category": "water_ride",
|
"ride_category": "water_ride",
|
||||||
"typical_list_size": 10,
|
"typical_list_size": 10,
|
||||||
"sort_order": 4,
|
"sort_order": 4,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="PK",
|
value="PK",
|
||||||
@@ -250,9 +286,9 @@ top_list_categories = ChoiceGroup(
|
|||||||
"entity_type": "park",
|
"entity_type": "park",
|
||||||
"typical_list_size": 10,
|
"typical_list_size": 10,
|
||||||
"sort_order": 5,
|
"sort_order": 5,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -276,7 +312,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["email", "push", "inapp"],
|
"default_channels": ["email", "push", "inapp"],
|
||||||
"priority": "normal",
|
"priority": "normal",
|
||||||
"sort_order": 1,
|
"sort_order": 1,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="submission_rejected",
|
value="submission_rejected",
|
||||||
@@ -290,7 +326,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["email", "push", "inapp"],
|
"default_channels": ["email", "push", "inapp"],
|
||||||
"priority": "normal",
|
"priority": "normal",
|
||||||
"sort_order": 2,
|
"sort_order": 2,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="submission_pending",
|
value="submission_pending",
|
||||||
@@ -304,7 +340,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["inapp"],
|
"default_channels": ["inapp"],
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sort_order": 3,
|
"sort_order": 3,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
# Review related
|
# Review related
|
||||||
RichChoice(
|
RichChoice(
|
||||||
@@ -319,7 +355,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["email", "push", "inapp"],
|
"default_channels": ["email", "push", "inapp"],
|
||||||
"priority": "normal",
|
"priority": "normal",
|
||||||
"sort_order": 4,
|
"sort_order": 4,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="review_helpful",
|
value="review_helpful",
|
||||||
@@ -333,7 +369,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["push", "inapp"],
|
"default_channels": ["push", "inapp"],
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sort_order": 5,
|
"sort_order": 5,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
# Social related
|
# Social related
|
||||||
RichChoice(
|
RichChoice(
|
||||||
@@ -348,7 +384,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["email", "push", "inapp"],
|
"default_channels": ["email", "push", "inapp"],
|
||||||
"priority": "normal",
|
"priority": "normal",
|
||||||
"sort_order": 6,
|
"sort_order": 6,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="friend_accepted",
|
value="friend_accepted",
|
||||||
@@ -362,7 +398,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["push", "inapp"],
|
"default_channels": ["push", "inapp"],
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sort_order": 7,
|
"sort_order": 7,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="message_received",
|
value="message_received",
|
||||||
@@ -376,7 +412,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["email", "push", "inapp"],
|
"default_channels": ["email", "push", "inapp"],
|
||||||
"priority": "normal",
|
"priority": "normal",
|
||||||
"sort_order": 8,
|
"sort_order": 8,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="profile_comment",
|
value="profile_comment",
|
||||||
@@ -390,7 +426,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["email", "push", "inapp"],
|
"default_channels": ["email", "push", "inapp"],
|
||||||
"priority": "normal",
|
"priority": "normal",
|
||||||
"sort_order": 9,
|
"sort_order": 9,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
# System related
|
# System related
|
||||||
RichChoice(
|
RichChoice(
|
||||||
@@ -405,7 +441,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["email", "inapp"],
|
"default_channels": ["email", "inapp"],
|
||||||
"priority": "normal",
|
"priority": "normal",
|
||||||
"sort_order": 10,
|
"sort_order": 10,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="account_security",
|
value="account_security",
|
||||||
@@ -419,7 +455,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["email", "push", "inapp"],
|
"default_channels": ["email", "push", "inapp"],
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sort_order": 11,
|
"sort_order": 11,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="feature_update",
|
value="feature_update",
|
||||||
@@ -433,7 +469,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["email", "inapp"],
|
"default_channels": ["email", "inapp"],
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sort_order": 12,
|
"sort_order": 12,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="maintenance",
|
value="maintenance",
|
||||||
@@ -447,7 +483,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["email", "inapp"],
|
"default_channels": ["email", "inapp"],
|
||||||
"priority": "normal",
|
"priority": "normal",
|
||||||
"sort_order": 13,
|
"sort_order": 13,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
# Achievement related
|
# Achievement related
|
||||||
RichChoice(
|
RichChoice(
|
||||||
@@ -462,7 +498,7 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["push", "inapp"],
|
"default_channels": ["push", "inapp"],
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sort_order": 14,
|
"sort_order": 14,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="milestone_reached",
|
value="milestone_reached",
|
||||||
@@ -476,9 +512,9 @@ notification_types = ChoiceGroup(
|
|||||||
"default_channels": ["push", "inapp"],
|
"default_channels": ["push", "inapp"],
|
||||||
"priority": "low",
|
"priority": "low",
|
||||||
"sort_order": 15,
|
"sort_order": 15,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -501,7 +537,7 @@ notification_priorities = ChoiceGroup(
|
|||||||
"batch_eligible": True,
|
"batch_eligible": True,
|
||||||
"delay_minutes": 60,
|
"delay_minutes": 60,
|
||||||
"sort_order": 1,
|
"sort_order": 1,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="normal",
|
value="normal",
|
||||||
@@ -515,7 +551,7 @@ notification_priorities = ChoiceGroup(
|
|||||||
"batch_eligible": True,
|
"batch_eligible": True,
|
||||||
"delay_minutes": 15,
|
"delay_minutes": 15,
|
||||||
"sort_order": 2,
|
"sort_order": 2,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="high",
|
value="high",
|
||||||
@@ -529,7 +565,7 @@ notification_priorities = ChoiceGroup(
|
|||||||
"batch_eligible": False,
|
"batch_eligible": False,
|
||||||
"delay_minutes": 0,
|
"delay_minutes": 0,
|
||||||
"sort_order": 3,
|
"sort_order": 3,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
RichChoice(
|
RichChoice(
|
||||||
value="urgent",
|
value="urgent",
|
||||||
@@ -544,9 +580,9 @@ notification_priorities = ChoiceGroup(
|
|||||||
"delay_minutes": 0,
|
"delay_minutes": 0,
|
||||||
"bypass_preferences": True,
|
"bypass_preferences": True,
|
||||||
"sort_order": 4,
|
"sort_order": 4,
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
]
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -557,6 +593,7 @@ notification_priorities = ChoiceGroup(
|
|||||||
# Register each choice group individually
|
# Register each choice group individually
|
||||||
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
|
register_choices("user_roles", user_roles.choices, "accounts", "User role classifications")
|
||||||
register_choices("theme_preferences", theme_preferences.choices, "accounts", "Theme preference options")
|
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("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("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_types", notification_types.choices, "accounts", "Notification type classifications")
|
||||||
|
|||||||
97
backend/apps/accounts/export_service.py
Normal file
97
backend/apps/accounts/export_service.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserExportService:
|
||||||
|
"""Service for exporting all user data."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def export_user_data(user: User) -> dict:
|
||||||
|
"""
|
||||||
|
Export all data associated with a user or an object containing counts/metadata and actual data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user to export data for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: The complete user data export
|
||||||
|
"""
|
||||||
|
# Import models locally to avoid circular imports
|
||||||
|
from apps.lists.models import UserList
|
||||||
|
from apps.parks.models import ParkReview
|
||||||
|
from apps.rides.models import RideReview
|
||||||
|
|
||||||
|
# User account and profile
|
||||||
|
user_data = {
|
||||||
|
"username": user.username,
|
||||||
|
"email": user.email,
|
||||||
|
"date_joined": user.date_joined,
|
||||||
|
"first_name": user.first_name,
|
||||||
|
"last_name": user.last_name,
|
||||||
|
"is_active": user.is_active,
|
||||||
|
"role": user.role,
|
||||||
|
}
|
||||||
|
|
||||||
|
profile_data = {}
|
||||||
|
if hasattr(user, "profile"):
|
||||||
|
profile = user.profile
|
||||||
|
profile_data = {
|
||||||
|
"display_name": profile.display_name,
|
||||||
|
"bio": profile.bio,
|
||||||
|
"location": profile.location,
|
||||||
|
"pronouns": profile.pronouns,
|
||||||
|
"unit_system": profile.unit_system,
|
||||||
|
"social_media": {
|
||||||
|
"twitter": profile.twitter,
|
||||||
|
"instagram": profile.instagram,
|
||||||
|
"youtube": profile.youtube,
|
||||||
|
"discord": profile.discord,
|
||||||
|
},
|
||||||
|
"ride_credits": {
|
||||||
|
"coaster": profile.coaster_credits,
|
||||||
|
"dark_ride": profile.dark_ride_credits,
|
||||||
|
"flat_ride": profile.flat_ride_credits,
|
||||||
|
"water_ride": profile.water_ride_credits,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reviews
|
||||||
|
park_reviews = list(
|
||||||
|
ParkReview.objects.filter(user=user).values(
|
||||||
|
"park__name", "rating", "review", "created_at", "updated_at", "is_published"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ride_reviews = list(
|
||||||
|
RideReview.objects.filter(user=user).values(
|
||||||
|
"ride__name", "rating", "review", "created_at", "updated_at", "is_published"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lists
|
||||||
|
user_lists = []
|
||||||
|
for user_list in UserList.objects.filter(user=user):
|
||||||
|
items = list(user_list.items.values("order", "content_type__model", "object_id", "comment"))
|
||||||
|
user_lists.append(
|
||||||
|
{
|
||||||
|
"title": user_list.title,
|
||||||
|
"description": user_list.description,
|
||||||
|
"created_at": user_list.created_at,
|
||||||
|
"items": items,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export_data = {
|
||||||
|
"account": user_data,
|
||||||
|
"profile": profile_data,
|
||||||
|
"preferences": getattr(user, "notification_preferences", {}),
|
||||||
|
"content": {
|
||||||
|
"park_reviews": park_reviews,
|
||||||
|
"ride_reviews": ride_reviews,
|
||||||
|
"lists": user_lists,
|
||||||
|
},
|
||||||
|
"export_info": {"generated_at": timezone.now(), "version": "1.0"},
|
||||||
|
}
|
||||||
|
|
||||||
|
return export_data
|
||||||
104
backend/apps/accounts/login_history.py
Normal file
104
backend/apps/accounts/login_history.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
Login History Model
|
||||||
|
|
||||||
|
Tracks user login events for security auditing and compliance with
|
||||||
|
the login_history_retention setting on the User model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pghistory
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
@pghistory.track()
|
||||||
|
class LoginHistory(models.Model):
|
||||||
|
"""
|
||||||
|
Records each successful login attempt for a user.
|
||||||
|
|
||||||
|
Used for security auditing, login notifications, and compliance with
|
||||||
|
the user's login_history_retention preference.
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="login_history",
|
||||||
|
help_text="User who logged in",
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_address = models.GenericIPAddressField(
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="IP address from which the login occurred",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_agent = models.CharField(
|
||||||
|
max_length=500,
|
||||||
|
blank=True,
|
||||||
|
help_text="Browser/client user agent string",
|
||||||
|
)
|
||||||
|
|
||||||
|
login_method = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
("PASSWORD", "Password"),
|
||||||
|
("GOOGLE", "Google OAuth"),
|
||||||
|
("DISCORD", "Discord OAuth"),
|
||||||
|
("MAGIC_LINK", "Magic Link"),
|
||||||
|
("SESSION", "Session Refresh"),
|
||||||
|
],
|
||||||
|
default="PASSWORD",
|
||||||
|
help_text="Method used for authentication",
|
||||||
|
)
|
||||||
|
|
||||||
|
login_timestamp = models.DateTimeField(
|
||||||
|
auto_now_add=True,
|
||||||
|
db_index=True,
|
||||||
|
help_text="When the login occurred",
|
||||||
|
)
|
||||||
|
|
||||||
|
success = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether the login was successful",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Optional geolocation data (can be populated asynchronously)
|
||||||
|
country = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
help_text="Country derived from IP (optional)",
|
||||||
|
)
|
||||||
|
|
||||||
|
city = models.CharField(
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
help_text="City derived from IP (optional)",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Login History"
|
||||||
|
verbose_name_plural = "Login History"
|
||||||
|
ordering = ["-login_timestamp"]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["user", "-login_timestamp"]),
|
||||||
|
models.Index(fields=["ip_address"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} login at {self.login_timestamp}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def cleanup_old_entries(cls, days=90):
|
||||||
|
"""
|
||||||
|
Remove login history entries older than the specified number of days.
|
||||||
|
Respects each user's login_history_retention preference.
|
||||||
|
"""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# Default cleanup for entries older than the specified days
|
||||||
|
cutoff = timezone.now() - timedelta(days=days)
|
||||||
|
deleted_count, _ = cls.objects.filter(login_timestamp__lt=cutoff).delete()
|
||||||
|
|
||||||
|
return deleted_count
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from allauth.socialaccount.models import SocialAccount, SocialApp, SocialToken
|
||||||
from allauth.socialaccount.models import SocialApp, SocialAccount, SocialToken
|
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -22,20 +22,14 @@ class Command(BaseCommand):
|
|||||||
# Check SocialAccount
|
# Check SocialAccount
|
||||||
self.stdout.write("\nChecking SocialAccount table:")
|
self.stdout.write("\nChecking SocialAccount table:")
|
||||||
for account in SocialAccount.objects.all():
|
for account in SocialAccount.objects.all():
|
||||||
self.stdout.write(
|
self.stdout.write(f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}")
|
||||||
f"ID: {account.pk}, Provider: {account.provider}, UID: {account.uid}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check SocialToken
|
# Check SocialToken
|
||||||
self.stdout.write("\nChecking SocialToken table:")
|
self.stdout.write("\nChecking SocialToken table:")
|
||||||
for token in SocialToken.objects.all():
|
for token in SocialToken.objects.all():
|
||||||
self.stdout.write(
|
self.stdout.write(f"ID: {token.pk}, Account: {token.account}, App: {token.app}")
|
||||||
f"ID: {token.pk}, Account: {token.account}, App: {token.app}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check Site
|
# Check Site
|
||||||
self.stdout.write("\nChecking Site table:")
|
self.stdout.write("\nChecking Site table:")
|
||||||
for site in Site.objects.all():
|
for site in Site.objects.all():
|
||||||
self.stdout.write(
|
self.stdout.write(f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}")
|
||||||
f"ID: {site.pk}, Domain: {site.domain}, Name: {site.name}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -17,6 +17,4 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(f"Name: {app.name}")
|
self.stdout.write(f"Name: {app.name}")
|
||||||
self.stdout.write(f"Client ID: {app.client_id}")
|
self.stdout.write(f"Client ID: {app.client_id}")
|
||||||
self.stdout.write(f"Secret: {app.secret}")
|
self.stdout.write(f"Secret: {app.secret}")
|
||||||
self.stdout.write(
|
self.stdout.write(f"Sites: {', '.join(str(site.domain) for site in app.sites.all())}")
|
||||||
f"Sites: {', '.join(str(site.domain) for site in app.sites.all())}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -15,14 +15,9 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Remove migration records
|
# Remove migration records
|
||||||
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
|
cursor.execute("DELETE FROM django_migrations WHERE app='socialaccount'")
|
||||||
cursor.execute(
|
cursor.execute("DELETE FROM django_migrations WHERE app='accounts' " "AND name LIKE '%social%'")
|
||||||
"DELETE FROM django_migrations WHERE app='accounts' "
|
|
||||||
"AND name LIKE '%social%'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Reset sequences
|
# Reset sequences
|
||||||
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
|
cursor.execute("DELETE FROM sqlite_sequence WHERE name LIKE '%social%'")
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS("Successfully cleaned up social auth configuration"))
|
||||||
self.style.SUCCESS("Successfully cleaned up social auth configuration")
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from apps.parks.models import ParkReview, Park, ParkPhoto
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.parks.models import Park, ParkPhoto, ParkReview
|
||||||
from apps.rides.models import Ride, RidePhoto
|
from apps.rides.models import Ride, RidePhoto
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -17,24 +18,18 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test users"))
|
||||||
|
|
||||||
# Delete test reviews
|
# Delete test reviews
|
||||||
reviews = ParkReview.objects.filter(
|
reviews = ParkReview.objects.filter(user__username__in=["testuser", "moderator"])
|
||||||
user__username__in=["testuser", "moderator"]
|
|
||||||
)
|
|
||||||
count = reviews.count()
|
count = reviews.count()
|
||||||
reviews.delete()
|
reviews.delete()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test reviews"))
|
||||||
|
|
||||||
# Delete test photos - both park and ride photos
|
# Delete test photos - both park and ride photos
|
||||||
park_photos = ParkPhoto.objects.filter(
|
park_photos = ParkPhoto.objects.filter(uploader__username__in=["testuser", "moderator"])
|
||||||
uploader__username__in=["testuser", "moderator"]
|
|
||||||
)
|
|
||||||
park_count = park_photos.count()
|
park_count = park_photos.count()
|
||||||
park_photos.delete()
|
park_photos.delete()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {park_count} test park photos"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {park_count} test park photos"))
|
||||||
|
|
||||||
ride_photos = RidePhoto.objects.filter(
|
ride_photos = RidePhoto.objects.filter(uploader__username__in=["testuser", "moderator"])
|
||||||
uploader__username__in=["testuser", "moderator"]
|
|
||||||
)
|
|
||||||
ride_count = ride_photos.count()
|
ride_count = ride_photos.count()
|
||||||
ride_photos.delete()
|
ride_photos.delete()
|
||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {ride_count} test ride photos"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {ride_count} test ride photos"))
|
||||||
@@ -52,8 +47,8 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
|
self.stdout.write(self.style.SUCCESS(f"Deleted {count} test rides"))
|
||||||
|
|
||||||
# Clean up test files
|
# Clean up test files
|
||||||
import os
|
|
||||||
import glob
|
import glob
|
||||||
|
import os
|
||||||
|
|
||||||
# Clean up test uploads
|
# Clean up test uploads
|
||||||
media_patterns = [
|
media_patterns = [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.sites.models import Site
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -37,18 +37,12 @@ class Command(BaseCommand):
|
|||||||
provider="google",
|
provider="google",
|
||||||
defaults={
|
defaults={
|
||||||
"name": "Google",
|
"name": "Google",
|
||||||
"client_id": (
|
"client_id": ("135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2." "apps.googleusercontent.com"),
|
||||||
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
|
|
||||||
"apps.googleusercontent.com"
|
|
||||||
),
|
|
||||||
"secret": "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue",
|
"secret": "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if not created:
|
if not created:
|
||||||
google_app.client_id = (
|
google_app.client_id = "135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2." "apps.googleusercontent.com"
|
||||||
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2."
|
|
||||||
"apps.googleusercontent.com"
|
|
||||||
)
|
|
||||||
google_app.secret = "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue"
|
google_app.secret = "GOCSPX-Wd_0Ue0Ue0Ue0Ue0Ue0Ue0Ue0Ue"
|
||||||
google_app.save()
|
google_app.save()
|
||||||
google_app.sites.add(site)
|
google_app.sites.add(site)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.auth.models import Group, Permission, User
|
from django.contrib.auth.models import Group, Permission, User
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -14,9 +14,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
user.set_password("testpass123")
|
user.set_password("testpass123")
|
||||||
user.save()
|
user.save()
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS(f"Created test user: {user.get_username()}"))
|
||||||
self.style.SUCCESS(f"Created test user: {user.get_username()}")
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.stdout.write(self.style.WARNING("Test user already exists"))
|
self.stdout.write(self.style.WARNING("Test user already exists"))
|
||||||
|
|
||||||
@@ -47,11 +45,7 @@ class Command(BaseCommand):
|
|||||||
# Add user to moderator group
|
# Add user to moderator group
|
||||||
moderator.groups.add(moderator_group)
|
moderator.groups.add(moderator_group)
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS(f"Created moderator user: {moderator.get_username()}"))
|
||||||
self.style.SUCCESS(
|
|
||||||
f"Created moderator user: {moderator.get_username()}"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
self.stdout.write(self.style.WARNING("Moderator user already exists"))
|
self.stdout.write(self.style.WARNING("Moderator user already exists"))
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Usage:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
from apps.accounts.services import UserDeletionService
|
from apps.accounts.services import UserDeletionService
|
||||||
|
|
||||||
@@ -16,9 +17,7 @@ class Command(BaseCommand):
|
|||||||
help = "Delete a user while preserving all their submissions"
|
help = "Delete a user while preserving all their submissions"
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument("username", nargs="?", type=str, help="Username of the user to delete")
|
||||||
"username", nargs="?", type=str, help="Username of the user to delete"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--user-id",
|
"--user-id",
|
||||||
type=str,
|
type=str,
|
||||||
@@ -29,9 +28,7 @@ class Command(BaseCommand):
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Show what would be deleted without actually deleting",
|
help="Show what would be deleted without actually deleting",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument("--force", action="store_true", help="Skip confirmation prompt")
|
||||||
"--force", action="store_true", help="Skip confirmation prompt"
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
username = options.get("username")
|
username = options.get("username")
|
||||||
@@ -48,13 +45,10 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Find the user
|
# Find the user
|
||||||
try:
|
try:
|
||||||
if username:
|
user = User.objects.get(username=username) if username else User.objects.get(user_id=user_id)
|
||||||
user = User.objects.get(username=username)
|
|
||||||
else:
|
|
||||||
user = User.objects.get(user_id=user_id)
|
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
identifier = username or user_id
|
identifier = username or user_id
|
||||||
raise CommandError(f'User "{identifier}" does not exist')
|
raise CommandError(f'User "{identifier}" does not exist') from None
|
||||||
|
|
||||||
# Check if user can be deleted
|
# Check if user can be deleted
|
||||||
can_delete, reason = UserDeletionService.can_delete_user(user)
|
can_delete, reason = UserDeletionService.can_delete_user(user)
|
||||||
@@ -63,27 +57,13 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Count submissions
|
# Count submissions
|
||||||
submission_counts = {
|
submission_counts = {
|
||||||
"park_reviews": getattr(
|
"park_reviews": getattr(user, "park_reviews", user.__class__.objects.none()).count(),
|
||||||
user, "park_reviews", user.__class__.objects.none()
|
"ride_reviews": getattr(user, "ride_reviews", user.__class__.objects.none()).count(),
|
||||||
).count(),
|
"uploaded_park_photos": getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count(),
|
||||||
"ride_reviews": getattr(
|
"uploaded_ride_photos": getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count(),
|
||||||
user, "ride_reviews", user.__class__.objects.none()
|
"top_lists": getattr(user, "top_lists", user.__class__.objects.none()).count(),
|
||||||
).count(),
|
"edit_submissions": getattr(user, "edit_submissions", user.__class__.objects.none()).count(),
|
||||||
"uploaded_park_photos": getattr(
|
"photo_submissions": getattr(user, "photo_submissions", user.__class__.objects.none()).count(),
|
||||||
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())
|
total_submissions = sum(submission_counts.values())
|
||||||
@@ -100,9 +80,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(self.style.WARNING("\nSubmissions to preserve:"))
|
self.stdout.write(self.style.WARNING("\nSubmissions to preserve:"))
|
||||||
for submission_type, count in submission_counts.items():
|
for submission_type, count in submission_counts.items():
|
||||||
if count > 0:
|
if count > 0:
|
||||||
self.stdout.write(
|
self.stdout.write(f' {submission_type.replace("_", " ").title()}: {count}')
|
||||||
f' {submission_type.replace("_", " ").title()}: {count}'
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write(f"\nTotal submissions: {total_submissions}")
|
self.stdout.write(f"\nTotal submissions: {total_submissions}")
|
||||||
|
|
||||||
@@ -113,9 +91,7 @@ class Command(BaseCommand):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.WARNING("\nNo submissions found for this user."))
|
||||||
self.style.WARNING("\nNo submissions found for this user.")
|
|
||||||
)
|
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self.stdout.write(self.style.SUCCESS("\n[DRY RUN] No changes were made."))
|
self.stdout.write(self.style.SUCCESS("\n[DRY RUN] No changes were made."))
|
||||||
@@ -138,11 +114,7 @@ class Command(BaseCommand):
|
|||||||
try:
|
try:
|
||||||
result = UserDeletionService.delete_user_preserve_submissions(user)
|
result = UserDeletionService.delete_user_preserve_submissions(user)
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS(f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'))
|
||||||
self.style.SUCCESS(
|
|
||||||
f'\nSuccessfully deleted user "{result["deleted_user"]["username"]}"'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
preserved_count = sum(result["preserved_submissions"].values())
|
preserved_count = sum(result["preserved_submissions"].values())
|
||||||
if preserved_count > 0:
|
if preserved_count > 0:
|
||||||
@@ -156,9 +128,7 @@ class Command(BaseCommand):
|
|||||||
self.stdout.write(self.style.WARNING("\nPreservation Summary:"))
|
self.stdout.write(self.style.WARNING("\nPreservation Summary:"))
|
||||||
for submission_type, count in result["preserved_submissions"].items():
|
for submission_type, count in result["preserved_submissions"].items():
|
||||||
if count > 0:
|
if count > 0:
|
||||||
self.stdout.write(
|
self.stdout.write(f' {submission_type.replace("_", " ").title()}: {count}')
|
||||||
f' {submission_type.replace("_", " ").title()}: {count}'
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise CommandError(f"Error deleting user: {str(e)}")
|
raise CommandError(f"Error deleting user: {str(e)}") from None
|
||||||
|
|||||||
@@ -7,12 +7,5 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute("DELETE FROM django_migrations WHERE app='rides' " "AND name='0001_initial';")
|
||||||
"DELETE FROM django_migrations WHERE app='rides' "
|
self.stdout.write(self.style.SUCCESS("Successfully removed rides.0001_initial from migration history"))
|
||||||
"AND name='0001_initial';"
|
|
||||||
)
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
"Successfully removed rides.0001_initial from migration history"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from django.core.management.base import BaseCommand
|
import os
|
||||||
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
import os
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -33,6 +34,4 @@ class Command(BaseCommand):
|
|||||||
secret=os.getenv("DISCORD_CLIENT_SECRET"),
|
secret=os.getenv("DISCORD_CLIENT_SECRET"),
|
||||||
)
|
)
|
||||||
discord_app.sites.add(site)
|
discord_app.sites.add(site)
|
||||||
self.stdout.write(
|
self.stdout.write(f"Created Discord app with client_id: {discord_app.client_id}")
|
||||||
f"Created Discord app with client_id: {discord_app.client_id}"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def generate_avatar(letter):
|
def generate_avatar(letter):
|
||||||
@@ -46,9 +47,7 @@ class Command(BaseCommand):
|
|||||||
help = "Generate avatars for letters A-Z and numbers 0-9"
|
help = "Generate avatars for letters A-Z and numbers 0-9"
|
||||||
|
|
||||||
def handle(self, *args, **kwargs):
|
def handle(self, *args, **kwargs):
|
||||||
characters = [chr(i) for i in range(65, 91)] + [
|
characters = [chr(i) for i in range(65, 91)] + [str(i) for i in range(10)] # A-Z and 0-9
|
||||||
str(i) for i in range(10)
|
|
||||||
] # A-Z and 0-9
|
|
||||||
for char in characters:
|
for char in characters:
|
||||||
generate_avatar(char)
|
generate_avatar(char)
|
||||||
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}"))
|
self.stdout.write(self.style.SUCCESS(f"Generated avatar for {char}"))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from apps.accounts.models import UserProfile
|
from apps.accounts.models import UserProfile
|
||||||
|
|
||||||
|
|
||||||
@@ -10,6 +11,4 @@ class Command(BaseCommand):
|
|||||||
for profile in profiles:
|
for profile in profiles:
|
||||||
# This will trigger the avatar generation logic in the save method
|
# This will trigger the avatar generation logic in the save method
|
||||||
profile.save()
|
profile.save()
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}"))
|
||||||
self.style.SUCCESS(f"Regenerated avatar for {profile.user.username}")
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
"""
|
||||||
|
Management command to reset the database and create an admin user.
|
||||||
|
|
||||||
|
Security Note: This command uses a mix of raw SQL (for PostgreSQL-specific operations
|
||||||
|
like dropping all tables) and Django ORM (for creating users). The raw SQL operations
|
||||||
|
use quote_ident() for table/sequence names which is safe from SQL injection.
|
||||||
|
|
||||||
|
WARNING: This command is destructive and should only be used in development.
|
||||||
|
"""
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
from django.contrib.auth.hashers import make_password
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -10,7 +18,8 @@ class Command(BaseCommand):
|
|||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
self.stdout.write("Resetting database...")
|
self.stdout.write("Resetting database...")
|
||||||
|
|
||||||
# Drop all tables
|
# Drop all tables using PostgreSQL-specific operations
|
||||||
|
# Security: Using quote_ident() to safely quote table/sequence names
|
||||||
with connection.cursor() as cursor:
|
with connection.cursor() as cursor:
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
"""
|
"""
|
||||||
@@ -21,7 +30,7 @@ class Command(BaseCommand):
|
|||||||
SELECT tablename FROM pg_tables
|
SELECT tablename FROM pg_tables
|
||||||
WHERE schemaname = current_schema()
|
WHERE schemaname = current_schema()
|
||||||
) LOOP
|
) LOOP
|
||||||
EXECUTE 'DROP TABLE IF EXISTS ' || \
|
EXECUTE 'DROP TABLE IF EXISTS ' ||
|
||||||
quote_ident(r.tablename) || ' CASCADE';
|
quote_ident(r.tablename) || ' CASCADE';
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
@@ -38,7 +47,7 @@ class Command(BaseCommand):
|
|||||||
SELECT sequencename FROM pg_sequences
|
SELECT sequencename FROM pg_sequences
|
||||||
WHERE schemaname = current_schema()
|
WHERE schemaname = current_schema()
|
||||||
) LOOP
|
) LOOP
|
||||||
EXECUTE 'ALTER SEQUENCE ' || \
|
EXECUTE 'ALTER SEQUENCE ' ||
|
||||||
quote_ident(r.sequencename) || ' RESTART WITH 1';
|
quote_ident(r.sequencename) || ' RESTART WITH 1';
|
||||||
END LOOP;
|
END LOOP;
|
||||||
END $$;
|
END $$;
|
||||||
@@ -54,51 +63,25 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stdout.write("Migrations applied.")
|
self.stdout.write("Migrations applied.")
|
||||||
|
|
||||||
# Create superuser using raw SQL
|
# Create superuser using Django ORM (safer than raw SQL)
|
||||||
try:
|
try:
|
||||||
with connection.cursor() as cursor:
|
from apps.accounts.models import User, UserProfile
|
||||||
# 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()
|
# Security: Using Django ORM instead of raw SQL for user creation
|
||||||
if result is None:
|
user = User.objects.create_superuser(
|
||||||
raise Exception("Failed to create user - no ID returned")
|
username="admin",
|
||||||
user_db_id = result[0]
|
email="admin@thrillwiki.com",
|
||||||
|
password="admin",
|
||||||
|
role="SUPERUSER",
|
||||||
|
)
|
||||||
|
|
||||||
# Create profile
|
# Create profile using ORM
|
||||||
profile_id = str(uuid.uuid4())[:10]
|
UserProfile.objects.create(
|
||||||
cursor.execute(
|
user=user,
|
||||||
"""
|
display_name="Admin",
|
||||||
INSERT INTO accounts_userprofile (
|
pronouns="they/them",
|
||||||
profile_id, display_name, pronouns, bio,
|
bio="ThrillWiki Administrator",
|
||||||
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.")
|
self.stdout.write("Superuser created.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|
||||||
|
|
||||||
@@ -30,9 +30,7 @@ class Command(BaseCommand):
|
|||||||
google_app = SocialApp.objects.create(
|
google_app = SocialApp.objects.create(
|
||||||
provider="google",
|
provider="google",
|
||||||
name="Google",
|
name="Google",
|
||||||
client_id=(
|
client_id=("135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"),
|
||||||
"135166769591-nopcgmo0fkqfqfs9qe783a137mtmcrt2.apps.googleusercontent.com"
|
|
||||||
),
|
|
||||||
secret="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",
|
secret="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",
|
||||||
)
|
)
|
||||||
google_app.sites.add(site)
|
google_app.sites.add(site)
|
||||||
|
|||||||
@@ -12,13 +12,7 @@ class Command(BaseCommand):
|
|||||||
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
|
cursor.execute("DELETE FROM socialaccount_socialapp_sites")
|
||||||
|
|
||||||
# Reset sequences
|
# Reset sequences
|
||||||
cursor.execute(
|
cursor.execute("DELETE FROM sqlite_sequence WHERE name='socialaccount_socialapp'")
|
||||||
"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_sites'"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS("Successfully reset social auth configuration"))
|
||||||
self.style.SUCCESS("Successfully reset social auth configuration")
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
from apps.accounts.signals import create_default_groups
|
from apps.accounts.signals import create_default_groups
|
||||||
|
|
||||||
@@ -29,9 +30,7 @@ class Command(BaseCommand):
|
|||||||
user.is_staff = True
|
user.is_staff = True
|
||||||
user.save()
|
user.save()
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS("Successfully set up groups and permissions"))
|
||||||
self.style.SUCCESS("Successfully set up groups and permissions")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Print summary
|
# Print summary
|
||||||
for group in Group.objects.all():
|
for group in Group.objects.all():
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -10,7 +10,5 @@ class Command(BaseCommand):
|
|||||||
Site.objects.all().delete()
|
Site.objects.all().delete()
|
||||||
|
|
||||||
# Create default site
|
# Create default site
|
||||||
site = Site.objects.create(
|
site = Site.objects.create(id=1, domain="localhost:8000", name="ThrillWiki Development")
|
||||||
id=1, domain="localhost:8000", name="ThrillWiki Development"
|
|
||||||
)
|
|
||||||
self.stdout.write(self.style.SUCCESS(f"Created site: {site.domain}"))
|
self.stdout.write(self.style.SUCCESS(f"Created site: {site.domain}"))
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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
|
import os
|
||||||
|
|
||||||
|
from allauth.socialaccount.models import SocialApp
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Sets up social authentication apps"
|
help = "Sets up social authentication apps"
|
||||||
@@ -48,27 +49,15 @@ class Command(BaseCommand):
|
|||||||
discord_client_secret,
|
discord_client_secret,
|
||||||
]
|
]
|
||||||
):
|
):
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.ERROR("Missing required environment variables"))
|
||||||
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(
|
self.stdout.write(f"DEBUG: discord_client_id is None: {discord_client_id is None}")
|
||||||
f"DEBUG: google_client_id is None: {google_client_id is None}"
|
self.stdout.write(f"DEBUG: discord_client_secret is None: {discord_client_secret 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
|
return
|
||||||
|
|
||||||
# Get or create the default site
|
# Get or create the default site
|
||||||
site, _ = Site.objects.get_or_create(
|
site, _ = Site.objects.get_or_create(id=1, defaults={"domain": "localhost:8000", "name": "localhost"})
|
||||||
id=1, defaults={"domain": "localhost:8000", "name": "localhost"}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set up Google
|
# Set up Google
|
||||||
google_app, created = SocialApp.objects.get_or_create(
|
google_app, created = SocialApp.objects.get_or_create(
|
||||||
@@ -91,11 +80,7 @@ class Command(BaseCommand):
|
|||||||
google_app.save()
|
google_app.save()
|
||||||
self.stdout.write("DEBUG: Successfully updated Google app")
|
self.stdout.write("DEBUG: Successfully updated Google app")
|
||||||
else:
|
else:
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.ERROR("Google client_id or secret is None, skipping update."))
|
||||||
self.style.ERROR(
|
|
||||||
"Google client_id or secret is None, skipping update."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
google_app.sites.add(site)
|
google_app.sites.add(site)
|
||||||
|
|
||||||
# Set up Discord
|
# Set up Discord
|
||||||
@@ -119,11 +104,7 @@ class Command(BaseCommand):
|
|||||||
discord_app.save()
|
discord_app.save()
|
||||||
self.stdout.write("DEBUG: Successfully updated Discord app")
|
self.stdout.write("DEBUG: Successfully updated Discord app")
|
||||||
else:
|
else:
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.ERROR("Discord client_id or secret is None, skipping update."))
|
||||||
self.style.ERROR(
|
|
||||||
"Discord client_id or secret is None, skipping update."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
discord_app.sites.add(site)
|
discord_app.sites.add(site)
|
||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS("Successfully set up social auth apps"))
|
self.stdout.write(self.style.SUCCESS("Successfully set up social auth apps"))
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from django.contrib.sites.models import Site
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
User = 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 allauth.socialaccount.models import SocialApp
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -42,6 +42,4 @@ class Command(BaseCommand):
|
|||||||
for app in SocialApp.objects.all():
|
for app in SocialApp.objects.all():
|
||||||
self.stdout.write(f"- {app.name} ({app.provider}): {app.client_id}")
|
self.stdout.write(f"- {app.name} ({app.provider}): {app.client_id}")
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(self.style.SUCCESS(f"\nTotal social apps: {SocialApp.objects.count()}"))
|
||||||
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.core.management.base import BaseCommand
|
||||||
from django.test import Client
|
from django.test import Client
|
||||||
from allauth.socialaccount.models import SocialApp
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -40,9 +40,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Show callback URL
|
# Show callback URL
|
||||||
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
|
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
|
||||||
self.stdout.write(
|
self.stdout.write("\nCallback URL to configure in Discord Developer Portal:")
|
||||||
"\nCallback URL to configure in Discord Developer Portal:"
|
|
||||||
)
|
|
||||||
self.stdout.write(callback_url)
|
self.stdout.write(callback_url)
|
||||||
|
|
||||||
# Show frontend login URL
|
# Show frontend login URL
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from django.core.management.base import BaseCommand
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -18,6 +18,4 @@ class Command(BaseCommand):
|
|||||||
# Add all sites
|
# Add all sites
|
||||||
for site in sites:
|
for site in sites:
|
||||||
app.sites.add(site)
|
app.sites.add(site)
|
||||||
self.stdout.write(
|
self.stdout.write(f"Added sites: {', '.join(site.domain for site in sites)}")
|
||||||
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 allauth.socialaccount.models import SocialApp
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -22,17 +22,13 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Show callback URL
|
# Show callback URL
|
||||||
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
|
callback_url = "http://localhost:8000/accounts/discord/login/callback/"
|
||||||
self.stdout.write(
|
self.stdout.write("\nCallback URL to configure in Discord Developer Portal:")
|
||||||
"\nCallback URL to configure in Discord Developer Portal:"
|
|
||||||
)
|
|
||||||
self.stdout.write(callback_url)
|
self.stdout.write(callback_url)
|
||||||
|
|
||||||
# Show OAuth2 settings
|
# Show OAuth2 settings
|
||||||
self.stdout.write("\nOAuth2 settings in settings.py:")
|
self.stdout.write("\nOAuth2 settings in settings.py:")
|
||||||
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get("discord", {})
|
discord_settings = settings.SOCIALACCOUNT_PROVIDERS.get("discord", {})
|
||||||
self.stdout.write(
|
self.stdout.write(f"PKCE Enabled: {discord_settings.get('OAUTH_PKCE_ENABLED', False)}")
|
||||||
f"PKCE Enabled: {discord_settings.get('OAUTH_PKCE_ENABLED', False)}"
|
|
||||||
)
|
|
||||||
self.stdout.write(f"Scopes: {discord_settings.get('SCOPE', [])}")
|
self.stdout.write(f"Scopes: {discord_settings.get('SCOPE', [])}")
|
||||||
|
|
||||||
except SocialApp.DoesNotExist:
|
except SocialApp.DoesNotExist:
|
||||||
|
|||||||
@@ -38,9 +38,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"last_login",
|
"last_login",
|
||||||
models.DateTimeField(
|
models.DateTimeField(blank=True, null=True, verbose_name="last login"),
|
||||||
blank=True, null=True, verbose_name="last login"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
@@ -53,29 +51,21 @@ class Migration(migrations.Migration):
|
|||||||
(
|
(
|
||||||
"username",
|
"username",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
error_messages={
|
error_messages={"unique": "A user with that username already exists."},
|
||||||
"unique": "A user with that username already exists."
|
|
||||||
},
|
|
||||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||||
max_length=150,
|
max_length=150,
|
||||||
unique=True,
|
unique=True,
|
||||||
validators=[
|
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
|
||||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
|
||||||
],
|
|
||||||
verbose_name="username",
|
verbose_name="username",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"first_name",
|
"first_name",
|
||||||
models.CharField(
|
models.CharField(blank=True, max_length=150, verbose_name="first name"),
|
||||||
blank=True, max_length=150, verbose_name="first name"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"last_name",
|
"last_name",
|
||||||
models.CharField(
|
models.CharField(blank=True, max_length=150, verbose_name="last name"),
|
||||||
blank=True, max_length=150, verbose_name="last name"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"email",
|
"email",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("accounts", "0002_remove_toplistevent_pgh_context_and_more"),
|
("accounts", "0002_remove_toplistevent_pgh_context_and_more"),
|
||||||
("pghistory", "0007_auto_20250421_0444"),
|
("pghistory", "0006_delete_aggregateevent"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -57,9 +57,7 @@ class Migration(migrations.Migration):
|
|||||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||||
(
|
(
|
||||||
"last_login",
|
"last_login",
|
||||||
models.DateTimeField(
|
models.DateTimeField(blank=True, null=True, verbose_name="last login"),
|
||||||
blank=True, null=True, verbose_name="last login"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"is_superuser",
|
"is_superuser",
|
||||||
@@ -72,34 +70,24 @@ class Migration(migrations.Migration):
|
|||||||
(
|
(
|
||||||
"username",
|
"username",
|
||||||
models.CharField(
|
models.CharField(
|
||||||
error_messages={
|
error_messages={"unique": "A user with that username already exists."},
|
||||||
"unique": "A user with that username already exists."
|
|
||||||
},
|
|
||||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||||
max_length=150,
|
max_length=150,
|
||||||
validators=[
|
validators=[django.contrib.auth.validators.UnicodeUsernameValidator()],
|
||||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
|
||||||
],
|
|
||||||
verbose_name="username",
|
verbose_name="username",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"first_name",
|
"first_name",
|
||||||
models.CharField(
|
models.CharField(blank=True, max_length=150, verbose_name="first name"),
|
||||||
blank=True, max_length=150, verbose_name="first name"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"last_name",
|
"last_name",
|
||||||
models.CharField(
|
models.CharField(blank=True, max_length=150, verbose_name="last name"),
|
||||||
blank=True, max_length=150, verbose_name="last name"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"email",
|
"email",
|
||||||
models.EmailField(
|
models.EmailField(blank=True, max_length=254, verbose_name="email address"),
|
||||||
blank=True, max_length=254, verbose_name="email address"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"is_staff",
|
"is_staff",
|
||||||
@@ -119,9 +107,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"date_joined",
|
"date_joined",
|
||||||
models.DateTimeField(
|
models.DateTimeField(default=django.utils.timezone.now, verbose_name="date joined"),
|
||||||
default=django.utils.timezone.now, verbose_name="date joined"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"user_id",
|
"user_id",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
|
|||||||
"accounts",
|
"accounts",
|
||||||
"0003_emailverificationevent_passwordresetevent_userevent_and_more",
|
"0003_emailverificationevent_passwordresetevent_userevent_and_more",
|
||||||
),
|
),
|
||||||
("pghistory", "0007_auto_20250421_0444"),
|
("pghistory", "0006_delete_aggregateevent"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -41,9 +41,7 @@ class Migration(migrations.Migration):
|
|||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
(
|
(
|
||||||
"expires_at",
|
"expires_at",
|
||||||
models.DateTimeField(
|
models.DateTimeField(help_text="When this deletion request expires"),
|
||||||
help_text="When this deletion request expires"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"email_sent_at",
|
"email_sent_at",
|
||||||
@@ -55,9 +53,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"attempts",
|
"attempts",
|
||||||
models.PositiveIntegerField(
|
models.PositiveIntegerField(default=0, help_text="Number of verification attempts made"),
|
||||||
default=0, help_text="Number of verification attempts made"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"max_attempts",
|
"max_attempts",
|
||||||
@@ -103,9 +99,7 @@ class Migration(migrations.Migration):
|
|||||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
(
|
(
|
||||||
"expires_at",
|
"expires_at",
|
||||||
models.DateTimeField(
|
models.DateTimeField(help_text="When this deletion request expires"),
|
||||||
help_text="When this deletion request expires"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"email_sent_at",
|
"email_sent_at",
|
||||||
@@ -117,9 +111,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"attempts",
|
"attempts",
|
||||||
models.PositiveIntegerField(
|
models.PositiveIntegerField(default=0, help_text="Number of verification attempts made"),
|
||||||
default=0, help_text="Number of verification attempts made"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"max_attempts",
|
"max_attempts",
|
||||||
@@ -171,21 +163,15 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="userdeletionrequest",
|
model_name="userdeletionrequest",
|
||||||
index=models.Index(
|
index=models.Index(fields=["verification_code"], name="accounts_us_verific_94460d_idx"),
|
||||||
fields=["verification_code"], name="accounts_us_verific_94460d_idx"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="userdeletionrequest",
|
model_name="userdeletionrequest",
|
||||||
index=models.Index(
|
index=models.Index(fields=["expires_at"], name="accounts_us_expires_1d1dca_idx"),
|
||||||
fields=["expires_at"], name="accounts_us_expires_1d1dca_idx"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="userdeletionrequest",
|
model_name="userdeletionrequest",
|
||||||
index=models.Index(
|
index=models.Index(fields=["user", "is_used"], name="accounts_us_user_id_1ce18a_idx"),
|
||||||
fields=["user", "is_used"], name="accounts_us_user_id_1ce18a_idx"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pgtrigger.migrations.AddTrigger(
|
pgtrigger.migrations.AddTrigger(
|
||||||
model_name="userdeletionrequest",
|
model_name="userdeletionrequest",
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="user",
|
model_name="user",
|
||||||
name="last_password_change",
|
name="last_password_change",
|
||||||
field=models.DateTimeField(
|
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||||
auto_now_add=True, default=django.utils.timezone.now
|
|
||||||
),
|
|
||||||
preserve_default=False,
|
preserve_default=False,
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
@@ -185,9 +183,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="userevent",
|
model_name="userevent",
|
||||||
name="last_password_change",
|
name="last_password_change",
|
||||||
field=models.DateTimeField(
|
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
|
||||||
auto_now_add=True, default=django.utils.timezone.now
|
|
||||||
),
|
|
||||||
preserve_default=False,
|
preserve_default=False,
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class Migration(migrations.Migration):
|
|||||||
("accounts", "0008_remove_first_last_name_fields"),
|
("accounts", "0008_remove_first_last_name_fields"),
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
("django_cloudflareimages_toolkit", "0001_initial"),
|
("django_cloudflareimages_toolkit", "0001_initial"),
|
||||||
("pghistory", "0007_auto_20250421_0444"),
|
("pghistory", "0006_delete_aggregateevent"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
@@ -454,9 +454,7 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="usernotification",
|
model_name="usernotification",
|
||||||
index=models.Index(
|
index=models.Index(fields=["user", "is_read"], name="accounts_us_user_id_785929_idx"),
|
||||||
fields=["user", "is_read"], name="accounts_us_user_id_785929_idx"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="usernotification",
|
model_name="usernotification",
|
||||||
@@ -467,15 +465,11 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="usernotification",
|
model_name="usernotification",
|
||||||
index=models.Index(
|
index=models.Index(fields=["created_at"], name="accounts_us_created_a62f54_idx"),
|
||||||
fields=["created_at"], name="accounts_us_created_a62f54_idx"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="usernotification",
|
model_name="usernotification",
|
||||||
index=models.Index(
|
index=models.Index(fields=["expires_at"], name="accounts_us_expires_f267b1_idx"),
|
||||||
fields=["expires_at"], name="accounts_us_expires_f267b1_idx"
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
pgtrigger.migrations.AddTrigger(
|
pgtrigger.migrations.AddTrigger(
|
||||||
model_name="usernotification",
|
model_name="usernotification",
|
||||||
|
|||||||
@@ -26,25 +26,24 @@ def safe_add_avatar_field(apps, schema_editor):
|
|||||||
"""
|
"""
|
||||||
# Check if the column already exists
|
# Check if the column already exists
|
||||||
with schema_editor.connection.cursor() as cursor:
|
with schema_editor.connection.cursor() as cursor:
|
||||||
cursor.execute("""
|
cursor.execute(
|
||||||
|
"""
|
||||||
SELECT column_name
|
SELECT column_name
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name='accounts_userprofile'
|
WHERE table_name='accounts_userprofile'
|
||||||
AND column_name='avatar_id'
|
AND column_name='avatar_id'
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
column_exists = cursor.fetchone() is not None
|
column_exists = cursor.fetchone() is not None
|
||||||
|
|
||||||
if not column_exists:
|
if not column_exists:
|
||||||
# Column doesn't exist, add it
|
# Column doesn't exist, add it
|
||||||
UserProfile = apps.get_model('accounts', 'UserProfile')
|
UserProfile = apps.get_model("accounts", "UserProfile")
|
||||||
field = models.ForeignKey(
|
field = models.ForeignKey(
|
||||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
"django_cloudflareimages_toolkit.CloudflareImage", on_delete=models.SET_NULL, null=True, blank=True
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True
|
|
||||||
)
|
)
|
||||||
field.set_attributes_from_name('avatar')
|
field.set_attributes_from_name("avatar")
|
||||||
schema_editor.add_field(UserProfile, field)
|
schema_editor.add_field(UserProfile, field)
|
||||||
|
|
||||||
|
|
||||||
@@ -54,24 +53,23 @@ def reverse_safe_add_avatar_field(apps, schema_editor):
|
|||||||
"""
|
"""
|
||||||
# Check if the column exists and remove it
|
# Check if the column exists and remove it
|
||||||
with schema_editor.connection.cursor() as cursor:
|
with schema_editor.connection.cursor() as cursor:
|
||||||
cursor.execute("""
|
cursor.execute(
|
||||||
|
"""
|
||||||
SELECT column_name
|
SELECT column_name
|
||||||
FROM information_schema.columns
|
FROM information_schema.columns
|
||||||
WHERE table_name='accounts_userprofile'
|
WHERE table_name='accounts_userprofile'
|
||||||
AND column_name='avatar_id'
|
AND column_name='avatar_id'
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
column_exists = cursor.fetchone() is not None
|
column_exists = cursor.fetchone() is not None
|
||||||
|
|
||||||
if column_exists:
|
if column_exists:
|
||||||
UserProfile = apps.get_model('accounts', 'UserProfile')
|
UserProfile = apps.get_model("accounts", "UserProfile")
|
||||||
field = models.ForeignKey(
|
field = models.ForeignKey(
|
||||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
"django_cloudflareimages_toolkit.CloudflareImage", on_delete=models.SET_NULL, null=True, blank=True
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
null=True,
|
|
||||||
blank=True
|
|
||||||
)
|
)
|
||||||
field.set_attributes_from_name('avatar')
|
field.set_attributes_from_name("avatar")
|
||||||
schema_editor.remove_field(UserProfile, field)
|
schema_editor.remove_field(UserProfile, field)
|
||||||
|
|
||||||
|
|
||||||
@@ -89,15 +87,13 @@ class Migration(migrations.Migration):
|
|||||||
# First, remove the old avatar column (CloudflareImageField)
|
# First, remove the old avatar column (CloudflareImageField)
|
||||||
migrations.RunSQL(
|
migrations.RunSQL(
|
||||||
"ALTER TABLE accounts_userprofile DROP COLUMN IF EXISTS avatar;",
|
"ALTER TABLE accounts_userprofile DROP COLUMN IF EXISTS avatar;",
|
||||||
reverse_sql="-- Cannot reverse this operation"
|
reverse_sql="-- Cannot reverse this operation",
|
||||||
),
|
),
|
||||||
|
|
||||||
# Safely add the new avatar_id column for ForeignKey
|
# Safely add the new avatar_id column for ForeignKey
|
||||||
migrations.RunPython(
|
migrations.RunPython(
|
||||||
safe_add_avatar_field,
|
safe_add_avatar_field,
|
||||||
reverse_safe_add_avatar_field,
|
reverse_safe_add_avatar_field,
|
||||||
),
|
),
|
||||||
|
|
||||||
# Run the data migration
|
# Run the data migration
|
||||||
migrations.RunPython(
|
migrations.RunPython(
|
||||||
migrate_avatar_data,
|
migrate_avatar_data,
|
||||||
|
|||||||
@@ -6,17 +6,16 @@ from django.db import migrations
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('accounts', '0010_auto_20250830_1657'),
|
("accounts", "0010_auto_20250830_1657"),
|
||||||
('django_cloudflareimages_toolkit', '0001_initial'),
|
("django_cloudflareimages_toolkit", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
# Remove the old avatar field from the event table
|
# Remove the old avatar field from the event table
|
||||||
migrations.RunSQL(
|
migrations.RunSQL(
|
||||||
"ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar;",
|
"ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar;",
|
||||||
reverse_sql="-- Cannot reverse this operation"
|
reverse_sql="-- Cannot reverse this operation",
|
||||||
),
|
),
|
||||||
|
|
||||||
# Add the new avatar_id field to match the main table (only if it doesn't exist)
|
# Add the new avatar_id field to match the main table (only if it doesn't exist)
|
||||||
migrations.RunSQL(
|
migrations.RunSQL(
|
||||||
"""
|
"""
|
||||||
@@ -32,6 +31,6 @@ class Migration(migrations.Migration):
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
END $$;
|
||||||
""",
|
""",
|
||||||
reverse_sql="ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar_id;"
|
reverse_sql="ALTER TABLE accounts_userprofileevent DROP COLUMN IF EXISTS avatar_id;",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# Generated by Django 5.2.5 on 2025-09-15 17:35
|
# Generated by Django 5.2.5 on 2025-09-15 17:35
|
||||||
|
|
||||||
import apps.core.choices.fields
|
|
||||||
from django.db import migrations
|
from django.db import migrations
|
||||||
|
|
||||||
|
import apps.core.choices.fields
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Add performance indexes and constraints to User model.
|
||||||
|
|
||||||
|
This migration adds:
|
||||||
|
1. db_index=True to is_banned and role fields for faster filtering
|
||||||
|
2. Composite index on (is_banned, role) for common query patterns
|
||||||
|
3. CheckConstraint to ensure banned users have a ban_date set
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0012_alter_toplist_category_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# Add db_index to is_banned field
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="is_banned",
|
||||||
|
field=models.BooleanField(default=False, db_index=True),
|
||||||
|
),
|
||||||
|
# Add composite index for common query patterns
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="user",
|
||||||
|
index=models.Index(fields=["is_banned", "role"], name="accounts_user_banned_role_idx"),
|
||||||
|
),
|
||||||
|
# Add CheckConstraint for ban consistency
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="user",
|
||||||
|
constraint=models.CheckConstraint(
|
||||||
|
name="user_ban_consistency",
|
||||||
|
check=models.Q(is_banned=False) | models.Q(ban_date__isnull=False),
|
||||||
|
violation_error_message="Banned users must have a ban_date set",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,888 @@
|
|||||||
|
# Generated by Django 5.1.6 on 2025-12-26 14:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import apps.core.choices.fields
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0013_add_user_query_indexes"),
|
||||||
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
|
("django_cloudflareimages_toolkit", "0001_initial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="user",
|
||||||
|
options={"verbose_name": "User", "verbose_name_plural": "Users"},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="userdeletionrequest",
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
"verbose_name": "User Deletion Request",
|
||||||
|
"verbose_name_plural": "User Deletion Requests",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="usernotification",
|
||||||
|
options={
|
||||||
|
"ordering": ["-created_at"],
|
||||||
|
"verbose_name": "User Notification",
|
||||||
|
"verbose_name_plural": "User Notifications",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="userprofile",
|
||||||
|
options={
|
||||||
|
"ordering": ["user"],
|
||||||
|
"verbose_name": "User Profile",
|
||||||
|
"verbose_name_plural": "User Profiles",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="insert_insert",
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.RemoveTrigger(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="update_update",
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="location",
|
||||||
|
field=models.CharField(blank=True, help_text="User's location (City, Country)", max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="unit_system",
|
||||||
|
field=apps.core.choices.fields.RichChoiceField(
|
||||||
|
allow_deprecated=False,
|
||||||
|
choice_group="unit_systems",
|
||||||
|
choices=[("metric", "Metric"), ("imperial", "Imperial")],
|
||||||
|
default="metric",
|
||||||
|
domain="accounts",
|
||||||
|
help_text="Preferred measurement system",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="location",
|
||||||
|
field=models.CharField(blank=True, help_text="User's location (City, Country)", max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="unit_system",
|
||||||
|
field=apps.core.choices.fields.RichChoiceField(
|
||||||
|
allow_deprecated=False,
|
||||||
|
choice_group="unit_systems",
|
||||||
|
choices=[("metric", "Metric"), ("imperial", "Imperial")],
|
||||||
|
default="metric",
|
||||||
|
domain="accounts",
|
||||||
|
help_text="Preferred measurement system",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="emailverification",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text="When this verification was created"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="emailverification",
|
||||||
|
name="last_sent",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text="When the verification email was last sent"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="emailverification",
|
||||||
|
name="token",
|
||||||
|
field=models.CharField(help_text="Verification token", max_length=64, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="emailverification",
|
||||||
|
name="user",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
help_text="User this verification belongs to",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="emailverificationevent",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text="When this verification was created"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="emailverificationevent",
|
||||||
|
name="last_sent",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text="When the verification email was last sent"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="emailverificationevent",
|
||||||
|
name="token",
|
||||||
|
field=models.CharField(help_text="Verification token", max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="emailverificationevent",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
help_text="User this verification belongs to",
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
related_query_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="notificationpreference",
|
||||||
|
name="user",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
help_text="User these preferences belong to",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="notification_preference",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="notificationpreferenceevent",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
help_text="User these preferences belong to",
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
related_query_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="passwordreset",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text="When this reset was requested"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="passwordreset",
|
||||||
|
name="expires_at",
|
||||||
|
field=models.DateTimeField(help_text="When this reset token expires"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="passwordreset",
|
||||||
|
name="token",
|
||||||
|
field=models.CharField(help_text="Reset token", max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="passwordreset",
|
||||||
|
name="used",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether this token has been used"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="passwordreset",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text="User requesting password reset",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="passwordresetevent",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text="When this reset was requested"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="passwordresetevent",
|
||||||
|
name="expires_at",
|
||||||
|
field=models.DateTimeField(help_text="When this reset token expires"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="passwordresetevent",
|
||||||
|
name="token",
|
||||||
|
field=models.CharField(help_text="Reset token", max_length=64),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="passwordresetevent",
|
||||||
|
name="used",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether this token has been used"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="passwordresetevent",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
help_text="User requesting password reset",
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
related_query_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="activity_visibility",
|
||||||
|
field=apps.core.choices.fields.RichChoiceField(
|
||||||
|
allow_deprecated=False,
|
||||||
|
choice_group="privacy_levels",
|
||||||
|
choices=[
|
||||||
|
("public", "Public"),
|
||||||
|
("friends", "Friends Only"),
|
||||||
|
("private", "Private"),
|
||||||
|
],
|
||||||
|
default="friends",
|
||||||
|
domain="accounts",
|
||||||
|
help_text="Who can see user activity",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="allow_friend_requests",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to allow friend requests"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="allow_messages",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to allow direct messages"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="allow_profile_comments",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether to allow profile comments"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="ban_date",
|
||||||
|
field=models.DateTimeField(blank=True, help_text="Date the user was banned", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="ban_reason",
|
||||||
|
field=models.TextField(blank=True, help_text="Reason for ban"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="email_notifications",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to send email notifications"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="is_banned",
|
||||||
|
field=models.BooleanField(db_index=True, default=False, help_text="Whether this user is banned"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="last_password_change",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text="When the password was last changed"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="login_history_retention",
|
||||||
|
field=models.IntegerField(default=90, help_text="How long to retain login history (days)"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="login_notifications",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to send login notifications"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="privacy_level",
|
||||||
|
field=apps.core.choices.fields.RichChoiceField(
|
||||||
|
allow_deprecated=False,
|
||||||
|
choice_group="privacy_levels",
|
||||||
|
choices=[
|
||||||
|
("public", "Public"),
|
||||||
|
("friends", "Friends Only"),
|
||||||
|
("private", "Private"),
|
||||||
|
],
|
||||||
|
default="public",
|
||||||
|
domain="accounts",
|
||||||
|
help_text="Overall privacy level",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="push_notifications",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether to send push notifications"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="role",
|
||||||
|
field=apps.core.choices.fields.RichChoiceField(
|
||||||
|
allow_deprecated=False,
|
||||||
|
choice_group="user_roles",
|
||||||
|
choices=[
|
||||||
|
("USER", "User"),
|
||||||
|
("MODERATOR", "Moderator"),
|
||||||
|
("ADMIN", "Admin"),
|
||||||
|
("SUPERUSER", "Superuser"),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default="USER",
|
||||||
|
domain="accounts",
|
||||||
|
help_text="User role (user, moderator, admin)",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="search_visibility",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether profile appears in search results"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="session_timeout",
|
||||||
|
field=models.IntegerField(default=30, help_text="Session timeout in days"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="show_email",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether to show email on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="show_join_date",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show join date on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="show_photos",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show photos on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="show_real_name",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show real name on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="show_reviews",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show reviews on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="show_statistics",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show statistics on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="show_top_lists",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show top lists on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="theme_preference",
|
||||||
|
field=apps.core.choices.fields.RichChoiceField(
|
||||||
|
allow_deprecated=False,
|
||||||
|
choice_group="theme_preferences",
|
||||||
|
choices=[("light", "Light"), ("dark", "Dark")],
|
||||||
|
default="light",
|
||||||
|
domain="accounts",
|
||||||
|
help_text="User's theme preference (light/dark)",
|
||||||
|
max_length=5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="two_factor_enabled",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether two-factor authentication is enabled"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="activity_visibility",
|
||||||
|
field=apps.core.choices.fields.RichChoiceField(
|
||||||
|
allow_deprecated=False,
|
||||||
|
choice_group="privacy_levels",
|
||||||
|
choices=[
|
||||||
|
("public", "Public"),
|
||||||
|
("friends", "Friends Only"),
|
||||||
|
("private", "Private"),
|
||||||
|
],
|
||||||
|
default="friends",
|
||||||
|
domain="accounts",
|
||||||
|
help_text="Who can see user activity",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="allow_friend_requests",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to allow friend requests"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="allow_messages",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to allow direct messages"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="allow_profile_comments",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether to allow profile comments"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="ban_date",
|
||||||
|
field=models.DateTimeField(blank=True, help_text="Date the user was banned", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="ban_reason",
|
||||||
|
field=models.TextField(blank=True, help_text="Reason for ban"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="email_notifications",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to send email notifications"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="is_banned",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether this user is banned"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="last_password_change",
|
||||||
|
field=models.DateTimeField(auto_now_add=True, help_text="When the password was last changed"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="login_history_retention",
|
||||||
|
field=models.IntegerField(default=90, help_text="How long to retain login history (days)"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="login_notifications",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to send login notifications"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="privacy_level",
|
||||||
|
field=apps.core.choices.fields.RichChoiceField(
|
||||||
|
allow_deprecated=False,
|
||||||
|
choice_group="privacy_levels",
|
||||||
|
choices=[
|
||||||
|
("public", "Public"),
|
||||||
|
("friends", "Friends Only"),
|
||||||
|
("private", "Private"),
|
||||||
|
],
|
||||||
|
default="public",
|
||||||
|
domain="accounts",
|
||||||
|
help_text="Overall privacy level",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="push_notifications",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether to send push notifications"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="role",
|
||||||
|
field=apps.core.choices.fields.RichChoiceField(
|
||||||
|
allow_deprecated=False,
|
||||||
|
choice_group="user_roles",
|
||||||
|
choices=[
|
||||||
|
("USER", "User"),
|
||||||
|
("MODERATOR", "Moderator"),
|
||||||
|
("ADMIN", "Admin"),
|
||||||
|
("SUPERUSER", "Superuser"),
|
||||||
|
],
|
||||||
|
default="USER",
|
||||||
|
domain="accounts",
|
||||||
|
help_text="User role (user, moderator, admin)",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="search_visibility",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether profile appears in search results"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="session_timeout",
|
||||||
|
field=models.IntegerField(default=30, help_text="Session timeout in days"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="show_email",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether to show email on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="show_join_date",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show join date on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="show_photos",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show photos on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="show_real_name",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show real name on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="show_reviews",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show reviews on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="show_statistics",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show statistics on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="show_top_lists",
|
||||||
|
field=models.BooleanField(default=True, help_text="Whether to show top lists on profile"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="theme_preference",
|
||||||
|
field=apps.core.choices.fields.RichChoiceField(
|
||||||
|
allow_deprecated=False,
|
||||||
|
choice_group="theme_preferences",
|
||||||
|
choices=[("light", "Light"), ("dark", "Dark")],
|
||||||
|
default="light",
|
||||||
|
domain="accounts",
|
||||||
|
help_text="User's theme preference (light/dark)",
|
||||||
|
max_length=5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userevent",
|
||||||
|
name="two_factor_enabled",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether two-factor authentication is enabled"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="content_type",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="Type of related object",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="email_sent",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether email was sent"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="email_sent_at",
|
||||||
|
field=models.DateTimeField(blank=True, help_text="When email was sent", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="is_read",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether this notification has been read"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="message",
|
||||||
|
field=models.TextField(help_text="Notification message"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="object_id",
|
||||||
|
field=models.PositiveIntegerField(blank=True, help_text="ID of related object", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="push_sent",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether push notification was sent"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="push_sent_at",
|
||||||
|
field=models.DateTimeField(blank=True, help_text="When push notification was sent", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="read_at",
|
||||||
|
field=models.DateTimeField(blank=True, help_text="When this notification was read", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="title",
|
||||||
|
field=models.CharField(help_text="Notification title", max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotification",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
help_text="User this notification is for",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="notifications",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="content_type",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
db_constraint=False,
|
||||||
|
help_text="Type of related object",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
related_query_name="+",
|
||||||
|
to="contenttypes.contenttype",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="email_sent",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether email was sent"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="email_sent_at",
|
||||||
|
field=models.DateTimeField(blank=True, help_text="When email was sent", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="is_read",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether this notification has been read"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="message",
|
||||||
|
field=models.TextField(help_text="Notification message"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="object_id",
|
||||||
|
field=models.PositiveIntegerField(blank=True, help_text="ID of related object", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="push_sent",
|
||||||
|
field=models.BooleanField(default=False, help_text="Whether push notification was sent"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="push_sent_at",
|
||||||
|
field=models.DateTimeField(blank=True, help_text="When push notification was sent", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="read_at",
|
||||||
|
field=models.DateTimeField(blank=True, help_text="When this notification was read", null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="title",
|
||||||
|
field=models.CharField(help_text="Notification title", max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="usernotificationevent",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
help_text="User this notification is for",
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
related_query_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="avatar",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
help_text="User's avatar image",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name="user_profiles",
|
||||||
|
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="bio",
|
||||||
|
field=models.TextField(blank=True, help_text="User biography", max_length=500),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="coaster_credits",
|
||||||
|
field=models.IntegerField(default=0, help_text="Number of roller coasters ridden"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="dark_ride_credits",
|
||||||
|
field=models.IntegerField(default=0, help_text="Number of dark rides ridden"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="discord",
|
||||||
|
field=models.CharField(blank=True, help_text="Discord username", max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="flat_ride_credits",
|
||||||
|
field=models.IntegerField(default=0, help_text="Number of flat rides ridden"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="instagram",
|
||||||
|
field=models.URLField(blank=True, help_text="Instagram profile URL"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="pronouns",
|
||||||
|
field=models.CharField(blank=True, help_text="User's preferred pronouns", max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="twitter",
|
||||||
|
field=models.URLField(blank=True, help_text="Twitter profile URL"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="user",
|
||||||
|
field=models.OneToOneField(
|
||||||
|
help_text="User this profile belongs to",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="profile",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="water_ride_credits",
|
||||||
|
field=models.IntegerField(default=0, help_text="Number of water rides ridden"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofile",
|
||||||
|
name="youtube",
|
||||||
|
field=models.URLField(blank=True, help_text="YouTube channel URL"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="avatar",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
db_constraint=False,
|
||||||
|
help_text="User's avatar image",
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
related_query_name="+",
|
||||||
|
to="django_cloudflareimages_toolkit.cloudflareimage",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="bio",
|
||||||
|
field=models.TextField(blank=True, help_text="User biography", max_length=500),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="coaster_credits",
|
||||||
|
field=models.IntegerField(default=0, help_text="Number of roller coasters ridden"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="dark_ride_credits",
|
||||||
|
field=models.IntegerField(default=0, help_text="Number of dark rides ridden"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="discord",
|
||||||
|
field=models.CharField(blank=True, help_text="Discord username", max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="flat_ride_credits",
|
||||||
|
field=models.IntegerField(default=0, help_text="Number of flat rides ridden"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="instagram",
|
||||||
|
field=models.URLField(blank=True, help_text="Instagram profile URL"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="pronouns",
|
||||||
|
field=models.CharField(blank=True, help_text="User's preferred pronouns", max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="twitter",
|
||||||
|
field=models.URLField(blank=True, help_text="Twitter profile URL"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="user",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
help_text="User this profile belongs to",
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
related_query_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="water_ride_credits",
|
||||||
|
field=models.IntegerField(default=0, help_text="Number of water rides ridden"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="userprofileevent",
|
||||||
|
name="youtube",
|
||||||
|
field=models.URLField(blank=True, help_text="YouTube channel URL"),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="userprofile",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "location", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "unit_system", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", NEW."location", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."unit_system", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||||
|
hash="dab03867fefb6b82eec203906fe25f4e43d95783",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_c09d7",
|
||||||
|
table="accounts_userprofile",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="userprofile",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "accounts_userprofileevent" ("avatar_id", "bio", "coaster_credits", "dark_ride_credits", "discord", "display_name", "flat_ride_credits", "id", "instagram", "location", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "profile_id", "pronouns", "twitter", "unit_system", "user_id", "water_ride_credits", "youtube") VALUES (NEW."avatar_id", NEW."bio", NEW."coaster_credits", NEW."dark_ride_credits", NEW."discord", NEW."display_name", NEW."flat_ride_credits", NEW."id", NEW."instagram", NEW."location", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."profile_id", NEW."pronouns", NEW."twitter", NEW."unit_system", NEW."user_id", NEW."water_ride_credits", NEW."youtube"); RETURN NULL;',
|
||||||
|
hash="b70f93243f5852ae882f51a191d69bb3d3d151f7",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_87ef6",
|
||||||
|
table="accounts_userprofile",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="TopList",
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name="TopListItem",
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2025-12-27 20:58
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import pgtrigger.compiler
|
||||||
|
import pgtrigger.migrations
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("accounts", "0014_remove_toplist_user_remove_toplistitem_top_list_and_more"),
|
||||||
|
("pghistory", "0007_auto_20250421_0444"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="LoginHistory",
|
||||||
|
fields=[
|
||||||
|
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||||
|
(
|
||||||
|
"ip_address",
|
||||||
|
models.GenericIPAddressField(
|
||||||
|
blank=True, help_text="IP address from which the login occurred", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_agent",
|
||||||
|
models.CharField(blank=True, help_text="Browser/client user agent string", max_length=500),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"login_method",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PASSWORD", "Password"),
|
||||||
|
("GOOGLE", "Google OAuth"),
|
||||||
|
("DISCORD", "Discord OAuth"),
|
||||||
|
("MAGIC_LINK", "Magic Link"),
|
||||||
|
("SESSION", "Session Refresh"),
|
||||||
|
],
|
||||||
|
default="PASSWORD",
|
||||||
|
help_text="Method used for authentication",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"login_timestamp",
|
||||||
|
models.DateTimeField(auto_now_add=True, db_index=True, help_text="When the login occurred"),
|
||||||
|
),
|
||||||
|
("success", models.BooleanField(default=True, help_text="Whether the login was successful")),
|
||||||
|
(
|
||||||
|
"country",
|
||||||
|
models.CharField(blank=True, help_text="Country derived from IP (optional)", max_length=100),
|
||||||
|
),
|
||||||
|
("city", models.CharField(blank=True, help_text="City derived from IP (optional)", max_length=100)),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
help_text="User who logged in",
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="login_history",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"verbose_name": "Login History",
|
||||||
|
"verbose_name_plural": "Login History",
|
||||||
|
"ordering": ["-login_timestamp"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="LoginHistoryEvent",
|
||||||
|
fields=[
|
||||||
|
("pgh_id", models.AutoField(primary_key=True, serialize=False)),
|
||||||
|
("pgh_created_at", models.DateTimeField(auto_now_add=True)),
|
||||||
|
("pgh_label", models.TextField(help_text="The event label.")),
|
||||||
|
("id", models.BigIntegerField()),
|
||||||
|
(
|
||||||
|
"ip_address",
|
||||||
|
models.GenericIPAddressField(
|
||||||
|
blank=True, help_text="IP address from which the login occurred", null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user_agent",
|
||||||
|
models.CharField(blank=True, help_text="Browser/client user agent string", max_length=500),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"login_method",
|
||||||
|
models.CharField(
|
||||||
|
choices=[
|
||||||
|
("PASSWORD", "Password"),
|
||||||
|
("GOOGLE", "Google OAuth"),
|
||||||
|
("DISCORD", "Discord OAuth"),
|
||||||
|
("MAGIC_LINK", "Magic Link"),
|
||||||
|
("SESSION", "Session Refresh"),
|
||||||
|
],
|
||||||
|
default="PASSWORD",
|
||||||
|
help_text="Method used for authentication",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("login_timestamp", models.DateTimeField(auto_now_add=True, help_text="When the login occurred")),
|
||||||
|
("success", models.BooleanField(default=True, help_text="Whether the login was successful")),
|
||||||
|
(
|
||||||
|
"country",
|
||||||
|
models.CharField(blank=True, help_text="Country derived from IP (optional)", max_length=100),
|
||||||
|
),
|
||||||
|
("city", models.CharField(blank=True, help_text="City derived from IP (optional)", max_length=100)),
|
||||||
|
(
|
||||||
|
"pgh_context",
|
||||||
|
models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
to="pghistory.context",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"pgh_obj",
|
||||||
|
models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="events",
|
||||||
|
to="accounts.loginhistory",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"user",
|
||||||
|
models.ForeignKey(
|
||||||
|
db_constraint=False,
|
||||||
|
help_text="User who logged in",
|
||||||
|
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||||
|
related_name="+",
|
||||||
|
related_query_name="+",
|
||||||
|
to=settings.AUTH_USER_MODEL,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"abstract": False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="loginhistory",
|
||||||
|
index=models.Index(fields=["user", "-login_timestamp"], name="accounts_lo_user_id_156da7_idx"),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="loginhistory",
|
||||||
|
index=models.Index(fields=["ip_address"], name="accounts_lo_ip_addr_142937_idx"),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="loginhistory",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="insert_insert",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
func='INSERT INTO "accounts_loginhistoryevent" ("city", "country", "id", "ip_address", "login_method", "login_timestamp", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "success", "user_agent", "user_id") VALUES (NEW."city", NEW."country", NEW."id", NEW."ip_address", NEW."login_method", NEW."login_timestamp", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."success", NEW."user_agent", NEW."user_id"); RETURN NULL;',
|
||||||
|
hash="9ccc4d52099a09097d02128eb427d58ae955a377",
|
||||||
|
operation="INSERT",
|
||||||
|
pgid="pgtrigger_insert_insert_dc41d",
|
||||||
|
table="accounts_loginhistory",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pgtrigger.migrations.AddTrigger(
|
||||||
|
model_name="loginhistory",
|
||||||
|
trigger=pgtrigger.compiler.Trigger(
|
||||||
|
name="update_update",
|
||||||
|
sql=pgtrigger.compiler.UpsertTriggerSql(
|
||||||
|
condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)",
|
||||||
|
func='INSERT INTO "accounts_loginhistoryevent" ("city", "country", "id", "ip_address", "login_method", "login_timestamp", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "success", "user_agent", "user_id") VALUES (NEW."city", NEW."country", NEW."id", NEW."ip_address", NEW."login_method", NEW."login_timestamp", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."success", NEW."user_agent", NEW."user_id"); RETURN NULL;',
|
||||||
|
hash="d5d998a5af1a55f181ebe8500a70022e8e4db724",
|
||||||
|
operation="UPDATE",
|
||||||
|
pgid="pgtrigger_update_update_110f5",
|
||||||
|
table="accounts_loginhistory",
|
||||||
|
when="AFTER",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,35 +1,45 @@
|
|||||||
import requests
|
"""
|
||||||
from django.conf import settings
|
Mixins for authentication views.
|
||||||
|
"""
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from apps.core.utils.turnstile import get_client_ip, validate_turnstile_token
|
||||||
|
|
||||||
|
|
||||||
class TurnstileMixin:
|
class TurnstileMixin:
|
||||||
"""
|
"""
|
||||||
Mixin to handle Cloudflare Turnstile validation.
|
Mixin to handle Cloudflare Turnstile validation.
|
||||||
Bypasses validation when DEBUG is True.
|
Works with both form POST data and JSON request bodies.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def validate_turnstile(self, request):
|
def validate_turnstile(self, request):
|
||||||
"""
|
"""
|
||||||
Validate the Turnstile response token.
|
Validate the Turnstile response token.
|
||||||
Skips validation when DEBUG is True.
|
|
||||||
|
The token can be provided as:
|
||||||
|
- 'cf-turnstile-response' in POST data (form submission)
|
||||||
|
- 'turnstile_token' in JSON body (API request)
|
||||||
"""
|
"""
|
||||||
if settings.DEBUG:
|
# Try to get token from various sources
|
||||||
return
|
token = None
|
||||||
|
|
||||||
token = request.POST.get("cf-turnstile-response")
|
# Check POST data (form submissions)
|
||||||
if not token:
|
if hasattr(request, "POST"):
|
||||||
raise ValidationError("Please complete the Turnstile challenge.")
|
token = request.POST.get("cf-turnstile-response")
|
||||||
|
|
||||||
# Verify the token with Cloudflare
|
# Check JSON body (API requests)
|
||||||
data = {
|
if not token and hasattr(request, "data"):
|
||||||
"secret": settings.TURNSTILE_SECRET_KEY,
|
data = getattr(request, "data", {})
|
||||||
"response": token,
|
if hasattr(data, "get"):
|
||||||
"remoteip": request.META.get("REMOTE_ADDR"),
|
token = data.get("turnstile_token") or data.get("cf-turnstile-response")
|
||||||
}
|
|
||||||
|
|
||||||
response = requests.post(settings.TURNSTILE_VERIFY_URL, data=data, timeout=60)
|
# Get client IP
|
||||||
result = response.json()
|
ip = get_client_ip(request)
|
||||||
|
|
||||||
|
# Validate the token
|
||||||
|
result = validate_turnstile_token(token, ip)
|
||||||
|
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
raise ValidationError("Turnstile validation failed. Please try again.")
|
error_msg = result.get("error", "Captcha verification failed. Please try again.")
|
||||||
|
raise ValidationError(error_msg)
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
from django.dispatch import receiver
|
import secrets
|
||||||
from django.db.models.signals import post_save
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pghistory
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.db import models
|
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.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
import secrets
|
|
||||||
from datetime import timedelta
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from apps.core.history import TrackedModel
|
|
||||||
from apps.core.choices import RichChoiceField
|
from apps.core.choices import RichChoiceField
|
||||||
import pghistory
|
from apps.core.history import TrackedModel
|
||||||
|
|
||||||
|
# from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||||
|
|
||||||
|
|
||||||
def generate_random_id(model_class, id_field):
|
def generate_random_id(model_class, id_field):
|
||||||
@@ -38,10 +41,7 @@ class User(AbstractUser):
|
|||||||
max_length=10,
|
max_length=10,
|
||||||
unique=True,
|
unique=True,
|
||||||
editable=False,
|
editable=False,
|
||||||
help_text=(
|
help_text=("Unique identifier for this user that remains constant even if the " "username changes"),
|
||||||
"Unique identifier for this user that remains constant even if the "
|
|
||||||
"username changes"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
role = RichChoiceField(
|
role = RichChoiceField(
|
||||||
@@ -49,21 +49,24 @@ class User(AbstractUser):
|
|||||||
domain="accounts",
|
domain="accounts",
|
||||||
max_length=10,
|
max_length=10,
|
||||||
default="USER",
|
default="USER",
|
||||||
|
db_index=True,
|
||||||
|
help_text="User role (user, moderator, admin)",
|
||||||
)
|
)
|
||||||
is_banned = models.BooleanField(default=False)
|
is_banned = models.BooleanField(default=False, db_index=True, help_text="Whether this user is banned")
|
||||||
ban_reason = models.TextField(blank=True)
|
ban_reason = models.TextField(blank=True, help_text="Reason for ban")
|
||||||
ban_date = models.DateTimeField(null=True, blank=True)
|
ban_date = models.DateTimeField(null=True, blank=True, help_text="Date the user was banned")
|
||||||
pending_email = models.EmailField(blank=True, null=True)
|
pending_email = models.EmailField(blank=True, null=True)
|
||||||
theme_preference = RichChoiceField(
|
theme_preference = RichChoiceField(
|
||||||
choice_group="theme_preferences",
|
choice_group="theme_preferences",
|
||||||
domain="accounts",
|
domain="accounts",
|
||||||
max_length=5,
|
max_length=5,
|
||||||
default="light",
|
default="light",
|
||||||
|
help_text="User's theme preference (light/dark)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Notification preferences
|
# Notification preferences
|
||||||
email_notifications = models.BooleanField(default=True)
|
email_notifications = models.BooleanField(default=True, help_text="Whether to send email notifications")
|
||||||
push_notifications = models.BooleanField(default=False)
|
push_notifications = models.BooleanField(default=False, help_text="Whether to send push notifications")
|
||||||
|
|
||||||
# Privacy settings
|
# Privacy settings
|
||||||
privacy_level = RichChoiceField(
|
privacy_level = RichChoiceField(
|
||||||
@@ -71,31 +74,33 @@ class User(AbstractUser):
|
|||||||
domain="accounts",
|
domain="accounts",
|
||||||
max_length=10,
|
max_length=10,
|
||||||
default="public",
|
default="public",
|
||||||
|
help_text="Overall privacy level",
|
||||||
)
|
)
|
||||||
show_email = models.BooleanField(default=False)
|
show_email = models.BooleanField(default=False, help_text="Whether to show email on profile")
|
||||||
show_real_name = models.BooleanField(default=True)
|
show_real_name = models.BooleanField(default=True, help_text="Whether to show real name on profile")
|
||||||
show_join_date = models.BooleanField(default=True)
|
show_join_date = models.BooleanField(default=True, help_text="Whether to show join date on profile")
|
||||||
show_statistics = models.BooleanField(default=True)
|
show_statistics = models.BooleanField(default=True, help_text="Whether to show statistics on profile")
|
||||||
show_reviews = models.BooleanField(default=True)
|
show_reviews = models.BooleanField(default=True, help_text="Whether to show reviews on profile")
|
||||||
show_photos = models.BooleanField(default=True)
|
show_photos = models.BooleanField(default=True, help_text="Whether to show photos on profile")
|
||||||
show_top_lists = models.BooleanField(default=True)
|
show_top_lists = models.BooleanField(default=True, help_text="Whether to show top lists on profile")
|
||||||
allow_friend_requests = models.BooleanField(default=True)
|
allow_friend_requests = models.BooleanField(default=True, help_text="Whether to allow friend requests")
|
||||||
allow_messages = models.BooleanField(default=True)
|
allow_messages = models.BooleanField(default=True, help_text="Whether to allow direct messages")
|
||||||
allow_profile_comments = models.BooleanField(default=False)
|
allow_profile_comments = models.BooleanField(default=False, help_text="Whether to allow profile comments")
|
||||||
search_visibility = models.BooleanField(default=True)
|
search_visibility = models.BooleanField(default=True, help_text="Whether profile appears in search results")
|
||||||
activity_visibility = RichChoiceField(
|
activity_visibility = RichChoiceField(
|
||||||
choice_group="privacy_levels",
|
choice_group="privacy_levels",
|
||||||
domain="accounts",
|
domain="accounts",
|
||||||
max_length=10,
|
max_length=10,
|
||||||
default="friends",
|
default="friends",
|
||||||
|
help_text="Who can see user activity",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Security settings
|
# Security settings
|
||||||
two_factor_enabled = models.BooleanField(default=False)
|
two_factor_enabled = models.BooleanField(default=False, help_text="Whether two-factor authentication is enabled")
|
||||||
login_notifications = models.BooleanField(default=True)
|
login_notifications = models.BooleanField(default=True, help_text="Whether to send login notifications")
|
||||||
session_timeout = models.IntegerField(default=30) # days
|
session_timeout = models.IntegerField(default=30, help_text="Session timeout in days")
|
||||||
login_history_retention = models.IntegerField(default=90) # 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)
|
last_password_change = models.DateTimeField(auto_now_add=True, help_text="When the password was last changed")
|
||||||
|
|
||||||
# Display name - core user data for better performance
|
# Display name - core user data for better performance
|
||||||
display_name = models.CharField(
|
display_name = models.CharField(
|
||||||
@@ -127,6 +132,20 @@ class User(AbstractUser):
|
|||||||
return profile.display_name
|
return profile.display_name
|
||||||
return self.username
|
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):
|
def save(self, *args, **kwargs):
|
||||||
if not self.user_id:
|
if not self.user_id:
|
||||||
self.user_id = generate_random_id(User, "user_id")
|
self.user_id = generate_random_id(User, "user_id")
|
||||||
@@ -143,33 +162,48 @@ class UserProfile(models.Model):
|
|||||||
help_text="Unique identifier for this profile that remains constant",
|
help_text="Unique identifier for this profile that remains constant",
|
||||||
)
|
)
|
||||||
|
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
user = models.OneToOneField(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="profile",
|
||||||
|
help_text="User this profile belongs to",
|
||||||
|
)
|
||||||
display_name = models.CharField(
|
display_name = models.CharField(
|
||||||
max_length=50,
|
max_length=50,
|
||||||
blank=True,
|
blank=True,
|
||||||
help_text="Legacy display name field - use User.display_name instead",
|
help_text="Legacy display name field - use User.display_name instead",
|
||||||
)
|
)
|
||||||
avatar = models.ForeignKey(
|
avatar = models.ForeignKey(
|
||||||
'django_cloudflareimages_toolkit.CloudflareImage',
|
"django_cloudflareimages_toolkit.CloudflareImage",
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True,
|
null=True,
|
||||||
blank=True
|
blank=True,
|
||||||
|
related_name="user_profiles",
|
||||||
|
help_text="User's avatar image",
|
||||||
)
|
)
|
||||||
pronouns = models.CharField(max_length=50, blank=True)
|
pronouns = models.CharField(max_length=50, blank=True, help_text="User's preferred pronouns")
|
||||||
|
|
||||||
bio = models.TextField(max_length=500, 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",
|
||||||
|
)
|
||||||
|
|
||||||
# Social media links
|
# Social media links
|
||||||
twitter = models.URLField(blank=True)
|
twitter = models.URLField(blank=True, help_text="Twitter profile URL")
|
||||||
instagram = models.URLField(blank=True)
|
instagram = models.URLField(blank=True, help_text="Instagram profile URL")
|
||||||
youtube = models.URLField(blank=True)
|
youtube = models.URLField(blank=True, help_text="YouTube channel URL")
|
||||||
discord = models.CharField(max_length=100, blank=True)
|
discord = models.CharField(max_length=100, blank=True, help_text="Discord username")
|
||||||
|
|
||||||
# Ride statistics
|
# Ride statistics
|
||||||
coaster_credits = models.IntegerField(default=0)
|
coaster_credits = models.IntegerField(default=0, help_text="Number of roller coasters ridden")
|
||||||
dark_ride_credits = models.IntegerField(default=0)
|
dark_ride_credits = models.IntegerField(default=0, help_text="Number of dark rides ridden")
|
||||||
flat_ride_credits = models.IntegerField(default=0)
|
flat_ride_credits = models.IntegerField(default=0, help_text="Number of flat rides ridden")
|
||||||
water_ride_credits = models.IntegerField(default=0)
|
water_ride_credits = models.IntegerField(default=0, help_text="Number of water rides ridden")
|
||||||
|
|
||||||
def get_avatar_url(self):
|
def get_avatar_url(self):
|
||||||
"""
|
"""
|
||||||
@@ -177,12 +211,12 @@ class UserProfile(models.Model):
|
|||||||
"""
|
"""
|
||||||
if self.avatar and self.avatar.is_uploaded:
|
if self.avatar and self.avatar.is_uploaded:
|
||||||
# Try to get avatar variant first, fallback to public
|
# 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:
|
if avatar_url:
|
||||||
return avatar_url
|
return avatar_url
|
||||||
|
|
||||||
# Fallback to public variant
|
# Fallback to public variant
|
||||||
public_url = self.avatar.get_url('public')
|
public_url = self.avatar.get_url("public")
|
||||||
if public_url:
|
if public_url:
|
||||||
return public_url
|
return public_url
|
||||||
|
|
||||||
@@ -209,10 +243,10 @@ class UserProfile(models.Model):
|
|||||||
variants = {}
|
variants = {}
|
||||||
|
|
||||||
# Try to get specific variants
|
# Try to get specific variants
|
||||||
thumbnail_url = self.avatar.get_url('thumbnail')
|
thumbnail_url = self.avatar.get_url("thumbnail")
|
||||||
avatar_url = self.avatar.get_url('avatar')
|
avatar_url = self.avatar.get_url("avatar")
|
||||||
large_url = self.avatar.get_url('large')
|
large_url = self.avatar.get_url("large")
|
||||||
public_url = self.avatar.get_url('public')
|
public_url = self.avatar.get_url("public")
|
||||||
|
|
||||||
# Use specific variants if available, otherwise fallback to public or first available
|
# Use specific variants if available, otherwise fallback to public or first available
|
||||||
fallback_url = public_url
|
fallback_url = public_url
|
||||||
@@ -252,13 +286,23 @@ class UserProfile(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.display_name
|
return self.display_name
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "User Profile"
|
||||||
|
verbose_name_plural = "User Profiles"
|
||||||
|
ordering = ["user"]
|
||||||
|
|
||||||
|
|
||||||
@pghistory.track()
|
@pghistory.track()
|
||||||
class EmailVerification(models.Model):
|
class EmailVerification(models.Model):
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
user = models.OneToOneField(
|
||||||
token = models.CharField(max_length=64, unique=True)
|
User,
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
on_delete=models.CASCADE,
|
||||||
last_sent = models.DateTimeField(auto_now_add=True)
|
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")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Email verification for {self.user.username}"
|
return f"Email verification for {self.user.username}"
|
||||||
@@ -270,11 +314,15 @@ class EmailVerification(models.Model):
|
|||||||
|
|
||||||
@pghistory.track()
|
@pghistory.track()
|
||||||
class PasswordReset(models.Model):
|
class PasswordReset(models.Model):
|
||||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
user = models.ForeignKey(
|
||||||
token = models.CharField(max_length=64)
|
User,
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
on_delete=models.CASCADE,
|
||||||
expires_at = models.DateTimeField()
|
help_text="User requesting password reset",
|
||||||
used = models.BooleanField(default=False)
|
)
|
||||||
|
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")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"Password reset for {self.user.username}"
|
return f"Password reset for {self.user.username}"
|
||||||
@@ -284,56 +332,6 @@ class PasswordReset(models.Model):
|
|||||||
verbose_name_plural = "Password Resets"
|
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()
|
@pghistory.track()
|
||||||
class UserDeletionRequest(models.Model):
|
class UserDeletionRequest(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -344,9 +342,7 @@ class UserDeletionRequest(models.Model):
|
|||||||
provide the correct code.
|
provide the correct code.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="deletion_request")
|
||||||
User, on_delete=models.CASCADE, related_name="deletion_request"
|
|
||||||
)
|
|
||||||
|
|
||||||
verification_code = models.CharField(
|
verification_code = models.CharField(
|
||||||
max_length=32,
|
max_length=32,
|
||||||
@@ -357,23 +353,17 @@ class UserDeletionRequest(models.Model):
|
|||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
expires_at = models.DateTimeField(help_text="When this deletion request expires")
|
expires_at = models.DateTimeField(help_text="When this deletion request expires")
|
||||||
|
|
||||||
email_sent_at = models.DateTimeField(
|
email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When the verification email was sent")
|
||||||
null=True, blank=True, help_text="When the verification email was sent"
|
|
||||||
)
|
|
||||||
|
|
||||||
attempts = models.PositiveIntegerField(
|
attempts = models.PositiveIntegerField(default=0, help_text="Number of verification attempts made")
|
||||||
default=0, help_text="Number of verification attempts made"
|
|
||||||
)
|
|
||||||
|
|
||||||
max_attempts = models.PositiveIntegerField(
|
max_attempts = models.PositiveIntegerField(default=5, help_text="Maximum number of verification attempts allowed")
|
||||||
default=5, help_text="Maximum number of verification attempts allowed"
|
|
||||||
)
|
|
||||||
|
|
||||||
is_used = models.BooleanField(
|
is_used = models.BooleanField(default=False, help_text="Whether this deletion request has been used")
|
||||||
default=False, help_text="Whether this deletion request has been used"
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
verbose_name = "User Deletion Request"
|
||||||
|
verbose_name_plural = "User Deletion Requests"
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["verification_code"]),
|
models.Index(fields=["verification_code"]),
|
||||||
@@ -399,9 +389,7 @@ class UserDeletionRequest(models.Model):
|
|||||||
"""Generate a unique 8-character verification code."""
|
"""Generate a unique 8-character verification code."""
|
||||||
while True:
|
while True:
|
||||||
# Generate a random 8-character alphanumeric code
|
# Generate a random 8-character alphanumeric code
|
||||||
code = "".join(
|
code = "".join(secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8))
|
||||||
secrets.choice("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") for _ in range(8)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Ensure it's unique
|
# Ensure it's unique
|
||||||
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
|
if not UserDeletionRequest.objects.filter(verification_code=code).exists():
|
||||||
@@ -413,11 +401,7 @@ class UserDeletionRequest(models.Model):
|
|||||||
|
|
||||||
def is_valid(self):
|
def is_valid(self):
|
||||||
"""Check if this deletion request is still valid."""
|
"""Check if this deletion request is still valid."""
|
||||||
return (
|
return not self.is_used and not self.is_expired() and self.attempts < self.max_attempts
|
||||||
not self.is_used
|
|
||||||
and not self.is_expired()
|
|
||||||
and self.attempts < self.max_attempts
|
|
||||||
)
|
|
||||||
|
|
||||||
def increment_attempts(self):
|
def increment_attempts(self):
|
||||||
"""Increment the number of verification attempts."""
|
"""Increment the number of verification attempts."""
|
||||||
@@ -432,9 +416,7 @@ class UserDeletionRequest(models.Model):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def cleanup_expired(cls):
|
def cleanup_expired(cls):
|
||||||
"""Remove expired deletion requests."""
|
"""Remove expired deletion requests."""
|
||||||
expired_requests = cls.objects.filter(
|
expired_requests = cls.objects.filter(expires_at__lt=timezone.now(), is_used=False)
|
||||||
expires_at__lt=timezone.now(), is_used=False
|
|
||||||
)
|
|
||||||
count = expired_requests.count()
|
count = expired_requests.count()
|
||||||
expired_requests.delete()
|
expired_requests.delete()
|
||||||
return count
|
return count
|
||||||
@@ -451,7 +433,10 @@ class UserNotification(TrackedModel):
|
|||||||
|
|
||||||
# Core fields
|
# Core fields
|
||||||
user = models.ForeignKey(
|
user = models.ForeignKey(
|
||||||
User, on_delete=models.CASCADE, related_name="notifications"
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notifications",
|
||||||
|
help_text="User this notification is for",
|
||||||
)
|
)
|
||||||
|
|
||||||
notification_type = RichChoiceField(
|
notification_type = RichChoiceField(
|
||||||
@@ -460,14 +445,18 @@ class UserNotification(TrackedModel):
|
|||||||
max_length=30,
|
max_length=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
title = models.CharField(max_length=200)
|
title = models.CharField(max_length=200, help_text="Notification title")
|
||||||
message = models.TextField()
|
message = models.TextField(help_text="Notification message")
|
||||||
|
|
||||||
# Optional related object (submission, review, etc.)
|
# Optional related object (submission, review, etc.)
|
||||||
content_type = models.ForeignKey(
|
content_type = models.ForeignKey(
|
||||||
"contenttypes.ContentType", on_delete=models.CASCADE, null=True, blank=True
|
"contenttypes.ContentType",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Type of related object",
|
||||||
)
|
)
|
||||||
object_id = models.PositiveIntegerField(null=True, blank=True)
|
object_id = models.PositiveIntegerField(null=True, blank=True, help_text="ID of related object")
|
||||||
related_object = GenericForeignKey("content_type", "object_id")
|
related_object = GenericForeignKey("content_type", "object_id")
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
@@ -479,14 +468,14 @@ class UserNotification(TrackedModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Status tracking
|
# Status tracking
|
||||||
is_read = models.BooleanField(default=False)
|
is_read = models.BooleanField(default=False, help_text="Whether this notification has been read")
|
||||||
read_at = models.DateTimeField(null=True, blank=True)
|
read_at = models.DateTimeField(null=True, blank=True, help_text="When this notification was read")
|
||||||
|
|
||||||
# Delivery tracking
|
# Delivery tracking
|
||||||
email_sent = models.BooleanField(default=False)
|
email_sent = models.BooleanField(default=False, help_text="Whether email was sent")
|
||||||
email_sent_at = models.DateTimeField(null=True, blank=True)
|
email_sent_at = models.DateTimeField(null=True, blank=True, help_text="When email was sent")
|
||||||
push_sent = models.BooleanField(default=False)
|
push_sent = models.BooleanField(default=False, help_text="Whether push notification was sent")
|
||||||
push_sent_at = models.DateTimeField(null=True, blank=True)
|
push_sent_at = models.DateTimeField(null=True, blank=True, help_text="When push notification was sent")
|
||||||
|
|
||||||
# Additional data (JSON field for flexibility)
|
# Additional data (JSON field for flexibility)
|
||||||
extra_data = models.JSONField(default=dict, blank=True)
|
extra_data = models.JSONField(default=dict, blank=True)
|
||||||
@@ -496,6 +485,8 @@ class UserNotification(TrackedModel):
|
|||||||
expires_at = models.DateTimeField(null=True, blank=True)
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
class Meta(TrackedModel.Meta):
|
class Meta(TrackedModel.Meta):
|
||||||
|
verbose_name = "User Notification"
|
||||||
|
verbose_name_plural = "User Notifications"
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["user", "is_read"]),
|
models.Index(fields=["user", "is_read"]),
|
||||||
@@ -531,9 +522,7 @@ class UserNotification(TrackedModel):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def mark_all_read_for_user(cls, user):
|
def mark_all_read_for_user(cls, user):
|
||||||
"""Mark all notifications as read for a specific user."""
|
"""Mark all notifications as read for a specific user."""
|
||||||
return cls.objects.filter(user=user, is_read=False).update(
|
return cls.objects.filter(user=user, is_read=False).update(is_read=True, read_at=timezone.now())
|
||||||
is_read=True, read_at=timezone.now()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pghistory.track()
|
@pghistory.track()
|
||||||
@@ -546,7 +535,10 @@ class NotificationPreference(TrackedModel):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
user = models.OneToOneField(
|
user = models.OneToOneField(
|
||||||
User, on_delete=models.CASCADE, related_name="notification_preference"
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="notification_preference",
|
||||||
|
help_text="User these preferences belong to",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Submission notifications
|
# Submission notifications
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ Selectors for user and account-related data retrieval.
|
|||||||
Following Django styleguide pattern for separating data access from business logic.
|
Following Django styleguide pattern for separating data access from business logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, Any
|
|
||||||
from django.db.models import QuerySet, Q, F, Count
|
|
||||||
from django.contrib.auth import get_user_model
|
|
||||||
from django.utils import timezone
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db.models import Count, F, Q, QuerySet
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
@@ -26,16 +27,10 @@ def user_profile_optimized(*, user_id: int) -> Any:
|
|||||||
User.DoesNotExist: If user doesn't exist
|
User.DoesNotExist: If user doesn't exist
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
User.objects.prefetch_related(
|
User.objects.prefetch_related("park_reviews", "ride_reviews", "socialaccount_set")
|
||||||
"park_reviews", "ride_reviews", "socialaccount_set"
|
|
||||||
)
|
|
||||||
.annotate(
|
.annotate(
|
||||||
park_review_count=Count(
|
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
|
||||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
ride_review_count=Count("ride_reviews", filter=Q(ride_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"),
|
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||||
)
|
)
|
||||||
.get(id=user_id)
|
.get(id=user_id)
|
||||||
@@ -52,12 +47,8 @@ def active_users_with_stats() -> QuerySet:
|
|||||||
return (
|
return (
|
||||||
User.objects.filter(is_active=True)
|
User.objects.filter(is_active=True)
|
||||||
.annotate(
|
.annotate(
|
||||||
park_review_count=Count(
|
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
|
||||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
ride_review_count=Count("ride_reviews", filter=Q(ride_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"),
|
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||||
)
|
)
|
||||||
.order_by("-total_review_count")
|
.order_by("-total_review_count")
|
||||||
@@ -111,12 +102,8 @@ def top_reviewers(*, limit: int = 10) -> QuerySet:
|
|||||||
return (
|
return (
|
||||||
User.objects.filter(is_active=True)
|
User.objects.filter(is_active=True)
|
||||||
.annotate(
|
.annotate(
|
||||||
park_review_count=Count(
|
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
|
||||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
ride_review_count=Count("ride_reviews", filter=Q(ride_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"),
|
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||||
)
|
)
|
||||||
.filter(total_review_count__gt=0)
|
.filter(total_review_count__gt=0)
|
||||||
@@ -158,9 +145,9 @@ def users_by_registration_date(*, start_date, end_date) -> QuerySet:
|
|||||||
Returns:
|
Returns:
|
||||||
QuerySet of users registered in the date range
|
QuerySet of users registered in the date range
|
||||||
"""
|
"""
|
||||||
return User.objects.filter(
|
return User.objects.filter(date_joined__date__gte=start_date, date_joined__date__lte=end_date).order_by(
|
||||||
date_joined__date__gte=start_date, date_joined__date__lte=end_date
|
"-date_joined"
|
||||||
).order_by("-date_joined")
|
)
|
||||||
|
|
||||||
|
|
||||||
def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
|
def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
|
||||||
@@ -175,8 +162,7 @@ def user_search_autocomplete(*, query: str, limit: int = 10) -> QuerySet:
|
|||||||
QuerySet of matching users for autocomplete
|
QuerySet of matching users for autocomplete
|
||||||
"""
|
"""
|
||||||
return User.objects.filter(
|
return User.objects.filter(
|
||||||
Q(username__icontains=query)
|
Q(username__icontains=query) | Q(display_name__icontains=query),
|
||||||
| Q(display_name__icontains=query),
|
|
||||||
is_active=True,
|
is_active=True,
|
||||||
).order_by("username")[:limit]
|
).order_by("username")[:limit]
|
||||||
|
|
||||||
@@ -196,7 +182,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.
|
Get overall user statistics for dashboard/analytics.
|
||||||
|
|
||||||
@@ -209,11 +195,7 @@ def user_statistics_summary() -> Dict[str, Any]:
|
|||||||
|
|
||||||
# Users with reviews
|
# Users with reviews
|
||||||
users_with_reviews = (
|
users_with_reviews = (
|
||||||
User.objects.filter(
|
User.objects.filter(Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)).distinct().count()
|
||||||
Q(park_reviews__isnull=False) | Q(ride_reviews__isnull=False)
|
|
||||||
)
|
|
||||||
.distinct()
|
|
||||||
.count()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Recent registrations (last 30 days)
|
# Recent registrations (last 30 days)
|
||||||
@@ -227,9 +209,7 @@ def user_statistics_summary() -> Dict[str, Any]:
|
|||||||
"staff_users": staff_users,
|
"staff_users": staff_users,
|
||||||
"users_with_reviews": users_with_reviews,
|
"users_with_reviews": users_with_reviews,
|
||||||
"recent_registrations": recent_registrations,
|
"recent_registrations": recent_registrations,
|
||||||
"review_participation_rate": (
|
"review_participation_rate": ((users_with_reviews / total_users * 100) if total_users > 0 else 0),
|
||||||
(users_with_reviews / total_users * 100) if total_users > 0 else 0
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -240,11 +220,7 @@ def users_needing_email_verification() -> QuerySet:
|
|||||||
Returns:
|
Returns:
|
||||||
QuerySet of users with unverified emails
|
QuerySet of users with unverified emails
|
||||||
"""
|
"""
|
||||||
return (
|
return User.objects.filter(is_active=True, emailaddress__verified=False).distinct().order_by("date_joined")
|
||||||
User.objects.filter(is_active=True, emailaddress__verified=False)
|
|
||||||
.distinct()
|
|
||||||
.order_by("date_joined")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
|
def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
|
||||||
@@ -259,12 +235,8 @@ def users_by_review_activity(*, min_reviews: int = 1) -> QuerySet:
|
|||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
User.objects.annotate(
|
User.objects.annotate(
|
||||||
park_review_count=Count(
|
park_review_count=Count("park_reviews", filter=Q(park_reviews__is_published=True)),
|
||||||
"park_reviews", filter=Q(park_reviews__is_published=True)
|
ride_review_count=Count("ride_reviews", filter=Q(ride_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"),
|
total_review_count=F("park_review_count") + F("ride_review_count"),
|
||||||
)
|
)
|
||||||
.filter(total_review_count__gte=min_reviews)
|
.filter(total_review_count__gte=min_reviews)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
from rest_framework import serializers
|
from datetime import timedelta
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from django.contrib.auth.password_validation import validate_password
|
from django.contrib.auth.password_validation import validate_password
|
||||||
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.shortcuts import get_current_site
|
||||||
from .models import User, PasswordReset
|
|
||||||
from django_forwardemail.services import EmailService
|
|
||||||
from django.template.loader import render_to_string
|
from django.template.loader import render_to_string
|
||||||
from typing import cast
|
from django.utils import timezone
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
from django_forwardemail.services import EmailService
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from .models import PasswordReset, User
|
||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
@@ -19,7 +21,9 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
avatar_url = serializers.SerializerMethodField()
|
avatar_url = serializers.SerializerMethodField()
|
||||||
display_name = serializers.SerializerMethodField()
|
display_name = serializers.CharField(source="profile.display_name", required=False)
|
||||||
|
unit_system = serializers.CharField(source="profile.unit_system", required=False)
|
||||||
|
location = serializers.CharField(source="profile.location", required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@@ -31,6 +35,8 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"date_joined",
|
"date_joined",
|
||||||
"is_active",
|
"is_active",
|
||||||
"avatar_url",
|
"avatar_url",
|
||||||
|
"unit_system",
|
||||||
|
"location",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "date_joined", "is_active"]
|
read_only_fields = ["id", "date_joined", "is_active"]
|
||||||
|
|
||||||
@@ -40,9 +46,15 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
return obj.profile.avatar.url
|
return obj.profile.avatar.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def get_display_name(self, obj) -> str:
|
def update(self, instance, validated_data):
|
||||||
"""Get user display name"""
|
profile_data = validated_data.pop("profile", {})
|
||||||
return obj.get_display_name()
|
profile = instance.profile
|
||||||
|
|
||||||
|
for attr, value in profile_data.items():
|
||||||
|
setattr(profile, attr, value)
|
||||||
|
profile.save()
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class LoginSerializer(serializers.Serializer):
|
class LoginSerializer(serializers.Serializer):
|
||||||
@@ -50,12 +62,8 @@ class LoginSerializer(serializers.Serializer):
|
|||||||
Serializer for user login
|
Serializer for user login
|
||||||
"""
|
"""
|
||||||
|
|
||||||
username = serializers.CharField(
|
username = serializers.CharField(max_length=254, help_text="Username or email address")
|
||||||
max_length=254, help_text="Username or email address"
|
password = serializers.CharField(max_length=128, style={"input_type": "password"}, trim_whitespace=False)
|
||||||
)
|
|
||||||
password = serializers.CharField(
|
|
||||||
max_length=128, style={"input_type": "password"}, trim_whitespace=False
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
username = attrs.get("username")
|
username = attrs.get("username")
|
||||||
@@ -77,9 +85,7 @@ class SignupSerializer(serializers.ModelSerializer):
|
|||||||
validators=[validate_password],
|
validators=[validate_password],
|
||||||
style={"input_type": "password"},
|
style={"input_type": "password"},
|
||||||
)
|
)
|
||||||
password_confirm = serializers.CharField(
|
password_confirm = serializers.CharField(write_only=True, style={"input_type": "password"})
|
||||||
write_only=True, style={"input_type": "password"}
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
@@ -106,9 +112,7 @@ class SignupSerializer(serializers.ModelSerializer):
|
|||||||
def validate_username(self, value):
|
def validate_username(self, value):
|
||||||
"""Validate username is unique"""
|
"""Validate username is unique"""
|
||||||
if UserModel.objects.filter(username=value).exists():
|
if UserModel.objects.filter(username=value).exists():
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError("A user with this username already exists.")
|
||||||
"A user with this username already exists."
|
|
||||||
)
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
@@ -117,9 +121,7 @@ class SignupSerializer(serializers.ModelSerializer):
|
|||||||
password_confirm = attrs.get("password_confirm")
|
password_confirm = attrs.get("password_confirm")
|
||||||
|
|
||||||
if password != password_confirm:
|
if password != password_confirm:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError({"password_confirm": "Passwords do not match."})
|
||||||
{"password_confirm": "Passwords do not match."}
|
|
||||||
)
|
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
@@ -182,9 +184,7 @@ class PasswordResetSerializer(serializers.Serializer):
|
|||||||
"site_name": site.name,
|
"site_name": site.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
email_html = render_to_string(
|
email_html = render_to_string("accounts/email/password_reset.html", context)
|
||||||
"accounts/email/password_reset.html", context
|
|
||||||
)
|
|
||||||
|
|
||||||
# Narrow and validate email type for the static checker
|
# Narrow and validate email type for the static checker
|
||||||
email = getattr(self.user, "email", None)
|
email = getattr(self.user, "email", None)
|
||||||
@@ -206,15 +206,11 @@ class PasswordChangeSerializer(serializers.Serializer):
|
|||||||
Serializer for password change
|
Serializer for password change
|
||||||
"""
|
"""
|
||||||
|
|
||||||
old_password = serializers.CharField(
|
old_password = serializers.CharField(max_length=128, style={"input_type": "password"})
|
||||||
max_length=128, style={"input_type": "password"}
|
|
||||||
)
|
|
||||||
new_password = serializers.CharField(
|
new_password = serializers.CharField(
|
||||||
max_length=128, validators=[validate_password], style={"input_type": "password"}
|
max_length=128, validators=[validate_password], style={"input_type": "password"}
|
||||||
)
|
)
|
||||||
new_password_confirm = serializers.CharField(
|
new_password_confirm = serializers.CharField(max_length=128, style={"input_type": "password"})
|
||||||
max_length=128, style={"input_type": "password"}
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_old_password(self, value):
|
def validate_old_password(self, value):
|
||||||
"""Validate old password is correct"""
|
"""Validate old password is correct"""
|
||||||
@@ -229,9 +225,7 @@ class PasswordChangeSerializer(serializers.Serializer):
|
|||||||
new_password_confirm = attrs.get("new_password_confirm")
|
new_password_confirm = attrs.get("new_password_confirm")
|
||||||
|
|
||||||
if new_password != new_password_confirm:
|
if new_password != new_password_confirm:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError({"new_password_confirm": "New passwords do not match."})
|
||||||
{"new_password_confirm": "New passwords do not match."}
|
|
||||||
)
|
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,246 @@
|
|||||||
User management services for ThrillWiki.
|
User management services for ThrillWiki.
|
||||||
|
|
||||||
This module contains services for user account management including
|
This module contains services for user account management including
|
||||||
user deletion while preserving submissions.
|
user deletion while preserving submissions, password management,
|
||||||
|
and email change functionality.
|
||||||
|
|
||||||
|
Recent additions:
|
||||||
|
- AccountService: Handles password and email change operations
|
||||||
|
- UserDeletionService: Manages user deletion while preserving content
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
import logging
|
||||||
from django.db import transaction
|
import re
|
||||||
from django.utils import timezone
|
from typing import Any
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import update_session_auth_hash
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
|
from django.db import transaction
|
||||||
|
from django.http import HttpRequest
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
from django_forwardemail.services import EmailService
|
from django_forwardemail.services import EmailService
|
||||||
from .models import User, UserProfile, UserDeletionRequest
|
|
||||||
|
from .models import EmailVerification, User, UserDeletionRequest, UserProfile
|
||||||
|
from apps.core.utils import capture_and_log
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountService:
|
||||||
|
"""Service for account management operations including password and email changes."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def validate_password(password: str) -> bool:
|
||||||
|
"""
|
||||||
|
Validate password meets requirements.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
password: The password to validate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if password meets requirements, False otherwise
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
len(password) >= 8
|
||||||
|
and bool(re.search(r"[A-Z]", password))
|
||||||
|
and bool(re.search(r"[a-z]", password))
|
||||||
|
and bool(re.search(r"[0-9]", password))
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def change_password(
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
old_password: str,
|
||||||
|
new_password: str,
|
||||||
|
request: HttpRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Change user password with validation and notification.
|
||||||
|
|
||||||
|
Validates the old password, checks new password requirements,
|
||||||
|
updates the password, and sends a confirmation email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user whose password is being changed
|
||||||
|
old_password: Current password for verification
|
||||||
|
new_password: New password to set
|
||||||
|
request: HTTP request for session handling
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status, message, and optional redirect URL:
|
||||||
|
{
|
||||||
|
'success': bool,
|
||||||
|
'message': str,
|
||||||
|
'redirect_url': Optional[str]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# Verify old password
|
||||||
|
if not user.check_password(old_password):
|
||||||
|
logger.warning(f"Password change failed: incorrect current password for user {user.id}")
|
||||||
|
return {"success": False, "message": "Current password is incorrect", "redirect_url": None}
|
||||||
|
|
||||||
|
# Validate new password
|
||||||
|
if not AccountService.validate_password(new_password):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Password must be at least 8 characters and contain uppercase, lowercase, and numbers",
|
||||||
|
"redirect_url": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
user.set_password(new_password)
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
# Keep user logged in after password change
|
||||||
|
update_session_auth_hash(request, user)
|
||||||
|
|
||||||
|
# Send confirmation email
|
||||||
|
AccountService._send_password_change_confirmation(request, user)
|
||||||
|
|
||||||
|
logger.info(f"Password changed successfully for user {user.id}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Password changed successfully. Please check your email for confirmation.",
|
||||||
|
"redirect_url": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _send_password_change_confirmation(request: HttpRequest, user: User) -> None:
|
||||||
|
"""Send password change confirmation email."""
|
||||||
|
site = get_current_site(request)
|
||||||
|
context = {
|
||||||
|
"user": user,
|
||||||
|
"site_name": site.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
email_html = render_to_string("accounts/email/password_change_confirmation.html", context)
|
||||||
|
|
||||||
|
try:
|
||||||
|
EmailService.send_email(
|
||||||
|
to=user.email,
|
||||||
|
subject="Password Changed Successfully",
|
||||||
|
text="Your password has been changed successfully.",
|
||||||
|
site=site,
|
||||||
|
html=email_html,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_and_log(e, 'Send password change confirmation email', source='service', severity='medium')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def initiate_email_change(
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
new_email: str,
|
||||||
|
request: HttpRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Initiate email change with verification.
|
||||||
|
|
||||||
|
Creates a verification token and sends a verification email
|
||||||
|
to the new email address.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: The user changing their email
|
||||||
|
new_email: The new email address
|
||||||
|
request: HTTP request for site context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status and message:
|
||||||
|
{
|
||||||
|
'success': bool,
|
||||||
|
'message': str
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not new_email:
|
||||||
|
return {"success": False, "message": "New email is required"}
|
||||||
|
|
||||||
|
# Check if email is already in use
|
||||||
|
if User.objects.filter(email=new_email).exclude(id=user.id).exists():
|
||||||
|
return {"success": False, "message": "This email address is already in use"}
|
||||||
|
|
||||||
|
# Generate verification token
|
||||||
|
token = get_random_string(64)
|
||||||
|
|
||||||
|
# Create or update email verification record
|
||||||
|
EmailVerification.objects.update_or_create(user=user, defaults={"token": token})
|
||||||
|
|
||||||
|
# Store pending email
|
||||||
|
user.pending_email = new_email
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
# Send verification email
|
||||||
|
AccountService._send_email_verification(request, user, new_email, token)
|
||||||
|
|
||||||
|
logger.info(f"Email change initiated for user {user.id} to {new_email}")
|
||||||
|
|
||||||
|
return {"success": True, "message": "Verification email sent to your new email address"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _send_email_verification(request: HttpRequest, user: User, new_email: str, token: str) -> None:
|
||||||
|
"""Send email verification for email change."""
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
site = get_current_site(request)
|
||||||
|
verification_url = reverse("verify_email", kwargs={"token": token})
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"user": user,
|
||||||
|
"verification_url": verification_url,
|
||||||
|
"site_name": site.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
email_html = render_to_string("accounts/email/verify_email.html", context)
|
||||||
|
|
||||||
|
try:
|
||||||
|
EmailService.send_email(
|
||||||
|
to=new_email,
|
||||||
|
subject="Verify your new email address",
|
||||||
|
text="Click the link to verify your new email address",
|
||||||
|
site=site,
|
||||||
|
html=email_html,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_and_log(e, 'Send email verification', source='service', severity='medium')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_email_change(*, token: str) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Verify email change token and update user email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: The verification token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with success status and message
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
verification = EmailVerification.objects.select_related("user").get(token=token)
|
||||||
|
except EmailVerification.DoesNotExist:
|
||||||
|
return {"success": False, "message": "Invalid or expired verification token"}
|
||||||
|
|
||||||
|
user = verification.user
|
||||||
|
|
||||||
|
if not user.pending_email:
|
||||||
|
return {"success": False, "message": "No pending email change found"}
|
||||||
|
|
||||||
|
# Update email
|
||||||
|
old_email = user.email
|
||||||
|
user.email = user.pending_email
|
||||||
|
user.pending_email = None
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
# Delete verification record
|
||||||
|
verification.delete()
|
||||||
|
|
||||||
|
logger.info(f"Email changed for user {user.id} from {old_email} to {user.email}")
|
||||||
|
|
||||||
|
return {"success": True, "message": "Email address updated successfully"}
|
||||||
|
|
||||||
|
|
||||||
class UserDeletionService:
|
class UserDeletionService:
|
||||||
@@ -72,73 +302,51 @@ class UserDeletionService:
|
|||||||
|
|
||||||
# Count submissions before transfer
|
# Count submissions before transfer
|
||||||
submission_counts = {
|
submission_counts = {
|
||||||
"park_reviews": getattr(
|
"park_reviews": getattr(user, "park_reviews", user.__class__.objects.none()).count(),
|
||||||
user, "park_reviews", user.__class__.objects.none()
|
"ride_reviews": getattr(user, "ride_reviews", user.__class__.objects.none()).count(),
|
||||||
).count(),
|
"uploaded_park_photos": getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count(),
|
||||||
"ride_reviews": getattr(
|
"uploaded_ride_photos": getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count(),
|
||||||
user, "ride_reviews", user.__class__.objects.none()
|
"top_lists": getattr(user, "top_lists", user.__class__.objects.none()).count(),
|
||||||
).count(),
|
"edit_submissions": getattr(user, "edit_submissions", user.__class__.objects.none()).count(),
|
||||||
"uploaded_park_photos": getattr(
|
"photo_submissions": getattr(user, "photo_submissions", user.__class__.objects.none()).count(),
|
||||||
user, "uploaded_park_photos", user.__class__.objects.none()
|
"moderated_park_reviews": getattr(user, "moderated_park_reviews", user.__class__.objects.none()).count(),
|
||||||
).count(),
|
"moderated_ride_reviews": getattr(user, "moderated_ride_reviews", user.__class__.objects.none()).count(),
|
||||||
"uploaded_ride_photos": getattr(
|
"handled_submissions": getattr(user, "handled_submissions", user.__class__.objects.none()).count(),
|
||||||
user, "uploaded_ride_photos", user.__class__.objects.none()
|
"handled_photos": getattr(user, "handled_photos", user.__class__.objects.none()).count(),
|
||||||
).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
|
# Transfer all submissions to deleted user
|
||||||
# Reviews
|
# Reviews
|
||||||
if hasattr(user, "park_reviews"):
|
if hasattr(user, "park_reviews"):
|
||||||
getattr(user, "park_reviews").update(user=deleted_user)
|
user.park_reviews.update(user=deleted_user)
|
||||||
if hasattr(user, "ride_reviews"):
|
if hasattr(user, "ride_reviews"):
|
||||||
getattr(user, "ride_reviews").update(user=deleted_user)
|
user.ride_reviews.update(user=deleted_user)
|
||||||
|
|
||||||
# Photos
|
# Photos
|
||||||
if hasattr(user, "uploaded_park_photos"):
|
if hasattr(user, "uploaded_park_photos"):
|
||||||
getattr(user, "uploaded_park_photos").update(uploaded_by=deleted_user)
|
user.uploaded_park_photos.update(uploaded_by=deleted_user)
|
||||||
if hasattr(user, "uploaded_ride_photos"):
|
if hasattr(user, "uploaded_ride_photos"):
|
||||||
getattr(user, "uploaded_ride_photos").update(uploaded_by=deleted_user)
|
user.uploaded_ride_photos.update(uploaded_by=deleted_user)
|
||||||
|
|
||||||
# Top Lists
|
# Top Lists
|
||||||
if hasattr(user, "top_lists"):
|
if hasattr(user, "top_lists"):
|
||||||
getattr(user, "top_lists").update(user=deleted_user)
|
user.top_lists.update(user=deleted_user)
|
||||||
|
|
||||||
# Moderation submissions
|
# Moderation submissions
|
||||||
if hasattr(user, "edit_submissions"):
|
if hasattr(user, "edit_submissions"):
|
||||||
getattr(user, "edit_submissions").update(user=deleted_user)
|
user.edit_submissions.update(user=deleted_user)
|
||||||
if hasattr(user, "photo_submissions"):
|
if hasattr(user, "photo_submissions"):
|
||||||
getattr(user, "photo_submissions").update(user=deleted_user)
|
user.photo_submissions.update(user=deleted_user)
|
||||||
|
|
||||||
# Moderation actions - these can be set to NULL since they're not user content
|
# Moderation actions - these can be set to NULL since they're not user content
|
||||||
if hasattr(user, "moderated_park_reviews"):
|
if hasattr(user, "moderated_park_reviews"):
|
||||||
getattr(user, "moderated_park_reviews").update(moderated_by=None)
|
user.moderated_park_reviews.update(moderated_by=None)
|
||||||
if hasattr(user, "moderated_ride_reviews"):
|
if hasattr(user, "moderated_ride_reviews"):
|
||||||
getattr(user, "moderated_ride_reviews").update(moderated_by=None)
|
user.moderated_ride_reviews.update(moderated_by=None)
|
||||||
if hasattr(user, "handled_submissions"):
|
if hasattr(user, "handled_submissions"):
|
||||||
getattr(user, "handled_submissions").update(handled_by=None)
|
user.handled_submissions.update(handled_by=None)
|
||||||
if hasattr(user, "handled_photos"):
|
if hasattr(user, "handled_photos"):
|
||||||
getattr(user, "handled_photos").update(handled_by=None)
|
user.handled_photos.update(handled_by=None)
|
||||||
|
|
||||||
# Store user info for the summary
|
# Store user info for the summary
|
||||||
user_info = {
|
user_info = {
|
||||||
@@ -161,7 +369,7 @@ class UserDeletionService:
|
|||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def can_delete_user(cls, user: User) -> tuple[bool, Optional[str]]:
|
def can_delete_user(cls, user: User) -> tuple[bool, str | None]:
|
||||||
"""
|
"""
|
||||||
Check if a user can be safely deleted.
|
Check if a user can be safely deleted.
|
||||||
|
|
||||||
@@ -175,11 +383,17 @@ class UserDeletionService:
|
|||||||
return False, "Cannot delete the system deleted user placeholder"
|
return False, "Cannot delete the system deleted user placeholder"
|
||||||
|
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
return False, "Superuser accounts cannot be deleted for security reasons. Please contact system administrator or remove superuser privileges first."
|
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
|
# Check if user has critical admin role
|
||||||
if user.role == User.Roles.ADMIN and user.is_staff:
|
if user.role == User.Roles.ADMIN and user.is_staff:
|
||||||
return False, "Admin accounts with staff privileges cannot be deleted. Please remove admin privileges first or contact system administrator."
|
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
|
# Add any other business rules here
|
||||||
|
|
||||||
@@ -227,9 +441,7 @@ class UserDeletionService:
|
|||||||
site = Site.objects.get_current()
|
site = Site.objects.get_current()
|
||||||
except Site.DoesNotExist:
|
except Site.DoesNotExist:
|
||||||
# Fallback to default site
|
# Fallback to default site
|
||||||
site = Site.objects.get_or_create(
|
site = Site.objects.get_or_create(id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"})[0]
|
||||||
id=1, defaults={"domain": "localhost:8000", "name": "localhost:8000"}
|
|
||||||
)[0]
|
|
||||||
|
|
||||||
# Prepare email context
|
# Prepare email context
|
||||||
context = {
|
context = {
|
||||||
@@ -237,9 +449,7 @@ class UserDeletionService:
|
|||||||
"verification_code": deletion_request.verification_code,
|
"verification_code": deletion_request.verification_code,
|
||||||
"expires_at": deletion_request.expires_at,
|
"expires_at": deletion_request.expires_at,
|
||||||
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
|
"site_name": getattr(settings, "SITE_NAME", "ThrillWiki"),
|
||||||
"frontend_domain": getattr(
|
"frontend_domain": getattr(settings, "FRONTEND_DOMAIN", "http://localhost:3000"),
|
||||||
settings, "FRONTEND_DOMAIN", "http://localhost:3000"
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Render email content
|
# Render email content
|
||||||
@@ -299,11 +509,9 @@ The ThrillWiki Team
|
|||||||
ValueError: If verification fails
|
ValueError: If verification fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
deletion_request = UserDeletionRequest.objects.get(
|
deletion_request = UserDeletionRequest.objects.get(verification_code=verification_code)
|
||||||
verification_code=verification_code
|
|
||||||
)
|
|
||||||
except UserDeletionRequest.DoesNotExist:
|
except UserDeletionRequest.DoesNotExist:
|
||||||
raise ValueError("Invalid verification code")
|
raise ValueError("Invalid verification code") from None
|
||||||
|
|
||||||
# Check if request is still valid
|
# Check if request is still valid
|
||||||
if not deletion_request.is_valid():
|
if not deletion_request.is_valid():
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ including social provider management, user authentication, and profile services.
|
|||||||
from .social_provider_service import SocialProviderService
|
from .social_provider_service import SocialProviderService
|
||||||
from .user_deletion_service import UserDeletionService
|
from .user_deletion_service import UserDeletionService
|
||||||
|
|
||||||
__all__ = ['SocialProviderService', 'UserDeletionService']
|
__all__ = ["SocialProviderService", "UserDeletionService"]
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ This service handles the creation, delivery, and management of notifications
|
|||||||
for various events including submission approvals/rejections.
|
for various events including submission approvals/rejections.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.utils import timezone
|
|
||||||
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
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from apps.accounts.models import User, UserNotification, NotificationPreference
|
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_forwardemail.services import EmailService
|
||||||
|
|
||||||
|
from apps.accounts.models import NotificationPreference, User, UserNotification
|
||||||
|
from apps.core.utils import capture_and_log
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -29,10 +31,10 @@ class NotificationService:
|
|||||||
notification_type: str,
|
notification_type: str,
|
||||||
title: str,
|
title: str,
|
||||||
message: str,
|
message: str,
|
||||||
related_object: Optional[Any] = None,
|
related_object: Any | None = None,
|
||||||
priority: str = UserNotification.Priority.NORMAL,
|
priority: str = UserNotification.Priority.NORMAL,
|
||||||
extra_data: Optional[Dict[str, Any]] = None,
|
extra_data: dict[str, Any] | None = None,
|
||||||
expires_at: Optional[datetime] = None,
|
expires_at: datetime | None = None,
|
||||||
) -> UserNotification:
|
) -> UserNotification:
|
||||||
"""
|
"""
|
||||||
Create a new notification for a user.
|
Create a new notification for a user.
|
||||||
@@ -138,7 +140,9 @@ class NotificationService:
|
|||||||
UserNotification: The created notification
|
UserNotification: The created notification
|
||||||
"""
|
"""
|
||||||
title = f"Your {submission_type} needs attention"
|
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}"
|
message += f"\n\nReason: {rejection_reason}"
|
||||||
|
|
||||||
if additional_message:
|
if additional_message:
|
||||||
@@ -215,9 +219,7 @@ class NotificationService:
|
|||||||
preferences = NotificationPreference.objects.create(user=user)
|
preferences = NotificationPreference.objects.create(user=user)
|
||||||
|
|
||||||
# Send email notification if enabled
|
# Send email notification if enabled
|
||||||
if preferences.should_send_notification(
|
if preferences.should_send_notification(notification.notification_type, "email"):
|
||||||
notification.notification_type, "email"
|
|
||||||
):
|
|
||||||
NotificationService._send_email_notification(notification)
|
NotificationService._send_email_notification(notification)
|
||||||
|
|
||||||
# Toast notifications are always created (the notification object itself)
|
# Toast notifications are always created (the notification object itself)
|
||||||
@@ -260,22 +262,18 @@ class NotificationService:
|
|||||||
notification.email_sent_at = timezone.now()
|
notification.email_sent_at = timezone.now()
|
||||||
notification.save(update_fields=["email_sent", "email_sent_at"])
|
notification.save(update_fields=["email_sent", "email_sent_at"])
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"Email notification sent to {user.email} for notification {notification.id}")
|
||||||
f"Email notification sent to {user.email} for notification {notification.id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
capture_and_log(e, f'Send email notification {notification.id}', source='service')
|
||||||
f"Failed to send email notification {notification.id}: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_user_notifications(
|
def get_user_notifications(
|
||||||
user: User,
|
user: User,
|
||||||
unread_only: bool = False,
|
unread_only: bool = False,
|
||||||
notification_types: Optional[List[str]] = None,
|
notification_types: list[str] | None = None,
|
||||||
limit: Optional[int] = None,
|
limit: int | None = None,
|
||||||
) -> List[UserNotification]:
|
) -> list[UserNotification]:
|
||||||
"""
|
"""
|
||||||
Get notifications for a user.
|
Get notifications for a user.
|
||||||
|
|
||||||
@@ -297,9 +295,7 @@ class NotificationService:
|
|||||||
queryset = queryset.filter(notification_type__in=notification_types)
|
queryset = queryset.filter(notification_type__in=notification_types)
|
||||||
|
|
||||||
# Exclude expired notifications
|
# Exclude expired notifications
|
||||||
queryset = queryset.filter(
|
queryset = queryset.filter(models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now()))
|
||||||
models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=timezone.now())
|
|
||||||
)
|
|
||||||
|
|
||||||
if limit:
|
if limit:
|
||||||
queryset = queryset[:limit]
|
queryset = queryset[:limit]
|
||||||
@@ -307,9 +303,7 @@ class NotificationService:
|
|||||||
return list(queryset)
|
return list(queryset)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def mark_notifications_read(
|
def mark_notifications_read(user: User, notification_ids: list[int] | None = None) -> int:
|
||||||
user: User, notification_ids: Optional[List[int]] = None
|
|
||||||
) -> int:
|
|
||||||
"""
|
"""
|
||||||
Mark notifications as read for a user.
|
Mark notifications as read for a user.
|
||||||
|
|
||||||
@@ -340,9 +334,7 @@ class NotificationService:
|
|||||||
"""
|
"""
|
||||||
cutoff_date = timezone.now() - timedelta(days=days)
|
cutoff_date = timezone.now() - timedelta(days=days)
|
||||||
|
|
||||||
old_notifications = UserNotification.objects.filter(
|
old_notifications = UserNotification.objects.filter(is_read=True, read_at__lt=cutoff_date)
|
||||||
is_read=True, read_at__lt=cutoff_date
|
|
||||||
)
|
|
||||||
|
|
||||||
count = old_notifications.count()
|
count = old_notifications.count()
|
||||||
old_notifications.delete()
|
old_notifications.delete()
|
||||||
|
|||||||
@@ -6,19 +6,22 @@ social authentication providers while ensuring users never lock themselves
|
|||||||
out of their accounts.
|
out of their accounts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, List, Tuple, TYPE_CHECKING
|
import logging
|
||||||
from django.contrib.auth import get_user_model
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from allauth.socialaccount.models import SocialApp
|
from allauth.socialaccount.models import SocialApp
|
||||||
from allauth.socialaccount.providers import registry
|
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.contrib.sites.shortcuts import get_current_site
|
||||||
from django.http import HttpRequest
|
from django.http import HttpRequest
|
||||||
import logging
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from apps.accounts.models import User
|
from apps.accounts.models import User
|
||||||
else:
|
else:
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
from apps.core.utils import capture_and_log
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +29,7 @@ class SocialProviderService:
|
|||||||
"""Service for managing social provider connections."""
|
"""Service for managing social provider connections."""
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
Check if a user can safely disconnect a social provider.
|
||||||
|
|
||||||
@@ -39,23 +42,20 @@ class SocialProviderService:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Count remaining social accounts after disconnection
|
# Count remaining social accounts after disconnection
|
||||||
remaining_social_accounts = user.socialaccount_set.exclude(
|
remaining_social_accounts = user.socialaccount_set.exclude(provider=provider).count()
|
||||||
provider=provider
|
|
||||||
).count()
|
|
||||||
|
|
||||||
# Check if user has email/password auth
|
# Check if user has email/password auth
|
||||||
has_password_auth = (
|
has_password_auth = user.email and user.has_usable_password() and bool(user.password) # Not empty/unusable
|
||||||
user.email and
|
|
||||||
user.has_usable_password() and
|
|
||||||
bool(user.password) # Not empty/unusable
|
|
||||||
)
|
|
||||||
|
|
||||||
# Allow disconnection only if alternative auth exists
|
# Allow disconnection only if alternative auth exists
|
||||||
can_disconnect = remaining_social_accounts > 0 or has_password_auth
|
can_disconnect = remaining_social_accounts > 0 or has_password_auth
|
||||||
|
|
||||||
if not can_disconnect:
|
if not can_disconnect:
|
||||||
if remaining_social_accounts == 0 and not has_password_auth:
|
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:
|
elif not has_password_auth:
|
||||||
return False, "Please set up email/password authentication before disconnecting this provider."
|
return False, "Please set up email/password authentication before disconnecting this provider."
|
||||||
else:
|
else:
|
||||||
@@ -64,12 +64,11 @@ class SocialProviderService:
|
|||||||
return True, "Provider can be safely disconnected."
|
return True, "Provider can be safely disconnected."
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
capture_and_log(e, f'Check disconnect permission for user {user.id}, provider {provider}', source='service')
|
||||||
f"Error checking disconnect permission for user {user.id}, provider {provider}: {e}")
|
|
||||||
return False, "Unable to verify disconnection safety. Please try again."
|
return False, "Unable to verify disconnection safety. Please try again."
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
Get all social providers connected to a user's account.
|
||||||
|
|
||||||
@@ -83,18 +82,16 @@ class SocialProviderService:
|
|||||||
connected_providers = []
|
connected_providers = []
|
||||||
|
|
||||||
for social_account in user.socialaccount_set.all():
|
for social_account in user.socialaccount_set.all():
|
||||||
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
|
can_disconnect, reason = SocialProviderService.can_disconnect_provider(user, social_account.provider)
|
||||||
user, social_account.provider
|
|
||||||
)
|
|
||||||
|
|
||||||
provider_info = {
|
provider_info = {
|
||||||
'provider': social_account.provider,
|
"provider": social_account.provider,
|
||||||
'provider_name': social_account.get_provider().name,
|
"provider_name": social_account.get_provider().name,
|
||||||
'uid': social_account.uid,
|
"uid": social_account.uid,
|
||||||
'date_joined': social_account.date_joined,
|
"date_joined": social_account.date_joined,
|
||||||
'can_disconnect': can_disconnect,
|
"can_disconnect": can_disconnect,
|
||||||
'disconnect_reason': reason if not can_disconnect else None,
|
"disconnect_reason": reason if not can_disconnect else None,
|
||||||
'extra_data': social_account.extra_data
|
"extra_data": social_account.extra_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
connected_providers.append(provider_info)
|
connected_providers.append(provider_info)
|
||||||
@@ -102,11 +99,11 @@ class SocialProviderService:
|
|||||||
return connected_providers
|
return connected_providers
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting connected providers for user {user.id}: {e}")
|
capture_and_log(e, f'Get connected providers for user {user.id}', source='service')
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
Get all available social providers for the current site.
|
||||||
|
|
||||||
@@ -121,38 +118,35 @@ class SocialProviderService:
|
|||||||
available_providers = []
|
available_providers = []
|
||||||
|
|
||||||
# Get all social apps configured for this site
|
# 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:
|
for social_app in social_apps:
|
||||||
try:
|
try:
|
||||||
provider = registry.by_id(social_app.provider)
|
provider = registry.by_id(social_app.provider)
|
||||||
|
|
||||||
provider_info = {
|
provider_info = {
|
||||||
'id': social_app.provider,
|
"id": social_app.provider,
|
||||||
'name': provider.name,
|
"name": provider.name,
|
||||||
'auth_url': request.build_absolute_uri(
|
"auth_url": request.build_absolute_uri(f"/accounts/{social_app.provider}/login/"),
|
||||||
f'/accounts/{social_app.provider}/login/'
|
"connect_url": request.build_absolute_uri(
|
||||||
|
f"/api/v1/auth/social/connect/{social_app.provider}/"
|
||||||
),
|
),
|
||||||
'connect_url': request.build_absolute_uri(
|
|
||||||
f'/api/v1/auth/social/connect/{social_app.provider}/'
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
available_providers.append(provider_info)
|
available_providers.append(provider_info)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(f"Error processing provider {social_app.provider}: {e}")
|
||||||
f"Error processing provider {social_app.provider}: {e}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return available_providers
|
return available_providers
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting available providers: {e}")
|
capture_and_log(e, 'Get available providers', source='service')
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
Disconnect a social provider from a user's account.
|
||||||
|
|
||||||
@@ -165,8 +159,7 @@ class SocialProviderService:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# First check if disconnection is allowed
|
# First check if disconnection is allowed
|
||||||
can_disconnect, reason = SocialProviderService.can_disconnect_provider(
|
can_disconnect, reason = SocialProviderService.can_disconnect_provider(user, provider)
|
||||||
user, provider)
|
|
||||||
|
|
||||||
if not can_disconnect:
|
if not can_disconnect:
|
||||||
return False, reason
|
return False, reason
|
||||||
@@ -181,17 +174,16 @@ class SocialProviderService:
|
|||||||
deleted_count = social_accounts.count()
|
deleted_count = social_accounts.count()
|
||||||
social_accounts.delete()
|
social_accounts.delete()
|
||||||
|
|
||||||
logger.info(
|
logger.info(f"User {user.id} disconnected {deleted_count} {provider} account(s)")
|
||||||
f"User {user.id} disconnected {deleted_count} {provider} account(s)")
|
|
||||||
|
|
||||||
return True, f"{provider.title()} account disconnected successfully."
|
return True, f"{provider.title()} account disconnected successfully."
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error disconnecting {provider} for user {user.id}: {e}")
|
capture_and_log(e, f'Disconnect {provider} for user {user.id}', source='service')
|
||||||
return False, f"Failed to disconnect {provider} account. Please try again."
|
return False, f"Failed to disconnect {provider} account. Please try again."
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_auth_status(user: "User") -> Dict:
|
def get_auth_status(user: "User") -> dict:
|
||||||
"""
|
"""
|
||||||
Get comprehensive authentication status for a user.
|
Get comprehensive authentication status for a user.
|
||||||
|
|
||||||
@@ -204,34 +196,27 @@ class SocialProviderService:
|
|||||||
try:
|
try:
|
||||||
connected_providers = SocialProviderService.get_connected_providers(user)
|
connected_providers = SocialProviderService.get_connected_providers(user)
|
||||||
|
|
||||||
has_password_auth = (
|
has_password_auth = user.email and user.has_usable_password() and bool(user.password)
|
||||||
user.email and
|
|
||||||
user.has_usable_password() and
|
|
||||||
bool(user.password)
|
|
||||||
)
|
|
||||||
|
|
||||||
auth_methods_count = len(connected_providers) + \
|
auth_methods_count = len(connected_providers) + (1 if has_password_auth else 0)
|
||||||
(1 if has_password_auth else 0)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'user_id': user.id,
|
"user_id": user.id,
|
||||||
'username': user.username,
|
"username": user.username,
|
||||||
'email': user.email,
|
"email": user.email,
|
||||||
'has_password_auth': has_password_auth,
|
"has_password_auth": has_password_auth,
|
||||||
'connected_providers': connected_providers,
|
"connected_providers": connected_providers,
|
||||||
'total_auth_methods': auth_methods_count,
|
"total_auth_methods": auth_methods_count,
|
||||||
'can_disconnect_any': auth_methods_count > 1,
|
"can_disconnect_any": auth_methods_count > 1,
|
||||||
'requires_password_setup': not has_password_auth and len(connected_providers) == 1
|
"requires_password_setup": not has_password_auth and len(connected_providers) == 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting auth status for user {user.id}: {e}")
|
capture_and_log(e, f'Get auth status for user {user.id}', source='service')
|
||||||
return {
|
return {"error": "Unable to retrieve authentication status"}
|
||||||
'error': 'Unable to retrieve authentication status'
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
Validate that a social provider is configured and available.
|
||||||
|
|
||||||
@@ -253,5 +238,5 @@ class SocialProviderService:
|
|||||||
return True, f"Provider '{provider}' is valid and available."
|
return True, f"Provider '{provider}' is valid and available."
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error validating provider {provider}: {e}")
|
capture_and_log(e, f'Validate provider {provider}', source='service')
|
||||||
return False, "Unable to validate provider."
|
return False, "Unable to validate provider."
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ This service handles user account deletion while preserving submissions
|
|||||||
and maintaining data integrity across the platform.
|
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 logging
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from apps.accounts.models import User
|
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
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ class UserDeletionService:
|
|||||||
_deletion_requests = {}
|
_deletion_requests = {}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_delete_user(user: User) -> Tuple[bool, Optional[str]]:
|
def can_delete_user(user: User) -> tuple[bool, str | None]:
|
||||||
"""
|
"""
|
||||||
Check if a user can be safely deleted.
|
Check if a user can be safely deleted.
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ class UserDeletionService:
|
|||||||
return False, "Cannot delete staff accounts"
|
return False, "Cannot delete staff accounts"
|
||||||
|
|
||||||
# Check for system users (if you have any special system 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 False, "Cannot delete admin or moderator accounts"
|
||||||
|
|
||||||
return True, None
|
return True, None
|
||||||
@@ -85,8 +86,7 @@ class UserDeletionService:
|
|||||||
raise ValueError(reason)
|
raise ValueError(reason)
|
||||||
|
|
||||||
# Generate verification code
|
# Generate verification code
|
||||||
verification_code = ''.join(secrets.choice(
|
verification_code = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
|
||||||
string.ascii_uppercase + string.digits) for _ in range(8))
|
|
||||||
|
|
||||||
# Set expiration (24 hours from now)
|
# Set expiration (24 hours from now)
|
||||||
expires_at = timezone.now() + timezone.timedelta(hours=24)
|
expires_at = timezone.now() + timezone.timedelta(hours=24)
|
||||||
@@ -98,13 +98,12 @@ class UserDeletionService:
|
|||||||
UserDeletionService._deletion_requests[verification_code] = deletion_request
|
UserDeletionService._deletion_requests[verification_code] = deletion_request
|
||||||
|
|
||||||
# Send verification email
|
# Send verification email
|
||||||
UserDeletionService._send_deletion_verification_email(
|
UserDeletionService._send_deletion_verification_email(user, verification_code, expires_at)
|
||||||
user, verification_code, expires_at)
|
|
||||||
|
|
||||||
return deletion_request
|
return deletion_request
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
Verify deletion code and delete user account.
|
||||||
|
|
||||||
@@ -137,10 +136,10 @@ class UserDeletionService:
|
|||||||
del UserDeletionService._deletion_requests[verification_code]
|
del UserDeletionService._deletion_requests[verification_code]
|
||||||
|
|
||||||
# Add verification info to result
|
# Add verification info to result
|
||||||
result['deletion_request'] = {
|
result["deletion_request"] = {
|
||||||
'verification_code': verification_code,
|
"verification_code": verification_code,
|
||||||
'created_at': deletion_request.created_at,
|
"created_at": deletion_request.created_at,
|
||||||
'verified_at': timezone.now(),
|
"verified_at": timezone.now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -169,7 +168,7 @@ class UserDeletionService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def delete_user_preserve_submissions(user: User) -> Dict[str, Any]:
|
def delete_user_preserve_submissions(user: User) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Delete a user account while preserving all their submissions.
|
Delete a user account while preserving all their submissions.
|
||||||
|
|
||||||
@@ -181,13 +180,13 @@ class UserDeletionService:
|
|||||||
"""
|
"""
|
||||||
# Get or create the "deleted_user" placeholder
|
# Get or create the "deleted_user" placeholder
|
||||||
deleted_user_placeholder, created = User.objects.get_or_create(
|
deleted_user_placeholder, created = User.objects.get_or_create(
|
||||||
username='deleted_user',
|
username="deleted_user",
|
||||||
defaults={
|
defaults={
|
||||||
'email': 'deleted@thrillwiki.com',
|
"email": "deleted@thrillwiki.com",
|
||||||
'first_name': 'Deleted',
|
"first_name": "Deleted",
|
||||||
'last_name': 'User',
|
"last_name": "User",
|
||||||
'is_active': False,
|
"is_active": False,
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Count submissions before transfer
|
# Count submissions before transfer
|
||||||
@@ -198,45 +197,38 @@ class UserDeletionService:
|
|||||||
|
|
||||||
# Store user info before deletion
|
# Store user info before deletion
|
||||||
deleted_user_info = {
|
deleted_user_info = {
|
||||||
'username': user.username,
|
"username": user.username,
|
||||||
'user_id': getattr(user, 'user_id', user.id),
|
"user_id": getattr(user, "user_id", user.id),
|
||||||
'email': user.email,
|
"email": user.email,
|
||||||
'date_joined': user.date_joined,
|
"date_joined": user.date_joined,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Delete the user account
|
# Delete the user account
|
||||||
user.delete()
|
user.delete()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'deleted_user': deleted_user_info,
|
"deleted_user": deleted_user_info,
|
||||||
'preserved_submissions': submission_counts,
|
"preserved_submissions": submission_counts,
|
||||||
'transferred_to': {
|
"transferred_to": {
|
||||||
'username': deleted_user_placeholder.username,
|
"username": deleted_user_placeholder.username,
|
||||||
'user_id': getattr(deleted_user_placeholder, 'user_id', deleted_user_placeholder.id),
|
"user_id": getattr(deleted_user_placeholder, "user_id", deleted_user_placeholder.id),
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@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."""
|
"""Count all submissions for a user."""
|
||||||
counts = {}
|
counts = {}
|
||||||
|
|
||||||
# Count different types of submissions
|
# Count different types of submissions
|
||||||
# Note: These are placeholder counts - adjust based on your actual models
|
# Note: These are placeholder counts - adjust based on your actual models
|
||||||
counts['park_reviews'] = getattr(
|
counts["park_reviews"] = getattr(user, "park_reviews", user.__class__.objects.none()).count()
|
||||||
user, 'park_reviews', user.__class__.objects.none()).count()
|
counts["ride_reviews"] = getattr(user, "ride_reviews", user.__class__.objects.none()).count()
|
||||||
counts['ride_reviews'] = getattr(
|
counts["uploaded_park_photos"] = getattr(user, "uploaded_park_photos", user.__class__.objects.none()).count()
|
||||||
user, 'ride_reviews', user.__class__.objects.none()).count()
|
counts["uploaded_ride_photos"] = getattr(user, "uploaded_ride_photos", user.__class__.objects.none()).count()
|
||||||
counts['uploaded_park_photos'] = getattr(
|
counts["top_lists"] = getattr(user, "top_lists", user.__class__.objects.none()).count()
|
||||||
user, 'uploaded_park_photos', user.__class__.objects.none()).count()
|
counts["edit_submissions"] = getattr(user, "edit_submissions", user.__class__.objects.none()).count()
|
||||||
counts['uploaded_ride_photos'] = getattr(
|
counts["photo_submissions"] = getattr(user, "photo_submissions", user.__class__.objects.none()).count()
|
||||||
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
|
return counts
|
||||||
|
|
||||||
@@ -248,30 +240,30 @@ class UserDeletionService:
|
|||||||
# Note: Adjust these based on your actual model relationships
|
# Note: Adjust these based on your actual model relationships
|
||||||
|
|
||||||
# Park reviews
|
# Park reviews
|
||||||
if hasattr(user, 'park_reviews'):
|
if hasattr(user, "park_reviews"):
|
||||||
user.park_reviews.all().update(user=placeholder_user)
|
user.park_reviews.all().update(user=placeholder_user)
|
||||||
|
|
||||||
# Ride reviews
|
# Ride reviews
|
||||||
if hasattr(user, 'ride_reviews'):
|
if hasattr(user, "ride_reviews"):
|
||||||
user.ride_reviews.all().update(user=placeholder_user)
|
user.ride_reviews.all().update(user=placeholder_user)
|
||||||
|
|
||||||
# Uploaded photos
|
# Uploaded photos
|
||||||
if hasattr(user, 'uploaded_park_photos'):
|
if hasattr(user, "uploaded_park_photos"):
|
||||||
user.uploaded_park_photos.all().update(user=placeholder_user)
|
user.uploaded_park_photos.all().update(user=placeholder_user)
|
||||||
|
|
||||||
if hasattr(user, 'uploaded_ride_photos'):
|
if hasattr(user, "uploaded_ride_photos"):
|
||||||
user.uploaded_ride_photos.all().update(user=placeholder_user)
|
user.uploaded_ride_photos.all().update(user=placeholder_user)
|
||||||
|
|
||||||
# Top lists
|
# Top lists
|
||||||
if hasattr(user, 'top_lists'):
|
if hasattr(user, "top_lists"):
|
||||||
user.top_lists.all().update(user=placeholder_user)
|
user.top_lists.all().update(user=placeholder_user)
|
||||||
|
|
||||||
# Edit submissions
|
# Edit submissions
|
||||||
if hasattr(user, 'edit_submissions'):
|
if hasattr(user, "edit_submissions"):
|
||||||
user.edit_submissions.all().update(user=placeholder_user)
|
user.edit_submissions.all().update(user=placeholder_user)
|
||||||
|
|
||||||
# Photo submissions
|
# Photo submissions
|
||||||
if hasattr(user, 'photo_submissions'):
|
if hasattr(user, "photo_submissions"):
|
||||||
user.photo_submissions.all().update(user=placeholder_user)
|
user.photo_submissions.all().update(user=placeholder_user)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -279,18 +271,16 @@ class UserDeletionService:
|
|||||||
"""Send verification email for account deletion."""
|
"""Send verification email for account deletion."""
|
||||||
try:
|
try:
|
||||||
context = {
|
context = {
|
||||||
'user': user,
|
"user": user,
|
||||||
'verification_code': verification_code,
|
"verification_code": verification_code,
|
||||||
'expires_at': expires_at,
|
"expires_at": expires_at,
|
||||||
'site_name': 'ThrillWiki',
|
"site_name": "ThrillWiki",
|
||||||
'site_url': getattr(settings, 'SITE_URL', 'https://thrillwiki.com'),
|
"site_url": getattr(settings, "SITE_URL", "https://thrillwiki.com"),
|
||||||
}
|
}
|
||||||
|
|
||||||
subject = 'ThrillWiki: Confirm Account Deletion'
|
subject = "ThrillWiki: Confirm Account Deletion"
|
||||||
html_message = render_to_string(
|
html_message = render_to_string("emails/account_deletion_verification.html", context)
|
||||||
'emails/account_deletion_verification.html', context)
|
plain_message = render_to_string("emails/account_deletion_verification.txt", context)
|
||||||
plain_message = render_to_string(
|
|
||||||
'emails/account_deletion_verification.txt', context)
|
|
||||||
|
|
||||||
send_mail(
|
send_mail(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
@@ -304,6 +294,5 @@ class UserDeletionService:
|
|||||||
logger.info(f"Deletion verification email sent to {user.email}")
|
logger.info(f"Deletion verification email sent to {user.email}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
capture_and_log(e, f'Send deletion verification email to {user.email}', source='service')
|
||||||
f"Failed to send deletion verification email to {user.email}: {str(e)}")
|
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
from django.db.models.signals import post_save, pre_save
|
import requests
|
||||||
from django.dispatch import receiver
|
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.db import transaction
|
from django.contrib.auth.signals import user_logged_in
|
||||||
from django.core.files import File
|
from django.core.files import File
|
||||||
from django.core.files.temp import NamedTemporaryFile
|
from django.core.files.temp import NamedTemporaryFile
|
||||||
import requests
|
from django.db import transaction
|
||||||
|
from django.db.models.signals import post_save, pre_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from .login_history import LoginHistory
|
||||||
from .models import User, UserProfile
|
from .models import User, UserProfile
|
||||||
|
|
||||||
|
|
||||||
@@ -105,7 +108,7 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
|
|||||||
User.Roles.MODERATOR,
|
User.Roles.MODERATOR,
|
||||||
]:
|
]:
|
||||||
instance.is_staff = True
|
instance.is_staff = True
|
||||||
elif old_instance.role in [
|
elif old_instance.role in [ # noqa: SIM102
|
||||||
User.Roles.ADMIN,
|
User.Roles.ADMIN,
|
||||||
User.Roles.MODERATOR,
|
User.Roles.MODERATOR,
|
||||||
]:
|
]:
|
||||||
@@ -116,9 +119,7 @@ def sync_user_role_with_groups(sender, instance, **kwargs):
|
|||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(
|
print(f"Error syncing role with groups for user {instance.username}: {str(e)}")
|
||||||
f"Error syncing role with groups for user {instance.username}: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_default_groups():
|
def create_default_groups():
|
||||||
@@ -185,3 +186,41 @@ def create_default_groups():
|
|||||||
print(f"Permission not found: {codename}")
|
print(f"Permission not found: {codename}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error creating default groups: {str(e)}")
|
print(f"Error creating default groups: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(user_logged_in)
|
||||||
|
def log_successful_login(sender, user, request, **kwargs):
|
||||||
|
"""
|
||||||
|
Log successful login events to LoginHistory.
|
||||||
|
|
||||||
|
This signal handler captures the IP address, user agent, and login method
|
||||||
|
for auditing and security purposes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get IP address
|
||||||
|
x_forwarded_for = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
ip_address = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else request.META.get("REMOTE_ADDR")
|
||||||
|
|
||||||
|
# Get user agent
|
||||||
|
user_agent = request.META.get("HTTP_USER_AGENT", "")[:500]
|
||||||
|
|
||||||
|
# Determine login method from session or request
|
||||||
|
login_method = "PASSWORD"
|
||||||
|
if hasattr(request, "session"):
|
||||||
|
sociallogin = getattr(request, "_sociallogin", None)
|
||||||
|
if sociallogin:
|
||||||
|
provider = sociallogin.account.provider.upper()
|
||||||
|
if provider in ["GOOGLE", "DISCORD"]:
|
||||||
|
login_method = provider
|
||||||
|
|
||||||
|
# Create login history entry
|
||||||
|
LoginHistory.objects.create(
|
||||||
|
user=user,
|
||||||
|
ip_address=ip_address,
|
||||||
|
user_agent=user_agent,
|
||||||
|
login_method=login_method,
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Don't let login history failure prevent login
|
||||||
|
print(f"Error logging login history for user {user.username}: {str(e)}")
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
from django.test import TestCase
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.contrib.auth.models import Group, Permission
|
from django.contrib.auth.models import Group, Permission
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from unittest.mock import patch, MagicMock
|
from django.test import TestCase
|
||||||
|
|
||||||
from .models import User, UserProfile
|
from .models import User, UserProfile
|
||||||
from .signals import create_default_groups
|
from .signals import create_default_groups
|
||||||
|
|
||||||
@@ -111,16 +113,10 @@ class SignalsTestCase(TestCase):
|
|||||||
|
|
||||||
moderator_group = Group.objects.get(name=User.Roles.MODERATOR)
|
moderator_group = Group.objects.get(name=User.Roles.MODERATOR)
|
||||||
self.assertIsNotNone(moderator_group)
|
self.assertIsNotNone(moderator_group)
|
||||||
self.assertTrue(
|
self.assertTrue(moderator_group.permissions.filter(codename="change_review").exists())
|
||||||
moderator_group.permissions.filter(codename="change_review").exists()
|
self.assertFalse(moderator_group.permissions.filter(codename="change_user").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=User.Roles.ADMIN)
|
||||||
self.assertIsNotNone(admin_group)
|
self.assertIsNotNone(admin_group)
|
||||||
self.assertTrue(
|
self.assertTrue(admin_group.permissions.filter(codename="change_review").exists())
|
||||||
admin_group.permissions.filter(codename="change_review").exists()
|
|
||||||
)
|
|
||||||
self.assertTrue(admin_group.permissions.filter(codename="change_user").exists())
|
self.assertTrue(admin_group.permissions.filter(codename="change_user").exists())
|
||||||
|
|||||||
0
backend/apps/accounts/tests/__init__.py
Normal file
0
backend/apps/accounts/tests/__init__.py
Normal file
152
backend/apps/accounts/tests/test_admin.py
Normal file
152
backend/apps/accounts/tests/test_admin.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Tests for accounts admin interfaces.
|
||||||
|
|
||||||
|
These tests verify the functionality of user, profile, email verification,
|
||||||
|
password reset, and top list admin classes including query optimization
|
||||||
|
and custom actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.admin.sites import AdminSite
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import RequestFactory, TestCase
|
||||||
|
|
||||||
|
from apps.accounts.admin import (
|
||||||
|
CustomUserAdmin,
|
||||||
|
EmailVerificationAdmin,
|
||||||
|
PasswordResetAdmin,
|
||||||
|
UserProfileAdmin,
|
||||||
|
)
|
||||||
|
from apps.accounts.models import (
|
||||||
|
EmailVerification,
|
||||||
|
PasswordReset,
|
||||||
|
User,
|
||||||
|
UserProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCustomUserAdmin(TestCase):
|
||||||
|
"""Tests for CustomUserAdmin class."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.site = AdminSite()
|
||||||
|
self.admin = CustomUserAdmin(model=User, admin_site=self.site)
|
||||||
|
|
||||||
|
def test_list_display_fields(self):
|
||||||
|
"""Verify all required fields are in list_display."""
|
||||||
|
required_fields = [
|
||||||
|
"username",
|
||||||
|
"email",
|
||||||
|
"get_avatar",
|
||||||
|
"get_status_badge",
|
||||||
|
"role",
|
||||||
|
"date_joined",
|
||||||
|
]
|
||||||
|
for field in required_fields:
|
||||||
|
assert field in self.admin.list_display
|
||||||
|
|
||||||
|
def test_list_select_related(self):
|
||||||
|
"""Verify select_related is configured for profile."""
|
||||||
|
assert "profile" in self.admin.list_select_related
|
||||||
|
|
||||||
|
def test_list_prefetch_related(self):
|
||||||
|
"""Verify prefetch_related is configured for groups."""
|
||||||
|
assert "groups" in self.admin.list_prefetch_related
|
||||||
|
|
||||||
|
def test_user_actions_registered(self):
|
||||||
|
"""Verify user management actions are registered."""
|
||||||
|
assert "activate_users" in self.admin.actions
|
||||||
|
assert "deactivate_users" in self.admin.actions
|
||||||
|
assert "ban_users" in self.admin.actions
|
||||||
|
assert "unban_users" in self.admin.actions
|
||||||
|
|
||||||
|
def test_export_fields_configured(self):
|
||||||
|
"""Verify export fields are configured."""
|
||||||
|
assert hasattr(self.admin, "export_fields")
|
||||||
|
assert "username" in self.admin.export_fields
|
||||||
|
assert "email" in self.admin.export_fields
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserProfileAdmin(TestCase):
|
||||||
|
"""Tests for UserProfileAdmin class."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.site = AdminSite()
|
||||||
|
self.admin = UserProfileAdmin(model=UserProfile, admin_site=self.site)
|
||||||
|
|
||||||
|
def test_list_select_related(self):
|
||||||
|
"""Verify select_related for user."""
|
||||||
|
assert "user" in self.admin.list_select_related
|
||||||
|
|
||||||
|
def test_recalculate_action(self):
|
||||||
|
"""Verify recalculate credits action exists."""
|
||||||
|
request = self.factory.get("/admin/")
|
||||||
|
request.user = UserModel(is_superuser=True)
|
||||||
|
|
||||||
|
actions = self.admin.get_actions(request)
|
||||||
|
assert "recalculate_credits" in actions
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmailVerificationAdmin(TestCase):
|
||||||
|
"""Tests for EmailVerificationAdmin class."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.site = AdminSite()
|
||||||
|
self.admin = EmailVerificationAdmin(model=EmailVerification, admin_site=self.site)
|
||||||
|
|
||||||
|
def test_list_select_related(self):
|
||||||
|
"""Verify select_related for user."""
|
||||||
|
assert "user" in self.admin.list_select_related
|
||||||
|
|
||||||
|
def test_readonly_fields(self):
|
||||||
|
"""Verify token fields are readonly."""
|
||||||
|
assert "token" in self.admin.readonly_fields
|
||||||
|
assert "created_at" in self.admin.readonly_fields
|
||||||
|
|
||||||
|
def test_verification_actions(self):
|
||||||
|
"""Verify verification actions exist."""
|
||||||
|
request = self.factory.get("/admin/")
|
||||||
|
request.user = UserModel(is_superuser=True)
|
||||||
|
|
||||||
|
actions = self.admin.get_actions(request)
|
||||||
|
assert "resend_verification" in actions
|
||||||
|
assert "delete_expired" in actions
|
||||||
|
|
||||||
|
|
||||||
|
class TestPasswordResetAdmin(TestCase):
|
||||||
|
"""Tests for PasswordResetAdmin class."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.site = AdminSite()
|
||||||
|
self.admin = PasswordResetAdmin(model=PasswordReset, admin_site=self.site)
|
||||||
|
|
||||||
|
def test_readonly_permissions(self):
|
||||||
|
"""Verify read-only permissions are set."""
|
||||||
|
request = self.factory.get("/admin/")
|
||||||
|
request.user = UserModel(is_superuser=False)
|
||||||
|
|
||||||
|
assert self.admin.has_add_permission(request) is False
|
||||||
|
assert self.admin.has_change_permission(request) is False
|
||||||
|
|
||||||
|
def test_list_select_related(self):
|
||||||
|
"""Verify select_related for user."""
|
||||||
|
assert "user" in self.admin.list_select_related
|
||||||
|
|
||||||
|
def test_cleanup_action_superuser_only(self):
|
||||||
|
"""Verify cleanup action is superuser only."""
|
||||||
|
request = self.factory.get("/admin/")
|
||||||
|
|
||||||
|
# Non-superuser shouldn't see cleanup action
|
||||||
|
request.user = UserModel(is_superuser=False)
|
||||||
|
actions = self.admin.get_actions(request)
|
||||||
|
assert "cleanup_old_tokens" not in actions
|
||||||
|
|
||||||
|
# Superuser should see cleanup action
|
||||||
|
request.user = UserModel(is_superuser=True)
|
||||||
|
actions = self.admin.get_actions(request)
|
||||||
|
assert "cleanup_old_tokens" in actions
|
||||||
100
backend/apps/accounts/tests/test_model_constraints.py
Normal file
100
backend/apps/accounts/tests/test_model_constraints.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""
|
||||||
|
Tests for model constraints and validators in the accounts app.
|
||||||
|
|
||||||
|
These tests verify that:
|
||||||
|
1. CheckConstraints raise appropriate errors
|
||||||
|
2. Validators work correctly
|
||||||
|
3. Business rules are enforced at the model level
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import IntegrityError
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.accounts.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserConstraintTests(TestCase):
|
||||||
|
"""Tests for User model constraints."""
|
||||||
|
|
||||||
|
def test_banned_user_without_ban_date_raises_error(self):
|
||||||
|
"""Verify banned users must have a ban_date set."""
|
||||||
|
user = User(
|
||||||
|
username="testuser",
|
||||||
|
email="test@example.com",
|
||||||
|
is_banned=True,
|
||||||
|
ban_date=None, # This should violate the constraint
|
||||||
|
)
|
||||||
|
|
||||||
|
# The constraint should be enforced at database level
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
def test_banned_user_with_ban_date_saves_successfully(self):
|
||||||
|
"""Verify banned users with ban_date save successfully."""
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="testuser2",
|
||||||
|
email="test2@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
is_banned=True,
|
||||||
|
ban_date=timezone.now(),
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(user.pk)
|
||||||
|
self.assertTrue(user.is_banned)
|
||||||
|
self.assertIsNotNone(user.ban_date)
|
||||||
|
|
||||||
|
def test_non_banned_user_without_ban_date_saves_successfully(self):
|
||||||
|
"""Verify non-banned users can be saved without ban_date."""
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="testuser3",
|
||||||
|
email="test3@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
is_banned=False,
|
||||||
|
ban_date=None,
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(user.pk)
|
||||||
|
self.assertFalse(user.is_banned)
|
||||||
|
|
||||||
|
def test_user_id_is_auto_generated(self):
|
||||||
|
"""Verify user_id is automatically generated on save."""
|
||||||
|
user = User.objects.create_user(
|
||||||
|
username="testuser4",
|
||||||
|
email="test4@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(user.user_id)
|
||||||
|
self.assertTrue(len(user.user_id) >= 4)
|
||||||
|
|
||||||
|
def test_user_id_is_unique(self):
|
||||||
|
"""Verify user_id is unique across users."""
|
||||||
|
user1 = User.objects.create_user(
|
||||||
|
username="testuser5",
|
||||||
|
email="test5@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
user2 = User.objects.create_user(
|
||||||
|
username="testuser6",
|
||||||
|
email="test6@example.com",
|
||||||
|
password="testpass123",
|
||||||
|
)
|
||||||
|
self.assertNotEqual(user1.user_id, user2.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
class UserIndexTests(TestCase):
|
||||||
|
"""Tests for User model indexes."""
|
||||||
|
|
||||||
|
def test_is_banned_field_is_indexed(self):
|
||||||
|
"""Verify is_banned field has db_index=True."""
|
||||||
|
field = User._meta.get_field("is_banned")
|
||||||
|
self.assertTrue(field.db_index)
|
||||||
|
|
||||||
|
def test_role_field_is_indexed(self):
|
||||||
|
"""Verify role field has db_index=True."""
|
||||||
|
field = User._meta.get_field("role")
|
||||||
|
self.assertTrue(field.db_index)
|
||||||
|
|
||||||
|
def test_composite_index_exists(self):
|
||||||
|
"""Verify composite index on (is_banned, role) exists."""
|
||||||
|
indexes = User._meta.indexes
|
||||||
|
index_names = [idx.name for idx in indexes]
|
||||||
|
self.assertIn("accounts_user_banned_role_idx", index_names)
|
||||||
@@ -2,10 +2,11 @@
|
|||||||
Tests for user deletion while preserving submissions.
|
Tests for user deletion while preserving submissions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.test import TestCase
|
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from apps.accounts.services import UserDeletionService
|
from django.test import TestCase
|
||||||
|
|
||||||
from apps.accounts.models import User, UserProfile
|
from apps.accounts.models import User, UserProfile
|
||||||
|
from apps.accounts.services import UserDeletionService
|
||||||
|
|
||||||
|
|
||||||
class UserDeletionServiceTest(TestCase):
|
class UserDeletionServiceTest(TestCase):
|
||||||
@@ -14,9 +15,7 @@ class UserDeletionServiceTest(TestCase):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up test data."""
|
"""Set up test data."""
|
||||||
# Create test users
|
# Create test users
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(username="testuser", email="test@example.com", password="testpass123")
|
||||||
username="testuser", email="test@example.com", password="testpass123"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.admin_user = User.objects.create_user(
|
self.admin_user = User.objects.create_user(
|
||||||
username="admin",
|
username="admin",
|
||||||
@@ -26,13 +25,9 @@ class UserDeletionServiceTest(TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create user profiles
|
# Create user profiles
|
||||||
UserProfile.objects.create(
|
UserProfile.objects.create(user=self.user, display_name="Test User", bio="Test bio")
|
||||||
user=self.user, display_name="Test User", bio="Test bio"
|
|
||||||
)
|
|
||||||
|
|
||||||
UserProfile.objects.create(
|
UserProfile.objects.create(user=self.admin_user, display_name="Admin User", bio="Admin bio")
|
||||||
user=self.admin_user, display_name="Admin User", bio="Admin bio"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_get_or_create_deleted_user(self):
|
def test_get_or_create_deleted_user(self):
|
||||||
"""Test that deleted user placeholder is created correctly."""
|
"""Test that deleted user placeholder is created correctly."""
|
||||||
@@ -107,9 +102,7 @@ class UserDeletionServiceTest(TestCase):
|
|||||||
with self.assertRaises(ValueError) as context:
|
with self.assertRaises(ValueError) as context:
|
||||||
UserDeletionService.delete_user_preserve_submissions(deleted_user)
|
UserDeletionService.delete_user_preserve_submissions(deleted_user)
|
||||||
|
|
||||||
self.assertIn(
|
self.assertIn("Cannot delete the system deleted user placeholder", str(context.exception))
|
||||||
"Cannot delete the system deleted user placeholder", str(context.exception)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_delete_user_with_submissions_transfers_correctly(self):
|
def test_delete_user_with_submissions_transfers_correctly(self):
|
||||||
"""Test that user submissions are transferred to deleted user placeholder."""
|
"""Test that user submissions are transferred to deleted user placeholder."""
|
||||||
@@ -140,13 +133,12 @@ class UserDeletionServiceTest(TestCase):
|
|||||||
original_user_count = User.objects.count()
|
original_user_count = User.objects.count()
|
||||||
|
|
||||||
# Mock a failure during the deletion process
|
# Mock a failure during the deletion process
|
||||||
with self.assertRaises(Exception):
|
with self.assertRaises(Exception), transaction.atomic(): # noqa: B017
|
||||||
with transaction.atomic():
|
# Start the deletion process
|
||||||
# Start the deletion process
|
UserDeletionService.get_or_create_deleted_user()
|
||||||
UserDeletionService.get_or_create_deleted_user()
|
|
||||||
|
|
||||||
# Simulate an error
|
# Simulate an error
|
||||||
raise Exception("Simulated error during deletion")
|
raise Exception("Simulated error during deletion")
|
||||||
|
|
||||||
# Verify user count hasn't changed
|
# Verify user count hasn't changed
|
||||||
self.assertEqual(User.objects.count(), original_user_count)
|
self.assertEqual(User.objects.count(), original_user_count)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.urls import path
|
|
||||||
from django.contrib.auth import views as auth_views
|
|
||||||
from allauth.account.views import LogoutView
|
from allauth.account.views import LogoutView
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
app_name = "accounts"
|
app_name = "accounts"
|
||||||
|
|||||||
@@ -1,38 +1,44 @@
|
|||||||
from django.views.generic import DetailView, TemplateView
|
import logging
|
||||||
from django.contrib.auth import get_user_model
|
import re
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
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.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
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.models import Site
|
||||||
from django.contrib.sites.requests import RequestSite
|
from django.contrib.sites.requests import RequestSite
|
||||||
from django.db.models import QuerySet
|
from django.contrib.sites.shortcuts import get_current_site
|
||||||
from django.http import HttpResponseRedirect, HttpResponse, HttpRequest
|
from django.core.exceptions import ValidationError
|
||||||
from django.urls import reverse
|
|
||||||
from django.contrib.auth import login
|
|
||||||
from django.core.files.uploadedfile import UploadedFile
|
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.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 apps.accounts.models import (
|
from apps.accounts.models import (
|
||||||
User,
|
|
||||||
PasswordReset,
|
|
||||||
TopList,
|
|
||||||
EmailVerification,
|
EmailVerification,
|
||||||
|
PasswordReset,
|
||||||
|
User,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
)
|
)
|
||||||
from django_forwardemail.services import EmailService
|
from apps.core.logging import log_security_event
|
||||||
|
from apps.lists.models import UserList
|
||||||
from apps.parks.models import ParkReview
|
from apps.parks.models import ParkReview
|
||||||
from apps.rides.models import RideReview
|
from apps.rides.models import RideReview
|
||||||
from allauth.account.views import LoginView, SignupView
|
|
||||||
from .mixins import TurnstileMixin
|
from .mixins import TurnstileMixin
|
||||||
from typing import Dict, Any, Optional, Union, cast
|
|
||||||
from django_htmx.http import HttpResponseClientRefresh
|
logger = logging.getLogger(__name__)
|
||||||
from contextlib import suppress
|
|
||||||
import re
|
|
||||||
|
|
||||||
UserModel = get_user_model()
|
UserModel = get_user_model()
|
||||||
|
|
||||||
@@ -46,13 +52,26 @@ class CustomLoginView(TurnstileMixin, LoginView):
|
|||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
return (
|
user = self.request.user
|
||||||
HttpResponseClientRefresh()
|
log_security_event(
|
||||||
if getattr(self.request, "htmx", False)
|
logger,
|
||||||
else response
|
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
|
||||||
|
|
||||||
def form_invalid(self, form):
|
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):
|
if getattr(self.request, "htmx", False):
|
||||||
return render(
|
return render(
|
||||||
self.request,
|
self.request,
|
||||||
@@ -80,11 +99,20 @@ class CustomSignupView(TurnstileMixin, SignupView):
|
|||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
response = super().form_valid(form)
|
response = super().form_valid(form)
|
||||||
return (
|
user = self.user
|
||||||
HttpResponseClientRefresh()
|
log_security_event(
|
||||||
if getattr(self.request, "htmx", False)
|
logger,
|
||||||
else response
|
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
|
||||||
|
|
||||||
def form_invalid(self, form):
|
def form_invalid(self, form):
|
||||||
if getattr(self.request, "htmx", False):
|
if getattr(self.request, "htmx", False):
|
||||||
@@ -149,7 +177,7 @@ class ProfileView(DetailView):
|
|||||||
def get_queryset(self) -> QuerySet[User]:
|
def get_queryset(self) -> QuerySet[User]:
|
||||||
return User.objects.select_related("profile")
|
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)
|
context = super().get_context_data(**kwargs)
|
||||||
user = cast(User, self.get_object())
|
user = cast(User, self.get_object())
|
||||||
|
|
||||||
@@ -173,9 +201,9 @@ class ProfileView(DetailView):
|
|||||||
.order_by("-created_at")[:5]
|
.order_by("-created_at")[:5]
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_user_top_lists(self, user: User) -> QuerySet[TopList]:
|
def _get_user_top_lists(self, user: User) -> QuerySet[UserList]:
|
||||||
return (
|
return (
|
||||||
TopList.objects.filter(user=user)
|
UserList.objects.filter(user=user)
|
||||||
.select_related("user", "user__profile")
|
.select_related("user", "user__profile")
|
||||||
.prefetch_related("items")
|
.prefetch_related("items")
|
||||||
.order_by("-created_at")[:5]
|
.order_by("-created_at")[:5]
|
||||||
@@ -185,7 +213,7 @@ class ProfileView(DetailView):
|
|||||||
class SettingsView(LoginRequiredMixin, TemplateView):
|
class SettingsView(LoginRequiredMixin, TemplateView):
|
||||||
template_name = "accounts/settings.html"
|
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 = super().get_context_data(**kwargs)
|
||||||
context["user"] = self.request.user
|
context["user"] = self.request.user
|
||||||
return context
|
return context
|
||||||
@@ -197,12 +225,22 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
if display_name := request.POST.get("display_name"):
|
if display_name := request.POST.get("display_name"):
|
||||||
profile.display_name = 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:
|
if "avatar" in request.FILES:
|
||||||
avatar_file = cast(UploadedFile, request.FILES["avatar"])
|
avatar_file = cast(UploadedFile, request.FILES["avatar"])
|
||||||
profile.avatar.save(avatar_file.name, avatar_file, save=False)
|
profile.avatar.save(avatar_file.name, avatar_file, save=False)
|
||||||
profile.save()
|
profile.save()
|
||||||
|
|
||||||
user.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")
|
messages.success(request, "Profile updated successfully")
|
||||||
|
|
||||||
def _validate_password(self, password: str) -> bool:
|
def _validate_password(self, password: str) -> bool:
|
||||||
@@ -214,9 +252,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
and bool(re.search(r"[0-9]", password))
|
and bool(re.search(r"[0-9]", password))
|
||||||
)
|
)
|
||||||
|
|
||||||
def _send_password_change_confirmation(
|
def _send_password_change_confirmation(self, request: HttpRequest, user: User) -> None:
|
||||||
self, request: HttpRequest, user: User
|
|
||||||
) -> None:
|
|
||||||
"""Send password change confirmation email."""
|
"""Send password change confirmation email."""
|
||||||
site = get_current_site(request)
|
site = get_current_site(request)
|
||||||
context = {
|
context = {
|
||||||
@@ -224,9 +260,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
"site_name": site.name,
|
"site_name": site.name,
|
||||||
}
|
}
|
||||||
|
|
||||||
email_html = render_to_string(
|
email_html = render_to_string("accounts/email/password_change_confirmation.html", context)
|
||||||
"accounts/email/password_change_confirmation.html", context
|
|
||||||
)
|
|
||||||
|
|
||||||
EmailService.send_email(
|
EmailService.send_email(
|
||||||
to=user.email,
|
to=user.email,
|
||||||
@@ -236,9 +270,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
html=email_html,
|
html=email_html,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _handle_password_change(
|
def _handle_password_change(self, request: HttpRequest) -> HttpResponseRedirect | None:
|
||||||
self, request: HttpRequest
|
|
||||||
) -> Optional[HttpResponseRedirect]:
|
|
||||||
user = cast(User, request.user)
|
user = cast(User, request.user)
|
||||||
old_password = request.POST.get("old_password", "")
|
old_password = request.POST.get("old_password", "")
|
||||||
new_password = request.POST.get("new_password", "")
|
new_password = request.POST.get("new_password", "")
|
||||||
@@ -262,6 +294,15 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
user.set_password(new_password)
|
user.set_password(new_password)
|
||||||
user.save()
|
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)
|
self._send_password_change_confirmation(request, user)
|
||||||
messages.success(
|
messages.success(
|
||||||
request,
|
request,
|
||||||
@@ -272,9 +313,7 @@ class SettingsView(LoginRequiredMixin, TemplateView):
|
|||||||
def _handle_email_change(self, request: HttpRequest) -> None:
|
def _handle_email_change(self, request: HttpRequest) -> None:
|
||||||
if new_email := request.POST.get("new_email"):
|
if new_email := request.POST.get("new_email"):
|
||||||
self._send_email_verification(request, new_email)
|
self._send_email_verification(request, new_email)
|
||||||
messages.success(
|
messages.success(request, "Verification email sent to your new email address")
|
||||||
request, "Verification email sent to your new email address"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
messages.error(request, "New email is required")
|
messages.error(request, "New email is required")
|
||||||
|
|
||||||
@@ -330,9 +369,7 @@ def create_password_reset_token(user: User) -> str:
|
|||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
def send_password_reset_email(
|
def send_password_reset_email(user: User, site: Site | RequestSite, token: str) -> None:
|
||||||
user: User, site: Union[Site, RequestSite], token: str
|
|
||||||
) -> None:
|
|
||||||
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
|
reset_url = reverse("password_reset_confirm", kwargs={"token": token})
|
||||||
context = {
|
context = {
|
||||||
"user": user,
|
"user": user,
|
||||||
@@ -363,6 +400,14 @@ def request_password_reset(request: HttpRequest) -> HttpResponse:
|
|||||||
token = create_password_reset_token(user)
|
token = create_password_reset_token(user)
|
||||||
site = get_current_site(request)
|
site = get_current_site(request)
|
||||||
send_password_reset_email(user, site, token)
|
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")
|
messages.success(request, "Password reset email sent")
|
||||||
return redirect("account_login")
|
return redirect("account_login")
|
||||||
@@ -373,7 +418,7 @@ def handle_password_reset(
|
|||||||
user: User,
|
user: User,
|
||||||
new_password: str,
|
new_password: str,
|
||||||
reset: PasswordReset,
|
reset: PasswordReset,
|
||||||
site: Union[Site, RequestSite],
|
site: Site | RequestSite,
|
||||||
) -> None:
|
) -> None:
|
||||||
user.set_password(new_password)
|
user.set_password(new_password)
|
||||||
user.save()
|
user.save()
|
||||||
@@ -381,20 +426,25 @@ def handle_password_reset(
|
|||||||
reset.used = True
|
reset.used = True
|
||||||
reset.save()
|
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)
|
send_password_reset_confirmation(user, site)
|
||||||
messages.success(request, "Password reset successfully")
|
messages.success(request, "Password reset successfully")
|
||||||
|
|
||||||
|
|
||||||
def send_password_reset_confirmation(
|
def send_password_reset_confirmation(user: User, site: Site | RequestSite) -> None:
|
||||||
user: User, site: Union[Site, RequestSite]
|
|
||||||
) -> None:
|
|
||||||
context = {
|
context = {
|
||||||
"user": user,
|
"user": user,
|
||||||
"site_name": site.name,
|
"site_name": site.name,
|
||||||
}
|
}
|
||||||
email_html = render_to_string(
|
email_html = render_to_string("accounts/email/password_reset_complete.html", context)
|
||||||
"accounts/email/password_reset_complete.html", context
|
|
||||||
)
|
|
||||||
|
|
||||||
EmailService.send_email(
|
EmailService.send_email(
|
||||||
to=user.email,
|
to=user.email,
|
||||||
@@ -407,9 +457,7 @@ def send_password_reset_confirmation(
|
|||||||
|
|
||||||
def reset_password(request: HttpRequest, token: str) -> HttpResponse:
|
def reset_password(request: HttpRequest, token: str) -> HttpResponse:
|
||||||
try:
|
try:
|
||||||
reset = PasswordReset.objects.select_related("user").get(
|
reset = PasswordReset.objects.select_related("user").get(token=token, expires_at__gt=timezone.now(), used=False)
|
||||||
token=token, expires_at__gt=timezone.now(), used=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if new_password := request.POST.get("new_password"):
|
if new_password := request.POST.get("new_password"):
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
from django.urls import path, include
|
from django.urls import include, path
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("v1/", include("apps.api.v1.urls")),
|
path("v1/", include("apps.api.v1.urls")),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from rest_framework import serializers
|
|
||||||
from drf_spectacular.utils import extend_schema_field
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from apps.accounts.models import UserProfile, TopList, TopListItem
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.accounts.models import UserProfile
|
||||||
from apps.accounts.serializers import UserSerializer # existing shared user serializer
|
from apps.accounts.serializers import UserSerializer # existing shared user serializer
|
||||||
|
|
||||||
|
|
||||||
@@ -11,10 +12,22 @@ class UserProfileCreateInputSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
|
|
||||||
class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
|
class UserProfileUpdateInputSerializer(serializers.ModelSerializer):
|
||||||
|
cloudflare_image_id = serializers.CharField(write_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserProfile
|
model = UserProfile
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
extra_kwargs = {"user": {"read_only": True}}
|
extra_kwargs = {"user": {"read_only": True}, "avatar": {"read_only": True}}
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
cloudflare_id = validated_data.pop("cloudflare_image_id", None)
|
||||||
|
if cloudflare_id:
|
||||||
|
from django_cloudflareimages_toolkit.models import CloudflareImage
|
||||||
|
|
||||||
|
image, _ = CloudflareImage.objects.get_or_create(cloudflare_id=cloudflare_id)
|
||||||
|
instance.avatar = image
|
||||||
|
|
||||||
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
class UserProfileOutputSerializer(serializers.ModelSerializer):
|
class UserProfileOutputSerializer(serializers.ModelSerializer):
|
||||||
@@ -38,49 +51,3 @@ class UserProfileOutputSerializer(serializers.ModelSerializer):
|
|||||||
if avatar:
|
if avatar:
|
||||||
return getattr(avatar, "url", None)
|
return getattr(avatar, "url", None)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
class TopListItemCreateInputSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = TopListItem
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class TopListItemUpdateInputSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = TopListItem
|
|
||||||
fields = "__all__"
|
|
||||||
# allow updates, adjust as needed
|
|
||||||
extra_kwargs = {"top_list": {"read_only": False}}
|
|
||||||
|
|
||||||
|
|
||||||
class TopListItemOutputSerializer(serializers.ModelSerializer):
|
|
||||||
# Remove the ride field since it doesn't exist on the model
|
|
||||||
# The model likely uses a generic foreign key or different field name
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TopListItem
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class TopListCreateInputSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = TopList
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
|
|
||||||
class TopListUpdateInputSerializer(serializers.ModelSerializer):
|
|
||||||
class Meta:
|
|
||||||
model = TopList
|
|
||||||
fields = "__all__"
|
|
||||||
# user is set by view's perform_create
|
|
||||||
extra_kwargs = {"user": {"read_only": True}}
|
|
||||||
|
|
||||||
|
|
||||||
class TopListOutputSerializer(serializers.ModelSerializer):
|
|
||||||
user = UserSerializer(read_only=True)
|
|
||||||
items = TopListItemOutputSerializer(many=True, read_only=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = TopList
|
|
||||||
fields = "__all__"
|
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
URL configuration for user account management API endpoints.
|
URL configuration for user account management API endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from django.urls import path
|
from django.urls import include, path
|
||||||
from . import views
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from . import views, views_credits, views_magic_link
|
||||||
|
|
||||||
|
# Register ViewSets
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"credits", views_credits.RideCreditViewSet, basename="ride-credit")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Admin endpoints for user management
|
# Admin endpoints for user management
|
||||||
@@ -33,6 +39,8 @@ urlpatterns = [
|
|||||||
views.cancel_account_deletion,
|
views.cancel_account_deletion,
|
||||||
name="cancel_account_deletion",
|
name="cancel_account_deletion",
|
||||||
),
|
),
|
||||||
|
# Data Export endpoint
|
||||||
|
path("data-export/", views.export_user_data, name="export_user_data"),
|
||||||
# User profile endpoints
|
# User profile endpoints
|
||||||
path("profile/", views.get_user_profile, name="get_user_profile"),
|
path("profile/", views.get_user_profile, name="get_user_profile"),
|
||||||
path("profile/account/", views.update_user_account, name="update_user_account"),
|
path("profile/account/", views.update_user_account, name="update_user_account"),
|
||||||
@@ -68,9 +76,7 @@ urlpatterns = [
|
|||||||
name="update_privacy_settings",
|
name="update_privacy_settings",
|
||||||
),
|
),
|
||||||
# Security settings endpoints
|
# Security settings endpoints
|
||||||
path(
|
path("settings/security/", views.get_security_settings, name="get_security_settings"),
|
||||||
"settings/security/", views.get_security_settings, name="get_security_settings"
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"settings/security/update/",
|
"settings/security/update/",
|
||||||
views.update_security_settings,
|
views.update_security_settings,
|
||||||
@@ -82,9 +88,7 @@ urlpatterns = [
|
|||||||
path("top-lists/", views.get_user_top_lists, name="get_user_top_lists"),
|
path("top-lists/", views.get_user_top_lists, name="get_user_top_lists"),
|
||||||
path("top-lists/create/", views.create_top_list, name="create_top_list"),
|
path("top-lists/create/", views.create_top_list, name="create_top_list"),
|
||||||
path("top-lists/<int:list_id>/", views.update_top_list, name="update_top_list"),
|
path("top-lists/<int:list_id>/", views.update_top_list, name="update_top_list"),
|
||||||
path(
|
path("top-lists/<int:list_id>/delete/", views.delete_top_list, name="delete_top_list"),
|
||||||
"top-lists/<int:list_id>/delete/", views.delete_top_list, name="delete_top_list"
|
|
||||||
),
|
|
||||||
# Notification endpoints
|
# Notification endpoints
|
||||||
path("notifications/", views.get_user_notifications, name="get_user_notifications"),
|
path("notifications/", views.get_user_notifications, name="get_user_notifications"),
|
||||||
path(
|
path(
|
||||||
@@ -106,4 +110,15 @@ urlpatterns = [
|
|||||||
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
|
path("profile/avatar/upload/", views.upload_avatar, name="upload_avatar"),
|
||||||
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
|
path("profile/avatar/save/", views.save_avatar_image, name="save_avatar_image"),
|
||||||
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
|
path("profile/avatar/delete/", views.delete_avatar, name="delete_avatar"),
|
||||||
|
# Login history endpoint
|
||||||
|
path("login-history/", views.get_login_history, name="get_login_history"),
|
||||||
|
# Email change cancellation endpoint
|
||||||
|
path("email-change/cancel/", views.cancel_email_change, name="cancel_email_change"),
|
||||||
|
# Magic Link (Login by Code) endpoints
|
||||||
|
path("magic-link/request/", views_magic_link.request_magic_link, name="request_magic_link"),
|
||||||
|
path("magic-link/verify/", views_magic_link.verify_magic_link, name="verify_magic_link"),
|
||||||
|
# Public Profile
|
||||||
|
path("profiles/<str:username>/", views.get_public_user_profile, name="get_public_user_profile"),
|
||||||
|
# ViewSet routes
|
||||||
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
101
backend/apps/api/v1/accounts/views_credits.py
Normal file
101
backend/apps/api/v1/accounts/views_credits.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from django.db import transaction
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
from drf_spectacular.types import OpenApiTypes
|
||||||
|
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||||
|
from rest_framework import filters, permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.api.v1.serializers.ride_credits import RideCreditSerializer
|
||||||
|
from apps.rides.models.credits import RideCredit
|
||||||
|
|
||||||
|
|
||||||
|
class RideCreditViewSet(viewsets.ModelViewSet):
|
||||||
|
"""
|
||||||
|
ViewSet for managing Ride Credits.
|
||||||
|
Allows users to track rides they have ridden.
|
||||||
|
"""
|
||||||
|
|
||||||
|
serializer_class = RideCreditSerializer
|
||||||
|
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
|
||||||
|
filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
|
||||||
|
filterset_fields = ["user__username", "ride__park__slug", "ride__manufacturer__slug"]
|
||||||
|
ordering_fields = ["first_ridden_at", "last_ridden_at", "created_at", "count", "rating", "display_order"]
|
||||||
|
ordering = ["display_order", "-last_ridden_at"]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Return ride credits.
|
||||||
|
Optionally filter by user via query param ?user=username
|
||||||
|
"""
|
||||||
|
queryset = RideCredit.objects.all().select_related("ride", "ride__park", "user")
|
||||||
|
|
||||||
|
# Filter by user if provided
|
||||||
|
username = self.request.query_params.get("user")
|
||||||
|
if username:
|
||||||
|
queryset = queryset.filter(user__username=username)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
"""Associate the current user with the ride credit."""
|
||||||
|
serializer.save(user=self.request.user)
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"], permission_classes=[permissions.IsAuthenticated])
|
||||||
|
@extend_schema(
|
||||||
|
summary="Reorder ride credits",
|
||||||
|
description="Bulk update the display order of ride credits. Send a list of {id, order} objects.",
|
||||||
|
request={
|
||||||
|
"application/json": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"order": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"id": {"type": "integer"}, "order": {"type": "integer"}},
|
||||||
|
"required": ["id", "order"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def reorder(self, request):
|
||||||
|
"""
|
||||||
|
Bulk update display_order for multiple credits.
|
||||||
|
Expects: {"order": [{"id": 1, "order": 0}, {"id": 2, "order": 1}, ...]}
|
||||||
|
"""
|
||||||
|
order_data = request.data.get("order", [])
|
||||||
|
|
||||||
|
if not order_data:
|
||||||
|
return Response({"detail": "No order data provided"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Validate that all credits belong to the current user
|
||||||
|
credit_ids = [item["id"] for item in order_data]
|
||||||
|
user_credits = RideCredit.objects.filter(id__in=credit_ids, user=request.user).values_list("id", flat=True)
|
||||||
|
|
||||||
|
if set(credit_ids) != set(user_credits):
|
||||||
|
return Response({"detail": "You can only reorder your own credits"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
|
# Bulk update in a transaction
|
||||||
|
with transaction.atomic():
|
||||||
|
for item in order_data:
|
||||||
|
RideCredit.objects.filter(id=item["id"], user=request.user).update(display_order=item["order"])
|
||||||
|
|
||||||
|
return Response({"status": "reordered", "count": len(order_data)})
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="List ride credits",
|
||||||
|
description="List ride credits. filter by user username.",
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="user",
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
description="Filter by username",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
return super().list(request, *args, **kwargs)
|
||||||
149
backend/apps/api/v1/accounts/views_magic_link.py
Normal file
149
backend/apps/api/v1/accounts/views_magic_link.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
"""
|
||||||
|
Magic Link (Login by Code) API views.
|
||||||
|
|
||||||
|
Provides API endpoints for passwordless login via email code.
|
||||||
|
Uses django-allauth's built-in login-by-code functionality.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from drf_spectacular.utils import OpenApiExample, extend_schema
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
try:
|
||||||
|
from allauth.account.internal.flows.login_by_code import perform_login_by_code, request_login_code
|
||||||
|
from allauth.account.models import EmailAddress
|
||||||
|
from allauth.account.utils import user_email # noqa: F401 - imported to verify availability
|
||||||
|
|
||||||
|
HAS_LOGIN_BY_CODE = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_LOGIN_BY_CODE = False
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Request magic link login code",
|
||||||
|
description="Send a one-time login code to the user's email address.",
|
||||||
|
request={
|
||||||
|
"application/json": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"email": {"type": "string", "format": "email"}},
|
||||||
|
"required": ["email"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: {"description": "Login code sent successfully"},
|
||||||
|
400: {"description": "Invalid email or feature disabled"},
|
||||||
|
},
|
||||||
|
examples=[OpenApiExample("Request login code", value={"email": "user@example.com"}, request_only=True)],
|
||||||
|
)
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def request_magic_link(request):
|
||||||
|
"""
|
||||||
|
Request a login code to be sent to the user's email.
|
||||||
|
|
||||||
|
This is the first step of the magic link flow:
|
||||||
|
1. User enters their email
|
||||||
|
2. If the email exists, a code is sent
|
||||||
|
3. User enters the code to complete login
|
||||||
|
"""
|
||||||
|
if not getattr(settings, "ACCOUNT_LOGIN_BY_CODE_ENABLED", False):
|
||||||
|
return Response({"detail": "Magic link login is not enabled"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
if not HAS_LOGIN_BY_CODE:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Login by code is not available in this version of allauth"}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
email = request.data.get("email", "").lower().strip()
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return Response({"detail": "Email is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# Check if email exists (don't reveal if it doesn't for security)
|
||||||
|
try:
|
||||||
|
email_address = EmailAddress.objects.get(email__iexact=email, verified=True)
|
||||||
|
user = email_address.user
|
||||||
|
|
||||||
|
# Request the login code
|
||||||
|
request_login_code(request._request, user)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": "If an account exists with this email, a login code has been sent.",
|
||||||
|
"timeout": getattr(settings, "ACCOUNT_LOGIN_BY_CODE_TIMEOUT", 300),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except EmailAddress.DoesNotExist:
|
||||||
|
# Don't reveal that the email doesn't exist
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": "If an account exists with this email, a login code has been sent.",
|
||||||
|
"timeout": getattr(settings, "ACCOUNT_LOGIN_BY_CODE_TIMEOUT", 300),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
summary="Verify magic link code",
|
||||||
|
description="Verify the login code and complete the login process.",
|
||||||
|
request={
|
||||||
|
"application/json": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"email": {"type": "string", "format": "email"}, "code": {"type": "string"}},
|
||||||
|
"required": ["email", "code"],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={
|
||||||
|
200: {"description": "Login successful"},
|
||||||
|
400: {"description": "Invalid or expired code"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([AllowAny])
|
||||||
|
def verify_magic_link(request):
|
||||||
|
"""
|
||||||
|
Verify the login code and complete the login.
|
||||||
|
|
||||||
|
This is the second step of the magic link flow.
|
||||||
|
"""
|
||||||
|
if not getattr(settings, "ACCOUNT_LOGIN_BY_CODE_ENABLED", False):
|
||||||
|
return Response({"detail": "Magic link login is not enabled"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
if not HAS_LOGIN_BY_CODE:
|
||||||
|
return Response({"detail": "Login by code is not available"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
email = request.data.get("email", "").lower().strip()
|
||||||
|
code = request.data.get("code", "").strip()
|
||||||
|
|
||||||
|
if not email or not code:
|
||||||
|
return Response({"detail": "Email and code are required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
try:
|
||||||
|
email_address = EmailAddress.objects.get(email__iexact=email, verified=True)
|
||||||
|
user = email_address.user
|
||||||
|
|
||||||
|
# Attempt to verify the code and log in
|
||||||
|
success = perform_login_by_code(request._request, user, code)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"detail": "Login successful",
|
||||||
|
"user": {"id": user.id, "username": user.username, "email": user.email},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Invalid or expired code. Please request a new one."}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
except EmailAddress.DoesNotExist:
|
||||||
|
return Response({"detail": "Invalid email or code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except Exception:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Invalid or expired code. Please request a new one."}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
1
backend/apps/api/v1/admin/__init__.py
Normal file
1
backend/apps/api/v1/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Admin API module
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user