docs: update README.md for Unified Inbox + Chat feature
This commit is contained in:
parent
b946de8193
commit
1565859a0c
107
README.md
107
README.md
@ -1,68 +1,65 @@
|
|||||||
# 🚀 Unified Help Desk Platform (MVP)
|
# Feature Branch: Unified Inbox Chat
|
||||||
|
|
||||||
[](LICENSE)
|
**Branch Name:** `feature/unified-inbox-chat`
|
||||||

|
**Base Branch:** `develop`
|
||||||

|
|
||||||
|
|
||||||
## 🌟 ภาพรวมโครงการ (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)
|
## 📝 สรุปผลการดำเนินการ
|
||||||
|
|
||||||
การพัฒนาขั้นต้นนี้ถูกบีบอัดให้เหลือเพียง **5 วัน** โดยการตัดฟังก์ชันที่ซับซ้อน (เช่น Real-time Messaging, Task Queues) ออกทั้งหมด:
|
จากโค้ดและ Test Coverage ปัจจุบัน สรุปภาพรวมการรองรับระบบ Ticketing / Shared Inbox และฟีเจอร์เทียบกับ Chatwoot/Freshdesk ได้ดังนี้:
|
||||||
|
|
||||||
### 1. แผนภาพรวม (5-Day POC Breakdown)
|
### 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 ซึ่งทำงานได้ครบถ้วน
|
||||||
|
|
||||||
| # | กิจกรรมหลัก | ระยะเวลา | สรุปเป้าหมาย (POC Focus) |
|
**Shared Inbox / Multi-channel** ยังจำกัด ดังนี้:
|
||||||
| :--- | :--- | :--- | :--- |
|
- ปัจจุบัน Inbox เป็น Unified Inbox แต่ยังรองรับเฉพาะ internal messages (Ticket + Chat messages)
|
||||||
| **1** | **Cleanup & Data Model** | 1 วัน | จัดการโครงสร้าง, สร้างแอป **`helpdesk`**, และสร้าง **Core Model: Ticket** |
|
- ยังไม่มี integration กับ external channels (Email, Live Chat, Line, WhatsApp, อื่น ๆ)
|
||||||
| **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 สำเร็จ** |
|
|
||||||
|
|
||||||
### 2. เทคโนโลยีและโครงสร้างที่ใช้ (Leveraged from Template)
|
**สรุป:** ระบบสามารถทำ Ticketing + Inbox ของตัวเองได้ แต่ยังไม่ใช่ “Shared Inbox” แบบ multi-channel เหมือน Chatwoot
|
||||||
|
|
||||||
* **Backend & Infrastructure:**
|
### 2. ช่องทางสื่อสาร
|
||||||
* **Authentication:** ระบบจัดการผู้ใช้และสิทธิ์ **JWT** (ตั้งค่าเสร็จแล้ว)
|
- ปัจจุบันรองรับ Internal Ticket + Chat message
|
||||||
* **Database HA:** ฐานข้อมูลที่มีความพร้อมใช้งานสูง (CockroachDB)
|
- ขาด Multi-channel (Email/Line/WhatsApp/Facebook/Twitter/SMS)
|
||||||
* **Task Queue:** โครงสร้าง **Celery / Redis** ตั้งค่าไว้พร้อมใช้งาน (แต่ไม่ได้ใช้ใน POC นี้)
|
- ยังไม่มีหน้า Customer Portal ให้ลูกค้าส่ง Ticket หรือดูสถานะ
|
||||||
* **Frontend Base:** React Hooks / Redux Toolkit / TanStack Query
|
|
||||||
|
**สรุป:** ระบบจะรองรับการทำงานได้ดีเฉพาะภายในองค์กร แต่ยังไม่ครบทุกช่องทาง
|
||||||
|
|
||||||
|
### 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 เป็นต้น
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ ปัจจัยความซับซ้อน (Scope Complexity)
|
## 📝 หมายเหตุ
|
||||||
|
- Feature branch นี้ยังไม่ merge กลับไป `develop`
|
||||||
|
- ใช้สำหรับพัฒนาและทดสอบฟีเจอร์ **Unified Inbox Chat**
|
||||||
|
|
||||||
* **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,6 +10,12 @@ from api.serializers.audit_serializer import InferenceAuditLogSerializer
|
|||||||
# นำเข้า Permission ที่จำเป็น
|
# นำเข้า Permission ที่จำเป็น
|
||||||
from permissions.permission_classes import IsAdminOrOperator, IsViewerOrHigher
|
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']),
|
||||||
|
create=extend_schema(tags=['2. Application Service']),
|
||||||
|
)
|
||||||
class AuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
class AuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม (รวมการดึง Summary ด้วย)
|
API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม (รวมการดึง Summary ด้วย)
|
||||||
|
|||||||
0
backend/chat/__init__.py
Normal file
0
backend/chat/__init__.py
Normal file
17
backend/chat/admin.py
Normal file
17
backend/chat/admin.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
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',)
|
||||||
6
backend/chat/apps.py
Normal file
6
backend/chat/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ChatConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'chat'
|
||||||
92
backend/chat/consumers.py
Normal file
92
backend/chat/consumers.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
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'
|
||||||
|
)
|
||||||
32
backend/chat/migrations/0001_initial.py
Normal file
32
backend/chat/migrations/0001_initial.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/chat/migrations/__init__.py
Normal file
0
backend/chat/migrations/__init__.py
Normal file
38
backend/chat/models.py
Normal file
38
backend/chat/models.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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}"
|
||||||
0
backend/chat/repositories/__init__.py
Normal file
0
backend/chat/repositories/__init__.py
Normal file
11
backend/chat/repositories/message_repository.py
Normal file
11
backend/chat/repositories/message_repository.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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)
|
||||||
7
backend/chat/routing.py
Normal file
7
backend/chat/routing.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
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()),
|
||||||
|
]
|
||||||
0
backend/chat/serializers/__init__.py
Normal file
0
backend/chat/serializers/__init__.py
Normal file
22
backend/chat/serializers/message_serializers.py
Normal file
22
backend/chat/serializers/message_serializers.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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
|
||||||
0
backend/chat/services/__init__.py
Normal file
0
backend/chat/services/__init__.py
Normal file
53
backend/chat/services/message_service.py
Normal file
53
backend/chat/services/message_service.py
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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)
|
||||||
0
backend/chat/tests/__init__.py
Normal file
0
backend/chat/tests/__init__.py
Normal file
105
backend/chat/tests/test_integration.py
Normal file
105
backend/chat/tests/test_integration.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
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)
|
||||||
50
backend/chat/tests/test_repositories.py
Normal file
50
backend/chat/tests/test_repositories.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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)
|
||||||
69
backend/chat/tests/test_services.py
Normal file
69
backend/chat/tests/test_services.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
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()
|
||||||
93
backend/chat/tests/test_views.py
Normal file
93
backend/chat/tests/test_views.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
# 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!")
|
||||||
3
backend/chat/views.py
Normal file
3
backend/chat/views.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
0
backend/chat/views/__init__.py
Normal file
0
backend/chat/views/__init__.py
Normal file
34
backend/chat/views/message_views.py
Normal file
34
backend/chat/views/message_views.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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
|
import os
|
||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
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')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
application = get_asgi_application()
|
application = ProtocolTypeRouter({
|
||||||
|
"http": get_asgi_application(),
|
||||||
|
"websocket": AuthMiddlewareStack(
|
||||||
|
URLRouter(
|
||||||
|
chat.routing.websocket_urlpatterns # ต้องสร้าง chat/routing.py และกำหนด websocket_urlpatterns
|
||||||
|
)
|
||||||
|
),
|
||||||
|
})
|
||||||
@ -57,6 +57,7 @@ THIRD_PARTY_APPS = [
|
|||||||
'drf_spectacular',
|
'drf_spectacular',
|
||||||
'drf_spectacular_sidecar',
|
'drf_spectacular_sidecar',
|
||||||
'djcelery_email',
|
'djcelery_email',
|
||||||
|
'channels',
|
||||||
]
|
]
|
||||||
|
|
||||||
LOCAL_APPS = [
|
LOCAL_APPS = [
|
||||||
@ -64,12 +65,12 @@ LOCAL_APPS = [
|
|||||||
'accounts',
|
'accounts',
|
||||||
'user_profile',
|
'user_profile',
|
||||||
'permissions',
|
'permissions',
|
||||||
#'model_registry'
|
'chat',
|
||||||
|
'helpdesk'
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'corsheaders.middleware.CorsMiddleware', # สำคัญมากสำหรับ Frontend
|
'corsheaders.middleware.CorsMiddleware', # สำคัญมากสำหรับ Frontend
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
@ -351,4 +352,17 @@ EMAIL_USE_TLS = os.getenv('MAILJET_SMTP_TLS', 'True') == 'True'
|
|||||||
EMAIL_HOST_USER = os.getenv('MAILJET_API_KEY') # API Key เป็น Username
|
EMAIL_HOST_USER = os.getenv('MAILJET_API_KEY') # API Key เป็น Username
|
||||||
EMAIL_HOST_PASSWORD = os.getenv('MAILJET_SECRET_KEY') # Secret Key เป็น Password
|
EMAIL_HOST_PASSWORD = os.getenv('MAILJET_SECRET_KEY') # Secret Key เป็น Password
|
||||||
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL') # อีเมลผู้ส่ง
|
DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL') # อีเมลผู้ส่ง
|
||||||
SERVER_EMAIL = DEFAULT_FROM_EMAIL # อีเมลสำหรับแจ้งเตือน Server
|
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,3 +11,4 @@ def rename_djoser_tags(result, generator, request, public):
|
|||||||
if 'v1' in tags:
|
if 'v1' in tags:
|
||||||
operation['tags'] = ['1. Authentication & User Management']
|
operation['tags'] = ['1. Authentication & User Management']
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@ -27,16 +27,21 @@ from api.views.audit_viewset import AuditLogViewSet
|
|||||||
|
|
||||||
from accounts.views import CustomTokenObtainPairView
|
from accounts.views import CustomTokenObtainPairView
|
||||||
|
|
||||||
|
from chat.views.message_views import MessageViewSet
|
||||||
|
from helpdesk.views.ticket_views import TicketViewSet
|
||||||
|
|
||||||
# 1. กำหนดตัวแปร router ก่อนใช้งาน
|
# 1. กำหนดตัวแปร router ก่อนใช้งาน
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
|
|
||||||
# 2. ลงทะเบียน API ViewSets (Project-Level Routing)
|
# 2. ลงทะเบียน API ViewSets (Project-Level Routing)
|
||||||
# URL: /api/v1/audit/ (AuditLogViewSet)
|
# URL: /api/v1/audit/ (AuditLogViewSet)
|
||||||
router.register(
|
router.register(r'audit', AuditLogViewSet, basename='auditlog')
|
||||||
r'audit',
|
|
||||||
AuditLogViewSet,
|
# Chat APIs
|
||||||
basename='auditlog',
|
router.register(r'chat/messages', MessageViewSet, basename='chat-messages')
|
||||||
)
|
|
||||||
|
# Helpdesk APIs
|
||||||
|
router.register(r'helpdesk/tickets', TicketViewSet, basename='helpdesk-tickets')
|
||||||
|
|
||||||
# 3. ลงทะเบียน ViewSet อื่น ๆ
|
# 3. ลงทะเบียน ViewSet อื่น ๆ
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
0
backend/helpdesk/__init__.py
Normal file
0
backend/helpdesk/__init__.py
Normal file
13
backend/helpdesk/admin.py
Normal file
13
backend/helpdesk/admin.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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]
|
||||||
6
backend/helpdesk/apps.py
Normal file
6
backend/helpdesk/apps.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class HelpdeskConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'helpdesk'
|
||||||
35
backend/helpdesk/migrations/0001_initial.py
Normal file
35
backend/helpdesk/migrations/0001_initial.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# 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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/helpdesk/migrations/__init__.py
Normal file
0
backend/helpdesk/migrations/__init__.py
Normal file
52
backend/helpdesk/models.py
Normal file
52
backend/helpdesk/models.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
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}"
|
||||||
0
backend/helpdesk/repositories/__init__.py
Normal file
0
backend/helpdesk/repositories/__init__.py
Normal file
22
backend/helpdesk/repositories/ticket_repository.py
Normal file
22
backend/helpdesk/repositories/ticket_repository.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
from helpdesk.models import Ticket
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
|
||||||
|
class TicketRepository:
|
||||||
|
|
||||||
|
def get_ticket_by_id(self, ticket_id: int) -> Ticket:
|
||||||
|
"""ดึง Ticket ตาม ID"""
|
||||||
|
return Ticket.objects.select_related('creator', 'assigned_to').get(pk=ticket_id)
|
||||||
|
|
||||||
|
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
|
||||||
0
backend/helpdesk/serializers/__init__.py
Normal file
0
backend/helpdesk/serializers/__init__.py
Normal file
15
backend/helpdesk/serializers/ticket_list_serializers.py
Normal file
15
backend/helpdesk/serializers/ticket_list_serializers.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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'
|
||||||
|
]
|
||||||
7
backend/helpdesk/serializers/user_serializers.py
Normal file
7
backend/helpdesk/serializers/user_serializers.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
class SimpleUserSerializer(serializers.Serializer):
|
||||||
|
"""Serializer พื้นฐานสำหรับผู้ใช้งาน"""
|
||||||
|
id = serializers.IntegerField()
|
||||||
|
username = serializers.CharField(source='get_username')
|
||||||
|
# สามารถเพิ่มฟิลด์ที่ต้องการแสดงได้ เช่น email, first_name
|
||||||
0
backend/helpdesk/services/__init__.py
Normal file
0
backend/helpdesk/services/__init__.py
Normal file
98
backend/helpdesk/services/ticket_service.py
Normal file
98
backend/helpdesk/services/ticket_service.py
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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: TicketStatus) -> 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.")
|
||||||
|
|
||||||
|
# Business Rule: ห้ามเปลี่ยนจาก CLOSED -> OPEN
|
||||||
|
if ticket.status == TicketStatus.CLOSED and new_status == TicketStatus.OPEN:
|
||||||
|
raise ValueError("cannot transition from CLOSED to OPEN")
|
||||||
|
|
||||||
|
return self.ticket_repo.update_ticket(ticket, {"status": new_status})
|
||||||
|
|
||||||
|
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, assigned_user):
|
||||||
|
"""
|
||||||
|
มอบหมาย 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.")
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
return ticket
|
||||||
|
|
||||||
|
ticket_service = TicketService()
|
||||||
0
backend/helpdesk/tests/__init__.py
Normal file
0
backend/helpdesk/tests/__init__.py
Normal file
66
backend/helpdesk/tests/test_integration.py
Normal file
66
backend/helpdesk/tests/test_integration.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
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,
|
||||||
|
assigned_user=self.agent_2
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
)
|
||||||
55
backend/helpdesk/tests/test_repositories.py
Normal file
55
backend/helpdesk/tests/test_repositories.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
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)
|
||||||
59
backend/helpdesk/tests/test_services.py
Normal file
59
backend/helpdesk/tests/test_services.py
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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()
|
||||||
57
backend/helpdesk/tests/test_views.py
Normal file
57
backend/helpdesk/tests/test_views.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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)
|
||||||
0
backend/helpdesk/views/__init__.py
Normal file
0
backend/helpdesk/views/__init__.py
Normal file
19
backend/helpdesk/views/ticket_views.py
Normal file
19
backend/helpdesk/views/ticket_views.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from rest_framework import viewsets
|
||||||
|
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']),
|
||||||
|
create=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()
|
||||||
@ -16,4 +16,6 @@ drf-spectacular
|
|||||||
drf-spectacular-sidecar
|
drf-spectacular-sidecar
|
||||||
django-celery-email
|
django-celery-email
|
||||||
python-dotenv
|
python-dotenv
|
||||||
flower
|
flower
|
||||||
|
drf-nested-routers
|
||||||
|
channels_redis
|
||||||
Loading…
x
Reference in New Issue
Block a user