Files
thrillwiki_django_no_react/docs/frontend.md

66 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 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:
    {
      "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)"}
      ]
    }
    
  • 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/{slug}/
  • Returns: Complete park information including rides, photos, and statistics

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:
    {
      "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_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:
    {
      "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 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:
    {
      "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 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:
    {
      "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:
    {
      "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

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:

  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

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:

// 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
  • 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

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 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:

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_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:

# 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>
  );
};
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

  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:

# 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-vengeancesteel-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.