Browse Source

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
mortezaei 7 months ago
parent
commit
0bc0830d87
  1. 51
      apps/chat/admin.py
  2. 24
      apps/chat/migrations/0003_add_file_and_image_attachments.py
  3. 49
      apps/chat/models.py
  4. 52
      utils/__init__.py
  5. 192
      utils/image_utils.py

51
apps/chat/admin.py

@ -120,7 +120,7 @@ class ChatMessageAdmin(ModelAdmin):
change_list_template = 'admin/chat/chatmessage/change_list.html' change_list_template = 'admin/chat/chatmessage/change_list.html'
list_display = ( list_display = (
'id', 'room', 'sender', 'content_type_badge', 'content_preview', '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 = ( list_filter = (
'room', 'room',
@ -132,7 +132,7 @@ class ChatMessageAdmin(ModelAdmin):
) )
search_fields = ('room__name', 'sender__username', 'content') search_fields = ('room__name', 'sender__username', 'content')
ordering = ('-sent_at',) ordering = ('-sent_at',)
readonly_fields = ('sent_at', 'updated_at', 'content_size')
readonly_fields = ('sent_at', 'updated_at', 'content_size', 'attachment_preview')
inlines = [MessageReadStatusInline] inlines = [MessageReadStatusInline]
fieldsets = ( fieldsets = (
@ -140,6 +140,10 @@ class ChatMessageAdmin(ModelAdmin):
'fields': ('room', 'sender', 'content', 'content_type'), 'fields': ('room', 'sender', 'content', 'content_type'),
'classes': ('grid-col-2',), 'classes': ('grid-col-2',),
}), }),
(_("Attachments"), {
'fields': ('file_attachment', 'image_attachment', 'attachment_preview'),
'classes': ('grid-col-2',),
}),
(_("Additional Info"), { (_("Additional Info"), {
'fields': ('content_size',), 'fields': ('content_size',),
'classes': ('grid-col-1',), 'classes': ('grid-col-1',),
@ -194,6 +198,49 @@ class ChatMessageAdmin(ModelAdmin):
return "-" return "-"
content_size_display.short_description = _("Size") 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(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">📷 Image</span>'
)
elif obj.file_attachment:
return format_html(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">📎 File</span>'
)
elif obj.content and obj.content_type != 'text':
return format_html(
'<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">🔗 Legacy</span>'
)
return "-"
has_attachment.short_description = _("Attachment")
def attachment_preview(self, obj):
"""Display attachment preview in detail view"""
if obj.image_attachment:
return format_html(
'<div><strong>Image:</strong><br/>'
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); margin-top: 10px;" />'
'<br/><a href="{}" target="_blank" style="margin-top: 10px; display: inline-block;">Open in new tab</a></div>',
obj.image_attachment.url,
obj.image_attachment.url
)
elif obj.file_attachment:
return format_html(
'<div><strong>File:</strong><br/>'
'<a href="{}" target="_blank" style="margin-top: 10px; display: inline-block; padding: 8px 16px; background: #3b82f6; color: white; border-radius: 4px; text-decoration: none;">📥 Download File</a></div>',
obj.file_attachment.url
)
elif obj.content and obj.content_type != 'text':
return format_html(
'<div><strong>Legacy URL:</strong><br/><code style="background: #f3f4f6; padding: 4px 8px; border-radius: 4px;">{}</code></div>',
obj.content
)
return "-"
attachment_preview.short_description = _("Attachment Preview")
# Register models with the custom admin site # Register models with the custom admin site
project_admin_site.register(RoomMessage, RoomMessageAdmin) project_admin_site.register(RoomMessage, RoomMessageAdmin)

24
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'),
),
]

49
apps/chat/models.py

@ -1,10 +1,20 @@
from django.db import models from django.db import models
from django.utils import timezone
from apps.account.models import User, User from apps.account.models import User, User
from apps.course.models import Course 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 RoomMessage(models.Model):
class RoomTypeChoices(models.TextChoices): class RoomTypeChoices(models.TextChoices):
@ -86,6 +96,22 @@ class ChatMessage(models.Model):
blank=True, blank=True,
null=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") is_read = models.BooleanField(default=False, verbose_name="Is Read")
message_metadata = models.JSONField(blank=True, null=True) message_metadata = models.JSONField(blank=True, null=True)
sent_at = models.DateTimeField(auto_now_add=True, verbose_name="Sent At") 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") deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Deleted At")
is_deleted = models.BooleanField(default=False, verbose_name="Is deleted") 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): def __str__(self):
return f"Message from {self.sender} in {self.room}" return f"Message from {self.sender} in {self.room}"

52
utils/__init__.py

@ -269,6 +269,7 @@ class UploadTmpSerializer(serializers.Serializer):
name = serializers.CharField(read_only=True) name = serializers.CharField(read_only=True)
size = serializers.CharField(read_only=True) size = serializers.CharField(read_only=True)
mime_type = 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): def to_representation(self, instance):
data = super(UploadTmpSerializer, self).to_representation(instance) data = super(UploadTmpSerializer, self).to_representation(instance)
@ -277,14 +278,21 @@ class UploadTmpSerializer(serializers.Serializer):
def store_file(self, file): def store_file(self, file):
from django.conf import settings 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 static_path = settings.STATIC_ROOT
os.makedirs(f'{static_path}/tmp', exist_ok=True) os.makedirs(f'{static_path}/tmp', exist_ok=True)
fpath = f"/tmp/{secrets.token_urlsafe(4)}-{file.name}" 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, 'file': fpath,
'url': absolute_url(self.context['request'], f"/static{fpath}"), 'url': absolute_url(self.context['request'], f"/static{fpath}"),
'name': file.name, 'name': file.name,
@ -292,6 +300,44 @@ class UploadTmpSerializer(serializers.Serializer):
'mime_type': guess_file_type(fpath) '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): def validate(self, attrs):
file_details = self.store_file(attrs['file']) file_details = self.store_file(attrs['file'])
return file_details return file_details

192
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)}")
Loading…
Cancel
Save