mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 06:51:08 -05:00
- 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.
2266 lines
67 KiB
Markdown
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.
|