update Unified Inbox

This commit is contained in:
Flook 2025-12-01 05:43:37 +07:00
parent 1565859a0c
commit 371deb40fa
9 changed files with 142 additions and 19 deletions

View File

@ -14,7 +14,8 @@ from drf_spectacular.utils import extend_schema, extend_schema_view
@extend_schema_view( @extend_schema_view(
list=extend_schema(tags=['2. Application Service']), list=extend_schema(tags=['2. Application Service']),
create=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):
""" """

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 datetime import datetime, timezone from drf_spectacular.utils import extend_schema
# Import Service Layer # Import Service Layer
from api.services.health_service import HealthService from api.services.health_service import HealthService
@ -9,6 +9,7 @@ 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,11 +1,18 @@
from typing import Optional
from helpdesk.models import Ticket from helpdesk.models import Ticket
from django.db.models import QuerySet from django.db.models import QuerySet
from django.core.exceptions import ObjectDoesNotExist
class TicketRepository: class TicketRepository:
def get_ticket_by_id(self, ticket_id: int) -> Ticket: def get_ticket_by_id(self, ticket_id: int) -> Optional[Ticket]:
"""ดึง Ticket ตาม ID""" """ดึง Ticket ตาม ID (คืนค่า None หากไม่พบ)"""
try:
return Ticket.objects.select_related('creator', 'assigned_to').get(pk=ticket_id) 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]: def get_unified_inbox_list(self) -> QuerySet[Ticket]:
""" """

View File

@ -5,7 +5,7 @@ from django.db.transaction import atomic
from helpdesk.repositories.ticket_repository import TicketRepository from helpdesk.repositories.ticket_repository import TicketRepository
from helpdesk.models import Ticket, TicketStatus from helpdesk.models import Ticket, TicketStatus
from django.contrib.auth import get_user_model
class TicketNotFoundError(Exception): class TicketNotFoundError(Exception):
"""Raised when the requested Ticket is not found.""" """Raised when the requested Ticket is not found."""
@ -47,16 +47,24 @@ class TicketService:
return self.ticket_repo.update_ticket(ticket, update_data) return self.ticket_repo.update_ticket(ticket, update_data)
def update_ticket_status(self, ticket_id: int, new_status: TicketStatus) -> Ticket: 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) ticket = self.ticket_repo.get_ticket_by_id(ticket_id)
if ticket is None: if ticket is None:
raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.") raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.")
# Business Rule: ห้ามเปลี่ยนจาก CLOSED -> OPEN # Business Rule ห้ามเปลี่ยนจาก CLOSED -> OPEN
if ticket.status == TicketStatus.CLOSED and new_status == TicketStatus.OPEN: if ticket.status == TicketStatus.CLOSED and new_status == TicketStatus.OPEN:
raise ValueError("cannot transition from CLOSED to OPEN") raise ValueError("Cannot transition from CLOSED to OPEN")
return self.ticket_repo.update_ticket(ticket, {"status": new_status}) # ตรวจสอบว่า 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): def get_inbox_summary(self):
return self.ticket_repo.get_unified_inbox_list() return self.ticket_repo.get_unified_inbox_list()
@ -77,7 +85,7 @@ class TicketService:
} }
@atomic @atomic
def assign_ticket_to_user(self, ticket_id: int, assigned_user): def assign_ticket_to_user(self, ticket_id: int, assignee_id: int):
""" """
มอบหมาย Ticket ใหบผใช (Agent) และเรยก NotificationService าม มอบหมาย Ticket ใหบผใช (Agent) และเรยก NotificationService าม
""" """
@ -85,6 +93,13 @@ class TicketService:
if not ticket: if not ticket:
raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.") 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}) updated_ticket = self.ticket_repo.update_ticket(ticket, {"assigned_to": assigned_user})
if self.notification_service: if self.notification_service:
@ -93,6 +108,7 @@ class TicketService:
user_id=assigned_user.id user_id=assigned_user.id
) )
return ticket # คืน updated_ticket
return updated_ticket
ticket_service = TicketService() ticket_service = TicketService()

View File

@ -53,7 +53,7 @@ class AssignmentNotificationIntegrationTest(TestCase):
# ACT: มอบหมาย ticket # ACT: มอบหมาย ticket
updated_ticket = ticket_service.assign_ticket_to_user( updated_ticket = ticket_service.assign_ticket_to_user(
ticket_id=self.ticket.id, ticket_id=self.ticket.id,
assigned_user=self.agent_2 assignee_id=self.agent_2.id
) )
# ASSERT 1: ตรวจสอบว่า Ticket ถูกมอบหมาย # ASSERT 1: ตรวจสอบว่า Ticket ถูกมอบหมาย

View File

@ -55,5 +55,5 @@ class TicketServiceStatusTest(TestCase):
new_status=TicketStatus.OPEN new_status=TicketStatus.OPEN
) )
self.assertIn("cannot transition from CLOSED to OPEN", str(ctx.exception)) self.assertIn("Cannot transition from CLOSED to OPEN", str(ctx.exception))
self.mock_repo.update_ticket.assert_not_called() self.mock_repo.update_ticket.assert_not_called()

View File

@ -55,3 +55,56 @@ class TicketViewSetFunctionalTest(APITestCase):
url = reverse('helpdesk-tickets-list') url = reverse('helpdesk-tickets-list')
response = client.get(url) response = client.get(url)
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) 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,4 +1,6 @@
from rest_framework import viewsets 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.services.ticket_service import ticket_service
from helpdesk.serializers.ticket_list_serializers import TicketListSerializer from helpdesk.serializers.ticket_list_serializers import TicketListSerializer
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
@ -7,13 +9,55 @@ from drf_spectacular.utils import extend_schema, extend_schema_view
@extend_schema_view( @extend_schema_view(
list=extend_schema(tags=['2. Application Service']), list=extend_schema(tags=['2. Application Service']),
create=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): class TicketViewSet(viewsets.ReadOnlyModelViewSet):
"""View สำหรับดึงรายการ Ticket ทั้งหมด (Unified Inbox List)""" """View สำหรับดึงรายการ Ticket ทั้งหมด (Unified Inbox List)"""
serializer_class = TicketListSerializer serializer_class = TicketListSerializer
permission_classes = [IsAuthenticated] # กำหนดให้เฉพาะผู้ที่ล็อกอินแล้วเท่านั้น permission_classes = [IsAuthenticated]
# 1. Viewset เรียก Service Layer # 1. Viewset เรียก Service Layer
def get_queryset(self): def get_queryset(self):
return ticket_service.get_inbox_summary() 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

@ -19,3 +19,4 @@ python-dotenv
flower flower
drf-nested-routers drf-nested-routers
channels_redis channels_redis
channels