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'
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(
'<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
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.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}"

52
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

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