Browse Source
feat(chat): add file and image attachment support to ChatMessage model
feat(chat): add file and image attachment support to ChatMessage model
- Introduced `file_attachment` and `image_attachment` fields to the ChatMessage model with appropriate upload paths. - Updated ChatMessageAdmin to display attachment status and previews in the admin interface. - Added utility functions for thumbnail generation and video frame extraction. - Created a migration to add new fields to the database. - Enhanced UploadTmpSerializer to include thumbnail URL generation for uploaded images and videos.master
5 changed files with 363 additions and 5 deletions
-
51apps/chat/admin.py
-
24apps/chat/migrations/0003_add_file_and_image_attachments.py
-
49apps/chat/models.py
-
52utils/__init__.py
-
192utils/image_utils.py
@ -0,0 +1,24 @@ |
|||||
|
# Generated by Django 3.2.4 on 2025-10-23 11:57 |
||||
|
|
||||
|
import apps.chat.models |
||||
|
from django.db import migrations, models |
||||
|
|
||||
|
|
||||
|
class Migration(migrations.Migration): |
||||
|
|
||||
|
dependencies = [ |
||||
|
('chat', '0002_chatmessage_metadata'), |
||||
|
] |
||||
|
|
||||
|
operations = [ |
||||
|
migrations.AddField( |
||||
|
model_name='chatmessage', |
||||
|
name='file_attachment', |
||||
|
field=models.FileField(blank=True, help_text='For file and audio messages', max_length=500, null=True, upload_to=apps.chat.models.chat_upload_path, verbose_name='File Attachment'), |
||||
|
), |
||||
|
migrations.AddField( |
||||
|
model_name='chatmessage', |
||||
|
name='image_attachment', |
||||
|
field=models.ImageField(blank=True, help_text='For image messages', max_length=500, null=True, upload_to=apps.chat.models.chat_upload_path, verbose_name='Image Attachment'), |
||||
|
), |
||||
|
] |
||||
@ -0,0 +1,192 @@ |
|||||
|
""" |
||||
|
Image and video processing utilities for thumbnail generation |
||||
|
""" |
||||
|
import os |
||||
|
import subprocess |
||||
|
from PIL import Image |
||||
|
from django.conf import settings |
||||
|
|
||||
|
|
||||
|
def create_thumbnail(image_path, size=(300, 300), quality=85, format='WEBP'): |
||||
|
""" |
||||
|
Create a thumbnail for an uploaded image |
||||
|
|
||||
|
Args: |
||||
|
image_path: Absolute path to original image |
||||
|
size: Tuple of (width, height) for thumbnail max dimensions |
||||
|
quality: Image quality (1-95 for JPEG, 1-100 for WebP) |
||||
|
format: Output format ('WEBP', 'JPEG', 'PNG') |
||||
|
|
||||
|
Returns: |
||||
|
Absolute path to thumbnail file |
||||
|
|
||||
|
Raises: |
||||
|
ValueError: If image cannot be processed |
||||
|
FileNotFoundError: If original image doesn't exist |
||||
|
""" |
||||
|
if not os.path.exists(image_path): |
||||
|
raise FileNotFoundError(f"Original image not found: {image_path}") |
||||
|
|
||||
|
try: |
||||
|
# Open and process image |
||||
|
img = Image.open(image_path) |
||||
|
|
||||
|
# Convert RGBA to RGB if needed (for JPEG/WebP compatibility) |
||||
|
if img.mode in ('RGBA', 'LA', 'P'): |
||||
|
background = Image.new('RGB', img.size, (255, 255, 255)) |
||||
|
if img.mode == 'RGBA': |
||||
|
background.paste(img, mask=img.split()[-1]) |
||||
|
else: |
||||
|
background.paste(img) |
||||
|
img = background |
||||
|
|
||||
|
# Create thumbnail maintaining aspect ratio |
||||
|
img.thumbnail(size, Image.Resampling.LANCZOS) |
||||
|
|
||||
|
# Generate thumbnail path |
||||
|
base, ext = os.path.splitext(image_path) |
||||
|
|
||||
|
# Choose extension based on format |
||||
|
if format.upper() == 'WEBP': |
||||
|
thumb_path = f"{base}_thumb.webp" |
||||
|
img.save(thumb_path, 'WEBP', quality=quality, method=6) |
||||
|
elif format.upper() == 'JPEG': |
||||
|
thumb_path = f"{base}_thumb.jpg" |
||||
|
img.save(thumb_path, 'JPEG', quality=quality, optimize=True) |
||||
|
elif format.upper() == 'PNG': |
||||
|
thumb_path = f"{base}_thumb.png" |
||||
|
img.save(thumb_path, 'PNG', optimize=True) |
||||
|
else: |
||||
|
# Default to WebP |
||||
|
thumb_path = f"{base}_thumb.webp" |
||||
|
img.save(thumb_path, 'WEBP', quality=quality, method=6) |
||||
|
|
||||
|
return thumb_path |
||||
|
|
||||
|
except Exception as e: |
||||
|
raise ValueError(f"Failed to create thumbnail: {str(e)}") |
||||
|
|
||||
|
|
||||
|
def get_image_dimensions(image_path): |
||||
|
""" |
||||
|
Get dimensions of an image |
||||
|
|
||||
|
Args: |
||||
|
image_path: Path to image file |
||||
|
|
||||
|
Returns: |
||||
|
Tuple of (width, height) or None if error |
||||
|
""" |
||||
|
try: |
||||
|
with Image.open(image_path) as img: |
||||
|
return img.size |
||||
|
except Exception: |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def is_image_file(file_path): |
||||
|
""" |
||||
|
Check if a file is an image based on extension and content |
||||
|
|
||||
|
Args: |
||||
|
file_path: Path to file |
||||
|
|
||||
|
Returns: |
||||
|
Boolean indicating if file is an image |
||||
|
""" |
||||
|
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff'} |
||||
|
ext = os.path.splitext(file_path)[1].lower() |
||||
|
|
||||
|
if ext not in image_extensions: |
||||
|
return False |
||||
|
|
||||
|
try: |
||||
|
with Image.open(file_path) as img: |
||||
|
img.verify() |
||||
|
return True |
||||
|
except Exception: |
||||
|
return False |
||||
|
|
||||
|
|
||||
|
def is_video_file(file_path): |
||||
|
""" |
||||
|
Check if a file is a video based on extension |
||||
|
|
||||
|
Args: |
||||
|
file_path: Path to file |
||||
|
|
||||
|
Returns: |
||||
|
Boolean indicating if file is a video |
||||
|
""" |
||||
|
video_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.flv', '.wmv', '.webm', '.m4v'} |
||||
|
ext = os.path.splitext(file_path)[1].lower() |
||||
|
return ext in video_extensions |
||||
|
|
||||
|
|
||||
|
def extract_video_thumbnail(video_path, time_offset='00:00:01', size=(300, 300), quality=85): |
||||
|
""" |
||||
|
Extract a frame from video as thumbnail using FFmpeg |
||||
|
|
||||
|
Args: |
||||
|
video_path: Absolute path to video file |
||||
|
time_offset: Time position to extract frame (default: 1 second) |
||||
|
size: Tuple of (width, height) for thumbnail max dimensions |
||||
|
quality: JPEG quality (1-31 for FFmpeg, lower is better, we convert to FFmpeg scale) |
||||
|
|
||||
|
Returns: |
||||
|
Absolute path to thumbnail file or None if failed |
||||
|
|
||||
|
Raises: |
||||
|
FileNotFoundError: If video file doesn't exist |
||||
|
ValueError: If FFmpeg is not installed or extraction fails |
||||
|
""" |
||||
|
if not os.path.exists(video_path): |
||||
|
raise FileNotFoundError(f"Video file not found: {video_path}") |
||||
|
|
||||
|
# Check if FFmpeg is available |
||||
|
try: |
||||
|
subprocess.run(['ffmpeg', '-version'], |
||||
|
stdout=subprocess.PIPE, |
||||
|
stderr=subprocess.PIPE, |
||||
|
timeout=5) |
||||
|
except (subprocess.TimeoutExpired, FileNotFoundError): |
||||
|
raise ValueError("FFmpeg is not installed or not available in PATH") |
||||
|
|
||||
|
try: |
||||
|
# Generate output path |
||||
|
base, ext = os.path.splitext(video_path) |
||||
|
output_path = f"{base}_thumb.jpg" |
||||
|
|
||||
|
# Convert quality (85 -> 2 in FFmpeg scale) |
||||
|
# FFmpeg uses 1-31 where lower is better, opposite of our 0-100 scale |
||||
|
ffmpeg_quality = max(1, min(31, int((100 - quality) * 31 / 100))) |
||||
|
|
||||
|
# FFmpeg command to extract frame |
||||
|
cmd = [ |
||||
|
'ffmpeg', |
||||
|
'-ss', time_offset, # Seek to time position (before input for speed) |
||||
|
'-i', video_path, # Input video |
||||
|
'-vframes', '1', # Extract 1 frame |
||||
|
'-vf', f'scale={size[0]}:-1', # Scale to width, maintain aspect ratio |
||||
|
'-q:v', str(ffmpeg_quality), # Quality |
||||
|
'-y', # Overwrite output |
||||
|
output_path |
||||
|
] |
||||
|
|
||||
|
result = subprocess.run( |
||||
|
cmd, |
||||
|
stdout=subprocess.PIPE, |
||||
|
stderr=subprocess.PIPE, |
||||
|
timeout=15 # 15 second timeout |
||||
|
) |
||||
|
|
||||
|
if result.returncode == 0 and os.path.exists(output_path): |
||||
|
return output_path |
||||
|
else: |
||||
|
error_msg = result.stderr.decode('utf-8', errors='ignore') |
||||
|
raise ValueError(f"FFmpeg extraction failed: {error_msg[:200]}") |
||||
|
|
||||
|
except subprocess.TimeoutExpired: |
||||
|
raise ValueError("Video thumbnail extraction timed out (>15s)") |
||||
|
except Exception as e: |
||||
|
raise ValueError(f"Failed to extract video thumbnail: {str(e)}") |
||||
Write
Preview
Loading…
Cancel
Save
Reference in new issue