Files
thrillwiki_django_no_react/docs/frontend.md
pacnpal 0fd6dc2560 feat: Enhance Park Detail Endpoint with Media URL Service Integration
- Updated ParkDetailOutputSerializer to utilize MediaURLService for generating Cloudflare URLs and friendly URLs for park photos.
- Added support for multiple lookup methods (ID and slug) in the park detail endpoint.
- Improved documentation for the park detail endpoint, including request properties and response structure.
- Created MediaURLService for generating SEO-friendly URLs and handling Cloudflare image URLs.
- Comprehensive updates to frontend documentation to reflect new endpoint capabilities and usage examples.
- Added detailed park detail endpoint documentation, including request and response structures, field descriptions, and usage examples.
2025-08-31 16:45:47 -04:00

2266 lines
67 KiB
Markdown

# ThrillWiki Frontend API Documentation
Last updated: 2025-08-29
This document provides comprehensive documentation for all ThrillWiki API endpoints that the NextJS frontend should use.
## Authentication
ThrillWiki uses JWT Bearer token authentication. After successful login or signup, you'll receive access and refresh tokens that must be included in subsequent API requests.
### Authentication Headers
Include the access token in the Authorization header using Bearer format:
```typescript
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
```
### Token Management
- **Access Token**: Short-lived token (1 hour) used for API requests
- **Refresh Token**: Long-lived token (7 days) used to obtain new access tokens
- **Token Rotation**: Refresh tokens are rotated on each refresh for enhanced security
## Base URL
The frontend uses a Next.js proxy that routes API requests:
- Frontend requests: `/v1/auth/login/`, `/v1/accounts/profile/`, etc.
- Proxy adds `/api/` prefix: `/api/v1/auth/login/`, `/api/v1/accounts/profile/`, etc.
- Backend receives: `/api/v1/auth/login/`, `/api/v1/accounts/profile/`, etc.
**Important**: Frontend code should make requests to `/v1/...` endpoints, not `/api/v1/...`
## Moderation System API
The moderation system provides comprehensive content moderation, user management, and administrative tools. All moderation endpoints require moderator-level permissions or above.
### Moderation Reports
#### List Reports
- **GET** `/api/v1/moderation/reports/`
- **Permissions**: Moderators and above can view all reports, regular users can only view their own reports
- **Query Parameters**:
- `status`: Filter by report status (PENDING, UNDER_REVIEW, RESOLVED, DISMISSED)
- `priority`: Filter by priority (LOW, MEDIUM, HIGH, URGENT)
- `report_type`: Filter by report type (SPAM, HARASSMENT, INAPPROPRIATE_CONTENT, etc.)
- `reported_by`: Filter by user ID who made the report
- `assigned_moderator`: Filter by assigned moderator ID
- `created_after`: Filter reports created after date (ISO format)
- `created_before`: Filter reports created before date (ISO format)
- `unassigned`: Boolean filter for unassigned reports
- `overdue`: Boolean filter for overdue reports based on SLA
- `search`: Search in reason and description fields
- `ordering`: Order by fields (created_at, updated_at, priority, status)
#### Create Report
- **POST** `/api/v1/moderation/reports/`
- **Permissions**: Any authenticated user
- **Body**: CreateModerationReportData
#### Get Report Details
- **GET** `/api/v1/moderation/reports/{id}/`
- **Permissions**: Moderators and above, or report creator
#### Update Report
- **PATCH** `/api/v1/moderation/reports/{id}/`
- **Permissions**: Moderators and above
- **Body**: Partial UpdateModerationReportData
#### Assign Report
- **POST** `/api/v1/moderation/reports/{id}/assign/`
- **Permissions**: Moderators and above
- **Body**: `{ "moderator_id": number }`
#### Resolve Report
- **POST** `/api/v1/moderation/reports/{id}/resolve/`
- **Permissions**: Moderators and above
- **Body**: `{ "resolution_action": string, "resolution_notes": string }`
#### Report Statistics
- **GET** `/api/v1/moderation/reports/stats/`
- **Permissions**: Moderators and above
- **Returns**: ModerationStatsData
### Moderation Queue
#### List Queue Items
- **GET** `/api/v1/moderation/queue/`
- **Permissions**: Moderators and above
- **Query Parameters**:
- `status`: Filter by status (PENDING, IN_PROGRESS, COMPLETED, CANCELLED)
- `priority`: Filter by priority (LOW, MEDIUM, HIGH, URGENT)
- `item_type`: Filter by item type (CONTENT_REVIEW, USER_REVIEW, BULK_ACTION, etc.)
- `assigned_to`: Filter by assigned moderator ID
- `unassigned`: Boolean filter for unassigned items
- `has_related_report`: Boolean filter for items with related reports
- `search`: Search in title and description fields
#### Get My Queue
- **GET** `/api/v1/moderation/queue/my_queue/`
- **Permissions**: Moderators and above
- **Returns**: Queue items assigned to current user
#### Assign Queue Item
- **POST** `/api/v1/moderation/queue/{id}/assign/`
- **Permissions**: Moderators and above
- **Body**: `{ "moderator_id": number }`
#### Unassign Queue Item
- **POST** `/api/v1/moderation/queue/{id}/unassign/`
- **Permissions**: Moderators and above
#### Complete Queue Item
- **POST** `/api/v1/moderation/queue/{id}/complete/`
- **Permissions**: Moderators and above
- **Body**: CompleteQueueItemData
### Moderation Actions
#### List Actions
- **GET** `/api/v1/moderation/actions/`
- **Permissions**: Moderators and above
- **Query Parameters**:
- `action_type`: Filter by action type (WARNING, USER_SUSPENSION, USER_BAN, etc.)
- `moderator`: Filter by moderator ID
- `target_user`: Filter by target user ID
- `is_active`: Boolean filter for active actions
- `expired`: Boolean filter for expired actions
- `expiring_soon`: Boolean filter for actions expiring within 24 hours
- `has_related_report`: Boolean filter for actions with related reports
#### Create Action
- **POST** `/api/v1/moderation/actions/`
- **Permissions**: Moderators and above (with role-based restrictions)
- **Body**: CreateModerationActionData
#### Get Active Actions
- **GET** `/api/v1/moderation/actions/active/`
- **Permissions**: Moderators and above
#### Get Expired Actions
- **GET** `/api/v1/moderation/actions/expired/`
- **Permissions**: Moderators and above
#### Deactivate Action
- **POST** `/api/v1/moderation/actions/{id}/deactivate/`
- **Permissions**: Moderators and above
### Bulk Operations
#### List Bulk Operations
- **GET** `/api/v1/moderation/bulk-operations/`
- **Permissions**: Admins and superusers only
- **Query Parameters**:
- `status`: Filter by status (PENDING, RUNNING, COMPLETED, FAILED, CANCELLED)
- `operation_type`: Filter by operation type
- `priority`: Filter by priority
- `created_by`: Filter by creator ID
- `can_cancel`: Boolean filter for cancellable operations
- `has_failures`: Boolean filter for operations with failures
- `in_progress`: Boolean filter for operations in progress
#### Create Bulk Operation
- **POST** `/api/v1/moderation/bulk-operations/`
- **Permissions**: Admins and superusers only
- **Body**: CreateBulkOperationData
#### Get Running Operations
- **GET** `/api/v1/moderation/bulk-operations/running/`
- **Permissions**: Admins and superusers only
#### Cancel Operation
- **POST** `/api/v1/moderation/bulk-operations/{id}/cancel/`
- **Permissions**: Admins and superusers only
#### Retry Operation
- **POST** `/api/v1/moderation/bulk-operations/{id}/retry/`
- **Permissions**: Admins and superusers only
#### Get Operation Logs
- **GET** `/api/v1/moderation/bulk-operations/{id}/logs/`
- **Permissions**: Admins and superusers only
### User Moderation
#### Get User Moderation Profile
- **GET** `/api/v1/moderation/users/{id}/`
- **Permissions**: Moderators and above
- **Returns**: UserModerationProfileData
#### Take Action Against User
- **POST** `/api/v1/moderation/users/{id}/moderate/`
- **Permissions**: Moderators and above
- **Body**: CreateModerationActionData
#### Search Users
- **GET** `/api/v1/moderation/users/search/`
- **Permissions**: Moderators and above
- **Query Parameters**:
- `query`: Search in username and email
- `role`: Filter by user role
- `has_restrictions`: Boolean filter for users with active restrictions
#### User Moderation Statistics
- **GET** `/api/v1/moderation/users/stats/`
- **Permissions**: Moderators and above
## Parks API
### Parks Listing
- **GET** `/api/v1/parks/`
- **Query Parameters** (24 filtering parameters fully supported by Django backend):
- `page` (int): Page number for pagination
- `page_size` (int): Number of results per page
- `search` (string): Search in park names and descriptions
- `continent` (string): Filter by continent
- `country` (string): Filter by country
- `state` (string): Filter by state/province
- `city` (string): Filter by city
- `park_type` (string): Filter by park type (THEME_PARK, AMUSEMENT_PARK, WATER_PARK, etc.)
- `status` (string): Filter by operational status
- `operator_id` (int): Filter by operator company ID
- `operator_slug` (string): Filter by operator company slug
- `property_owner_id` (int): Filter by property owner company ID
- `property_owner_slug` (string): Filter by property owner company slug
- `min_rating` (number): Minimum average rating
- `max_rating` (number): Maximum average rating
- `min_ride_count` (int): Minimum total ride count
- `max_ride_count` (int): Maximum total ride count
- `opening_year` (int): Filter by specific opening year
- `min_opening_year` (int): Minimum opening year
- `max_opening_year` (int): Maximum opening year
- `has_roller_coasters` (boolean): Filter parks that have roller coasters
- `min_roller_coaster_count` (int): Minimum roller coaster count
- `max_roller_coaster_count` (int): Maximum roller coaster count
- `ordering` (string): Order by fields (name, opening_date, ride_count, average_rating, coaster_count, etc.)
### Filter Options
- **GET** `/api/v1/parks/filter-options/`
- **Returns**: Comprehensive filter options including continents, countries, states, park types, and ordering options
- **Response**:
```json
{
"park_types": [
{"value": "THEME_PARK", "label": "Theme Park"},
{"value": "AMUSEMENT_PARK", "label": "Amusement Park"},
{"value": "WATER_PARK", "label": "Water Park"}
],
"continents": ["North America", "Europe", "Asia", "Australia"],
"countries": ["United States", "Canada", "United Kingdom", "Germany"],
"states": ["California", "Florida", "Ohio", "Pennsylvania"],
"ordering_options": [
{"value": "name", "label": "Name (A-Z)"},
{"value": "-name", "label": "Name (Z-A)"},
{"value": "opening_date", "label": "Opening Date (Oldest First)"},
{"value": "-opening_date", "label": "Opening Date (Newest First)"},
{"value": "ride_count", "label": "Ride Count (Low to High)"},
{"value": "-ride_count", "label": "Ride Count (High to Low)"},
{"value": "average_rating", "label": "Rating (Low to High)"},
{"value": "-average_rating", "label": "Rating (High to Low)"},
{"value": "roller_coaster_count", "label": "Coaster Count (Low to High)"},
{"value": "-roller_coaster_count", "label": "Coaster Count (High to Low)"}
]
}
```
### Company Search
- **GET** `/api/v1/parks/search/companies/?q={query}`
- **Returns**: Autocomplete results for park operators and property owners
- **Response**:
```json
[
{
"id": 1,
"name": "Six Flags Entertainment",
"slug": "six-flags",
"roles": ["OPERATOR"]
}
]
```
### Search Suggestions
- **GET** `/api/v1/parks/search-suggestions/?q={query}`
- **Returns**: Search suggestions for park names
### Park Details
- **GET** `/api/v1/parks/{identifier}/`
- **Description**: Retrieve comprehensive park details including location, photos, areas, rides, and company information
- **Supports Multiple Lookup Methods**:
- By ID: `/api/v1/parks/123/`
- By current slug: `/api/v1/parks/cedar-point/`
- By historical slug: `/api/v1/parks/old-cedar-point-name/`
- **Query Parameters**: None required - returns full details by default
- **Returns**: Complete park information including:
- Core park details (name, slug, description, status, park_type)
- Operational details (opening/closing dates, size, website)
- Statistics (average rating, ride count, coaster count)
- Full location data with coordinates and formatted address
- Operating company and property owner information
- Park areas/themed sections
- All approved photos with Cloudflare variants
- Primary, banner, and card image designations
- Frontend URL and metadata
- **Authentication**: None required (public endpoint)
- **Documentation**: See `docs/park-detail-endpoint-documentation.md` for complete details
### Park Rides
- **GET** `/api/v1/parks/{park_slug}/rides/`
- **Query Parameters**: Similar filtering options as global rides endpoint
### Park Photos
- **GET** `/api/v1/parks/{park_slug}/photos/`
- **Query Parameters**:
- `photo_type`: Filter by photo type (banner, card, gallery)
- `ordering`: Order by upload date, likes, etc.
## Rides API
### Rides Listing
- **GET** `/api/v1/rides/`
- **Query Parameters**:
- `search`: Search in ride names and descriptions
- `park`: Filter by park slug
- `manufacturer`: Filter by manufacturer slug
- `ride_type`: Filter by ride type
- `status`: Filter by operational status
- `opened_after`: Filter rides opened after date
- `opened_before`: Filter rides opened before date
- `height_min`: Minimum height requirement
- `height_max`: Maximum height requirement
- `has_photos`: Boolean filter for rides with photos
- `ordering`: Order by fields (name, opened_date, height, etc.)
### Ride Details
- **GET** `/api/v1/rides/{park_slug}/{ride_slug}/`
- **Returns**: Complete ride information including specifications, photos, and reviews
### Ride Photos
- **GET** `/api/v1/rides/{park_slug}/{ride_slug}/photos/`
### Ride Reviews
- **GET** `/api/v1/rides/{park_slug}/{ride_slug}/reviews/`
- **POST** `/api/v1/rides/{park_slug}/{ride_slug}/reviews/`
## Manufacturers API
### Manufacturers Listing
- **GET** `/api/v1/rides/manufacturers/`
- **Query Parameters**:
- `search`: Search in manufacturer names
- `country`: Filter by country
- `has_rides`: Boolean filter for manufacturers with rides
- `ordering`: Order by name, ride_count, etc.
### Manufacturer Details
- **GET** `/api/v1/rides/manufacturers/{slug}/`
### Manufacturer Rides
- **GET** `/api/v1/rides/manufacturers/{slug}/rides/`
## Authentication API
### Login
- **POST** `/api/v1/auth/login/`
- **Body**: `{ "username": string, "password": string, "turnstile_token"?: string }`
- **Returns**: JWT tokens and user data
- **Response**:
```typescript
{
"access": string,
"refresh": string,
"user": {
"id": number,
"username": string,
"email": string,
"display_name": string,
"is_active": boolean,
"date_joined": string
},
"message": string
}
```
### Signup
- **POST** `/api/v1/auth/signup/`
- **Body**:
```typescript
{
"username": string,
"email": string,
"password": string,
"password_confirm": string,
"display_name": string, // Required field
"turnstile_token"?: string
}
```
- **Returns**: User data with email verification requirement
- **Response**:
```typescript
{
"access": null, // No tokens until email verified
"refresh": null,
"user": {
"id": number,
"username": string,
"email": string,
"display_name": string,
"is_active": false, // User inactive until email verified
"date_joined": string
},
"message": "Registration successful. Please check your email to verify your account.",
"email_verification_required": true
}
```
- **Note**:
- `display_name` is now required during registration
- **Email verification is mandatory** - users must verify their email before they can log in
- No JWT tokens are returned until email is verified
- Users receive a verification email with a link to activate their account
### Email Verification
- **GET** `/api/v1/auth/verify-email/{token}/`
- **Permissions**: Public access
- **Returns**: Verification result
- **Response**:
```typescript
{
"message": "Email verified successfully. You can now log in.",
"success": true
}
```
- **Error Response (404)**:
```typescript
{
"error": "Invalid or expired verification token"
}
```
### Resend Verification Email
- **POST** `/api/v1/auth/resend-verification/`
- **Body**: `{ "email": string }`
- **Returns**: Resend confirmation
- **Response**:
```typescript
{
"message": "Verification email sent successfully",
"success": true
}
```
- **Error Responses**:
- `400`: Email already verified or email address required
- `500`: Failed to send verification email
- **Note**: For security, the endpoint returns success even if the email doesn't exist
### Token Refresh
- **POST** `/api/v1/auth/token/refresh/`
- **Body**: `{ "refresh": string }`
- **Returns**: New access token and optionally a new refresh token
- **Response**:
```typescript
{
"access": string,
"refresh"?: string // Only returned if refresh token rotation is enabled
}
```
### Social Authentication
#### Google Login
- **POST** `/api/v1/auth/social/google/`
- **Body**: `{ "access_token": string }`
- **Returns**: JWT tokens and user data (same format as regular login)
- **Note**: The access_token should be obtained from Google OAuth flow
#### Discord Login
- **POST** `/api/v1/auth/social/discord/`
- **Body**: `{ "access_token": string }`
- **Returns**: JWT tokens and user data (same format as regular login)
- **Note**: The access_token should be obtained from Discord OAuth flow
#### Connect Social Account
- **POST** `/api/v1/auth/social/{provider}/connect/`
- **Permissions**: Authenticated users only
- **Body**: `{ "access_token": string }`
- **Returns**: `{ "success": boolean, "message": string }`
- **Note**: Links a social account to an existing ThrillWiki account
#### Disconnect Social Account
- **POST** `/api/v1/auth/social/{provider}/disconnect/`
- **Permissions**: Authenticated users only
- **Returns**: `{ "success": boolean, "message": string }`
#### Get Social Connections
- **GET** `/api/v1/auth/social/connections/`
- **Permissions**: Authenticated users only
- **Returns**:
```typescript
{
"google": { "connected": boolean, "email"?: string },
"discord": { "connected": boolean, "username"?: string }
}
```
### Social Provider Management
#### List Available Providers
- **GET** `/api/v1/auth/social/providers/available/`
- **Permissions**: Public access
- **Returns**: List of available social providers for connection
- **Response**:
```typescript
{
"available_providers": [
{
"id": "google",
"name": "Google",
"auth_url": "https://example.com/accounts/google/login/",
"connect_url": "https://example.com/api/v1/auth/social/connect/google/"
}
],
"count": number
}
```
#### List Connected Providers
- **GET** `/api/v1/auth/social/connected/`
- **Permissions**: Authenticated users only
- **Returns**: List of social providers connected to user's account
- **Response**:
```typescript
{
"connected_providers": [
{
"provider": "google",
"provider_name": "Google",
"uid": "user_id_on_provider",
"date_joined": "2025-01-01T00:00:00Z",
"can_disconnect": boolean,
"disconnect_reason": string | null,
"extra_data": object
}
],
"count": number,
"has_password_auth": boolean,
"can_disconnect_any": boolean
}
```
#### Connect Social Provider
- **POST** `/api/v1/auth/social/connect/{provider}/`
- **Permissions**: Authenticated users only
- **Parameters**: `provider` - Provider ID (e.g., 'google', 'discord')
- **Returns**: Connection initiation response with auth URL
- **Response**:
```typescript
{
"success": boolean,
"message": string,
"provider": string,
"auth_url": string
}
```
- **Error Responses**:
- `400`: Provider already connected or invalid provider
- `500`: Connection initiation failed
#### Disconnect Social Provider
- **DELETE** `/api/v1/auth/social/disconnect/{provider}/`
- **Permissions**: Authenticated users only
- **Parameters**: `provider` - Provider ID to disconnect
- **Returns**: Disconnection result with safety information
- **Response**:
```typescript
{
"success": boolean,
"message": string,
"provider": string,
"remaining_providers": string[],
"has_password_auth": boolean,
"suggestions"?: string[]
}
```
- **Error Responses**:
- `400`: Cannot disconnect (safety validation failed)
- `404`: Provider not connected
- `500`: Disconnection failed
#### Get Social Authentication Status
- **GET** `/api/v1/auth/social/status/`
- **Permissions**: Authenticated users only
- **Returns**: Comprehensive social authentication status
- **Response**:
```typescript
{
"user_id": number,
"username": string,
"email": string,
"has_password_auth": boolean,
"connected_providers": ConnectedProvider[],
"total_auth_methods": number,
"can_disconnect_any": boolean,
"requires_password_setup": boolean
}
```
### Social Provider Safety Rules
The social provider management system enforces strict safety rules to prevent users from locking themselves out:
1. **Disconnection Safety**: Users can only disconnect a social provider if they have:
- Another social provider connected, OR
- Email + password authentication set up
2. **Error Scenarios**:
- **Only Provider + No Password**: "Cannot disconnect your only authentication method. Please set up a password or connect another social provider first."
- **No Password Auth**: "Please set up email/password authentication before disconnecting this provider."
3. **Suggested Actions**: When disconnection is blocked, the API provides suggestions:
- "Set up password authentication"
- "Connect another social provider"
### Usage Examples
#### Check Social Provider Status
```typescript
const checkSocialStatus = async () => {
try {
const response = await fetch('/api/v1/auth/social/status/', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.requires_password_setup) {
// Show password setup prompt
showPasswordSetupModal();
}
return data;
} catch (error) {
console.error('Failed to get social status:', error);
}
};
```
#### Connect Social Provider
```typescript
const connectProvider = async (provider: string) => {
try {
const response = await fetch(`/api/v1/auth/social/connect/${provider}/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
// Redirect to provider auth URL
window.location.href = data.auth_url;
}
} catch (error) {
console.error('Failed to connect provider:', error);
}
};
```
#### Disconnect Social Provider with Safety Check
```typescript
const disconnectProvider = async (provider: string) => {
try {
const response = await fetch(`/api/v1/auth/social/disconnect/${provider}/`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (!response.ok) {
if (response.status === 400) {
// Show safety warning with suggestions
showSafetyWarning(data.error, data.suggestions);
}
return;
}
// Success - update UI
toast.success(data.message);
refreshConnectedProviders();
} catch (error) {
console.error('Failed to disconnect provider:', error);
}
};
```
#### React Component Example
```typescript
import { useState, useEffect } from 'react';
interface SocialProvider {
provider: string;
provider_name: string;
can_disconnect: boolean;
disconnect_reason?: string;
}
const SocialProviderManager: React.FC = () => {
const [connectedProviders, setConnectedProviders] = useState<SocialProvider[]>([]);
const [hasPasswordAuth, setHasPasswordAuth] = useState(false);
useEffect(() => {
loadConnectedProviders();
}, []);
const loadConnectedProviders = async () => {
const response = await fetch('/api/v1/auth/social/connected/', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const data = await response.json();
setConnectedProviders(data.connected_providers);
setHasPasswordAuth(data.has_password_auth);
};
const handleDisconnect = async (provider: string) => {
const response = await fetch(`/api/v1/auth/social/disconnect/${provider}/`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${accessToken}` }
});
if (!response.ok) {
const error = await response.json();
alert(error.error + '\n\nSuggestions:\n' + error.suggestions?.join('\n'));
return;
}
loadConnectedProviders(); // Refresh list
};
return (
<div className="space-y-4">
<h3>Connected Social Accounts</h3>
{!hasPasswordAuth && connectedProviders.length === 1 && (
<div className="bg-yellow-50 p-4 rounded-lg">
<p className="text-yellow-800">
⚠️ Set up a password to safely manage your social connections
</p>
</div>
)}
{connectedProviders.map((provider) => (
<div key={provider.provider} className="flex items-center justify-between p-4 border rounded">
<span>{provider.provider_name}</span>
<button
onClick={() => handleDisconnect(provider.provider)}
disabled={!provider.can_disconnect}
className={`px-4 py-2 rounded ${
provider.can_disconnect
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
title={provider.disconnect_reason}
>
Disconnect
</button>
</div>
))}
</div>
);
};
```
### Logout
- **POST** `/api/v1/auth/logout/`
- **Returns**: `{ "message": string }`
### Current User
- **GET** `/api/v1/auth/user/`
- **Returns**: Current user profile data
- **Response**:
```typescript
{
"id": number,
"username": string,
"email": string,
"display_name": string,
"is_active": boolean,
"date_joined": string
}
```
### Password Reset
- **POST** `/api/v1/auth/password/reset/`
- **Body**: `{ "email": string }`
### Password Change
- **POST** `/api/v1/auth/password/change/`
- **Body**: `{ "old_password": string, "new_password": string }`
## User Account Management API
### User Profile
- **GET** `/api/v1/accounts/profile/`
- **Permissions**: Authenticated users only
- **Returns**: Complete user profile including account details, preferences, and statistics
### Update Account
- **PATCH** `/api/v1/accounts/profile/account/`
- **Permissions**: Authenticated users only
- **Body**: `{ "display_name"?: string, "email"?: string }`
### Update Profile
- **PATCH** `/api/v1/accounts/profile/update/`
- **Permissions**: Authenticated users only
- **Body**: `{ "display_name"?: string, "pronouns"?: string, "bio"?: string, "twitter"?: string, "instagram"?: string, "youtube"?: string, "discord"?: string }`
### Avatar Upload System
**✅ FIXED (2025-08-30)**: Avatar upload system is now fully functional! The critical variants field extraction bug has been resolved, and avatar uploads now properly display Cloudflare images instead of falling back to UI-Avatars.
The avatar upload system uses Django-CloudflareImages-Toolkit for secure, direct uploads to Cloudflare Images. This prevents API key exposure to the frontend while providing optimized image delivery.
#### Three-Step Upload Process
**Step 1: Get Upload URL**
- **POST** `/api/v1/cloudflare-images/api/upload-url/`
- **Permissions**: Authenticated users only
- **Body**:
```typescript
{
metadata: {
type: 'avatar',
user_id: number,
context: 'user_profile'
},
require_signed_urls?: boolean,
expiry_minutes?: number,
filename?: string
}
```
- **Returns**:
```typescript
{
id: string, // CloudflareImage ID
cloudflare_id: string, // Cloudflare's image ID
upload_url: string, // Temporary upload URL
expires_at: string, // URL expiration
status: 'pending'
}
```
**Step 2: Direct Upload to Cloudflare**
```javascript
// Frontend uploads directly to Cloudflare
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(upload_url, {
method: 'POST',
body: formData
});
```
**Step 3: Save Avatar Reference**
- **POST** `/api/v1/accounts/profile/avatar/save/`
- **Permissions**: Authenticated users only
- **Body**:
```typescript
{
cloudflare_image_id: string // Cloudflare ID from step 1 response
}
```
- **Returns**:
```typescript
{
success: boolean,
message: string,
avatar_url: string,
avatar_variants: {
thumbnail: string, // 64x64
avatar: string, // 200x200
large: string // 400x400
}
}
```
**CRITICAL FIX (2025-08-30)**: Fixed avatar save endpoint to properly handle Cloudflare API integration. The backend now:
1. **First attempts to find existing CloudflareImage record** by `cloudflare_id`
2. **If not found, calls Cloudflare API** to fetch image details using the `cloudflare_id`
3. **Creates CloudflareImage record** from the API response with proper metadata
4. **Associates the image** with the user's profile
This resolves the "Image not found" error by ensuring the backend can handle cases where the CloudflareImage record doesn't exist in the database yet, but the image exists in Cloudflare.
**ADDITIONAL FIX (2025-08-30)**: Fixed pghistory database schema issue where the `accounts_userprofileevent` table was missing the `avatar_id` field, causing PostgreSQL trigger failures. Updated the event table schema and regenerated pghistory triggers to use the correct field names.
#### Complete Frontend Implementation
```javascript
const uploadAvatar = async (file) => {
try {
// Step 1: Get upload URL with metadata
const uploadUrlResponse = await fetch('/api/v1/cloudflare-images/api/upload-url/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
metadata: {
type: 'avatar',
user_id: currentUser.id,
context: 'user_profile'
},
require_signed_urls: true,
expiry_minutes: 60,
filename: file.name
})
});
const { upload_url, cloudflare_id } = await uploadUrlResponse.json();
// Step 2: Upload directly to Cloudflare
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(upload_url, {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) {
throw new Error('Upload to Cloudflare failed');
}
// Step 3: Save avatar reference in Django
const saveResponse = await fetch('/api/v1/accounts/profile/avatar/save/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
cloudflare_image_id: cloudflare_id
})
});
const result = await saveResponse.json();
if (result.success) {
// Avatar successfully uploaded and saved
return result.avatar_variants;
}
} catch (error) {
console.error('Avatar upload failed:', error);
throw error;
}
};
```
#### Alternative: Legacy Upload Method
- **POST** `/api/v1/accounts/profile/avatar/upload/`
- **Permissions**: Authenticated users only
- **Content-Type**: `multipart/form-data`
- **Body**: FormData with `avatar` field containing image file (JPEG, PNG, WebP)
- **Note**: This method uploads through Django instead of direct to Cloudflare
**⚠️ CRITICAL AUTHENTICATION REQUIREMENT**:
- This endpoint requires authentication via JWT token in Authorization header
- **Common Issue**: "Authentication credentials were not provided" (401 error)
- **Root Cause**: User not logged in or JWT token not being sent
- **Debug Steps**: See `docs/avatar-upload-debugging.md` for comprehensive troubleshooting guide
**Usage Example**:
```typescript
// Ensure user is logged in first
const { user } = useAuth();
if (!user) {
// Redirect to login
return;
}
// Upload avatar
const file = event.target.files[0];
const response = await accountApi.uploadAvatar(file);
```
**Test Credentials** (for debugging):
- Username: `testuser`
- Password: `testpass123`
- Email: `test@example.com`
**Recent Fix (2025-08-29)**:
- Fixed file corruption issue where PNG headers were being corrupted during upload
- Frontend now passes FormData directly instead of extracting and re-wrapping files
- Backend includes corruption detection and repair mechanism
- See `docs/avatar-upload-debugging.md` for complete technical details
### Avatar Delete
- **DELETE** `/api/v1/accounts/profile/avatar/delete/`
- **Permissions**: Authenticated users only
- **Returns**: `{ "success": boolean, "message": string, "avatar_url": string }`
### User Preferences
- **GET** `/api/v1/accounts/preferences/`
- **PATCH** `/api/v1/accounts/preferences/update/`
- **Permissions**: Authenticated users only
### Notification Settings
- **GET** `/api/v1/accounts/settings/notifications/`
- **PATCH** `/api/v1/accounts/settings/notifications/update/`
- **Permissions**: Authenticated users only
### Privacy Settings
- **GET** `/api/v1/accounts/settings/privacy/`
- **PATCH** `/api/v1/accounts/settings/privacy/update/`
- **Permissions**: Authenticated users only
### Security Settings
- **GET** `/api/v1/accounts/settings/security/`
- **PATCH** `/api/v1/accounts/settings/security/update/`
- **Permissions**: Authenticated users only
### User Statistics
- **GET** `/api/v1/accounts/statistics/`
- **Permissions**: Authenticated users only
- **Returns**: User activity statistics, ride credits, contributions, and achievements
### Top Lists
- **GET** `/api/v1/accounts/top-lists/`
- **POST** `/api/v1/accounts/top-lists/create/`
- **PATCH** `/api/v1/accounts/top-lists/{id}/`
- **DELETE** `/api/v1/accounts/top-lists/{id}/delete/`
- **Permissions**: Authenticated users only
### Notifications
- **GET** `/api/v1/accounts/notifications/`
- **PATCH** `/api/v1/accounts/notifications/mark-read/`
- **Permissions**: Authenticated users only
### Account Deletion
- **POST** `/api/v1/accounts/delete-account/request/`
- **POST** `/api/v1/accounts/delete-account/verify/`
- **POST** `/api/v1/accounts/delete-account/cancel/`
- **Permissions**: Authenticated users only
## Statistics API
### Global Statistics
- **GET** `/api/v1/stats/`
- **Returns**: Global platform statistics
### Trending Content
- **GET** `/api/v1/trending/`
- **Query Parameters**:
- `content_type`: Filter by content type (parks, rides, reviews)
- `time_period`: Time period for trending (24h, 7d, 30d)
### Latest Reviews
- **GET** `/api/v1/reviews/latest/`
- **Query Parameters**:
- `limit`: Number of reviews to return
- `park`: Filter by park slug
- `ride`: Filter by ride slug
## Error Handling
All API endpoints return standardized error responses. The system provides enhanced error handling with detailed messages, error codes, and contextual information.
### Standard Error Response Format
```typescript
interface ApiError {
status: "error";
error: {
code: string;
message: string;
details?: any;
request_user?: string;
};
data: null;
}
```
### Enhanced Error Response Format
For critical operations like account deletion, the API returns enhanced error responses with additional context:
```typescript
interface EnhancedApiError {
status: "error";
error: {
code: string;
message: string;
error_code: string;
user_info?: {
username: string;
role: string;
is_superuser: boolean;
is_staff: boolean;
};
help_text?: string;
details?: any;
request_user?: string;
};
data: null;
}
```
### Error Handling in React Components
Here's how to handle and display enhanced error messages in your NextJS components:
```typescript
import { useState } from 'react';
import { toast } from 'react-hot-toast';
interface ErrorDisplayProps {
error: EnhancedApiError | null;
onDismiss: () => void;
}
const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onDismiss }) => {
if (!error) return null;
const { error: errorData } = error;
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-5 w-5 text-red-400" />
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-red-800">
{errorData.message}
</h3>
{errorData.error_code && (
<p className="mt-1 text-xs text-red-600">
Error Code: {errorData.error_code}
</p>
)}
{errorData.user_info && (
<div className="mt-2 text-xs text-red-600">
<p>User: {errorData.user_info.username} ({errorData.user_info.role})</p>
{errorData.user_info.is_superuser && (
<p className="font-medium">⚠️ Superuser Account</p>
)}
</div>
)}
{errorData.help_text && (
<div className="mt-3 p-2 bg-red-100 rounded text-xs text-red-700">
<strong>Help:</strong> {errorData.help_text}
</div>
)}
</div>
<button
onClick={onDismiss}
className="ml-3 flex-shrink-0 text-red-400 hover:text-red-600"
>
<XMarkIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
// Usage in account deletion component
const AccountDeletionForm: React.FC = () => {
const [error, setError] = useState<EnhancedApiError | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleDeleteAccount = async () => {
setIsLoading(true);
setError(null);
try {
await api.accounts.requestAccountDeletion();
toast.success('Account deletion request submitted successfully');
} catch (err: any) {
if (err.response?.data) {
const apiError = err.response.data as EnhancedApiError;
setError(apiError);
// Also show toast for immediate feedback
toast.error(apiError.error.message);
// Log security-related errors for monitoring
if (apiError.error.error_code === 'SUPERUSER_DELETION_BLOCKED') {
console.warn('Superuser deletion attempt blocked:', {
user: apiError.error.user_info?.username,
timestamp: new Date().toISOString()
});
}
} else {
setError({
status: "error",
error: {
code: "UNKNOWN_ERROR",
message: "An unexpected error occurred",
error_code: "UNKNOWN_ERROR"
},
data: null
});
}
} finally {
setIsLoading(false);
}
};
return (
<div className="max-w-md mx-auto">
<ErrorDisplay
error={error}
onDismiss={() => setError(null)}
/>
<button
onClick={handleDeleteAccount}
disabled={isLoading}
className="w-full bg-red-600 text-white py-2 px-4 rounded hover:bg-red-700 disabled:opacity-50"
>
{isLoading ? 'Processing...' : 'Delete Account'}
</button>
</div>
);
};
```
### Toast Notifications for Errors
For immediate user feedback, combine detailed error displays with toast notifications:
```typescript
import { toast } from 'react-hot-toast';
const handleApiError = (error: EnhancedApiError) => {
const { error: errorData } = error;
// Show immediate toast
toast.error(errorData.message, {
duration: 5000,
position: 'top-right',
});
// For critical errors, show additional context
if (errorData.error_code === 'SUPERUSER_DELETION_BLOCKED') {
toast.error('Superuser accounts cannot be deleted for security reasons', {
duration: 8000,
icon: '🔒',
});
}
if (errorData.error_code === 'ADMIN_DELETION_BLOCKED') {
toast.error('Admin accounts with staff privileges cannot be deleted', {
duration: 8000,
icon: '⚠️',
});
}
};
```
### Error Boundary for Global Error Handling
Create an error boundary to catch and display API errors globally:
```typescript
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error: EnhancedApiError | null;
}
class ApiErrorBoundary extends React.Component<
React.PropsWithChildren<{}>,
ErrorBoundaryState
> {
constructor(props: React.PropsWithChildren<{}>) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: any): ErrorBoundaryState {
if (error.response?.data?.status === 'error') {
return {
hasError: true,
error: error.response.data as EnhancedApiError
};
}
return { hasError: true, error: null };
}
render() {
if (this.state.hasError && this.state.error) {
return (
<ErrorDisplay
error={this.state.error}
onDismiss={() => this.setState({ hasError: false, error: null })}
/>
);
}
return this.props.children;
}
}
```
### Common Error Codes
The system uses specific error codes for different scenarios:
- `NOT_AUTHENTICATED`: User not logged in
- `PERMISSION_DENIED`: Insufficient permissions
- `NOT_FOUND`: Resource not found
- `VALIDATION_ERROR`: Invalid request data
- `RATE_LIMITED`: Too many requests
- `SUPERUSER_DELETION_BLOCKED`: Superuser account deletion attempt
- `ADMIN_DELETION_BLOCKED`: Admin account with staff privileges deletion attempt
- `ACCOUNT_DELETION_FAILED`: General account deletion failure
- `SECURITY_VIOLATION`: Security policy violation detected
### Error Logging and Monitoring
For production applications, implement error logging:
```typescript
const logError = (error: EnhancedApiError, context: string) => {
// Log to your monitoring service (e.g., Sentry, LogRocket)
console.error(`API Error in ${context}:`, {
code: error.error.code,
message: error.error.message,
errorCode: error.error.error_code,
userInfo: error.error.user_info,
timestamp: new Date().toISOString(),
context
});
// Send to analytics if it's a security-related error
if (error.error.error_code?.includes('DELETION_BLOCKED')) {
// Track security events
analytics.track('Security Event', {
event: 'Account Deletion Blocked',
errorCode: error.error.error_code,
user: error.error.user_info?.username
});
}
};
```
## Pagination
List endpoints use cursor-based pagination:
```typescript
interface PaginatedResponse<T> {
status: "success";
data: {
results: T[];
count: number;
next: string | null;
previous: string | null;
};
error: null;
}
```
## Rate Limiting
API endpoints are rate limited based on user role:
- Anonymous users: 100 requests/hour
- Authenticated users: 1000 requests/hour
- Moderators: 5000 requests/hour
- Admins: 10000 requests/hour
## WebSocket Connections
Real-time updates are available for:
- Moderation queue updates
- New reports and actions
- Bulk operation progress
- Live statistics updates
Connect to: `ws://localhost:8000/ws/moderation/` (requires authentication)
## Django-CloudflareImages-Toolkit Integration
Successfully migrated from django-cloudflare-images==0.6.0 to django-cloudflareimages-toolkit==1.0.7 with complete field migration from CloudflareImageField to ForeignKey relationships.
### Version 1.0.7 Updates (2025-08-30)
**Critical Bug Fix**: Resolved 415 "Unsupported Media Type" error that was preventing upload URL generation.
**Fixed Issues**:
- ✅ **JSON-encoded metadata**: Metadata is now properly JSON-encoded for Cloudflare API compatibility
- ✅ **Multipart/form-data format**: Upload requests now use the correct multipart/form-data format
- ✅ **Upload URL generation**: The `create_direct_upload_url` method now works correctly
- ✅ **Direct upload flow**: Complete end-to-end upload functionality is now operational
**What This Means for Frontend**:
- Upload URL requests to `/api/v1/cloudflare-images/api/upload-url/` now work correctly
- No more 415 errors when requesting upload URLs
- Direct upload flow is fully functional
- All existing code examples below are now working as documented
### Migration Overview
The migration involved a fundamental architectural change from direct field usage to ForeignKey relationships with the CloudflareImage model, providing enhanced functionality and better integration with Cloudflare Images.
### Key Changes:
- **Package Migration**: Updated dependencies and configuration
- **Model Field Migration**: Changed from direct field usage to ForeignKey relationships
- **Database Schema**: Created CloudflareImage and ImageUploadLog tables
- **Functionality Preserved**: All existing image functionality maintained
### Updated Model Structure:
```python
# User avatars
class User(AbstractUser):
avatar = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.SET_NULL,
null=True,
blank=True
)
# Park photos
class ParkPhoto(TrackedModel):
image = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.CASCADE,
help_text="Park photo stored on Cloudflare Images"
)
# Ride photos
class RidePhoto(TrackedModel):
image = models.ForeignKey(
'django_cloudflareimages_toolkit.CloudflareImage',
on_delete=models.CASCADE,
help_text="Ride photo stored on Cloudflare Images"
)
```
### Direct Upload Flow
The toolkit uses a secure direct upload flow that prevents API key exposure to the frontend:
#### 1. Frontend requests upload URL from backend
```javascript
// Frontend JavaScript
const response = await fetch('/api/v1/cloudflare-images/api/upload-url/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
metadata: { type: 'avatar', user_id: user.id },
require_signed_urls: true,
expiry_minutes: 60,
filename: file.name
})
});
const uploadData = await response.json();
// Returns: {
// id: "uuid-here",
// cloudflare_id: "cloudflare-image-id",
// upload_url: "https://upload.imagedelivery.net/...",
// expires_at: "2024-01-01T12:00:00Z",
// status: "pending"
// }
```
#### 2. Frontend uploads directly to Cloudflare
```javascript
// Upload directly to Cloudflare using temporary URL
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(uploadData.upload_url, {
method: 'POST',
body: formData
});
if (uploadResponse.ok) {
const result = await uploadResponse.json();
console.log('Upload successful:', result);
}
```
#### 3. Backend receives webhook notification
```python
# Django webhook view (automatically handled by toolkit)
@csrf_exempt
def cloudflare_webhook(request):
# Webhook automatically updates CloudflareImage status
# from 'pending' to 'uploaded' when upload completes
pass
```
#### 4. Frontend can now use the permanent image
```javascript
// Check upload status and get permanent URL
const checkStatus = async () => {
const response = await fetch(`/api/v1/cloudflare-images/${uploadData.id}/`);
const image = await response.json();
if (image.status === 'uploaded') {
// Image is ready - use permanent public URL
const permanentUrl = image.public_url;
// e.g., "https://imagedelivery.net/account-hash/image-id/public"
}
};
```
### API Endpoints
The toolkit provides several API endpoints for image management:
#### Create Upload URL
- **POST** `/api/v1/cloudflare-images/api/upload-url/`
- **Body**:
```typescript
{
metadata?: object,
require_signed_urls?: boolean,
expiry_minutes?: number,
filename?: string
}
```
- **Returns**: Upload URL and image metadata
#### List Images
- **GET** `/api/v1/cloudflare-images/`
- **Query Parameters**: Filtering and pagination options
#### Get Image Details
- **GET** `/api/v1/cloudflare-images/{id}/`
- **Returns**: Complete image information including status and URLs
#### Check Image Status
- **POST** `/api/v1/cloudflare-images/{id}/check-status/`
- **Returns**: Updated image status from Cloudflare
#### Get Upload Statistics
- **GET** `/api/v1/cloudflare-images/stats/`
- **Returns**: Upload statistics and metrics
### Usage Examples
#### Avatar Upload Component
```typescript
import { useState } from 'react';
interface AvatarUploadProps {
userId: number;
onUploadComplete: (avatarUrl: string) => void;
}
const AvatarUpload: React.FC<AvatarUploadProps> = ({ userId, onUploadComplete }) => {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setUploading(true);
setProgress(0);
try {
// Step 1: Get upload URL from backend
const uploadUrlResponse = await fetch('/api/v1/cloudflare-images/api/upload-url/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
metadata: { type: 'avatar', user_id: userId },
require_signed_urls: true,
expiry_minutes: 60,
filename: file.name
})
});
const uploadData = await uploadUrlResponse.json();
setProgress(25);
// Step 2: Upload directly to Cloudflare
const formData = new FormData();
formData.append('file', file);
const uploadResponse = await fetch(uploadData.upload_url, {
method: 'POST',
body: formData
});
if (!uploadResponse.ok) {
throw new Error('Upload failed');
}
setProgress(75);
// Step 3: Wait for processing and get final URL
let attempts = 0;
const maxAttempts = 10;
while (attempts < maxAttempts) {
const statusResponse = await fetch(`/api/v1/cloudflare-images/${uploadData.id}/`);
const imageData = await statusResponse.json();
if (imageData.status === 'uploaded' && imageData.public_url) {
setProgress(100);
onUploadComplete(imageData.public_url);
break;
}
// Wait 1 second before checking again
await new Promise(resolve => setTimeout(resolve, 1000));
attempts++;
}
} catch (error) {
console.error('Avatar upload failed:', error);
alert('Upload failed. Please try again.');
} finally {
setUploading(false);
setProgress(0);
}
};
return (
<div className="avatar-upload">
<input
type="file"
accept="image/*"
onChange={handleFileSelect}
disabled={uploading}
className="hidden"
id="avatar-input"
/>
<label
htmlFor="avatar-input"
className={`cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 ${
uploading ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{uploading ? `Uploading... ${progress}%` : 'Choose Avatar'}
</label>
{uploading && (
<div className="mt-2 w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
)}
</div>
);
};
```
#### Park Photo Gallery Upload
```typescript
const ParkPhotoUpload: React.FC<{ parkId: number }> = ({ parkId }) => {
const [photos, setPhotos] = useState<CloudflareImage[]>([]);
const uploadPhoto = async (file: File, caption: string) => {
// Get upload URL
const uploadUrlResponse = await fetch('/api/v1/cloudflare-images/api/upload-url/', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
metadata: {
type: 'park_photo',
park_id: parkId,
caption: caption
},
filename: file.name
})
});
const uploadData = await uploadUrlResponse.json();
// Upload to Cloudflare
const formData = new FormData();
formData.append('file', file);
await fetch(uploadData.upload_url, {
method: 'POST',
body: formData
});
// Create ParkPhoto record
await fetch(`/api/v1/parks/${parkSlug}/photos/`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
image_id: uploadData.id,
caption: caption
})
});
// Refresh photo list
loadPhotos();
};
return (
<div className="photo-upload">
{/* Upload form and photo gallery */}
</div>
);
};
```
### Image Transformations
The toolkit supports Cloudflare Images transformations:
```typescript
// Get different image variants
const getImageUrl = (image: CloudflareImage, variant: string = 'public') => {
return `https://imagedelivery.net/${accountHash}/${image.cloudflare_id}/${variant}`;
};
// Common variants
const thumbnailUrl = getImageUrl(image, 'thumbnail'); // 150x150
const avatarUrl = getImageUrl(image, 'avatar'); // 200x200
const largeUrl = getImageUrl(image, 'large'); // 800x800
const publicUrl = getImageUrl(image, 'public'); // Original size
// Custom transformations
const customUrl = `https://imagedelivery.net/${accountHash}/${image.cloudflare_id}/w=400,h=300,fit=cover,q=85`;
```
### Error Handling
```typescript
const handleUploadError = (error: any) => {
if (error.response?.status === 413) {
toast.error('File too large. Maximum size is 10MB.');
} else if (error.response?.status === 415) {
toast.error('Unsupported file format. Please use JPEG, PNG, or WebP.');
} else if (error.message?.includes('expired')) {
toast.error('Upload URL expired. Please try again.');
} else {
toast.error('Upload failed. Please try again.');
}
};
```
### Configuration
The toolkit is configured in Django settings:
```python
CLOUDFLARE_IMAGES = {
'ACCOUNT_ID': 'your-cloudflare-account-id',
'API_TOKEN': 'your-api-token',
'ACCOUNT_HASH': 'your-account-hash',
'DEFAULT_VARIANT': 'public',
'UPLOAD_TIMEOUT': 300,
'WEBHOOK_SECRET': 'your-webhook-secret',
'CLEANUP_EXPIRED_HOURS': 24,
'MAX_FILE_SIZE': 10 * 1024 * 1024, # 10MB
'ALLOWED_FORMATS': ['jpeg', 'png', 'gif', 'webp'],
'REQUIRE_SIGNED_URLS': False,
'DEFAULT_METADATA': {},
}
```
### Security Features
1. **Temporary Upload URLs**: Upload URLs expire after specified time (default 60 minutes)
2. **No API Key Exposure**: Frontend never sees Cloudflare API credentials
3. **Webhook Verification**: Webhooks are verified using HMAC signatures
4. **File Validation**: Server-side validation of file types and sizes
5. **Signed URLs**: Optional signed URLs for private images
### Cleanup and Maintenance
The toolkit provides management commands for cleanup:
```bash
# Clean up expired upload URLs
python manage.py cleanup_expired_images
# Clean up images older than 7 days
python manage.py cleanup_expired_images --days 7
# Dry run to see what would be deleted
python manage.py cleanup_expired_images --dry-run
```
### Usage Remains Identical
Despite the architectural changes, usage from the application perspective remains the same:
```python
# Getting image URLs works exactly as before
avatar_url = user.avatar.get_url() if user.avatar else None
park_photo_url = park_photo.image.get_url()
ride_photo_url = ride_photo.image.get_url()
# In serializers
class UserSerializer(serializers.ModelSerializer):
avatar_url = serializers.SerializerMethodField()
def get_avatar_url(self, obj):
return obj.avatar.get_url() if obj.avatar else None
```
The migration successfully preserves all existing image functionality while upgrading to the more powerful and feature-rich Django-CloudflareImages-Toolkit.
## Ride Park Change Management
### Overview
The ThrillWiki API provides comprehensive support for moving rides between parks with proper handling of related data, URL updates, slug conflicts, and park area validation.
### Moving Rides Between Parks
#### Update Ride Park
- **PATCH** `/api/v1/rides/{id}/`
- **Body**: `{ "park_id": number }`
- **Permissions**: Authenticated users with appropriate permissions
- **Returns**: Updated ride data with park change information
**Enhanced Response Format**:
```typescript
{
// Standard ride data
"id": number,
"name": string,
"slug": string,
"park": {
"id": number,
"name": string,
"slug": string
},
"url": string, // Updated URL with new park
// Park change information (only present when park changes)
"park_change_info": {
"old_park": {
"id": number,
"name": string,
"slug": string
},
"new_park": {
"id": number,
"name": string,
"slug": string
},
"url_changed": boolean,
"old_url": string,
"new_url": string,
"park_area_cleared": boolean,
"old_park_area": {
"id": number,
"name": string
} | null,
"slug_changed": boolean,
"final_slug": string
}
}
```
### Automatic Handling Features
#### 1. URL Updates
- Frontend URLs are automatically updated to reflect the new park
- Old URL: `https://thrillwiki.com/parks/cedar-point/rides/steel-vengeance/`
- New URL: `https://thrillwiki.com/parks/six-flags-magic-mountain/rides/steel-vengeance/`
#### 2. Slug Conflict Resolution
- System automatically handles slug conflicts within the target park
- If a ride with the same slug exists in the target park, a number suffix is added
- Example: `steel-vengeance` → `steel-vengeance-2`
#### 3. Park Area Validation
- Park areas are automatically cleared if they don't belong to the new park
- Prevents invalid park area assignments across park boundaries
- Frontend should refresh park area options when park changes
#### 4. Historical Data Preservation
- Reviews, photos, and other related data stay with the ride
- All historical data is preserved during park changes
- pghistory tracks all changes for audit purposes
### Frontend Implementation
#### Basic Park Change
```typescript
const moveRideToNewPark = async (rideId: number, newParkId: number) => {
try {
const response = await fetch(`/api/v1/rides/${rideId}/`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
park_id: newParkId
})
});
const updatedRide = await response.json();
if (updatedRide.park_change_info) {
// Handle park change notifications
handleParkChangeNotifications(updatedRide.park_change_info);
}
return updatedRide;
} catch (error) {
if (error.response?.status === 404) {
throw new Error('Target park not found');
}
throw error;
}
};
```
#### Advanced Park Change with Validation
```typescript
interface ParkChangeOptions {
rideId: number;
newParkId: number;
clearParkArea?: boolean;
validateAreas?: boolean;
}
const moveRideWithValidation = async (options: ParkChangeOptions) => {
const { rideId, newParkId, clearParkArea = true, validateAreas = true } = options;
try {
// Optional: Validate park areas before change
if (validateAreas) {
const parkAreas = await fetch(`/api/v1/parks/${newParkId}/areas/`);
const areas = await parkAreas.json();
if (areas.length === 0) {
console.warn('Target park has no defined areas');
}
}
// Perform the park change
const response = await fetch(`/api/v1/rides/${rideId}/`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
park_id: newParkId,
park_area_id: clearParkArea ? null : undefined
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Park change failed');
}
const result = await response.json();
// Handle change notifications
if (result.park_change_info) {
showParkChangeSuccess(result.park_change_info);
}
return result;
} catch (error) {
console.error('Park change failed:', error);
throw error;
}
};
```
#### React Component for Park Change
```typescript
import { useState, useEffect } from 'react';
import { toast } from 'react-hot-toast';
interface ParkChangeModalProps {
ride: Ride;
isOpen: boolean;
onClose: () => void;
onSuccess: (updatedRide: Ride) => void;
}
const ParkChangeModal: React.FC<ParkChangeModalProps> = ({
ride,
isOpen,
onClose,
onSuccess
}) => {
const [parks, setParks] = useState<Park[]>([]);
const [selectedParkId, setSelectedParkId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [warnings, setWarnings] = useState<string[]>([]);
useEffect(() => {
if (isOpen) {
loadParks();
}
}, [isOpen]);
const loadParks = async () => {
try {
const response = await fetch('/api/v1/parks/', {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const data = await response.json();
setParks(data.results.filter(p => p.id !== ride.park.id));
} catch (error) {
toast.error('Failed to load parks');
}
};
const handleParkChange = async () => {
if (!selectedParkId) return;
setIsLoading(true);
setWarnings([]);
try {
const response = await fetch(`/api/v1/rides/${ride.id}/`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
park_id: selectedParkId
})
});
const updatedRide = await response.json();
if (updatedRide.park_change_info) {
const info = updatedRide.park_change_info;
// Show success message
toast.success(
`${ride.name} moved from ${info.old_park.name} to ${info.new_park.name}`
);
// Show warnings if applicable
const newWarnings = [];
if (info.slug_changed) {
newWarnings.push(`Ride slug changed to "${info.final_slug}" to avoid conflicts`);
}
if (info.park_area_cleared) {
newWarnings.push('Park area was cleared (not compatible with new park)');
}
if (info.url_changed) {
newWarnings.push('Ride URL has changed - update any bookmarks');
}
if (newWarnings.length > 0) {
setWarnings(newWarnings);
setTimeout(() => setWarnings([]), 5000);
}
}
onSuccess(updatedRide);
onClose();
} catch (error) {
console.error('Park change failed:', error);
toast.error('Failed to move ride to new park');
} finally {
setIsLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-xl font-bold mb-4">Move Ride to Different Park</h2>
<div className="mb-4">
<p className="text-gray-600 mb-2">
Moving: <strong>{ride.name}</strong>
</p>
<p className="text-gray-600 mb-4">
From: <strong>{ride.park.name}</strong>
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select New Park:
</label>
<select
value={selectedParkId || ''}
onChange={(e) => setSelectedParkId(Number(e.target.value))}
className="w-full border border-gray-300 rounded-md px-3 py-2"
disabled={isLoading}
>
<option value="">Choose a park...</option>
{parks.map((park) => (
<option key={park.id} value={park.id}>
{park.name}
</option>
))}
</select>
</div>
{warnings.length > 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded">
<h4 className="font-medium text-yellow-800 mb-2">Important Changes:</h4>
<ul className="text-sm text-yellow-700 space-y-1">
{warnings.map((warning, index) => (
<li key={index}>• {warning}</li>
))}
</ul>
</div>
)}
<div className="bg-blue-50 border border-blue-200 rounded p-3 mb-4">
<h4 className="font-medium text-blue-800 mb-2">What happens when you move a ride:</h4>
<ul className="text-sm text-blue-700 space-y-1">
<li>• Ride URL will be updated automatically</li>
<li>• Park area will be cleared if incompatible</li>
<li>• Slug conflicts will be resolved automatically</li>
<li>• All reviews and photos stay with the ride</li>
<li>• Change will be logged for audit purposes</li>
</ul>
</div>
<div className="flex space-x-3">
<button
onClick={onClose}
disabled={isLoading}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleParkChange}
disabled={!selectedParkId || isLoading}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? 'Moving...' : 'Move Ride'}
</button>
</div>
</div>
</div>
);
};
```
### Error Handling
#### Common Error Scenarios
```typescript
const handleParkChangeError = (error: any) => {
if (error.response?.status === 404) {
if (error.response.data?.detail === "Target park not found") {
toast.error('The selected park no longer exists');
} else {
toast.error('Ride not found');
}
} else if (error.response?.status === 400) {
const details = error.response.data?.detail;
if (details?.includes('park area')) {
toast.error('Invalid park area for the selected park');
} else {
toast.error('Invalid park change request');
}
} else if (error.response?.status === 403) {
toast.error('You do not have permission to move this ride');
} else {
toast.error('Failed to move ride. Please try again.');
}
};
```
### Validation Rules
#### Park Area Compatibility
```typescript
// Validate park area belongs to selected park
const validateParkArea = async (parkId: number, parkAreaId: number) => {
try {
const response = await fetch(`/api/v1/parks/${parkId}/areas/`);
const areas = await response.json();
const isValid = areas.some((area: any) => area.id === parkAreaId);
if (!isValid) {
throw new Error('Park area does not belong to the selected park');
}
return true;
} catch (error) {
console.error('Park area validation failed:', error);
return false;
}
};
```
### Best Practices
#### 1. User Experience
- Always show confirmation dialogs for park changes
- Display clear information about what will change
- Provide warnings for potential issues (slug conflicts, URL changes)
- Show progress indicators during the operation
#### 2. Data Integrity
- Validate park existence before attempting changes
- Clear incompatible park areas automatically
- Handle slug conflicts gracefully
- Preserve all historical data
#### 3. Error Recovery
- Provide clear error messages
- Offer suggestions for resolving issues
- Allow users to retry failed operations
- Log errors for debugging
#### 4. Performance
- Use optimistic updates where appropriate
- Cache park lists to avoid repeated API calls
- Batch multiple changes when possible
- Provide immediate feedback to users
This comprehensive park change management system ensures data integrity while providing a smooth user experience for moving rides between parks.