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`
|
[](LICENSE)
|
||||||
**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)
|
||||||
|
|
||||||
จากโค้ดและ Test Coverage ปัจจุบัน สรุปภาพรวมการรองรับระบบ Ticketing / Shared Inbox และฟีเจอร์เทียบกับ Chatwoot/Freshdesk ได้ดังนี้:
|
การพัฒนาขั้นต้นนี้ถูกบีบอัดให้เหลือเพียง **5 วัน** โดยการตัดฟังก์ชันที่ซับซ้อน (เช่น Real-time Messaging, Task Queues) ออกทั้งหมด:
|
||||||
|
|
||||||
### 1. โครงสร้างระบบปัจจุบัน
|
### 1. แผนภาพรวม (5-Day POC Breakdown)
|
||||||
**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 ซึ่งทำงานได้ครบถ้วน
|
|
||||||
|
|
||||||
**Shared Inbox / Multi-channel** ยังจำกัด ดังนี้:
|
| # | กิจกรรมหลัก | ระยะเวลา | สรุปเป้าหมาย (POC Focus) |
|
||||||
- ปัจจุบัน Inbox เป็น Unified Inbox แต่ยังรองรับเฉพาะ internal messages (Ticket + Chat messages)
|
| :--- | :--- | :--- | :--- |
|
||||||
- ยังไม่มี integration กับ external channels (Email, Live Chat, Line, WhatsApp, อื่น ๆ)
|
| **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
|
### 2. เทคโนโลยีและโครงสร้างที่ใช้ (Leveraged from Template)
|
||||||
สามารถดูรายละเอียดได้ที่
|
|
||||||
http://localhost:8000/api/schema/swagger-ui/
|
|
||||||
|
|
||||||
### 2. ช่องทางสื่อสาร
|
* **Backend & Infrastructure:**
|
||||||
- ปัจจุบันรองรับ Internal Ticket + Chat message
|
* **Authentication:** ระบบจัดการผู้ใช้และสิทธิ์ **JWT** (ตั้งค่าเสร็จแล้ว)
|
||||||
- ขาด Multi-channel (Email/Line/WhatsApp/Facebook/Twitter/SMS)
|
* **Database HA:** ฐานข้อมูลที่มีความพร้อมใช้งานสูง (CockroachDB)
|
||||||
- ยังไม่มีหน้า Customer Portal ให้ลูกค้าส่ง Ticket หรือดูสถานะ
|
* **Task Queue:** โครงสร้าง **Celery / Redis** ตั้งค่าไว้พร้อมใช้งาน (แต่ไม่ได้ใช้ใน POC นี้)
|
||||||
|
* **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,13 +10,6 @@ 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']),
|
|
||||||
get_summary=extend_schema(tags=['2. Application Service']),
|
|
||||||
retrieve=extend_schema(tags=['2. Application Service']),
|
|
||||||
)
|
|
||||||
class AuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
class AuditLogViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
"""
|
"""
|
||||||
API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม (รวมการดึง Summary ด้วย)
|
API สำหรับการเข้าถึง Inference Audit Log และสถิติรวม (รวมการดึง Summary ด้วย)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status, permissions
|
from rest_framework import status, permissions
|
||||||
from drf_spectacular.utils import extend_schema
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
# Import Service Layer
|
# Import Service Layer
|
||||||
from api.services.health_service import HealthService
|
from api.services.health_service import HealthService
|
||||||
@ -9,7 +9,6 @@ from api.services.health_service import HealthService
|
|||||||
# Dependency Injection: สร้าง Instance ของ Service
|
# Dependency Injection: สร้าง Instance ของ Service
|
||||||
health_service = HealthService()
|
health_service = HealthService()
|
||||||
|
|
||||||
@extend_schema(tags=['2. Application Service'])
|
|
||||||
class SystemHealthCheck(APIView):
|
class SystemHealthCheck(APIView):
|
||||||
"""
|
"""
|
||||||
GET /api/v1/health/
|
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
|
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 = ProtocolTypeRouter({
|
application = get_asgi_application()
|
||||||
"http": get_asgi_application(),
|
|
||||||
"websocket": AuthMiddlewareStack(
|
|
||||||
URLRouter(
|
|
||||||
chat.routing.websocket_urlpatterns # ต้องสร้าง chat/routing.py และกำหนด websocket_urlpatterns
|
|
||||||
)
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|||||||
@ -57,7 +57,6 @@ THIRD_PARTY_APPS = [
|
|||||||
'drf_spectacular',
|
'drf_spectacular',
|
||||||
'drf_spectacular_sidecar',
|
'drf_spectacular_sidecar',
|
||||||
'djcelery_email',
|
'djcelery_email',
|
||||||
'channels',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
LOCAL_APPS = [
|
LOCAL_APPS = [
|
||||||
@ -65,12 +64,12 @@ LOCAL_APPS = [
|
|||||||
'accounts',
|
'accounts',
|
||||||
'user_profile',
|
'user_profile',
|
||||||
'permissions',
|
'permissions',
|
||||||
'chat',
|
#'model_registry'
|
||||||
'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',
|
||||||
@ -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
|
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,4 +11,3 @@ 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,21 +27,16 @@ 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(r'audit', AuditLogViewSet, basename='auditlog')
|
router.register(
|
||||||
|
r'audit',
|
||||||
# Chat APIs
|
AuditLogViewSet,
|
||||||
router.register(r'chat/messages', MessageViewSet, basename='chat-messages')
|
basename='auditlog',
|
||||||
|
)
|
||||||
# Helpdesk APIs
|
|
||||||
router.register(r'helpdesk/tickets', TicketViewSet, basename='helpdesk-tickets')
|
|
||||||
|
|
||||||
# 3. ลงทะเบียน ViewSet อื่น ๆ
|
# 3. ลงทะเบียน ViewSet อื่น ๆ
|
||||||
urlpatterns = [
|
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
|
django-celery-email
|
||||||
python-dotenv
|
python-dotenv
|
||||||
flower
|
flower
|
||||||
drf-nested-routers
|
|
||||||
channels_redis
|
|
||||||
channels
|
|
||||||
Loading…
x
Reference in New Issue
Block a user