Compare commits

..

No commits in common. "feature/unified-inbox-chat" and "develop" have entirely different histories.

50 changed files with 76 additions and 1362 deletions

109
README.md
View File

@ -1,67 +1,68 @@
# Feature Branch: Unified Inbox Chat # 🚀 Unified Help Desk Platform (MVP)
**Branch Name:** `feature/unified-inbox-chat` [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
**Base Branch:** `develop` ![Build Status](https://img.shields.io/badge/Status-In%20Progress-yellow)
![Tech Stack](https://img.shields.io/badge/Tech%20Stack-Django%20DRF%20%7C%20React%20%7C%20Docker-blueviolet)
## 🌟 ภาพรวมโครงการ (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)

View File

@ -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 วย)

View File

@ -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/

View File

@ -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',)

View File

@ -1,6 +0,0 @@
from django.apps import AppConfig
class ChatConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'chat'

View File

@ -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'
)

View File

@ -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'],
},
),
]

View File

@ -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}"

View File

@ -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)

View File

@ -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()),
]

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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()

View File

@ -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!")

View File

@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.

View File

@ -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)

View File

@ -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
)
),
})

View File

@ -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',
@ -352,17 +351,4 @@ 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)],
},
},
}

View File

@ -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

View File

@ -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 = [

View File

@ -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]

View File

@ -1,6 +0,0 @@
from django.apps import AppConfig
class HelpdeskConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'helpdesk'

View File

@ -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'],
},
),
]

View File

@ -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}"

View File

@ -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

View File

@ -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'
]

View File

@ -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

View File

@ -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()

View File

@ -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
)

View File

@ -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)

View File

@ -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()

View File

@ -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'])

View File

@ -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)

View File

@ -16,7 +16,4 @@ 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
channels