mirror of
https://github.com/pacnpal/thrillwiki_django_no_react.git
synced 2025-12-20 10:51:09 -05:00
feat: Implement avatar upload system with Cloudflare integration
- Added migration to transition avatar data from CloudflareImageField to ForeignKey structure in UserProfile. - Fixed UserProfileEvent avatar field to align with new avatar structure. - Created serializers for social authentication, including connected and available providers. - Developed request logging middleware for comprehensive request/response logging. - Updated moderation and parks migrations to remove outdated triggers and adjust foreign key relationships. - Enhanced rides migrations to ensure proper handling of image uploads and triggers. - Introduced a test script for the 3-step avatar upload process, ensuring functionality with Cloudflare. - Documented the fix for avatar upload issues, detailing root cause, implementation, and verification steps. - Implemented automatic deletion of Cloudflare images upon avatar, park, and ride photo changes or removals.
This commit is contained in:
322
docs/lib-api.ts
322
docs/lib-api.ts
@@ -7,6 +7,9 @@ import type {
|
||||
LoginResponse,
|
||||
SignupRequest,
|
||||
SignupResponse,
|
||||
EmailVerificationResponse,
|
||||
ResendVerificationRequest,
|
||||
ResendVerificationResponse,
|
||||
LogoutResponse,
|
||||
CurrentUserResponse,
|
||||
PasswordResetRequest,
|
||||
@@ -16,6 +19,20 @@ import type {
|
||||
SocialProvidersResponse,
|
||||
AuthStatusResponse,
|
||||
|
||||
// Django-CloudflareImages-Toolkit Types
|
||||
CloudflareImage,
|
||||
CloudflareImageUploadRequest,
|
||||
CloudflareImageUploadResponse,
|
||||
CloudflareDirectUploadRequest,
|
||||
CloudflareDirectUploadResponse,
|
||||
CloudflareImageVariant,
|
||||
CloudflareImageStats,
|
||||
CloudflareImageListResponse,
|
||||
CloudflareImageDeleteResponse,
|
||||
CloudflareWebhookPayload,
|
||||
EnhancedPhoto,
|
||||
EnhancedImageVariants,
|
||||
|
||||
// Social Provider Management Types
|
||||
ConnectedProvider,
|
||||
AvailableProvider,
|
||||
@@ -111,6 +128,9 @@ import type {
|
||||
CreateParkRequest,
|
||||
ParkDetail,
|
||||
ParkFilterOptions,
|
||||
ParkSearchFilters,
|
||||
ParkCompanySearchResponse,
|
||||
ParkSearchSuggestionsResponse,
|
||||
ParkImageSettings,
|
||||
ParkPhotosResponse,
|
||||
UploadParkPhoto,
|
||||
@@ -347,13 +367,25 @@ export const authApi = {
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
// Store access token on successful signup
|
||||
setAuthToken(response.access);
|
||||
// Store refresh token separately
|
||||
setRefreshToken(response.refresh);
|
||||
// Only store tokens if email verification is not required
|
||||
if (response.access && response.refresh) {
|
||||
setAuthToken(response.access);
|
||||
setRefreshToken(response.refresh);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
|
||||
async verifyEmail(token: string): Promise<EmailVerificationResponse> {
|
||||
return makeRequest<EmailVerificationResponse>(`/auth/verify-email/${token}/`);
|
||||
},
|
||||
|
||||
async resendVerificationEmail(data: ResendVerificationRequest): Promise<ResendVerificationResponse> {
|
||||
return makeRequest<ResendVerificationResponse>('/auth/resend-verification/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async refreshToken(): Promise<{ access: string; refresh: string }> {
|
||||
const refreshToken = getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
@@ -547,6 +579,13 @@ export const accountApi = {
|
||||
});
|
||||
},
|
||||
|
||||
async saveAvatarImage(data: { cloudflare_image_id: string }): Promise<AvatarUploadResponse> {
|
||||
return makeRequest<AvatarUploadResponse>('/accounts/profile/avatar/save/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async getPreferences(): Promise<UserPreferences> {
|
||||
return makeRequest<UserPreferences>('/accounts/preferences/');
|
||||
},
|
||||
@@ -700,18 +739,13 @@ export const accountApi = {
|
||||
// ============================================================================
|
||||
|
||||
export const parksApi = {
|
||||
async getParks(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
country?: string;
|
||||
state?: string;
|
||||
ordering?: string;
|
||||
}): Promise<ParkListResponse> {
|
||||
async getParks(params?: ParkSearchFilters): Promise<ParkListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
// Note: continent and park_type parameters are accepted but ignored by backend
|
||||
// due to missing model fields (ParkLocation has no continent, Park has no park_type)
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
@@ -749,12 +783,12 @@ export const parksApi = {
|
||||
return makeRequest<ParkFilterOptions>('/parks/filter-options/');
|
||||
},
|
||||
|
||||
async searchCompanies(query: string): Promise<CompanySearchResponse> {
|
||||
return makeRequest<CompanySearchResponse>(`/parks/search/companies/?q=${encodeURIComponent(query)}`);
|
||||
async searchCompanies(query: string): Promise<ParkCompanySearchResponse> {
|
||||
return makeRequest<ParkCompanySearchResponse>(`/parks/search/companies/?q=${encodeURIComponent(query)}`);
|
||||
},
|
||||
|
||||
async getSearchSuggestions(query: string): Promise<SearchSuggestionsResponse> {
|
||||
return makeRequest<SearchSuggestionsResponse>(`/parks/search-suggestions/?q=${encodeURIComponent(query)}`);
|
||||
async getSearchSuggestions(query: string): Promise<ParkSearchSuggestionsResponse> {
|
||||
return makeRequest<ParkSearchSuggestionsResponse>(`/parks/search-suggestions/?q=${encodeURIComponent(query)}`);
|
||||
},
|
||||
|
||||
async setParkImages(parkId: number, data: ParkImageSettings): Promise<ParkDetail> {
|
||||
@@ -807,6 +841,13 @@ export const parksApi = {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async saveParkPhoto(parkId: number, data: { cloudflare_image_id: string; caption?: string; alt_text?: string; photo_type?: string; is_primary?: boolean }): Promise<any> {
|
||||
return makeRequest(`/parks/${parkId}/photos/save_image/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -927,6 +968,13 @@ export const ridesApi = {
|
||||
async getManufacturerRideModels(manufacturerSlug: string): Promise<ManufacturerRideModels> {
|
||||
return makeRequest<ManufacturerRideModels>(`/rides/manufacturers/${manufacturerSlug}/`);
|
||||
},
|
||||
|
||||
async saveRidePhoto(rideId: number, data: { cloudflare_image_id: string; caption?: string; alt_text?: string; photo_type?: string; is_primary?: boolean }): Promise<any> {
|
||||
return makeRequest(`/rides/${rideId}/photos/save_image/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
@@ -1692,6 +1740,247 @@ export const parkReviewsApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Django-CloudflareImages-Toolkit API (Updated to match actual endpoints)
|
||||
// ============================================================================
|
||||
|
||||
export const cloudflareImagesApi = {
|
||||
// Direct Upload Flow - Get temporary upload URL
|
||||
async createDirectUploadUrl(data?: CloudflareDirectUploadRequest): Promise<CloudflareDirectUploadResponse> {
|
||||
return makeRequest<CloudflareDirectUploadResponse>('/cloudflare-images/api/upload-url/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data || {}),
|
||||
});
|
||||
},
|
||||
|
||||
// Upload image using temporary URL (client-side to Cloudflare)
|
||||
async uploadToCloudflare(uploadUrl: string, file: File, metadata?: Record<string, any>): Promise<CloudflareImageUploadResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (metadata) {
|
||||
Object.entries(metadata).forEach(([key, value]) => {
|
||||
formData.append(`metadata[${key}]`, value.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// Upload directly to Cloudflare (bypasses our API)
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new ApiError(
|
||||
errorData.errors?.[0]?.message || `Upload failed: ${response.status}`,
|
||||
response.status,
|
||||
errorData
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
// Complete upload flow - get URL then upload
|
||||
async uploadImage(file: File, options?: {
|
||||
expiry_minutes?: number; // Minutes until upload URL expires
|
||||
metadata?: Record<string, any>;
|
||||
require_signed_urls?: boolean;
|
||||
filename?: string;
|
||||
}): Promise<{
|
||||
uploadResponse: CloudflareImageUploadResponse;
|
||||
directUploadResponse: CloudflareDirectUploadResponse;
|
||||
}> {
|
||||
// Step 1: Get temporary upload URL
|
||||
const directUploadResponse = await this.createDirectUploadUrl({
|
||||
expiry_minutes: options?.expiry_minutes,
|
||||
metadata: options?.metadata,
|
||||
require_signed_urls: options?.require_signed_urls,
|
||||
filename: options?.filename,
|
||||
});
|
||||
|
||||
// Step 2: Upload to Cloudflare
|
||||
const uploadResponse = await this.uploadToCloudflare(
|
||||
directUploadResponse.upload_url,
|
||||
file,
|
||||
options?.metadata
|
||||
);
|
||||
|
||||
return { uploadResponse, directUploadResponse };
|
||||
},
|
||||
|
||||
// List images with pagination
|
||||
async listImages(params?: {
|
||||
status?: "pending" | "uploaded" | "failed" | "expired";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<CloudflareImageListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const query = searchParams.toString();
|
||||
return makeRequest<CloudflareImageListResponse>(`/cloudflare-images/api/images/${query ? `?${query}` : ''}`);
|
||||
},
|
||||
|
||||
// Get image details
|
||||
async getImage(imageId: string): Promise<CloudflareImage> {
|
||||
return makeRequest<CloudflareImage>(`/cloudflare-images/api/images/${imageId}/`);
|
||||
},
|
||||
|
||||
// Check image status
|
||||
async checkImageStatus(imageId: string): Promise<CloudflareImage> {
|
||||
return makeRequest<CloudflareImage>(`/cloudflare-images/api/images/${imageId}/check_status/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
// Update image metadata
|
||||
async updateImage(imageId: string, data: {
|
||||
metadata?: Record<string, any>;
|
||||
require_signed_urls?: boolean;
|
||||
}): Promise<CloudflareImage> {
|
||||
return makeRequest<CloudflareImage>(`/cloudflare-images/api/images/${imageId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
// Delete image
|
||||
async deleteImage(imageId: string): Promise<CloudflareImageDeleteResponse> {
|
||||
return makeRequest<CloudflareImageDeleteResponse>(`/cloudflare-images/api/images/${imageId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
// Get account statistics
|
||||
async getStats(): Promise<CloudflareImageStats> {
|
||||
return makeRequest<CloudflareImageStats>('/cloudflare-images/api/stats/');
|
||||
},
|
||||
|
||||
// Get available variants
|
||||
async getVariants(): Promise<CloudflareImageVariant[]> {
|
||||
return makeRequest<CloudflareImageVariant[]>('/cloudflare-images/api/variants/');
|
||||
},
|
||||
|
||||
// Create new variant
|
||||
async createVariant(data: Omit<CloudflareImageVariant, 'id'>): Promise<CloudflareImageVariant> {
|
||||
return makeRequest<CloudflareImageVariant>('/cloudflare-images/api/variants/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
// Update variant
|
||||
async updateVariant(variantId: string, data: Partial<Omit<CloudflareImageVariant, 'id'>>): Promise<CloudflareImageVariant> {
|
||||
return makeRequest<CloudflareImageVariant>(`/cloudflare-images/api/variants/${variantId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
// Delete variant
|
||||
async deleteVariant(variantId: string): Promise<void> {
|
||||
return makeRequest<void>(`/cloudflare-images/api/variants/${variantId}/`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
// Webhook handling (for server-side processing)
|
||||
async processWebhook(payload: CloudflareWebhookPayload): Promise<{ success: boolean; message: string }> {
|
||||
return makeRequest('/cloudflare-images/api/webhook/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
// Utility functions for URL generation
|
||||
generateImageUrl(imageId: string, variant: string = 'public'): string {
|
||||
// This would typically use your Cloudflare account hash
|
||||
// The actual implementation would get this from your backend configuration
|
||||
return `/cloudflare-images/serve/${imageId}/${variant}`;
|
||||
},
|
||||
|
||||
generateSignedUrl(imageId: string, variant: string = 'public', expiryMinutes: number = 60): Promise<{ url: string; expires_at: string }> {
|
||||
return makeRequest(`/cloudflare-images/api/signed-url/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
image_id: imageId,
|
||||
variant,
|
||||
expiry_minutes: expiryMinutes,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
// Batch operations
|
||||
async batchDelete(imageIds: string[]): Promise<{
|
||||
success: string[];
|
||||
failed: Array<{ id: string; error: string }>;
|
||||
}> {
|
||||
return makeRequest('/cloudflare-images/api/batch/delete/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ image_ids: imageIds }),
|
||||
});
|
||||
},
|
||||
|
||||
async batchUpdateMetadata(updates: Array<{
|
||||
image_id: string;
|
||||
metadata: Record<string, any>;
|
||||
}>): Promise<{
|
||||
success: string[];
|
||||
failed: Array<{ id: string; error: string }>;
|
||||
}> {
|
||||
return makeRequest('/cloudflare-images/api/batch/update-metadata/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ updates }),
|
||||
});
|
||||
},
|
||||
|
||||
// Search and filter images
|
||||
async searchImages(params: {
|
||||
metadata_key?: string;
|
||||
metadata_value?: string;
|
||||
uploaded_after?: string; // ISO date
|
||||
uploaded_before?: string; // ISO date
|
||||
filename_contains?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}): Promise<CloudflareImageListResponse> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const query = searchParams.toString();
|
||||
return makeRequest<CloudflareImageListResponse>(`/cloudflare-images/api/search/${query ? `?${query}` : ''}`);
|
||||
},
|
||||
|
||||
// Cleanup expired upload URLs
|
||||
async cleanupExpiredUploads(): Promise<{
|
||||
cleaned_count: number;
|
||||
message: string;
|
||||
}> {
|
||||
return makeRequest('/cloudflare-images/api/cleanup/', {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
// Backward compatibility aliases
|
||||
getDirectUploadUrl: function(data?: CloudflareDirectUploadRequest): Promise<CloudflareDirectUploadResponse> {
|
||||
return this.createDirectUploadUrl(data);
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// History API
|
||||
// ============================================================================
|
||||
@@ -2168,6 +2457,7 @@ export default {
|
||||
userModeration: userModerationApi,
|
||||
bulkOperations: bulkOperationsApi,
|
||||
parkReviews: parkReviewsApi,
|
||||
cloudflareImages: cloudflareImagesApi,
|
||||
external: externalApi,
|
||||
utils: apiUtils,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user