Refactor image processing and add image file serving functionality

- Updated image directory handling to prioritize mounted volume for persistence.
- Enhanced logging for directory write tests and fallback mechanisms.
- Introduced a new endpoint to serve image files directly from local paths.
- Added error handling for file serving, including checks for file existence and readability.
- Updated the frontend to include a new ContentView component and corresponding route.
This commit is contained in:
IGNY8 VPS (Salman)
2025-11-12 01:24:44 +00:00
parent 18505de848
commit 645c6f3f9e
8 changed files with 594 additions and 20 deletions

View File

@@ -536,9 +536,10 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
from django.conf import settings
from pathlib import Path
# Create /data/app/images directory if it doesn't exist
# Try absolute path first, fallback to project-relative if needed
images_dir = '/data/app/images'
# Create images directory if it doesn't exist
# Use /data/app/igny8/images (mounted volume) for persistence
# Fallback to /data/app/images if mounted path not available
images_dir = '/data/app/igny8/images' # Use mounted volume path
write_test_passed = False
try:
@@ -549,16 +550,13 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
f.write('test')
os.remove(test_file)
write_test_passed = True
logger.info(f"[process_image_generation_queue] Image {image_id} - Directory writable: {images_dir}")
logger.info(f"[process_image_generation_queue] Image {image_id} - Directory writable (mounted volume): {images_dir}")
except Exception as write_test_error:
logger.warning(f"[process_image_generation_queue] Image {image_id} - Directory not writable: {images_dir}, error: {write_test_error}")
# Fallback to project-relative path
from django.conf import settings
base_dir = Path(settings.BASE_DIR) if hasattr(settings, 'BASE_DIR') else Path(__file__).resolve().parent.parent.parent
images_dir = str(base_dir / 'data' / 'app' / 'images')
logger.warning(f"[process_image_generation_queue] Image {image_id} - Mounted directory not writable: {images_dir}, error: {write_test_error}")
# Fallback to /data/app/images
images_dir = '/data/app/images'
try:
os.makedirs(images_dir, exist_ok=True)
# Test fallback directory write access
test_file = os.path.join(images_dir, '.write_test')
with open(test_file, 'w') as f:
f.write('test')
@@ -566,8 +564,22 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
write_test_passed = True
logger.info(f"[process_image_generation_queue] Image {image_id} - Using fallback directory (writable): {images_dir}")
except Exception as fallback_error:
logger.error(f"[process_image_generation_queue] Image {image_id} - Fallback directory also not writable: {images_dir}, error: {fallback_error}")
raise Exception(f"Neither /data/app/images nor {images_dir} is writable. Last error: {fallback_error}")
logger.warning(f"[process_image_generation_queue] Image {image_id} - Fallback directory also not writable: {images_dir}, error: {fallback_error}")
# Final fallback to project-relative path
from django.conf import settings
base_dir = Path(settings.BASE_DIR) if hasattr(settings, 'BASE_DIR') else Path(__file__).resolve().parent.parent.parent
images_dir = str(base_dir / 'data' / 'app' / 'images')
try:
os.makedirs(images_dir, exist_ok=True)
test_file = os.path.join(images_dir, '.write_test')
with open(test_file, 'w') as f:
f.write('test')
os.remove(test_file)
write_test_passed = True
logger.info(f"[process_image_generation_queue] Image {image_id} - Using project-relative directory (writable): {images_dir}")
except Exception as final_error:
logger.error(f"[process_image_generation_queue] Image {image_id} - All directories not writable. Last error: {final_error}")
raise Exception(f"None of the image directories are writable. Last error: {final_error}")
if not write_test_passed:
raise Exception(f"Failed to find writable directory for saving images")
@@ -620,15 +632,29 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
logger.info(f"[process_image_generation_queue] Image {image_id} - URL length {len(image_url)} chars (was limited to 200, now supports 500)")
try:
# Save file path if available, otherwise save URL
# Save file path and URL appropriately
if saved_file_path:
# Store relative path or full path in image_url field
image.image_url = saved_file_path
# Store local file path in image_path field
image.image_path = saved_file_path
# Also keep the original URL in image_url field for reference
if image_url:
image.image_url = image_url
logger.info(f"[process_image_generation_queue] Image {image_id} - Saved local path: {saved_file_path}")
else:
# Only URL available, save to image_url
image.image_url = image_url
logger.info(f"[process_image_generation_queue] Image {image_id} - Saved URL only: {image_url[:100] if image_url else 'None'}...")
image.status = 'generated'
logger.info(f"[process_image_generation_queue] Image {image_id} - Attempting to save to database")
image.save(update_fields=['image_url', 'status'])
# Determine which fields to update
update_fields = ['status']
if saved_file_path:
update_fields.append('image_path')
if image_url:
update_fields.append('image_url')
logger.info(f"[process_image_generation_queue] Image {image_id} - Attempting to save to database (fields: {update_fields})")
image.save(update_fields=update_fields)
logger.info(f"[process_image_generation_queue] Image {image_id} - Successfully saved to database")
except Exception as save_error:
error_str = str(save_error)
@@ -659,7 +685,8 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
'results': results + [{
'image_id': image_id,
'status': 'completed',
'image_url': saved_file_path or image_url,
'image_url': image_url, # Original URL from API
'image_path': saved_file_path, # Local file path if saved
'revised_prompt': result.get('revised_prompt')
}]
}
@@ -668,7 +695,8 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
results.append({
'image_id': image_id,
'status': 'completed',
'image_url': saved_file_path or image_url,
'image_url': image_url, # Original URL from API
'image_path': saved_file_path, # Local file path if saved
'revised_prompt': result.get('revised_prompt')
})
completed += 1

View File

@@ -367,6 +367,76 @@ class ImagesViewSet(SiteSectorModelViewSet):
else:
serializer.save()
@action(detail=True, methods=['get'], url_path='file', url_name='image_file')
def serve_image_file(self, request, pk=None):
"""
Serve image file from local path via URL
GET /api/v1/writer/images/{id}/file/
"""
import os
from django.http import FileResponse, Http404
from django.conf import settings
try:
# Get image directly without account filtering for file serving
# This allows public access to image files
try:
image = Images.objects.get(pk=pk)
except Images.DoesNotExist:
return Response({
'error': 'Image not found'
}, status=status.HTTP_404_NOT_FOUND)
# Check if image has a local path
if not image.image_path:
return Response({
'error': 'No local file path available for this image'
}, status=status.HTTP_404_NOT_FOUND)
file_path = image.image_path
# Verify file exists
if not os.path.exists(file_path):
return Response({
'error': f'Image file not found at: {file_path}'
}, status=status.HTTP_404_NOT_FOUND)
# Check if file is readable
if not os.access(file_path, os.R_OK):
return Response({
'error': 'Image file is not readable'
}, status=status.HTTP_403_FORBIDDEN)
# Determine content type from file extension
import mimetypes
content_type, _ = mimetypes.guess_type(file_path)
if not content_type:
content_type = 'image/png' # Default to PNG
# Serve the file
try:
return FileResponse(
open(file_path, 'rb'),
content_type=content_type,
filename=os.path.basename(file_path)
)
except Exception as e:
return Response({
'error': f'Failed to serve file: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
except Images.DoesNotExist:
return Response({
'error': 'Image not found'
}, status=status.HTTP_404_NOT_FOUND)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error serving image file: {str(e)}", exc_info=True)
return Response({
'error': f'Failed to serve image: {str(e)}'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
@action(detail=False, methods=['post'], url_path='auto_generate', url_name='auto_generate_images')
def auto_generate_images(self, request):
"""Auto-generate images for tasks using AI"""

File diff suppressed because one or more lines are too long

View File

@@ -25,6 +25,7 @@ const KeywordOpportunities = lazy(() => import("./pages/Planner/KeywordOpportuni
const WriterDashboard = lazy(() => import("./pages/Writer/Dashboard"));
const Tasks = lazy(() => import("./pages/Writer/Tasks"));
const Content = lazy(() => import("./pages/Writer/Content"));
const ContentView = lazy(() => import("./pages/Writer/ContentView"));
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
const Images = lazy(() => import("./pages/Writer/Images"));
const Published = lazy(() => import("./pages/Writer/Published"));
@@ -159,11 +160,18 @@ export default function App() {
<Tasks />
</Suspense>
} />
{/* Writer Content Routes - Order matters: list route must come before detail route */}
<Route path="/writer/content" element={
<Suspense fallback={null}>
<Content />
</Suspense>
} />
{/* Content detail view - matches /writer/content/:id (e.g., /writer/content/10) */}
<Route path="/writer/content/:id" element={
<Suspense fallback={null}>
<ContentView />
</Suspense>
} />
<Route path="/writer/drafts" element={<Navigate to="/writer/content" replace />} />
<Route path="/writer/images" element={
<Suspense fallback={null}>

View File

@@ -0,0 +1,71 @@
/**
* ContentView Page - Displays individual content using ContentViewTemplate
* Route: /writer/content/:id
*/
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router';
import ContentViewTemplate from '../../templates/ContentViewTemplate';
import { fetchContentById, Content } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import PageMeta from '../../components/common/PageMeta';
export default function ContentView() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const toast = useToast();
const [content, setContent] = useState<Content | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadContent = async () => {
// Validate ID parameter - must be a number
if (!id) {
toast.error('Content ID is required');
navigate('/writer/content');
return;
}
const contentId = parseInt(id, 10);
if (isNaN(contentId) || contentId <= 0) {
toast.error('Invalid content ID');
navigate('/writer/content');
return;
}
setLoading(true);
try {
const data = await fetchContentById(contentId);
setContent(data);
} catch (error: any) {
console.error('Error loading content:', error);
toast.error(`Failed to load content: ${error.message || 'Unknown error'}`);
setContent(null);
} finally {
setLoading(false);
}
};
loadContent();
}, [id, navigate, toast]);
const handleBack = () => {
navigate('/writer/content');
};
return (
<>
<PageMeta
title={content ? `${content.meta_title || content.title || `Content #${content.id}`} - IGNY8` : 'Content View - IGNY8'}
description={content?.meta_description || 'View content details and metadata'}
/>
<ContentViewTemplate
content={content}
loading={loading}
onBack={handleBack}
/>
</>
);
}

View File

@@ -1589,3 +1589,7 @@ export async function fetchContent(filters: ContentFilters = {}): Promise<Conten
return fetchAPI(`/v1/writer/content/${queryString ? `?${queryString}` : ''}`);
}
export async function fetchContentById(id: number): Promise<Content> {
return fetchAPI(`/v1/writer/content/${id}/`);
}

View File

@@ -0,0 +1,393 @@
/**
* ContentViewTemplate - Template for displaying individual content with all metadata
*
* Features:
* - Centered layout with max-width 1200px
* - Modern styling with Tailwind CSS
* - Displays all content metadata
* - Responsive design
*
* Usage:
* <ContentViewTemplate
* content={contentData}
* loading={false}
* onBack={() => navigate('/writer/content')}
* />
*/
import React from 'react';
import { Content } from '../services/api';
import { ArrowLeftIcon, CalendarIcon, TagIcon, FileTextIcon, CheckCircleIcon, XCircleIcon, ClockIcon } from '../icons';
interface ContentViewTemplateProps {
content: Content | null;
loading: boolean;
onBack?: () => void;
}
export default function ContentViewTemplate({ content, loading, onBack }: ContentViewTemplateProps) {
if (loading) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="animate-pulse">
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-1/4 mb-6"></div>
<div className="h-12 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-4"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
);
}
if (!content) {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 p-8 text-center">
<XCircleIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h2 className="text-2xl font-semibold text-gray-900 dark:text-white mb-2">Content Not Found</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">The content you're looking for doesn't exist or has been deleted.</p>
{onBack && (
<button
onClick={onBack}
className="inline-flex items-center gap-2 px-4 py-2 bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors"
>
<ArrowLeftIcon className="w-4 h-4" />
Back to Content List
</button>
)}
</div>
</div>
</div>
);
}
const formatDate = (dateString: string) => {
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return dateString;
}
};
const getStatusColor = (status: string) => {
const statusLower = status.toLowerCase();
if (statusLower === 'generated' || statusLower === 'published' || statusLower === 'complete') {
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
}
if (statusLower === 'pending' || statusLower === 'draft') {
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
}
if (statusLower === 'failed' || statusLower === 'error') {
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
}
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-8">
<div className="max-w-[1200px] mx-auto px-4 sm:px-6 lg:px-8">
{/* Back Button */}
{onBack && (
<button
onClick={onBack}
className="mb-6 inline-flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<ArrowLeftIcon className="w-5 h-5" />
<span className="font-medium">Back to Content</span>
</button>
)}
{/* Main Content Card */}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header Section */}
<div className="bg-gradient-to-r from-brand-500 to-brand-600 px-8 py-6 text-white">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-3">
<FileTextIcon className="w-6 h-6" />
<span className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(content.status)}`}>
{content.status}
</span>
</div>
<h1 className="text-3xl font-bold mb-2">
{content.meta_title || content.title || `Content #${content.id}`}
</h1>
{content.meta_description && (
<p className="text-brand-50 text-lg leading-relaxed">
{content.meta_description}
</p>
)}
</div>
</div>
</div>
{/* Metadata Section */}
<div className="px-8 py-6 border-b border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* Basic Info */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Basic Information
</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<CalendarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Generated</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{formatDate(content.generated_at)}
</p>
</div>
</div>
{content.updated_at && content.updated_at !== content.generated_at && (
<div className="flex items-start gap-3">
<ClockIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Last Updated</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{formatDate(content.updated_at)}
</p>
</div>
</div>
)}
<div className="flex items-start gap-3">
<FileTextIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Word Count</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{content.word_count?.toLocaleString() || 'N/A'} words
</p>
</div>
</div>
</div>
</div>
{/* Task & Sector Info */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Related Information
</h3>
<div className="space-y-3">
{content.task_title && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Task</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{content.task_title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">ID: {content.task_id}</p>
</div>
)}
{content.sector_name && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Sector</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{content.sector_name}
</p>
</div>
)}
</div>
</div>
{/* Keywords & Tags */}
<div className="space-y-4">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
SEO & Tags
</h3>
<div className="space-y-3">
{content.primary_keyword && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">Primary Keyword</p>
<p className="text-sm font-medium text-gray-900 dark:text-white">
{content.primary_keyword}
</p>
</div>
)}
{content.secondary_keywords && content.secondary_keywords.length > 0 && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Secondary Keywords</p>
<div className="flex flex-wrap gap-2">
{content.secondary_keywords.map((keyword, idx) => (
<span
key={idx}
className="px-2 py-1 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded text-xs"
>
{keyword}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Tags and Categories */}
{(content.tags && content.tags.length > 0) || (content.categories && content.categories.length > 0) ? (
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
<div className="flex flex-wrap gap-4">
{content.tags && content.tags.length > 0 && (
<div className="flex items-center gap-2">
<TagIcon className="w-4 h-4 text-gray-400" />
<div className="flex flex-wrap gap-2">
{content.tags.map((tag, idx) => (
<span
key={idx}
className="px-3 py-1 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-full text-xs font-medium"
>
{tag}
</span>
))}
</div>
</div>
)}
{content.categories && content.categories.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">Categories:</span>
<div className="flex flex-wrap gap-2">
{content.categories.map((category, idx) => (
<span
key={idx}
className="px-3 py-1 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium"
>
{category}
</span>
))}
</div>
</div>
)}
</div>
</div>
) : null}
</div>
{/* Image Status */}
{(content.has_image_prompts || content.has_generated_images) && (
<div className="px-8 py-4 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-4">
{content.has_image_prompts && (
<div className="flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5 text-green-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">Image Prompts Generated</span>
</div>
)}
{content.has_generated_images && (
<div className="flex items-center gap-2">
<CheckCircleIcon className="w-5 h-5 text-green-500" />
<span className="text-sm text-gray-700 dark:text-gray-300">Images Generated</span>
</div>
)}
</div>
</div>
)}
{/* HTML Content */}
<div className="px-8 py-8">
<div className="prose prose-lg dark:prose-invert max-w-none">
<div
dangerouslySetInnerHTML={{ __html: content.html_content }}
className="content-html"
/>
</div>
</div>
{/* Metadata JSON (Collapsible) */}
{content.metadata && Object.keys(content.metadata).length > 0 && (
<div className="px-8 py-6 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<details className="group">
<summary className="cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
View Full Metadata
</summary>
<div className="mt-4 p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-x-auto">
{JSON.stringify(content.metadata, null, 2)}
</pre>
</div>
</details>
</div>
)}
</div>
</div>
{/* Custom Styles for Content HTML */}
<style>{`
.content-html {
line-height: 1.75;
}
.content-html h1,
.content-html h2,
.content-html h3,
.content-html h4 {
font-weight: 700;
margin-top: 2em;
margin-bottom: 1em;
}
.content-html h1 { font-size: 2.25em; }
.content-html h2 { font-size: 1.875em; }
.content-html h3 { font-size: 1.5em; }
.content-html h4 { font-size: 1.25em; }
.content-html p {
margin-bottom: 1.25em;
}
.content-html ul,
.content-html ol {
margin-bottom: 1.25em;
padding-left: 1.625em;
}
.content-html li {
margin-bottom: 0.5em;
}
.content-html img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1.5em 0;
}
.content-html a {
color: #3b82f6;
text-decoration: underline;
}
.content-html a:hover {
color: #2563eb;
}
.content-html blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1em;
margin: 1.5em 0;
font-style: italic;
}
.content-html code {
background-color: #f3f4f6;
padding: 0.125em 0.375em;
border-radius: 0.25rem;
font-size: 0.875em;
}
.content-html pre {
background-color: #f3f4f6;
padding: 1em;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1.5em 0;
}
.dark .content-html blockquote {
border-left-color: #4b5563;
}
.dark .content-html code,
.dark .content-html pre {
background-color: #1f2937;
}
`}</style>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB