You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

410 lines
18 KiB

from django.contrib import admin
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from django.db.models import Count
from django import forms
from unfold.admin import ModelAdmin, TabularInline
from unfold.contrib.filters.admin import RangeNumericFilter, RangeDateTimeFilter
from django.shortcuts import redirect
from django.urls import reverse
from unfold.decorators import action, display
from django.contrib import messages
from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus
from utils.admin import project_admin_site, admin_url_generator
from django.contrib.auth import get_user_model
from django.db.models import Count, Q
User = get_user_model()
# --- HELPER FUNCTION: GET ALLOWED USERS FOR A ROOM ---
def get_allowed_users_for_room(room):
"""
Returns a queryset of active users allowed in a specific room.
Private: Initiator + Recipient
Group: Initiator + Recipient + Course Professor + Active Course Participants
"""
allowed_ids = set()
if room.initiator_id:
allowed_ids.add(room.initiator_id)
if room.recipient_id:
allowed_ids.add(room.recipient_id)
if room.room_type == 'group' and room.course_id:
# Add Professor
if room.course.professor_id:
allowed_ids.add(room.course.professor_id)
# Add Active Participants
from apps.course.models import Participant
participant_ids = Participant.objects.filter(
course_id=room.course_id,
is_active=True
).values_list('student_id', flat=True)
allowed_ids.update(participant_ids)
return User.objects.filter(is_active=True, id__in=allowed_ids)
def get_rooms_queryset_for_user(user, queryset):
"""
Absolute Privacy Rule:
- NO ONE sees a PRIVATE chat unless they are the initiator or recipient.
- Admins/Consultants see ALL GROUP chats.
- Others see only their enrolled GROUP chats.
"""
# شرط پایه: چت‌هایی که خود شخص در آن‌ها حضور مستقیم دارد (خصوصی یا گروهی فرقی ندارد)
personal_q = Q(initiator=user) | Q(recipient=user)
if user.is_superuser or user.has_role('admin') or user.has_role('super_admin') or user.has_role('consultant'):
# ادمین‌ها و مشاورین: چت‌های خودشان + تمام چت‌های گروهی
return queryset.filter(
personal_q |
Q(room_type=RoomMessage.RoomTypeChoices.GROUP)
).distinct()
# اساتید و دانشجویان: چت‌های خودشان + چت‌های گروهی که در آن عضو هستند
return queryset.filter(
personal_q |
Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__professor=user) |
Q(room_type=RoomMessage.RoomTypeChoices.GROUP, course__participants__student=user, course__participants__is_active=True)
).distinct()
# --- WIDTH ENFORCEMENT FOR SELECT2 DROPDOWNS IN INLINES ---
class MinWidthInlineForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
target_dropdown_fields = ['sender', 'user']
for field_name, field in self.fields.items():
if field_name in target_dropdown_fields and hasattr(field.widget, 'attrs'):
existing_class = field.widget.attrs.get('class', '')
field.widget.attrs['class'] = f"{existing_class} min-w-[250px] w-full"
existing_style = field.widget.attrs.get('style', '')
field.widget.attrs['style'] = f"{existing_style} min-width: 250px; width: 250px;"
class ChatMessageInline(TabularInline):
model = ChatMessage
form = MinWidthInlineForm
extra = 1 # 🔔 Allows you to add 1 new message from the room tab
tab = True
# 🔔 Using real database fields so you can actually input new messages
fields = ('sender', 'content', 'content_type', 'file_attachment', 'sent_at', 'is_deleted')
readonly_fields = ('sent_at',)
can_delete = False
show_change_link = True
verbose_name = _("Recent Message")
verbose_name_plural = _("Recent Messages (Latest 50)")
def get_queryset(self, request):
qs = super().get_queryset(request)
object_id = request.resolver_match.kwargs.get('object_id')
if object_id:
latest_ids = list(qs.filter(room_id=object_id).order_by('-sent_at').values_list('id', flat=True)[:50])
return qs.filter(id__in=latest_ids).order_by('-sent_at')
return qs.none()
# 🔔 FILTER THE SENDER DROPDOWN IN THE TAB
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "sender":
room_id = request.resolver_match.kwargs.get('object_id')
if room_id:
try:
room = RoomMessage.objects.get(pk=room_id)
kwargs["queryset"] = get_allowed_users_for_room(room)
except RoomMessage.DoesNotExist:
kwargs["queryset"] = User.objects.filter(is_active=True)
else:
kwargs["queryset"] = User.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class MessageReadStatusAdmin(ModelAdmin):
list_display = (
'user', 'message', 'is_read_status', 'read_at',
)
list_filter = (
('read_at', RangeDateTimeFilter),
'is_read',
)
search_fields = ('user__username', 'user__email', 'message__content')
readonly_fields = ('read_at',)
@display(description=_("Read Status"))
def is_read_status(self, obj):
if obj.is_read:
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Read"))
return format_html('<span class="bg-red-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Unread"))
class RoomMessageAdmin(ModelAdmin):
list_display = (
'name', 'room_type_badge', 'course', 'initiator',
'messages_count', 'is_locked'
)
list_filter = (
'room_type',
('created_at', RangeDateTimeFilter),
('updated_at', RangeDateTimeFilter),
'course','is_locked'
)
search_fields = ('name', 'description', 'course__title', 'initiator__username', 'recipient__username')
ordering = ('-created_at',)
readonly_fields = ('created_at', 'updated_at', 'messages_count', 'course', 'initiator', 'recipient')
inlines = []
actions_detail = []
fieldsets = (
(_("Room Information"), {
'fields': ('name', 'description', 'room_type', 'messages_count','is_locked'),
'classes': ('grid-col-2',),
}),
(_("Relations"), {
'fields': ('course', 'initiator', 'recipient'),
'classes': ('grid-col-2',),
}),
(_("Timestamps"), {
'fields': ('created_at', 'updated_at'),
'classes': ('grid-col-2',),
}),
)
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name in ["initiator", "recipient"]:
kwargs["queryset"] = User.objects.filter(is_active=True, email__isnull=False)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@display(description=_("Messages Count"))
def messages_count(self, obj):
count = obj.messages.count()
return format_html(
'<span class="bg-primary-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center min-w-[2rem]">'
'{}</span>', count
)
@display(description=_("Room Type"))
def room_type_badge(self, obj):
if obj.room_type == 'group':
return format_html('<span class="bg-purple-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Group"))
return format_html('<span class="bg-indigo-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Private"))
def get_queryset(self, request):
queryset = super().get_queryset(request)
queryset = queryset.annotate(
total_messages=Count('messages')
)
return get_rooms_queryset_for_user(request.user, queryset)
@action(
description=_("Manage All Messages"),
icon="chat",
)
def manage_all_messages(self, request, object_id):
"""Redirect to the pre-filtered Chat Message changelist for this room."""
room = self.get_object(request, object_id)
if not room:
messages.error(request, _("Room not found"))
return redirect(admin_url_generator(request, "chat_roommessage_changelist"))
base_url = admin_url_generator(request, "chat_chatmessage_changelist")
url = f"{base_url}?room__id__exact={object_id}"
return redirect(url)
class MessageReadStatusInline(TabularInline):
model = MessageReadStatus
form = MinWidthInlineForm
extra = 0
fields = ('user', 'is_read', 'read_at')
readonly_fields = ('read_at',)
can_delete = False
show_change_link = True
classes = ['collapse']
verbose_name = _("Read Status")
verbose_name_plural = _("Read Statuses")
# 🔔 FILTER THE USER DROPDOWN IN THE READ STATUS TAB
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "user":
message_id = request.resolver_match.kwargs.get('object_id')
if message_id:
try:
msg = ChatMessage.objects.get(pk=message_id)
kwargs["queryset"] = get_allowed_users_for_room(msg.room)
except ChatMessage.DoesNotExist:
kwargs["queryset"] = User.objects.filter(is_active=True)
else:
kwargs["queryset"] = User.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
class ChatMessageAdmin(ModelAdmin):
list_display = (
'id', 'room', 'sender', 'content_type_badge', 'content_preview',
'content_size_display', 'has_attachment', 'sent_at', 'is_deleted_status'
)
list_filter = (
'room',
'content_type',
'is_deleted',
('sent_at', RangeDateTimeFilter),
('updated_at', RangeDateTimeFilter),
('content_size', RangeNumericFilter)
)
search_fields = ('room__name', 'sender__username', 'content')
ordering = ('-sent_at',)
readonly_fields = ('sent_at', 'updated_at', 'content_size', 'attachment_preview')
inlines = [MessageReadStatusInline]
fieldsets = (
(_("Message Information"), {
'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',),
}),
(_("Status"), {
'fields': ('is_deleted', 'deleted_at'),
'classes': ('grid-col-2',),
}),
(_("Timestamps"), {
'fields': ('sent_at', 'updated_at'),
'classes': ('grid-col-2',),
}),
)
actions_list = ["back_to_chat_rooms"]
def get_queryset(self, request):
queryset = super().get_queryset(request)
user = request.user
personal_q = Q(room__initiator=user) | Q(room__recipient=user)
if user.is_superuser or user.has_role('admin') or user.has_role('super_admin') or user.has_role('consultant'):
# ادمین‌ها و مشاورین: پیام‌های خودشان + تمام پیام‌های چت‌های گروهی
return queryset.filter(
personal_q |
Q(room__room_type=RoomMessage.RoomTypeChoices.GROUP)
).distinct()
# اساتید و دانشجویان
return queryset.filter(
personal_q |
Q(room__room_type=RoomMessage.RoomTypeChoices.GROUP, room__course__professor=user) |
Q(room__room_type=RoomMessage.RoomTypeChoices.GROUP, room__course__participants__student=user, room__course__participants__is_active=True)
).distinct()
# 🔔 FILTER THE SENDER DROPDOWN IN THE CHAT MESSAGE FORM
def formfield_for_foreignkey(self, db_field, request, **kwargs):
if db_field.name == "sender":
message_id = request.resolver_match.kwargs.get('object_id')
if message_id:
try:
msg = ChatMessage.objects.get(pk=message_id)
kwargs["queryset"] = get_allowed_users_for_room(msg.room)
except ChatMessage.DoesNotExist:
kwargs["queryset"] = User.objects.filter(is_active=True)
else:
kwargs["queryset"] = User.objects.filter(is_active=True)
return super().formfield_for_foreignkey(db_field, request, **kwargs)
@action(
description=_("Back to Chat Rooms"),
icon="arrow_back",
)
def back_to_chat_rooms(self, request):
url = reverse('admin:chat_roommessage_changelist')
return redirect(url)
@display(description=_("Content Preview"))
def content_preview(self, obj):
if obj.content_type == 'text':
preview = obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
return preview
return _("%(type)s content") % {'type': obj.get_content_type_display()}
@display(description=_("Type"))
def content_type_badge(self, obj):
badges = {
'text': ('bg-green-500', _('Text')),
'file': ('bg-green-500', _('File')),
'audio': ('bg-green-500', _('Audio')),
'image': ('bg-green-500', _('Image')),
}
bg_color, label = badges.get(obj.content_type, ('bg-gray-500', obj.content_type))
return format_html(
'<span class="{} text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center min-w-[4rem]">{}</span>',
bg_color, label
)
@display(description=_("Status"))
def is_deleted_status(self, obj):
if obj.is_deleted:
return format_html('<span class="bg-red-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Deleted"))
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("Active"))
@display(description=_("Size"))
def content_size_display(self, obj):
if obj.content_size:
if obj.content_size > 1024:
size_kb = obj.content_size / 1024
return f"{size_kb:.1f} KB"
return _("{} bytes").format(obj.content_size)
return "-"
@display(description=_("Attachment"))
def has_attachment(self, obj):
if obj.image_attachment:
return format_html('<span class="bg-blue-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("📷 Image"))
elif obj.file_attachment:
return format_html('<span class="bg-green-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("📎 File"))
elif obj.content and obj.content_type != 'text':
return format_html('<span class="bg-orange-500 text-white font-medium rounded-full px-3 py-1 text-xs text-center inline-flex items-center justify-center">{}</span>', _("🔗 Legacy"))
return "-"
@display(description=_("Attachment Preview"))
def attachment_preview(self, obj):
if obj.image_attachment:
return format_html(
'<div><strong>{}:</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;">{}</a></div>',
_("Image"),
obj.image_attachment.url,
obj.image_attachment.url,
_("Open in new tab")
)
elif obj.file_attachment:
return format_html(
'<div><strong>{}:</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;">{}</a></div>',
_("File"),
obj.file_attachment.url,
_("📥 Download File")
)
elif obj.content and obj.content_type != 'text':
return format_html(
'<div><strong>{}:</strong><br/><code style="background: #f3f4f6; padding: 4px 8px; border-radius: 4px;">{}</code></div>',
_("Legacy URL"),
obj.content
)
return "-"
project_admin_site.register(RoomMessage, RoomMessageAdmin)
# project_admin_site.register(ChatMessage, ChatMessageAdmin)
project_admin_site.register(MessageReadStatus, MessageReadStatusAdmin)