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(
+ '
',
+ obj.image_attachment.url,
+ obj.image_attachment.url
+ )
+ elif obj.file_attachment:
+ return format_html(
+ '',
+ 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)}")