Compare commits
No commits in common. "feature/unified-inbox-chat" and "develop" have entirely different histories.
feature/un
...
develop
109
README.md
109
README.md
@ -1,67 +1,68 @@
|
||||
# Feature Branch: Unified Inbox Chat
|
||||
# 🚀 Unified Help Desk Platform (MVP)
|
||||
|
||||
**Branch Name:** `feature/unified-inbox-chat`
|
||||
**Base Branch:** `develop`
|
||||
[](LICENSE)
|
||||

|
||||

|
||||
|
||||
## 🌟 ภาพรวมโครงการ (Project Overview)
|
||||
|
||||
โครงการนี้คือการพัฒนาแพลตฟอร์ม **Help Desk/Unified Inbox** ที่มีฟังก์ชันการทำงานพื้นฐานที่จำเป็น (Minimum Viable Product - MVP) สำหรับการจัดการ Ticket และการสนทนาระหว่างลูกค้าและ Agent
|
||||
|
||||
โครงการนี้เริ่มต้นจาก **`monorepo-starter-full-auth-web-app-template`** ซึ่งมีโครงสร้างพื้นฐานที่จำเป็น (Monorepo, High Availability, Authentication) ทำให้ลดเวลาในการตั้งค่าโครงสร้างพื้นฐานลงได้อย่างมาก
|
||||
|
||||
### 🎯 เป้าหมายของ MVP
|
||||
|
||||
1. สร้างระบบฐานข้อมูลหลักสำหรับจัดการ Ticket และ Agent
|
||||
2. พัฒนาระบบ **Unified Inbox** สำหรับ Agent เพื่อจัดการและติดตามสถานะ Ticket ทั้งหมด
|
||||
3. สร้างระบบ **Real-time Conversation** เพื่อให้ Agent และลูกค้าสามารถสื่อสารกันได้
|
||||
|
||||
---
|
||||
|
||||
## 📝 สรุปผลการดำเนินการ
|
||||
## 📅 แผนการพัฒนาและไทม์ไลน์ (5-Day Sprint Plan)
|
||||
|
||||
จากโค้ดและ Test Coverage ปัจจุบัน สรุปภาพรวมการรองรับระบบ Ticketing / Shared Inbox และฟีเจอร์เทียบกับ Chatwoot/Freshdesk ได้ดังนี้:
|
||||
การพัฒนาขั้นต้นนี้ถูกบีบอัดให้เหลือเพียง **5 วัน** โดยการตัดฟังก์ชันที่ซับซ้อน (เช่น Real-time Messaging, Task Queues) ออกทั้งหมด:
|
||||
|
||||
### 1. โครงสร้างระบบปัจจุบัน
|
||||
**Ticketing System** รองรับเต็มรูปแบบ ดังนี้:
|
||||
- สร้าง Ticket (`Ticket` model)
|
||||
- Assign Ticket (`TicketService.assign_ticket_to_user`)
|
||||
- Update Status (`TicketService.update_ticket_status`)
|
||||
- ติดตาม Last Message (`Ticket.last_message_content`, `last_message_at`)
|
||||
- Test ครอบคลุมทั้ง Unit + Integration + Functional Tests ซึ่งทำงานได้ครบถ้วน
|
||||
### 1. แผนภาพรวม (5-Day POC Breakdown)
|
||||
|
||||
**Shared Inbox / Multi-channel** ยังจำกัด ดังนี้:
|
||||
- ปัจจุบัน Inbox เป็น Unified Inbox แต่ยังรองรับเฉพาะ internal messages (Ticket + Chat messages)
|
||||
- ยังไม่มี integration กับ external channels (Email, Live Chat, Line, WhatsApp, อื่น ๆ)
|
||||
| # | กิจกรรมหลัก | ระยะเวลา | สรุปเป้าหมาย (POC Focus) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **1** | **Cleanup & Data Model** | 1 วัน | จัดการโครงสร้าง, สร้างแอป **`helpdesk`**, และสร้าง **Core Model: Ticket** |
|
||||
| **2** | **Backend API & Logic** | 2 วัน | สร้าง API (CRUD) สำหรับ Ticket, พัฒนาฟังก์ชัน **Update Status** และ **Assign Agent** |
|
||||
| **3** | **Frontend UI & Read** | 1 วัน | พัฒนาหน้า Inbox UI พื้นฐาน, เชื่อมต่อ API เพื่อ **แสดงรายการ Ticket ทั้งหมด** |
|
||||
| **4** | **Testing & Documentation** | 1 วัน | ทดสอบ End-to-End (Create $\rightarrow$ Read $\rightarrow$ Update) และจัดทำเอกสาร Setup |
|
||||
| **รวม** | | **5 วัน** | **Ticket Management POC สำเร็จ** |
|
||||
|
||||
**สรุป:** ระบบสามารถทำ Ticketing + Inbox ของตัวเองได้ แต่ยังไม่ใช่ “Shared Inbox” แบบ multi-channel เหมือน Chatwoot
|
||||
สามารถดูรายละเอียดได้ที่
|
||||
http://localhost:8000/api/schema/swagger-ui/
|
||||
### 2. เทคโนโลยีและโครงสร้างที่ใช้ (Leveraged from Template)
|
||||
|
||||
### 2. ช่องทางสื่อสาร
|
||||
- ปัจจุบันรองรับ Internal Ticket + Chat message
|
||||
- ขาด Multi-channel (Email/Line/WhatsApp/Facebook/Twitter/SMS)
|
||||
- ยังไม่มีหน้า Customer Portal ให้ลูกค้าส่ง Ticket หรือดูสถานะ
|
||||
|
||||
**สรุป:** ระบบจะรองรับการทำงานได้ดีเฉพาะภายในองค์กร แต่ยังไม่ครบทุกช่องทาง
|
||||
|
||||
### 3. AI / Automation
|
||||
- ยังไม่มี Auto-Routing / SLA / AI Agent
|
||||
- โครงสร้าง `TicketService` และ `MessageService` สามารถต่อเติมได้
|
||||
|
||||
### 4. Security
|
||||
- RBAC ขั้นพื้นฐาน (Django `is_staff`)
|
||||
- ยังไม่มี Audit Logs, SSO, Shift/Business Hours management
|
||||
|
||||
**สรุป:** เหมาะกับ internal team ขนาดเล็ก/กลาง แต่ยังไม่เทียบ Freshdesk ในด้าน Security
|
||||
|
||||
### 5. Collaboration
|
||||
- รองรับการพัฒนา Internal Notes / Private Messages ด้วย `MessageService` + `Ticket.last_message_content` / `is_read` สำหรับการทำงานร่วมกันของ Agent
|
||||
- รองรับการพัฒนา Assignment Notification ด้วย `TicketService` + `NotificationService`
|
||||
- ยังไม่มี Canned Responses, Internal Threads, Rich Collaboration Tools
|
||||
|
||||
### 6. Reporting / Analytics
|
||||
- ยังไม่มี KPI / CSAT / Agent Performance Reports
|
||||
- ยังไม่มี Knowledge Base / Self-Service Portal
|
||||
- ยังไม่มีระบบสรุป Inbox / Ticket summary แบบ Dashboard
|
||||
|
||||
### 7. Test Coverage ปัจจุบัน
|
||||
- ครอบคลุม Logic หลัก, Query และ Data Integrity
|
||||
- ครอบคลุม workflow
|
||||
- ครอบคลุมสำหรับ API Endpoints ปัจจุบัน
|
||||
|
||||
**สรุป Coverage:** ดีมากสำหรับฟีเจอร์ที่มีอยู่ แต่ยังไม่มีสำหรับฟีเจอร์ขั้นสูง เช่น multi-channel, AI routing, SLA, analytics เป็นต้น
|
||||
* **Backend & Infrastructure:**
|
||||
* **Authentication:** ระบบจัดการผู้ใช้และสิทธิ์ **JWT** (ตั้งค่าเสร็จแล้ว)
|
||||
* **Database HA:** ฐานข้อมูลที่มีความพร้อมใช้งานสูง (CockroachDB)
|
||||
* **Task Queue:** โครงสร้าง **Celery / Redis** ตั้งค่าไว้พร้อมใช้งาน (แต่ไม่ได้ใช้ใน POC นี้)
|
||||
* **Frontend Base:** React Hooks / Redux Toolkit / TanStack Query
|
||||
|
||||
---
|
||||
|
||||
## 📝 หมายเหตุ
|
||||
- Feature branch นี้ยังไม่ merge กลับไป `develop`
|
||||
- ใช้สำหรับพัฒนาและทดสอบฟีเจอร์ **Unified Inbox Chat**
|
||||
## ⚠️ ปัจจัยความซับซ้อน (Scope Complexity)
|
||||
|
||||
* **Real-time Messaging:** หากต้องเปลี่ยนจากการใช้ Polling ไปใช้ **WebSockets** (Django Channels/FastAPI) เพื่อให้การสนทนาเกิดขึ้นทันที อาจทำให้ระยะเวลาพัฒนาส่วนนี้เพิ่มขึ้น
|
||||
* **AI Integration:** การเพิ่มฟีเจอร์ **AI Automation** (เช่น การจัดประเภท Ticket อัตโนมัติ) ถูกจัดอยู่ในขอบเขตการพัฒนาเฟสถัดไป และจะใช้เวลาพัฒนาแยกต่างหาก
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ การเริ่มต้น (Getting Started)
|
||||
|
||||
### 1. Clone Project
|
||||
|
||||
```bash
|
||||
git clone https://gitea.softwarecraft.tech/gitea/help-desk.git
|
||||
cd help-desk
|
||||
```
|
||||
---
|
||||
|
||||
## คู่มือต่าง ๆ
|
||||
|
||||
คำแนะนำสำหรับ Developer ในการรันโปรเจคครั้งแรก [ที่นี่](docs/dev_manual.pdf)
|
||||
|
||||
คำแนะนำสำหรับการพัฒนา Django DRF ตามแนวคิด Clean Architecture [ที่นี่](docs/clean_architecture_in_django_drf_manual.pdf)
|
||||
|
||||
คำแนะนำสำหรับการพัฒนา Frontend [ที่นี่](docs/react_manual.pdf)
|
||||
|
||||
@ -10,13 +10,6 @@ from api.serializers.audit_serializer import InferenceAuditLogSerializer
|
||||
# นำเข้า Permission ที่จำเป็น
|
||||
from permissions.permission_classes import IsAdminOrOperator, IsViewerOrHigher
|
||||
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['2. Application Service']),
|
||||
get_summary=extend_schema(tags=['2. Application Service']),
|
||||
retrieve=extend_schema(tags=['2. Application Service']),
|
||||
)
|
||||
class AuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม (รวมการดึง Summary ด้วย)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status, permissions
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Import Service Layer
|
||||
from api.services.health_service import HealthService
|
||||
@ -9,7 +9,6 @@ from api.services.health_service import HealthService
|
||||
# Dependency Injection: สร้าง Instance ของ Service
|
||||
health_service = HealthService()
|
||||
|
||||
@extend_schema(tags=['2. Application Service'])
|
||||
class SystemHealthCheck(APIView):
|
||||
"""
|
||||
GET /api/v1/health/
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
from django.contrib import admin
|
||||
from .models import Message
|
||||
|
||||
class MessageInline(admin.TabularInline):
|
||||
model = Message
|
||||
extra = 0
|
||||
readonly_fields = ('timestamp',)
|
||||
fields = ('sender', 'content', 'message_type', 'timestamp')
|
||||
show_change_link = True
|
||||
|
||||
@admin.register(Message)
|
||||
class MessageAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'ticket', 'sender', 'message_type', 'timestamp')
|
||||
list_filter = ('message_type', 'sender')
|
||||
search_fields = ('content', 'ticket__title', 'sender__username')
|
||||
ordering = ('timestamp',)
|
||||
readonly_fields = ('timestamp',)
|
||||
@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChatConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'chat'
|
||||
@ -1,92 +0,0 @@
|
||||
import json
|
||||
from channels.generic.websocket import AsyncWebsocketConsumer
|
||||
from channels.db import database_sync_to_async
|
||||
from helpdesk.services.ticket_service import TicketService # ตรวจสอบ Ticket
|
||||
|
||||
class ChatConsumer(AsyncWebsocketConsumer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ticket_id = None
|
||||
self.ticket_group_name = None
|
||||
|
||||
@database_sync_to_async
|
||||
def check_ticket_and_auth(self, ticket_id, user):
|
||||
"""ตรวจสอบสิทธิ์และสถานะของ Ticket ใน DB"""
|
||||
if not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Business Logic Authorization:
|
||||
# ใช้ TicketService เพื่อตรวจสอบว่าผู้ใช้มีสิทธิ์เข้าถึง Ticket นี้หรือไม่
|
||||
if not TicketService().is_user_authorized(ticket_id, user):
|
||||
return False
|
||||
|
||||
# สำหรับตัวอย่างนี้ให้แค่ตรวจสอบว่า Ticket มีอยู่จริงหรือไม่
|
||||
from helpdesk.models import Ticket
|
||||
return Ticket.objects.filter(pk=ticket_id).exists()
|
||||
|
||||
async def connect(self):
|
||||
self.ticket_id = self.scope['url_route']['kwargs']['ticket_id']
|
||||
self.ticket_group_name = f'ticket_{self.ticket_id}'
|
||||
user = self.scope['user']
|
||||
|
||||
# 1. ตรวจสอบสิทธิ์ก่อนเชื่อมต่อ
|
||||
is_allowed = await self.check_ticket_and_auth(self.ticket_id, user)
|
||||
|
||||
if is_allowed:
|
||||
# Join room group
|
||||
await self.channel_layer.group_add(
|
||||
self.ticket_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
await self.accept()
|
||||
else:
|
||||
await self.close()
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
if self.ticket_group_name:
|
||||
# Leave room group
|
||||
await self.channel_layer.group_discard(
|
||||
self.ticket_group_name,
|
||||
self.channel_name
|
||||
)
|
||||
|
||||
# Receive message from WebSocket (ไคลเอนต์ส่งข้อความมา)
|
||||
async def receive(self, text_data=None, bytes_data=None):
|
||||
# Consumer ไม่ควรมี Business Logic
|
||||
# ข้อมูลขาเข้าควรถูกส่งไปให้ Service Layer ประมวลผล
|
||||
text_data_json = json.loads(text_data)
|
||||
content = text_data_json.get('content')
|
||||
|
||||
# เรียก Service เพื่อสร้างและส่งข้อความ (ต้องทำแบบ sync)
|
||||
# เนื่องจาก Consumer เป็น async, เราจะส่งไปให้ Service แบบ sync/async
|
||||
await self.save_message_and_broadcast(self.ticket_id, self.scope['user'], content)
|
||||
|
||||
# เมธอดที่ Service Layer เรียกผ่าน Channel Layer (group_send)
|
||||
async def chat_message(self, event):
|
||||
"""ส่งข้อความที่ถูก broadcast กลับไปยัง WebSocket Client"""
|
||||
message = event['message']
|
||||
|
||||
# Send message to WebSocket
|
||||
await self.send(text_data=json.dumps({
|
||||
'type': 'new_message',
|
||||
'message': message
|
||||
}))
|
||||
|
||||
@database_sync_to_async
|
||||
def save_message_and_broadcast(self, ticket_id, user, content):
|
||||
"""
|
||||
เมธอด sync ที่เรียก Service Layer เพื่อทำ Business Logic
|
||||
และส่ง broadcast กลับมา (ผ่าน _send_realtime_update ใน Service)
|
||||
"""
|
||||
from chat.services.message_service import chat_svc # ต้องแน่ใจว่า import ถูกต้อง
|
||||
if not content:
|
||||
return
|
||||
|
||||
# Service จะสร้างข้อความใน DB, อัปเดต Ticket, และส่ง broadcast
|
||||
chat_svc.create_and_send_message(
|
||||
ticket_id=ticket_id,
|
||||
sender=user,
|
||||
content=content,
|
||||
message_type='T'
|
||||
)
|
||||
@ -1,32 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 22:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('helpdesk', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Message',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('content', models.TextField()),
|
||||
('message_type', models.CharField(choices=[('T', 'Ticket Message (Public)'), ('P', 'Private Note (Internal)')], default='T', max_length=1)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||
('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
|
||||
('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='helpdesk.ticket')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['timestamp'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,38 +0,0 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from helpdesk.models import Ticket # อ้างอิงข้ามโดเมน
|
||||
|
||||
class Message(models.Model):
|
||||
"""โมเดลสำหรับเก็บข้อความสนทนาในแต่ละ Ticket"""
|
||||
|
||||
TICKET_MESSAGE = 'T'
|
||||
PRIVATE_NOTE = 'P'
|
||||
MESSAGE_TYPES = [
|
||||
(TICKET_MESSAGE, 'Ticket Message (Public)'),
|
||||
(PRIVATE_NOTE, 'Private Note (Internal)')
|
||||
]
|
||||
|
||||
ticket = models.ForeignKey(
|
||||
Ticket,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='messages' # เชื่อมโยงกับ Ticket ที่เพิ่งสร้าง
|
||||
)
|
||||
sender = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='sent_messages'
|
||||
)
|
||||
content = models.TextField()
|
||||
message_type = models.CharField(
|
||||
max_length=1,
|
||||
choices=MESSAGE_TYPES,
|
||||
default=TICKET_MESSAGE
|
||||
)
|
||||
timestamp = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['timestamp']
|
||||
|
||||
def __str__(self):
|
||||
return f"Msg {self.id} on TKT {self.ticket_id}"
|
||||
@ -1,11 +0,0 @@
|
||||
from chat.models import Message
|
||||
from django.db.models import QuerySet
|
||||
|
||||
class MessageRepository:
|
||||
|
||||
def get_messages_by_ticket(self, ticket_id: int) -> QuerySet[Message]:
|
||||
return Message.objects.filter(ticket_id=ticket_id).select_related('sender')
|
||||
|
||||
def create_message(self, **data):
|
||||
# data = {ticket_id, sender, content, message_type}
|
||||
return Message.objects.create(**data)
|
||||
@ -1,7 +0,0 @@
|
||||
from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
# ws://localhost:8000/ws/ticket/<int:ticket_id>/
|
||||
re_path(r'ws/ticket/(?P<ticket_id>\d+)/$', consumers.ChatConsumer.as_asgi()),
|
||||
]
|
||||
@ -1,22 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
from chat.models import Message
|
||||
from helpdesk.models import Ticket
|
||||
from helpdesk.serializers.user_serializers import SimpleUserSerializer
|
||||
|
||||
class MessageSerializer(serializers.ModelSerializer):
|
||||
sender = SimpleUserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = ['id', 'ticket', 'sender', 'content', 'message_type', 'timestamp']
|
||||
read_only_fields = fields
|
||||
|
||||
class MessageCreateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Message
|
||||
fields = ['ticket', 'content', 'message_type']
|
||||
|
||||
def validate_content(self, value):
|
||||
if not value.strip():
|
||||
raise serializers.ValidationError("Content cannot be empty.")
|
||||
return value
|
||||
@ -1,53 +0,0 @@
|
||||
from chat.repositories.message_repository import MessageRepository
|
||||
from helpdesk.services.ticket_service import ticket_service
|
||||
from chat.serializers.message_serializers import MessageSerializer
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.db import transaction
|
||||
|
||||
from helpdesk.services.ticket_service import ticket_service as helpdesk_ticket_service
|
||||
|
||||
class MessageService:
|
||||
def __init__(self, message_repo=None, ticket_service=None):
|
||||
# รองรับ DI สำหรับ unit test
|
||||
self.message_repo = message_repo or MessageRepository()
|
||||
self.ticket_service = ticket_service # ไม่ใช่ global อีกต่อไป
|
||||
|
||||
def get_ticket_messages(self, ticket_id: int):
|
||||
return self.message_repo.get_messages_by_ticket(ticket_id)
|
||||
|
||||
@transaction.atomic
|
||||
def create_and_send_message(self, ticket_id, sender, content, message_type):
|
||||
sender_is_agent = sender.is_staff
|
||||
|
||||
ticket_ref = self.ticket_service.get_ticket_ref(ticket_id)
|
||||
|
||||
new_message = self.message_repo.create_message(
|
||||
ticket_id=ticket_ref["id"],
|
||||
sender=sender,
|
||||
content=content,
|
||||
message_type=message_type
|
||||
)
|
||||
|
||||
self.ticket_service.update_ticket_on_new_message(
|
||||
ticket_id=ticket_ref["id"],
|
||||
content=new_message.content,
|
||||
timestamp=new_message.timestamp,
|
||||
sender_is_agent=sender_is_agent
|
||||
)
|
||||
|
||||
self._send_realtime_update(new_message)
|
||||
return new_message
|
||||
|
||||
def _send_realtime_update(self, message):
|
||||
channel_layer = get_channel_layer()
|
||||
data = MessageSerializer(message).data
|
||||
ticket_group_name = f'ticket_{message.ticket_id}'
|
||||
|
||||
async_to_sync(channel_layer.group_send)(
|
||||
ticket_group_name,
|
||||
{'type': 'chat.message', 'message': data}
|
||||
)
|
||||
|
||||
# สร้าง instance สำหรับใช้งานจริง
|
||||
message_service = MessageService(ticket_service=helpdesk_ticket_service)
|
||||
@ -1,105 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from helpdesk.models import Ticket
|
||||
|
||||
from chat.services.message_service import MessageService
|
||||
from helpdesk.services.ticket_service import ticket_service as global_ticket_service
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
class MessageTicketIntegrationTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# ผู้ใช้ Agent (ต้องใส่ email ให้ไม่ซ้ำ)
|
||||
self.user = UserModel.objects.create_user(
|
||||
username='tester',
|
||||
email='tester@example.com',
|
||||
password='testpassword',
|
||||
is_staff=True
|
||||
)
|
||||
|
||||
# Ticket เริ่มต้น (เก็บเวลาไว้เพื่อใช้เปรียบเทียบ)
|
||||
self.ticket = Ticket.objects.create(
|
||||
creator=self.user,
|
||||
title="Ticket for Chat Integration Test",
|
||||
last_message_at=timezone.now()
|
||||
)
|
||||
|
||||
# เก็บค่า datetime เดิมไว้ (ก่อนมีข้อความใหม่)
|
||||
self.original_last_message_at = self.ticket.last_message_at
|
||||
|
||||
self.message_service = MessageService()
|
||||
self.message_service.ticket_service = global_ticket_service
|
||||
|
||||
# ปิด Realtime ด้วย mock (เพื่อตัด websocket ออกจาก test)
|
||||
@patch('chat.services.message_service.MessageService._send_realtime_update')
|
||||
def test_message_creation_updates_ticket_data(self, mock_send_realtime):
|
||||
"""
|
||||
GIVEN: มี Ticket อยู่ก่อนแล้ว
|
||||
WHEN: Agent ส่งข้อความใหม่ผ่าน MessageService
|
||||
THEN: last_message_content, last_message_at, is_read ต้องถูกอัปเดตถูกต้อง
|
||||
"""
|
||||
new_content = "This is the newest message content from Agent."
|
||||
|
||||
# ACT
|
||||
new_message = self.message_service.create_and_send_message(
|
||||
ticket_id=self.ticket.id,
|
||||
sender=self.user,
|
||||
content=new_content,
|
||||
message_type='T'
|
||||
)
|
||||
|
||||
# ASSERT 1 Message ถูกสร้างในฐานข้อมูล
|
||||
self.assertIsNotNone(new_message.id)
|
||||
|
||||
# ดึง Ticket ใหม่จาก DB
|
||||
self.ticket.refresh_from_db()
|
||||
|
||||
# ASSERT 2 เนื้อหาข้อความล่าสุดถูกอัปเดต
|
||||
self.assertEqual(self.ticket.last_message_content, new_content)
|
||||
|
||||
# ASSERT 3 Agent ส่ง → is_read ต้องเป็น True
|
||||
self.assertEqual(self.ticket.is_read, True)
|
||||
|
||||
# ASSERT 4 เวลา last_message_at ต้องใหม่กว่าเวลาของ ticket เดิม
|
||||
self.assertGreater(self.ticket.last_message_at, self.original_last_message_at)
|
||||
|
||||
# ASSERT 5 realtime broadcast ถูกเรียก (เพราะ mock)
|
||||
mock_send_realtime.assert_called_once()
|
||||
|
||||
|
||||
@patch('chat.services.message_service.MessageService._send_realtime_update')
|
||||
def test_message_creation_handles_non_agent_message_correctly(self, mock_send_realtime):
|
||||
"""
|
||||
GIVEN: ผู้ส่งเป็น customer (is_staff=False)
|
||||
WHEN: ส่งข้อความใหม่เข้ามา
|
||||
THEN: Ticket.is_read ต้องเป็น False (รอ Agent อ่าน)
|
||||
"""
|
||||
customer = UserModel.objects.create_user(
|
||||
username='customer',
|
||||
email='customer@example.com', # ต้องใส่ email เพื่อไม่ให้ซ้ำ
|
||||
password='p',
|
||||
is_staff=False
|
||||
)
|
||||
|
||||
new_content = "Customer: I need help."
|
||||
|
||||
# ACT
|
||||
self.message_service.create_and_send_message(
|
||||
ticket_id=self.ticket.id,
|
||||
sender=customer,
|
||||
content=new_content,
|
||||
message_type='T'
|
||||
)
|
||||
|
||||
# ASSERT: ดึงข้อมูลล่าสุดจาก DB
|
||||
self.ticket.refresh_from_db()
|
||||
|
||||
# เนื้อหาต้องตรงกับข้อความใหม่
|
||||
self.assertEqual(self.ticket.last_message_content, new_content)
|
||||
|
||||
# ลูกค้าส่ง → Agent ยังไม่ได้อ่าน → is_read = False
|
||||
self.assertEqual(self.ticket.is_read, False)
|
||||
@ -1,50 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from chat.repositories.message_repository import MessageRepository
|
||||
from helpdesk.models import Ticket # จำเป็นต้องสร้าง Ticket ก่อนสร้าง Message
|
||||
from chat.models import Message
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
class MessageRepositoryTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = MessageRepository()
|
||||
self.user = UserModel.objects.create_user(
|
||||
username='msg_user',
|
||||
email='msg_user@test.com',
|
||||
password='p'
|
||||
)
|
||||
self.ticket = Ticket.objects.create(
|
||||
creator=self.user,
|
||||
title="Test Ticket for Message",
|
||||
)
|
||||
|
||||
def test_create_message_saves_data_correctly(self):
|
||||
"""
|
||||
WHEN: เรียก create_message ด้วย data ที่ถูกต้อง
|
||||
THEN: Message ต้องถูกบันทึกใน DB และมี content ตรงตามที่ส่ง
|
||||
"""
|
||||
content = "Testing message content storage."
|
||||
message_type = 'P' # Private Note
|
||||
|
||||
data = {
|
||||
'ticket': self.ticket,
|
||||
'sender': self.user,
|
||||
'content': content,
|
||||
'message_type': message_type
|
||||
}
|
||||
|
||||
# ACT
|
||||
new_message = self.repo.create_message(**data)
|
||||
|
||||
# ASSERT 1 ตรวจสอบว่า Message ถูกสร้างใน DB
|
||||
self.assertIsNotNone(new_message.id)
|
||||
self.assertEqual(Message.objects.count(), 1)
|
||||
|
||||
# ASSERT 2 ตรวจสอบความถูกต้องของข้อมูลที่ถูกบันทึก
|
||||
retrieved_message = Message.objects.get(pk=new_message.id)
|
||||
self.assertEqual(retrieved_message.content, content)
|
||||
self.assertEqual(retrieved_message.message_type, message_type)
|
||||
self.assertEqual(retrieved_message.ticket_id, self.ticket.id)
|
||||
@ -1,69 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from unittest.mock import MagicMock, patch
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from chat.services.message_service import MessageService
|
||||
from chat.models import Message
|
||||
from helpdesk.services.ticket_service import TicketService # Service ที่ถูกเรียก
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
class MessageServiceTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Mock Repository
|
||||
self.mock_repo = MagicMock()
|
||||
# Mock TicketService
|
||||
self.mock_ticket_service = MagicMock(spec=TicketService)
|
||||
self.mock_ticket_service.get_ticket_ref.return_value = {
|
||||
"id": 100,
|
||||
"last_message_at": timezone.now(),
|
||||
"last_message_content": "",
|
||||
"is_read": False
|
||||
}
|
||||
|
||||
# Inject Mock เข้าไปใน Service
|
||||
self.service = MessageService(message_repo=self.mock_repo)
|
||||
self.service.ticket_service = self.mock_ticket_service
|
||||
|
||||
# User จำลอง
|
||||
self.user = UserModel.objects.create_user(
|
||||
username='svc_tester', email='svc_tester@test.com', password='p', is_staff=True
|
||||
)
|
||||
|
||||
# Message Data
|
||||
self.message_data = {
|
||||
'ticket_id': 100,
|
||||
'sender': self.user,
|
||||
'content': 'Test Message Content',
|
||||
'message_type': 'T'
|
||||
}
|
||||
|
||||
# Mock create_message ให้คืนค่า Message
|
||||
self.mock_repo.create_message.return_value = Message(
|
||||
id=1, **self.message_data, timestamp=timezone.now()
|
||||
)
|
||||
|
||||
@patch('chat.services.message_service.MessageService._send_realtime_update')
|
||||
def test_create_and_send_message_calls_ticket_service_contract(self, mock_send_realtime):
|
||||
# ACT
|
||||
new_message = self.service.create_and_send_message(
|
||||
ticket_id=self.message_data['ticket_id'],
|
||||
sender=self.message_data['sender'],
|
||||
content=self.message_data['content'],
|
||||
message_type=self.message_data['message_type']
|
||||
)
|
||||
|
||||
# ASSERT 1 Repository ถูกเรียกเพื่อบันทึก Message
|
||||
self.mock_repo.create_message.assert_called_once_with(**self.message_data)
|
||||
|
||||
# ASSERT 2 TicketService ถูกเรียกถูกต้อง
|
||||
self.mock_ticket_service.update_ticket_on_new_message.assert_called_once()
|
||||
args, kwargs = self.mock_ticket_service.update_ticket_on_new_message.call_args
|
||||
self.assertEqual(kwargs['ticket_id'], self.message_data['ticket_id'])
|
||||
self.assertIsInstance(kwargs['timestamp'], timezone.datetime)
|
||||
self.assertEqual(kwargs['sender_is_agent'], self.user.is_staff)
|
||||
|
||||
# ASSERT 3 Realtime Broadcast ถูกเรียก
|
||||
mock_send_realtime.assert_called_once()
|
||||
@ -1,93 +0,0 @@
|
||||
# chat/tests/test_views.py
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase, APIClient
|
||||
from rest_framework import status
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from helpdesk.models import Ticket
|
||||
from chat.models import Message
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
class MessageViewSetFunctionalTest(APITestCase):
|
||||
"""Functional Test สำหรับ MessageViewSet"""
|
||||
|
||||
def setUp(self):
|
||||
# 1. สร้าง Users
|
||||
self.agent = UserModel.objects.create_user(
|
||||
username="agent1",
|
||||
email="agent1@example.com",
|
||||
password="password123",
|
||||
is_staff=True
|
||||
)
|
||||
self.customer = UserModel.objects.create_user(
|
||||
username="customer1",
|
||||
email="customer1@example.com",
|
||||
password="password123",
|
||||
is_staff=False
|
||||
)
|
||||
|
||||
# 2. สร้าง APIClient และ force_authenticate
|
||||
self.client_agent = APIClient()
|
||||
self.client_agent.force_authenticate(user=self.agent)
|
||||
|
||||
self.client_customer = APIClient()
|
||||
self.client_customer.force_authenticate(user=self.customer)
|
||||
|
||||
# 3. สร้าง Ticket ตัวอย่าง
|
||||
self.ticket = Ticket.objects.create(
|
||||
creator=self.customer,
|
||||
title="Chat Functional Test Ticket",
|
||||
last_message_at=timezone.now()
|
||||
)
|
||||
|
||||
def test_message_list_and_create(self):
|
||||
"""Test GET /messages/?ticket_id= และ POST /messages/"""
|
||||
url = reverse('chat-messages-list') # ต้องตรงกับ router ของ MessageViewSet
|
||||
|
||||
# GET messages → ต้องว่างก่อนสร้าง
|
||||
response = self.client_agent.get(url, {"ticket_id": self.ticket.id})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 0)
|
||||
|
||||
# POST ข้อความ Agent ส่ง
|
||||
post_data_agent = {
|
||||
"ticket": self.ticket.id,
|
||||
"content": "Hello from Agent",
|
||||
"message_type": "T"
|
||||
}
|
||||
|
||||
response = self.client_agent.post(url, post_data_agent, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['content'], post_data_agent['content'])
|
||||
|
||||
# ตรวจสอบ Ticket.is_read → Agent ส่งเอง → ต้อง True
|
||||
self.ticket.refresh_from_db()
|
||||
self.assertTrue(self.ticket.is_read)
|
||||
|
||||
# POST ข้อความ Customer ส่ง
|
||||
post_data_customer = {
|
||||
"ticket": self.ticket.id,
|
||||
"content": "Customer: I need help!",
|
||||
"message_type": "T"
|
||||
}
|
||||
response = self.client_customer.post(url, post_data_customer, format='json')
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
|
||||
# ตรวจสอบ Ticket.is_read → Customer ส่ง → ต้อง False
|
||||
self.ticket.refresh_from_db()
|
||||
self.assertFalse(self.ticket.is_read)
|
||||
|
||||
# GET messages → ต้องมี 2 ข้อความ ----
|
||||
response = self.client_agent.get(url, {"ticket_id": self.ticket.id})
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(response.data[0]['content'], "Hello from Agent")
|
||||
self.assertEqual(response.data[1]['content'], "Customer: I need help!")
|
||||
|
||||
# ตรวจสอบว่า Message ใน DB ถูกสร้างครบ ----
|
||||
messages = Message.objects.filter(ticket=self.ticket).order_by('timestamp')
|
||||
self.assertEqual(messages.count(), 2)
|
||||
self.assertEqual(messages[0].content, "Hello from Agent")
|
||||
self.assertEqual(messages[1].content, "Customer: I need help!")
|
||||
@ -1,3 +0,0 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
@ -1,34 +0,0 @@
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from chat.services.message_service import message_service
|
||||
from chat.serializers.message_serializers import MessageSerializer, MessageCreateSerializer
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['2. Application Service']),
|
||||
create=extend_schema(tags=['2. Application Service']),
|
||||
)
|
||||
class MessageViewSet(viewsets.GenericViewSet):
|
||||
"""View สำหรับจัดการข้อความที่อยู่ภายใต้ Ticket"""
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def list(self, request):
|
||||
ticket_id = request.query_params.get('ticket_id')
|
||||
messages = message_service.get_ticket_messages(ticket_id=ticket_id)
|
||||
serializer = MessageSerializer(messages, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def create(self, request):
|
||||
serializer = MessageCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
new_message = message_service.create_and_send_message(
|
||||
ticket_id=serializer.validated_data['ticket'].id,
|
||||
sender=request.user,
|
||||
content=serializer.validated_data['content'],
|
||||
message_type=serializer.validated_data['message_type']
|
||||
)
|
||||
response_serializer = MessageSerializer(new_message)
|
||||
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
"""
|
||||
ASGI config for core project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
import chat.routing
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
"http": get_asgi_application(),
|
||||
"websocket": AuthMiddlewareStack(
|
||||
URLRouter(
|
||||
chat.routing.websocket_urlpatterns # ต้องสร้าง chat/routing.py และกำหนด websocket_urlpatterns
|
||||
)
|
||||
),
|
||||
})
|
||||
application = get_asgi_application()
|
||||
|
||||
@ -57,7 +57,6 @@ THIRD_PARTY_APPS = [
|
||||
'drf_spectacular',
|
||||
'drf_spectacular_sidecar',
|
||||
'djcelery_email',
|
||||
'channels',
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
@ -65,12 +64,12 @@ LOCAL_APPS = [
|
||||
'accounts',
|
||||
'user_profile',
|
||||
'permissions',
|
||||
'chat',
|
||||
'helpdesk'
|
||||
#'model_registry'
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware', # สำคัญมากสำหรับ Frontend
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
@ -353,16 +352,3 @@ EMAIL_HOST_USER = os.getenv('MAILJET_API_KEY') # API Key เป็น Usern
|
||||
EMAIL_HOST_PASSWORD = os.getenv('MAILJET_SECRET_KEY') # Secret Key เป็น Password
|
||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL') # อีเมลผู้ส่ง
|
||||
SERVER_EMAIL = DEFAULT_FROM_EMAIL # อีเมลสำหรับแจ้งเตือน Server
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# การตั้งค่า Channels (ASGI)
|
||||
# ----------------------------------------------------------------------
|
||||
ASGI_APPLICATION = 'core.asgi.application'
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [("127.0.0.1", 6379)],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -11,4 +11,3 @@ def rename_djoser_tags(result, generator, request, public):
|
||||
if 'v1' in tags:
|
||||
operation['tags'] = ['1. Authentication & User Management']
|
||||
return result
|
||||
|
||||
|
||||
@ -27,21 +27,16 @@ from api.views.audit_viewset import AuditLogViewSet
|
||||
|
||||
from accounts.views import CustomTokenObtainPairView
|
||||
|
||||
from chat.views.message_views import MessageViewSet
|
||||
from helpdesk.views.ticket_views import TicketViewSet
|
||||
|
||||
# 1. กำหนดตัวแปร router ก่อนใช้งาน
|
||||
router = DefaultRouter()
|
||||
|
||||
# 2. ลงทะเบียน API ViewSets (Project-Level Routing)
|
||||
# URL: /api/v1/audit/ (AuditLogViewSet)
|
||||
router.register(r'audit', AuditLogViewSet, basename='auditlog')
|
||||
|
||||
# Chat APIs
|
||||
router.register(r'chat/messages', MessageViewSet, basename='chat-messages')
|
||||
|
||||
# Helpdesk APIs
|
||||
router.register(r'helpdesk/tickets', TicketViewSet, basename='helpdesk-tickets')
|
||||
router.register(
|
||||
r'audit',
|
||||
AuditLogViewSet,
|
||||
basename='auditlog',
|
||||
)
|
||||
|
||||
# 3. ลงทะเบียน ViewSet อื่น ๆ
|
||||
urlpatterns = [
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
from django.contrib import admin
|
||||
from .models import Ticket
|
||||
from chat.models import Message
|
||||
|
||||
class MessageInline(admin.TabularInline):
|
||||
model = Message
|
||||
extra = 0
|
||||
readonly_fields = ('sender', 'content', 'message_type', 'timestamp')
|
||||
|
||||
@admin.register(Ticket)
|
||||
class TicketAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'title', 'creator', 'assigned_to', 'status', 'last_message_at')
|
||||
inlines = [MessageInline]
|
||||
@ -1,6 +0,0 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class HelpdeskConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'helpdesk'
|
||||
@ -1,35 +0,0 @@
|
||||
# Generated by Django 5.2.8 on 2025-11-28 22:05
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Ticket',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('status', models.CharField(choices=[('OPEN', 'Open'), ('IN_PROGRESS', 'In Progress'), ('RESOLVED', 'Resolved'), ('CLOSED', 'Closed')], default='OPEN', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('last_message_at', models.DateTimeField(blank=True, null=True)),
|
||||
('last_message_content', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('is_read', models.BooleanField(default=False)),
|
||||
('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL)),
|
||||
('creator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tickets', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -1,52 +0,0 @@
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
# Constants สำหรับสถานะ
|
||||
class TicketStatus(models.TextChoices):
|
||||
OPEN = 'OPEN', _('Open')
|
||||
IN_PROGRESS = 'IN_PROGRESS', _('In Progress')
|
||||
RESOLVED = 'RESOLVED', _('Resolved')
|
||||
CLOSED = 'CLOSED', _('Closed')
|
||||
|
||||
class Ticket(models.Model):
|
||||
"""โมเดลหลักสำหรับ Ticket/Case"""
|
||||
|
||||
# ฐานข้อมูลจะอ้างอิง User model หลักของ Django (CustomUser)
|
||||
creator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='created_tickets'
|
||||
)
|
||||
assigned_to = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True, blank=True,
|
||||
related_name='assigned_tickets'
|
||||
)
|
||||
title = models.CharField(max_length=255)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=TicketStatus.choices,
|
||||
default=TicketStatus.OPEN
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
# ฟิลด์สำคัญสำหรับการรวมเป็น Unified Inbox
|
||||
last_message_at = models.DateTimeField(
|
||||
null=True, blank=True
|
||||
)
|
||||
last_message_content = models.CharField(
|
||||
max_length=255, null=True, blank=True
|
||||
)
|
||||
is_read = models.BooleanField(
|
||||
default=False # สถานะอ่านล่าสุดโดย Agent
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-updated_at'] # จัดเรียงตามการอัปเดตล่าสุด
|
||||
|
||||
def __str__(self):
|
||||
return f"TKT-{self.id}: {self.title}"
|
||||
@ -1,29 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
from helpdesk.models import Ticket
|
||||
from django.db.models import QuerySet
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
class TicketRepository:
|
||||
|
||||
def get_ticket_by_id(self, ticket_id: int) -> Optional[Ticket]:
|
||||
"""ดึง Ticket ตาม ID (คืนค่า None หากไม่พบ)"""
|
||||
try:
|
||||
return Ticket.objects.select_related('creator', 'assigned_to').get(pk=ticket_id)
|
||||
# ดักจับ ObjectDoesNotExist แล้วคืนค่า None เพื่อให้ TicketService ตรวจสอบ if ticket is None และโยน TicketNotFoundError แทน
|
||||
except ObjectDoesNotExist:
|
||||
return None
|
||||
|
||||
def get_unified_inbox_list(self) -> QuerySet[Ticket]:
|
||||
"""
|
||||
ดึงรายการ Ticket ทั้งหมด จัดเรียงตามการอัปเดตล่าสุด
|
||||
(สำหรับหน้า Unified Inbox)
|
||||
"""
|
||||
return Ticket.objects.all().select_related('creator', 'assigned_to').order_by('-last_message_at')
|
||||
|
||||
def update_ticket(self, ticket: Ticket, update_data: dict) -> Ticket:
|
||||
"""อัปเดตฟิลด์ที่ต้องการของ Ticket instance"""
|
||||
for key, value in update_data.items():
|
||||
setattr(ticket, key, value)
|
||||
ticket.save()
|
||||
return ticket
|
||||
@ -1,15 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
from helpdesk.models import Ticket
|
||||
from .user_serializers import SimpleUserSerializer
|
||||
|
||||
class TicketListSerializer(serializers.ModelSerializer):
|
||||
"""ใช้สำหรับแสดงรายการ Ticket ใน Unified Inbox"""
|
||||
creator = SimpleUserSerializer(read_only=True)
|
||||
assigned_to = SimpleUserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Ticket
|
||||
fields = [
|
||||
'id', 'title', 'status', 'creator', 'assigned_to',
|
||||
'last_message_at', 'last_message_content', 'is_read'
|
||||
]
|
||||
@ -1,7 +0,0 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
class SimpleUserSerializer(serializers.Serializer):
|
||||
"""Serializer พื้นฐานสำหรับผู้ใช้งาน"""
|
||||
id = serializers.IntegerField()
|
||||
username = serializers.CharField(source='get_username')
|
||||
# สามารถเพิ่มฟิลด์ที่ต้องการแสดงได้ เช่น email, first_name
|
||||
@ -1,114 +0,0 @@
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from django.db.transaction import atomic
|
||||
|
||||
from helpdesk.repositories.ticket_repository import TicketRepository
|
||||
from helpdesk.models import Ticket, TicketStatus
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
class TicketNotFoundError(Exception):
|
||||
"""Raised when the requested Ticket is not found."""
|
||||
pass
|
||||
|
||||
class NotificationService:
|
||||
"""Placeholder สำหรับ NotificationService"""
|
||||
def send_assignment_notification(self, ticket_id, user_id):
|
||||
pass
|
||||
|
||||
class TicketService:
|
||||
def __init__(self, ticket_repo: Optional[TicketRepository] = None, notification_service=None):
|
||||
# รองรับ DI ในการทดสอบ — หากไม่ส่งมา จะสร้าง repo ใหม่สำหรับ production
|
||||
self.ticket_repo = ticket_repo or TicketRepository()
|
||||
self.notification_service = notification_service
|
||||
|
||||
@atomic
|
||||
def update_ticket_on_new_message(
|
||||
self,
|
||||
ticket_id: int,
|
||||
content: str,
|
||||
timestamp: datetime,
|
||||
sender_is_agent: bool,
|
||||
) -> Ticket:
|
||||
"""
|
||||
อัปเดต Ticket เมื่อมีข้อความใหม่เข้ามาใน Chat
|
||||
- ถ้าไม่พบ Ticket จะโยน TicketNotFoundError
|
||||
"""
|
||||
ticket = self.ticket_repo.get_ticket_by_id(ticket_id)
|
||||
|
||||
if ticket is None:
|
||||
raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.")
|
||||
|
||||
update_data = {
|
||||
"last_message_at": timestamp,
|
||||
"last_message_content": content,
|
||||
"is_read": True if sender_is_agent else False,
|
||||
}
|
||||
|
||||
return self.ticket_repo.update_ticket(ticket, update_data)
|
||||
|
||||
def update_ticket_status(self, ticket_id: int, new_status: str) -> Ticket:
|
||||
"""อัปเดตสถานะของ Ticket และตรวจสอบ Business Rule"""
|
||||
ticket = self.ticket_repo.get_ticket_by_id(ticket_id)
|
||||
if ticket is None:
|
||||
raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.")
|
||||
|
||||
# Business Rule ห้ามเปลี่ยนจาก CLOSED -> OPEN
|
||||
if ticket.status == TicketStatus.CLOSED and new_status == TicketStatus.OPEN:
|
||||
raise ValueError("Cannot transition from CLOSED to OPEN")
|
||||
|
||||
# ตรวจสอบว่า new_status เป็นค่าที่ถูกต้องใน enum หรือไม่ (เผื่อกรณี View ส่ง string ที่ผิดมา)
|
||||
if new_status not in TicketStatus:
|
||||
raise ValueError(f"Invalid status value: {new_status}")
|
||||
|
||||
updated_ticket = self.ticket_repo.update_ticket(ticket, {"status": new_status})
|
||||
|
||||
# คืน updated_ticket
|
||||
return updated_ticket
|
||||
|
||||
def get_inbox_summary(self):
|
||||
return self.ticket_repo.get_unified_inbox_list()
|
||||
|
||||
def get_ticket_detail(self, ticket_id: int) -> Ticket:
|
||||
ticket = self.ticket_repo.get_ticket_by_id(ticket_id)
|
||||
if ticket is None:
|
||||
raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.")
|
||||
return ticket
|
||||
|
||||
def get_ticket_ref(self, ticket_id: int) -> dict:
|
||||
ticket = self.ticket_repo.get_ticket_by_id(ticket_id)
|
||||
return {
|
||||
"id": ticket.id,
|
||||
"last_message_at": ticket.last_message_at,
|
||||
"last_message_content": ticket.last_message_content,
|
||||
"is_read": ticket.is_read
|
||||
}
|
||||
|
||||
@atomic
|
||||
def assign_ticket_to_user(self, ticket_id: int, assignee_id: int):
|
||||
"""
|
||||
มอบหมาย Ticket ให้กับผู้ใช้ (Agent) และเรียก NotificationService ถ้ามี
|
||||
"""
|
||||
ticket = self.ticket_repo.get_ticket_by_id(ticket_id)
|
||||
if not ticket:
|
||||
raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.")
|
||||
|
||||
# ดึง User Object จาก ID
|
||||
User = get_user_model()
|
||||
try:
|
||||
assigned_user = User.objects.get(pk=assignee_id)
|
||||
except User.DoesNotExist:
|
||||
raise ValueError("User not found")
|
||||
|
||||
updated_ticket = self.ticket_repo.update_ticket(ticket, {"assigned_to": assigned_user})
|
||||
|
||||
if self.notification_service:
|
||||
self.notification_service.send_assignment_notification(
|
||||
ticket_id=updated_ticket.id,
|
||||
user_id=assigned_user.id
|
||||
)
|
||||
|
||||
# คืน updated_ticket
|
||||
return updated_ticket
|
||||
|
||||
ticket_service = TicketService()
|
||||
@ -1,66 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from unittest.mock import patch
|
||||
|
||||
from helpdesk.models import Ticket
|
||||
from helpdesk.services.ticket_service import TicketService
|
||||
from helpdesk.repositories.ticket_repository import TicketRepository
|
||||
from unittest import skip
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
class AssignmentNotificationIntegrationTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# 1. Setup Users (Agents)
|
||||
self.agent_1 = UserModel.objects.create_user(
|
||||
username='agent_one',
|
||||
email='agent1@test.com',
|
||||
password='p',
|
||||
is_staff=True
|
||||
)
|
||||
self.agent_2 = UserModel.objects.create_user(
|
||||
username='agent_two',
|
||||
email='agent2@test.com',
|
||||
password='p',
|
||||
is_staff=True
|
||||
)
|
||||
|
||||
# 2. Setup Ticket
|
||||
self.ticket = Ticket.objects.create(
|
||||
creator=self.agent_1,
|
||||
title="Ticket for Assignment Test"
|
||||
)
|
||||
|
||||
# 3. Setup Repository
|
||||
self.repo = TicketRepository()
|
||||
|
||||
@patch("helpdesk.services.ticket_service.NotificationService") # mock class
|
||||
def test_assignment_triggers_notification_call(self, MockNotificationService):
|
||||
"""
|
||||
Integration Test: ตรวจสอบว่า assign_ticket_to_user() เรียก
|
||||
NotificationService.send_assignment_notification()
|
||||
"""
|
||||
# Mock instance ของ NotificationService
|
||||
mock_notification_instance = MockNotificationService.return_value
|
||||
|
||||
# Inject mock instance เข้าไปใน TicketService
|
||||
ticket_service = TicketService(
|
||||
ticket_repo=self.repo,
|
||||
notification_service=mock_notification_instance
|
||||
)
|
||||
|
||||
# ACT: มอบหมาย ticket
|
||||
updated_ticket = ticket_service.assign_ticket_to_user(
|
||||
ticket_id=self.ticket.id,
|
||||
assignee_id=self.agent_2.id
|
||||
)
|
||||
|
||||
# ASSERT 1: ตรวจสอบว่า Ticket ถูกมอบหมาย
|
||||
self.assertEqual(updated_ticket.assigned_to, self.agent_2)
|
||||
|
||||
# ASSERT 2: ตรวจสอบว่า NotificationService ถูกเรียก
|
||||
mock_notification_instance.send_assignment_notification.assert_called_once_with(
|
||||
ticket_id=self.ticket.id,
|
||||
user_id=self.agent_2.id
|
||||
)
|
||||
@ -1,55 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from helpdesk.repositories.ticket_repository import TicketRepository
|
||||
from helpdesk.models import Ticket, TicketStatus
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
class TicketRepositoryTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.repo = TicketRepository()
|
||||
|
||||
# สร้าง Users
|
||||
self.creator = UserModel.objects.create_user(
|
||||
username='repo_creator',
|
||||
email='repo_creator@test.com',
|
||||
password='p'
|
||||
)
|
||||
|
||||
self.assigned_user = UserModel.objects.create_user(
|
||||
username='repo_assignee',
|
||||
email='repo_assignee@test.com',
|
||||
password='p'
|
||||
)
|
||||
|
||||
# สร้าง Tickets
|
||||
self.ticket1 = Ticket.objects.create(
|
||||
creator=self.creator,
|
||||
assigned_to=self.assigned_user,
|
||||
title="Test Ticket 1",
|
||||
last_message_at=timezone.now(),
|
||||
last_message_content="Initial message 1",
|
||||
status=TicketStatus.OPEN,
|
||||
is_read=True,
|
||||
)
|
||||
|
||||
self.ticket2 = Ticket.objects.create(
|
||||
creator=self.creator,
|
||||
title="Test Ticket 2",
|
||||
last_message_at=timezone.now(),
|
||||
last_message_content="Initial message 2",
|
||||
status=TicketStatus.OPEN,
|
||||
is_read=True,
|
||||
)
|
||||
|
||||
def test_get_unified_inbox_list_uses_select_related(self):
|
||||
"""ตรวจสอบว่าการ Query ใช้ select_related() เพื่อลดจำนวน Query (แก้ปัญหา N+1)"""
|
||||
with self.assertNumQueries(1):
|
||||
tickets = list(self.repo.get_unified_inbox_list())
|
||||
for ticket in tickets:
|
||||
_ = ticket.creator.username
|
||||
|
||||
self.assertEqual(len(tickets), 2)
|
||||
@ -1,59 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from unittest.mock import MagicMock
|
||||
from django.utils import timezone
|
||||
from helpdesk.services.ticket_service import TicketService
|
||||
from helpdesk.models import TicketStatus, Ticket
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
class TicketServiceStatusTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.mock_repo = MagicMock()
|
||||
self.service = TicketService(ticket_repo=self.mock_repo)
|
||||
|
||||
# Mock Ticket Object
|
||||
self.mock_ticket = Ticket(
|
||||
id=1,
|
||||
title="Test Ticket",
|
||||
status=TicketStatus.OPEN,
|
||||
last_message_at=timezone.now(),
|
||||
last_message_content="Initial message",
|
||||
is_read=True,
|
||||
)
|
||||
|
||||
self.mock_repo.get_ticket_by_id.return_value = self.mock_ticket
|
||||
|
||||
def test_allow_status_change_from_open_to_inprogress(self):
|
||||
new_status = TicketStatus.IN_PROGRESS
|
||||
|
||||
# Mock update_ticket return value
|
||||
updated_ticket = Ticket(
|
||||
id=1,
|
||||
title="Test Ticket",
|
||||
status=new_status,
|
||||
last_message_at=timezone.now(),
|
||||
last_message_content="Initial message",
|
||||
is_read=True,
|
||||
)
|
||||
self.mock_repo.update_ticket.return_value = updated_ticket
|
||||
|
||||
# ACT
|
||||
result = self.service.update_ticket_status(1, new_status)
|
||||
|
||||
# ASSERT
|
||||
self.assertEqual(result.status, new_status)
|
||||
self.mock_repo.update_ticket.assert_called_once()
|
||||
|
||||
def test_prevent_status_change_from_closed_to_open(self):
|
||||
self.mock_ticket.status = TicketStatus.CLOSED
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
self.service.update_ticket_status(
|
||||
ticket_id=1,
|
||||
new_status=TicketStatus.OPEN
|
||||
)
|
||||
|
||||
self.assertIn("Cannot transition from CLOSED to OPEN", str(ctx.exception))
|
||||
self.mock_repo.update_ticket.assert_not_called()
|
||||
@ -1,110 +0,0 @@
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase, APIClient
|
||||
from rest_framework import status
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
|
||||
from helpdesk.models import Ticket
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
class TicketViewSetFunctionalTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
# สร้าง Agent User
|
||||
self.agent = UserModel.objects.create_user(
|
||||
username="agent1",
|
||||
email="agent1@example.com",
|
||||
password="password123",
|
||||
is_staff=True
|
||||
)
|
||||
|
||||
# สร้าง Customer User
|
||||
self.customer = UserModel.objects.create_user(
|
||||
username="customer1",
|
||||
email="customer1@example.com",
|
||||
password="password123",
|
||||
is_staff=False
|
||||
)
|
||||
|
||||
# Client สำหรับ Agent
|
||||
self.client_agent = APIClient()
|
||||
self.client_agent.force_authenticate(user=self.agent)
|
||||
|
||||
# Client สำหรับ Customer
|
||||
self.client_customer = APIClient()
|
||||
self.client_customer.force_authenticate(user=self.customer)
|
||||
|
||||
# สร้าง Ticket ตัวอย่าง
|
||||
self.ticket = Ticket.objects.create(
|
||||
creator=self.customer,
|
||||
title="Functional Test Ticket",
|
||||
last_message_at=timezone.now()
|
||||
)
|
||||
|
||||
def test_ticket_list_authenticated(self):
|
||||
"""GET /tickets/ ต้อง return 200 สำหรับผู้ใช้ล็อกอิน"""
|
||||
url = reverse('helpdesk-tickets-list') # ต้องตรงกับ router name
|
||||
response = self.client_agent.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertTrue(isinstance(response.data, list))
|
||||
|
||||
def test_ticket_list_unauthenticated(self):
|
||||
"""GET /tickets/ ต้อง return 401 สำหรับผู้ใช้ไม่ล็อกอิน"""
|
||||
client = APIClient() # client ไม่ล็อกอิน
|
||||
url = reverse('helpdesk-tickets-list')
|
||||
response = client.get(url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_resolve_ticket_by_agent(self):
|
||||
"""POST /tickets/{id}/resolve/ ต้องเปลี่ยนสถานะ Ticket เป็น RESOLVED"""
|
||||
resolve_url = reverse('helpdesk-tickets-resolve', args=[self.ticket.id])
|
||||
|
||||
# ACT: Agent เรียก resolve
|
||||
response = self.client_agent.post(resolve_url)
|
||||
|
||||
# ASSERT
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['status'], 'RESOLVED')
|
||||
|
||||
# ตรวจสอบใน DB อีกครั้ง
|
||||
self.ticket.refresh_from_db()
|
||||
self.assertEqual(self.ticket.status, 'RESOLVED')
|
||||
|
||||
def test_resolve_ticket_unauthenticated_fails(self):
|
||||
"""POST /tickets/{id}/resolve/ ต้อง return 401 เมื่อไม่ล็อกอิน"""
|
||||
client = APIClient()
|
||||
resolve_url = reverse('helpdesk-tickets-resolve', args=[self.ticket.id])
|
||||
response = client.post(resolve_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_assign_ticket_by_agent(self):
|
||||
"""POST /tickets/{id}/assign/ ต้องมอบหมาย Ticket ให้ Agent ที่ระบุ"""
|
||||
# สร้าง Agent ที่สองเพื่อมอบหมายไปหา
|
||||
agent2 = UserModel.objects.create_user(username="agent2", email="agent2@example.com", password="p", is_staff=True)
|
||||
assign_url = reverse('helpdesk-tickets-assign', args=[self.ticket.id])
|
||||
payload = {'assignee_id': agent2.id}
|
||||
|
||||
# ACT: Agent1 เรียก assign
|
||||
response = self.client_agent.post(assign_url, payload, format='json')
|
||||
|
||||
# ASSERT
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
# ตรวจสอบว่า assigned_to ใน Response ตรงกับ ID ของ agent2
|
||||
self.assertEqual(response.data['assigned_to']['id'], agent2.id)
|
||||
|
||||
# ตรวจสอบใน DB อีกครั้ง
|
||||
self.ticket.refresh_from_db()
|
||||
self.assertEqual(self.ticket.assigned_to, agent2)
|
||||
|
||||
def test_assign_ticket_to_non_existent_user_fails(self):
|
||||
"""POST /tickets/{id}/assign/ ต้อง return 400 เมื่อมอบหมายให้ User ที่ไม่มีอยู่"""
|
||||
assign_url = reverse('helpdesk-tickets-assign', args=[self.ticket.id])
|
||||
# ใช้ ID ที่ไม่มีอยู่จริง (สมมติว่า ID 999999 ไม่มี)
|
||||
payload = {'assignee_id': 999999}
|
||||
|
||||
response = self.client_agent.post(assign_url, payload, format='json')
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
# ตรวจสอบว่าข้อความ Error สื่อถึงปัญหา (ขึ้นอยู่กับ logic ใน ViewSet/Service)
|
||||
self.assertIn('not found', response.data['detail'])
|
||||
@ -1,63 +0,0 @@
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import action
|
||||
from helpdesk.services.ticket_service import ticket_service
|
||||
from helpdesk.serializers.ticket_list_serializers import TicketListSerializer
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
|
||||
from drf_spectacular.utils import extend_schema, extend_schema_view
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(tags=['2. Application Service']),
|
||||
retrieve=extend_schema(tags=['2. Application Service']),
|
||||
resolve_ticket=extend_schema(tags=['2. Application Service']),
|
||||
assign_ticket=extend_schema(tags=['2. Application Service']),
|
||||
)
|
||||
class TicketViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""View สำหรับดึงรายการ Ticket ทั้งหมด (Unified Inbox List)"""
|
||||
serializer_class = TicketListSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
# 1. Viewset เรียก Service Layer
|
||||
def get_queryset(self):
|
||||
return ticket_service.get_inbox_summary()
|
||||
|
||||
# Custom Actions สำหรับการจัดการ Ticket
|
||||
@action(detail=True, methods=['post'], url_path='resolve', url_name='resolve')
|
||||
@extend_schema(
|
||||
summary='ปิด (Resolve) Ticket ด้วย ID',
|
||||
description='เปลี่ยนสถานะ Ticket ที่กำหนดให้เป็น "Resolved" หรือ "Closed".',
|
||||
)
|
||||
def resolve_ticket(self, request, pk=None):
|
||||
try:
|
||||
updated_ticket = ticket_service.update_ticket_status(
|
||||
ticket_id=pk,
|
||||
new_status='RESOLVED',
|
||||
)
|
||||
return Response(self.get_serializer(updated_ticket).data)
|
||||
except Exception as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=True, methods=['post'], url_path='assign', url_name='assign')
|
||||
@extend_schema(
|
||||
summary='มอบหมาย (Assign) Ticket ให้ Agent',
|
||||
description='มอบหมาย Ticket นี้ให้กับผู้ใช้/Agent ที่ระบุใน body (เช่น {"assignee_id": 123}).',
|
||||
request={
|
||||
'application/json': {'type': 'object', 'properties': {'assignee_id': {'type': 'integer'}}}
|
||||
}
|
||||
)
|
||||
def assign_ticket(self, request, pk=None):
|
||||
assignee_id = request.data.get('assignee_id')
|
||||
if not assignee_id:
|
||||
return Response({"assignee_id": "Field is required"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
updated_ticket = ticket_service.assign_ticket_to_user(
|
||||
ticket_id=pk,
|
||||
assignee_id=assignee_id
|
||||
)
|
||||
return Response(self.get_serializer(updated_ticket).data)
|
||||
except ValueError as e:
|
||||
return Response({"detail": "User not found"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
except Exception as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
@ -17,6 +17,3 @@ drf-spectacular-sidecar
|
||||
django-celery-email
|
||||
python-dotenv
|
||||
flower
|
||||
drf-nested-routers
|
||||
channels_redis
|
||||
channels
|
||||
Loading…
x
Reference in New Issue
Block a user