From 0bc0830d87571f0a35a271e2c727643ce50640ec Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 23 Oct 2025 12:07:28 +0330 Subject: [PATCH] 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. --- apps/chat/admin.py | 51 ++++- .../0003_add_file_and_image_attachments.py | 24 +++ apps/chat/models.py | 49 +++++ utils/__init__.py | 52 ++++- utils/image_utils.py | 192 ++++++++++++++++++ 5 files changed, 363 insertions(+), 5 deletions(-) create mode 100644 apps/chat/migrations/0003_add_file_and_image_attachments.py create mode 100644 utils/image_utils.py diff --git a/apps/chat/admin.py b/apps/chat/admin.py index 52d6a50..8efe223 100644 --- a/apps/chat/admin.py +++ b/apps/chat/admin.py @@ -120,7 +120,7 @@ class ChatMessageAdmin(ModelAdmin): change_list_template = 'admin/chat/chatmessage/change_list.html' list_display = ( 'id', 'room', 'sender', 'content_type_badge', 'content_preview', - 'content_size_display', 'sent_at', 'is_deleted_status' + 'content_size_display', 'has_attachment', 'sent_at', 'is_deleted_status' ) list_filter = ( 'room', @@ -132,7 +132,7 @@ class ChatMessageAdmin(ModelAdmin): ) search_fields = ('room__name', 'sender__username', 'content') ordering = ('-sent_at',) - readonly_fields = ('sent_at', 'updated_at', 'content_size') + readonly_fields = ('sent_at', 'updated_at', 'content_size', 'attachment_preview') inlines = [MessageReadStatusInline] fieldsets = ( @@ -140,6 +140,10 @@ class ChatMessageAdmin(ModelAdmin): 'fields': ('room', 'sender', 'content', 'content_type'), 'classes': ('grid-col-2',), }), + (_("Attachments"), { + 'fields': ('file_attachment', 'image_attachment', 'attachment_preview'), + 'classes': ('grid-col-2',), + }), (_("Additional Info"), { 'fields': ('content_size',), 'classes': ('grid-col-1',), @@ -194,6 +198,49 @@ class ChatMessageAdmin(ModelAdmin): return "-" content_size_display.short_description = _("Size") + + def has_attachment(self, obj): + """Show if message has file/image attachment""" + if obj.image_attachment: + return format_html( + '📷 Image' + ) + elif obj.file_attachment: + return format_html( + '📎 File' + ) + elif obj.content and obj.content_type != 'text': + return format_html( + '🔗 Legacy' + ) + return "-" + + has_attachment.short_description = _("Attachment") + + def attachment_preview(self, obj): + """Display attachment preview in detail view""" + if obj.image_attachment: + return format_html( + '
Image:
' + '' + '
Open in new tab
', + obj.image_attachment.url, + obj.image_attachment.url + ) + elif obj.file_attachment: + return format_html( + '
File:
' + '📥 Download File
', + obj.file_attachment.url + ) + elif obj.content and obj.content_type != 'text': + return format_html( + '
Legacy URL:
{}
', + obj.content + ) + return "-" + + attachment_preview.short_description = _("Attachment Preview") # Register models with the custom admin site project_admin_site.register(RoomMessage, RoomMessageAdmin) diff --git a/apps/chat/migrations/0003_add_file_and_image_attachments.py b/apps/chat/migrations/0003_add_file_and_image_attachments.py new file mode 100644 index 0000000..aef8937 --- /dev/null +++ b/apps/chat/migrations/0003_add_file_and_image_attachments.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'), + ), + ] diff --git a/apps/chat/models.py b/apps/chat/models.py index 1ab7a62..253b947 100644 --- a/apps/chat/models.py +++ b/apps/chat/models.py @@ -1,10 +1,20 @@ from django.db import models +from django.utils import timezone from apps.account.models import User, User from apps.course.models import Course +def chat_upload_path(instance, filename): + """ + Generate upload path for chat attachments + Format: chat/room_{room_id}/YYYY/MM/DD/filename + """ + date = timezone.now() + return f'chat/room_{instance.room_id}/{date.year}/{date.month:02d}/{date.day:02d}/{filename}' + + class RoomMessage(models.Model): class RoomTypeChoices(models.TextChoices): @@ -86,6 +96,22 @@ class ChatMessage(models.Model): blank=True, null=True ) + file_attachment = models.FileField( + upload_to=chat_upload_path, + blank=True, + null=True, + max_length=500, + verbose_name="File Attachment", + help_text="For file and audio messages" + ) + image_attachment = models.ImageField( + upload_to=chat_upload_path, + blank=True, + null=True, + max_length=500, + verbose_name="Image Attachment", + help_text="For image messages" + ) is_read = models.BooleanField(default=False, verbose_name="Is Read") message_metadata = models.JSONField(blank=True, null=True) sent_at = models.DateTimeField(auto_now_add=True, verbose_name="Sent At") @@ -93,6 +119,29 @@ class ChatMessage(models.Model): deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Deleted At") is_deleted = models.BooleanField(default=False, verbose_name="Is deleted") + @property + def file_url(self): + """ + Get file URL - works for both old and new messages + For backward compatibility with messages using content field + """ + if self.image_attachment: + return self.image_attachment.url + elif self.file_attachment: + return self.file_attachment.url + elif self.content and self.content_type != 'text': + # Legacy messages with URL in content field + return self.content + return None + + def delete(self, *args, **kwargs): + """Override delete to remove uploaded files""" + if self.file_attachment: + self.file_attachment.delete(save=False) + if self.image_attachment: + self.image_attachment.delete(save=False) + super().delete(*args, **kwargs) + def __str__(self): return f"Message from {self.sender} in {self.room}" diff --git a/utils/__init__.py b/utils/__init__.py index 88fb9dc..d5ab321 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -269,6 +269,7 @@ class UploadTmpSerializer(serializers.Serializer): name = serializers.CharField(read_only=True) size = serializers.CharField(read_only=True) mime_type = serializers.CharField(read_only=True) + thumbnail_url = serializers.URLField(read_only=True, required=False) def to_representation(self, instance): data = super(UploadTmpSerializer, self).to_representation(instance) @@ -277,14 +278,21 @@ class UploadTmpSerializer(serializers.Serializer): def store_file(self, file): from django.conf import settings + from utils.image_utils import ( + create_thumbnail, + is_image_file, + is_video_file, + extract_video_thumbnail + ) static_path = settings.STATIC_ROOT os.makedirs(f'{static_path}/tmp', exist_ok=True) fpath = f"/tmp/{secrets.token_urlsafe(4)}-{file.name}" - shutil.move(file.temporary_file_path(), static_path + fpath) - os.chmod(static_path + fpath, 0o644) + full_path = static_path + fpath + shutil.move(file.temporary_file_path(), full_path) + os.chmod(full_path, 0o644) - return { + result = { 'file': fpath, 'url': absolute_url(self.context['request'], f"/static{fpath}"), 'name': file.name, @@ -292,6 +300,44 @@ class UploadTmpSerializer(serializers.Serializer): 'mime_type': guess_file_type(fpath) } + # Generate thumbnail if file is an image + if is_image_file(full_path): + try: + thumbnail_path = create_thumbnail(full_path, size=(300, 300), quality=85) + # Convert absolute path to relative URL path + thumbnail_relative = thumbnail_path.replace(static_path, '') + result['thumbnail_url'] = absolute_url( + self.context['request'], + f"/static{thumbnail_relative}" + ) + except Exception as e: + # Log error but don't fail the upload + print(f"Failed to generate image thumbnail: {e}") + result['thumbnail_url'] = None + # Generate thumbnail if file is a video + elif is_video_file(full_path): + try: + thumbnail_path = extract_video_thumbnail( + full_path, + time_offset='00:00:01', # Extract frame at 1 second + size=(300, 300), + quality=85 + ) + # Convert absolute path to relative URL path + thumbnail_relative = thumbnail_path.replace(static_path, '') + result['thumbnail_url'] = absolute_url( + self.context['request'], + f"/static{thumbnail_relative}" + ) + except Exception as e: + # Log error but don't fail the upload + print(f"Failed to generate video thumbnail: {e}") + result['thumbnail_url'] = None + else: + result['thumbnail_url'] = None + + return result + def validate(self, attrs): file_details = self.store_file(attrs['file']) return file_details diff --git a/utils/image_utils.py b/utils/image_utils.py new file mode 100644 index 0000000..9f68b9d --- /dev/null +++ b/utils/image_utils.py @@ -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)}")