- 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.
67 KiB
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:
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 reportassigned_moderator: Filter by assigned moderator IDcreated_after: Filter reports created after date (ISO format)created_before: Filter reports created before date (ISO format)unassigned: Boolean filter for unassigned reportsoverdue: Boolean filter for overdue reports based on SLAsearch: Search in reason and description fieldsordering: 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 IDunassigned: Boolean filter for unassigned itemshas_related_report: Boolean filter for items with related reportssearch: 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 IDtarget_user: Filter by target user IDis_active: Boolean filter for active actionsexpired: Boolean filter for expired actionsexpiring_soon: Boolean filter for actions expiring within 24 hourshas_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 typepriority: Filter by prioritycreated_by: Filter by creator IDcan_cancel: Boolean filter for cancellable operationshas_failures: Boolean filter for operations with failuresin_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 emailrole: Filter by user rolehas_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 paginationpage_size(int): Number of results per pagesearch(string): Search in park names and descriptionscontinent(string): Filter by continentcountry(string): Filter by countrystate(string): Filter by state/provincecity(string): Filter by citypark_type(string): Filter by park type (THEME_PARK, AMUSEMENT_PARK, WATER_PARK, etc.)status(string): Filter by operational statusoperator_id(int): Filter by operator company IDoperator_slug(string): Filter by operator company slugproperty_owner_id(int): Filter by property owner company IDproperty_owner_slug(string): Filter by property owner company slugmin_rating(number): Minimum average ratingmax_rating(number): Maximum average ratingmin_ride_count(int): Minimum total ride countmax_ride_count(int): Maximum total ride countopening_year(int): Filter by specific opening yearmin_opening_year(int): Minimum opening yearmax_opening_year(int): Maximum opening yearhas_roller_coasters(boolean): Filter parks that have roller coastersmin_roller_coaster_count(int): Minimum roller coaster countmax_roller_coaster_count(int): Maximum roller coaster countordering(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:
{ "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:
[ { "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/
- By ID:
- 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.mdfor 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 descriptionspark: Filter by park slugmanufacturer: Filter by manufacturer slugride_type: Filter by ride typestatus: Filter by operational statusopened_after: Filter rides opened after dateopened_before: Filter rides opened before dateheight_min: Minimum height requirementheight_max: Maximum height requirementhas_photos: Boolean filter for rides with photosordering: 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 namescountry: Filter by countryhas_rides: Boolean filter for manufacturers with ridesordering: 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:
{ "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:
{ "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:
{ "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_nameis 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:
{ "message": "Email verified successfully. You can now log in.", "success": true } - Error Response (404):
{ "error": "Invalid or expired verification token" }
Resend Verification Email
- POST
/api/v1/auth/resend-verification/ - Body:
{ "email": string } - Returns: Resend confirmation
- Response:
{ "message": "Verification email sent successfully", "success": true } - Error Responses:
400: Email already verified or email address required500: 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:
{ "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:
{ "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:
{ "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:
{ "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:
{ "success": boolean, "message": string, "provider": string, "auth_url": string } - Error Responses:
400: Provider already connected or invalid provider500: 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:
{ "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 connected500: Disconnection failed
Get Social Authentication Status
- GET
/api/v1/auth/social/status/ - Permissions: Authenticated users only
- Returns: Comprehensive social authentication status
- Response:
{ "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:
-
Disconnection Safety: Users can only disconnect a social provider if they have:
- Another social provider connected, OR
- Email + password authentication set up
-
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."
-
Suggested Actions: When disconnection is blocked, the API provides suggestions:
- "Set up password authentication"
- "Connect another social provider"
Usage Examples
Check Social Provider Status
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
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
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
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:
{ "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:
{ metadata: { type: 'avatar', user_id: number, context: 'user_profile' }, require_signed_urls?: boolean, expiry_minutes?: number, filename?: string } - Returns:
{ 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
// 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:
{ cloudflare_image_id: string // Cloudflare ID from step 1 response } - Returns:
{ 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:
- First attempts to find existing CloudflareImage record by
cloudflare_id - If not found, calls Cloudflare API to fetch image details using the
cloudflare_id - Creates CloudflareImage record from the API response with proper metadata
- 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
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
avatarfield 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.mdfor comprehensive troubleshooting guide
Usage Example:
// 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.mdfor 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 returnpark: Filter by park slugride: 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
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:
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:
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:
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:
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 inPERMISSION_DENIED: Insufficient permissionsNOT_FOUND: Resource not foundVALIDATION_ERROR: Invalid request dataRATE_LIMITED: Too many requestsSUPERUSER_DELETION_BLOCKED: Superuser account deletion attemptADMIN_DELETION_BLOCKED: Admin account with staff privileges deletion attemptACCOUNT_DELETION_FAILED: General account deletion failureSECURITY_VIOLATION: Security policy violation detected
Error Logging and Monitoring
For production applications, implement error logging:
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:
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_urlmethod 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:
# 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
// 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
// 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
# 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
// 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:
{ 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
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
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:
// 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
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:
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
- Temporary Upload URLs: Upload URLs expire after specified time (default 60 minutes)
- No API Key Exposure: Frontend never sees Cloudflare API credentials
- Webhook Verification: Webhooks are verified using HMAC signatures
- File Validation: Server-side validation of file types and sizes
- Signed URLs: Optional signed URLs for private images
Cleanup and Maintenance
The toolkit provides management commands for cleanup:
# 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:
# 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:
{
// 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
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
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
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
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
// 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.