diff --git a/apps/chat/admin_views.py b/apps/chat/admin_views.py new file mode 100644 index 0000000..120ce13 --- /dev/null +++ b/apps/chat/admin_views.py @@ -0,0 +1,149 @@ +import json +import requests +import threading # 🟢 برای اجرای بکگراند +from django.conf import settings +from django.http import JsonResponse +from django.shortcuts import render, get_object_or_404 +from django.views.decorators.http import require_http_methods +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +from apps.chat.models import RoomMessage, ChatMessage +from utils.admin import project_admin_site + + +def send_webhook_to_fastapi(room_id, message_data): + fastapi_url = f"{getattr(settings, 'FASTAPI_SERVER_URL', 'http://127.0.0.1:8000')}/api/internal/webhook/chat-message/" + headers = { + "Authorization": getattr(settings, 'INTERNAL_WEBHOOK_SECRET', 'super-secret-key-12345'), + "Content-Type": "application/json" + } + payload = { + "room_id": room_id, + "message": message_data + } + try: + # با تایماوت 2 ثانیه میفرستیم که اگر سرور گیر بود، پروسه متوقف نشود + requests.post(fastapi_url, json=payload, headers=headers, timeout=2.0) + except Exception as e: + print(f"⚠️ Webhook to FastAPI failed: {e}") + +def live_chat_dashboard_view(request): + """رندر کردن اسکلت اصلی صفحه چت""" + context = { + **project_admin_site.each_context(request), + "title": _("Live Support Chat"), + } + return render(request, "admin/chat/live_chat.html", context) + +def api_get_rooms(request): + """گرفتن لیست رومها بر اساس دسترسی ادمین""" + user = request.user + + # ادمین کل همه رومها رو میبینه، استاد فقط رومهای خودش رو + if user.is_superuser or user.has_role('admin'): + rooms = RoomMessage.objects.all().order_by('-updated_at')[:50] + else: + rooms = RoomMessage.objects.filter( + Q(initiator=user) | Q(recipient=user) | Q(course__professor=user) + ).distinct().order_by('-updated_at')[:50] + + data = [] + for r in rooms: + title = r.name or f"Room #{r.id}" + if r.room_type == 'group' and r.course: + title = r.course.title + + data.append({ + "id": r.id, + "title": title, + "type": r.room_type, + "unread": r.unread_messages_count, + "time": r.updated_at.strftime("%H:%M") if r.updated_at else "" + }) + + return JsonResponse({"rooms": data}) + +def api_get_messages(request, room_id): + """گرفتن پیامهای یک روم خاص (فقط پیامهای جدیدتر از last_id)""" + last_id = int(request.GET.get('last_id', 0)) + room = get_object_or_404(RoomMessage, id=room_id) + + messages = ChatMessage.objects.filter( + room=room, + id__gt=last_id, # جادوی پولینگ: فقط آیدیهای بزرگتر از آخرین پیامی که داریم رو بگیر + is_deleted=False + ).order_by('id') + + data = [] + for m in messages: + avatar = m.sender.avatar.url if getattr(m.sender, 'avatar', None) else None + sender_name = m.sender.fullname or m.sender.email or "Unknown" + + data.append({ + "id": m.id, + "sender_id": m.sender_id, + "sender_name": sender_name, + "avatar": avatar, + "content": m.content, + "type": m.content_type, + "file_url": m.file_url, # از پراپرتی مدلت استفاده کردیم + "sent_at": m.sent_at.strftime("%H:%M"), + "is_me": m.sender_id == request.user.id + }) + + return JsonResponse({"messages": data}) + +@require_http_methods(["POST"]) +def api_send_message(request, room_id): + """ارسال پیام جدید از طریق ایجکس (AJAX)""" + try: + body = json.loads(request.body) + content = body.get('content', '').strip() + + if not content: + return JsonResponse({"error": "Empty content"}, status=400) + + room = get_object_or_404(RoomMessage, id=room_id) + + # ذخیره در دیتابیس + msg = ChatMessage.objects.create( + room=room, + sender=request.user, + content=content, + content_type='text' + ) + + # آپدیت کردن زمان روم + room.updated_at = msg.sent_at + room.save(update_fields=['updated_at']) + + # 🟢 ساخت ساختار JSON پیام دقیقاً شبیه به چیزی که FastAPI به فرانت میفرستد + avatar_url = request.user.avatar.url if getattr(request.user, 'avatar', None) else None + sender_name = request.user.fullname or request.user.email or "Support" + + message_data = { + "id": msg.id, + "room_id": room_id, + "sender": { + "id": request.user.id, + "email": request.user.email, + "fullname": sender_name, + "user_type": getattr(request.user, 'user_type', 'admin'), + "avatar": avatar_url + }, + "content": msg.content, + "content_type": msg.content_type, + "content_size": msg.content_size, + "sent_at": msg.sent_at.isoformat(), + "metadata": msg.message_metadata, + "updated_at": None, + "is_deleted": False + } + + # 🟢 شلیک به سمت FastAPI در یک Thread جداگانه + threading.Thread(target=send_webhook_to_fastapi, args=(room_id, message_data)).start() + + return JsonResponse({"success": True, "message_id": msg.id}) + except Exception as e: + return JsonResponse({"error": str(e)}, status=500) \ No newline at end of file diff --git a/config/settings/base.py b/config/settings/base.py index aaef522..eb28a0a 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -674,6 +674,12 @@ UNFOLD = { "link": lambda request: admin_url_generator(request, "blog_blog_changelist"), "permission": is_main_panel, }, + { + "title": _("Live Chat"), + "icon": "forum", # آیکون چت + "link": lambda request: admin_url_generator(request, "live_chat_dashboard"), + "permission": is_main_panel, + }, ] }, # --- DOVOODI SECTIONS --- @@ -902,4 +908,8 @@ PLAUSIBLE_DOMAIN = env("PLAUSIBLE_DOMAIN") sentry_sdk.init( dsn="https://31aaeeb3a42f9a8c1b26272a0cb8ad3e@o4507991743725568.ingest.us.sentry.io/4511127356768256", send_default_pii=True, -) \ No newline at end of file +) + + +INTERNAL_WEBHOOK_SECRET = env('INTERNAL_WEBHOOK_SECRET', default='super-secret-key-12345') +FASTAPI_SERVER_URL = env('FASTAPI_SERVER_URL', default='http://88.99.212.243:8020') \ No newline at end of file diff --git a/static/css/live_chat.css b/static/css/live_chat.css new file mode 100644 index 0000000..0b884ea --- /dev/null +++ b/static/css/live_chat.css @@ -0,0 +1,295 @@ +/* ── در هم شکستن محدودیت عرض و پدینگهای پیشفرض انفولد ── */ +#content { + padding-left: 0 !important; + padding-right: 0 !important; + padding-bottom: 0 !important; + max-width: 100% !important; + width: 100% !important; +} + +div:has(> #content) { + padding-left: 0 !important; + padding-right: 0 !important; + padding-bottom: 0 !important; +} + +body, +html { + overflow: hidden !important; + font-family: 'Vazirmatn', -apple-system, BlinkMacSystemFont, sans-serif !important; +} + +/* ── Layout & Structure ── */ +.chat-main-wrapper { + height: calc(100vh - 140px); + margin-top: -1rem; +} + +.rooms-sidebar { + width: 320px; + background: #f8fafc; + /* رنگ پسزمینه خیلی ملایم برای سایدبار */ +} + +@media (min-width: 1024px) { + .rooms-sidebar { + width: 360px; + } +} + +.dark .rooms-sidebar { + background: #1f2937; +} + +/* ── Custom Scrollbar ── */ +.chat-scrollbar::-webkit-scrollbar, +.chat-messages::-webkit-scrollbar { + width: 6px; +} + +.chat-scrollbar::-webkit-scrollbar-track, +.chat-messages::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; +} + +.chat-scrollbar::-webkit-scrollbar-thumb, +.chat-messages::-webkit-scrollbar-thumb { + background: rgba(0, 136, 204, 0.3); + border-radius: 4px; + transition: all 0.3s ease; +} + +.chat-scrollbar::-webkit-scrollbar-thumb:hover, +.chat-messages::-webkit-scrollbar-thumb:hover { + background: rgba(0, 136, 204, 0.5); +} + +/* ── Room List Styles (New Telegram/Modern Style) ── */ +.room-item { + margin: 4px 8px; + /* ایجاد فاصله از دیوارههای سایدبار */ + border-radius: 12px; + /* گرد کردن گوشههای کل روم */ + border-left: none !important; + /* حذف بوردر چپ قدیمی */ + transition: background-color 0.2s ease, transform 0.1s ease; +} + +.room-item:hover { + background-color: rgba(0, 136, 204, 0.05); +} + +.dark .room-item:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.room-item-active { + background: linear-gradient(135deg, rgba(0, 136, 204, 0.1), rgba(28, 156, 232, 0.05)) !important; + box-shadow: 0 2px 8px rgba(0, 136, 204, 0.05); +} + +.dark .room-item-active { + background: linear-gradient(135deg, rgba(28, 156, 232, 0.15), rgba(0, 136, 204, 0.05)) !important; +} + +/* آواتار کوچک داخل لیست چتها */ +.room-avatar { + width: 44px; + height: 44px; + border-radius: 50%; + background: linear-gradient(135deg, #cbd5e1, #94a3b8); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + flex-shrink: 0; + box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.room-item-active .room-avatar { + background: linear-gradient(135deg, #0088cc, #1c9ce8); +} + +/* ── Telegram-style Header ── */ +.telegram-header { + background: linear-gradient(135deg, #0088cc, #1c9ce8); + border: none; + box-shadow: 0 2px 8px rgba(0, 136, 204, 0.2); + backdrop-filter: blur(20px); +} + +.dark .telegram-header { + background: linear-gradient(135deg, #1f2937, #374151); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.user-avatar { + width: 40px; + height: 40px; + border-radius: 11px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid rgba(255, 255, 255, 0.25); +} + +/* ── Chat Area Background ── */ +.chat-messages { + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.5), rgba(248, 250, 252, 0.8)); + overflow-y: auto; + overscroll-behavior: contain; + padding: 1.5rem; +} + +.dark .chat-messages { + background: linear-gradient(to bottom, rgba(17, 24, 39, 0.5), rgba(31, 41, 55, 0.8)); +} + +/* ── Message Bubbles (Telegram Style) ── */ +.message-bubble { + max-width: 80%; + margin-bottom: 16px; + animation: slideInUp 0.3s cubic-bezier(0.165, 0.84, 0.44, 1); +} + +@keyframes slideInUp { + from { + opacity: 0; + transform: translateY(15px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.message-sent { + margin-left: auto; + margin-right: 0; + display: flex; + justify-content: flex-end; +} + +.message-received { + margin-right: auto; + margin-left: 0; + display: flex; + justify-content: flex-start; +} + +.message-bubble-sent { + background: linear-gradient(135deg, #0088cc, #1c9ce8); + color: white; + border-radius: 18px 18px 4px 18px; + box-shadow: 0 2px 8px rgba(0, 136, 204, 0.25); +} + +.message-bubble-received { + background: #ffffff; + color: #374151; + border-radius: 18px 18px 18px 4px; + border: 1px solid rgba(229, 231, 235, 0.6); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); +} + +.dark .message-bubble-received { + background: #374151; + color: #f9fafb; + border: 1px solid rgba(75, 85, 99, 0.3); +} + +.message-content { + padding: 10px 14px; + font-size: 14px; + line-height: 1.6; + word-wrap: break-word; +} + +.message-meta { + font-size: 10px; + opacity: 0.7; + margin-top: 4px; + text-align: right; + font-weight: 500; +} + +/* ── Input Area ── */ +.message-input-area { + background: linear-gradient(135deg, #ffffff, #f8fafc); + border-top: 1px solid rgba(229, 231, 235, 0.3); +} + +.dark .message-input-area { + background: linear-gradient(135deg, #374151, #4b5563); + border-top: 1px solid rgba(75, 85, 99, 0.3); +} + +.telegram-input-container { + background: #ffffff; + border: 2px solid rgba(0, 136, 204, 0.1); + border-radius: 25px; + padding: 6px 16px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04); + transition: all 0.3s ease; +} + +.telegram-input-container:focus-within { + border-color: #0088cc; + box-shadow: 0 6px 25px rgba(0, 136, 204, 0.12); +} + +.dark .telegram-input-container { + background: rgba(55, 65, 81, 0.9); + border-color: rgba(75, 85, 99, 0.3); +} + +.dark .telegram-input-container:focus-within { + border-color: #1c9ce8; +} + +.telegram-message-input { + background: transparent; + border: none; + outline: none; + font-size: 14px; + padding: 8px 0; + color: #374151; +} + +.dark .telegram-message-input { + color: #f9fafb; +} + +.telegram-message-input::placeholder { + color: #9ca3af; +} + +.telegram-action-btn { + width: 44px; + height: 44px; + border: none; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + cursor: pointer; + flex-shrink: 0; +} + +.send-btn-telegram { + background: linear-gradient(135deg, #0088cc, #1c9ce8); + color: white; +} + +.send-btn-telegram:hover { + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(0, 136, 204, 0.4); +} + +.send-btn-telegram:active { + transform: scale(0.95); +} \ No newline at end of file diff --git a/static/js/live_chat.js b/static/js/live_chat.js new file mode 100644 index 0000000..069eb51 --- /dev/null +++ b/static/js/live_chat.js @@ -0,0 +1,197 @@ +document.addEventListener('DOMContentLoaded', function () { + + // متغیرهای وضعیت + let currentRoomId = null; + let lastMessageId = 0; + let messagePollInterval = null; + let roomPollInterval = null; + + // 🟢 متغیر قفل برای جلوگیری از دریافت پیامهای تکراری (Race Condition) + let isFetchingMessages = false; + + // المانهای DOM + const UI = { + roomList: document.getElementById('room-list'), + noChatSelected: document.getElementById('no-chat-selected'), + chatHeader: document.getElementById('active-chat-header'), + chatTitle: document.getElementById('active-chat-title'), + messages: document.getElementById('chat-messages'), + inputArea: document.getElementById('chat-input-area'), + form: document.getElementById('chat-form'), + input: document.getElementById('chat-input'), + indicator: document.getElementById('polling-indicator') + }; + + // گرفتن لیست چتها از سرور + async function fetchRooms() { + try { + const res = await fetch(window.CHAT_CONFIG.apiUrlRooms); + const data = await res.json(); + renderRooms(data.rooms); + } catch (e) { + console.error("Failed to fetch rooms:", e); + } + } + + // رندر کردن لیست چتها + function renderRooms(rooms) { + if (rooms.length === 0) { + UI.roomList.innerHTML = '
No active chats found.
'; + return; + } + + UI.roomList.innerHTML = rooms.map(room => { + const isActive = currentRoomId === room.id; + const safeTitle = room.title.replace(/'/g, "\\'"); + return ` +No messages yet. Say hi!
+{% trans "Select a chat from the sidebar to start messaging" %}
+