diff --git a/backend/api/views/audit_viewset.py b/backend/api/views/audit_viewset.py index 504f975..fd2101a 100644 --- a/backend/api/views/audit_viewset.py +++ b/backend/api/views/audit_viewset.py @@ -14,7 +14,8 @@ 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']), + get_summary=extend_schema(tags=['2. Application Service']), + retrieve=extend_schema(tags=['2. Application Service']), ) class AuditLogViewSet(viewsets.ReadOnlyModelViewSet): """ diff --git a/backend/api/views/health_check_view.py b/backend/api/views/health_check_view.py index fc5a255..357181e 100644 --- a/backend/api/views/health_check_view.py +++ b/backend/api/views/health_check_view.py @@ -1,7 +1,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status, permissions -from datetime import datetime, timezone +from drf_spectacular.utils import extend_schema # Import Service Layer from api.services.health_service import HealthService @@ -9,6 +9,7 @@ from api.services.health_service import HealthService # Dependency Injection: สร้าง Instance ของ Service health_service = HealthService() +@extend_schema(tags=['2. Application Service']) class SystemHealthCheck(APIView): """ GET /api/v1/health/ diff --git a/backend/helpdesk/repositories/ticket_repository.py b/backend/helpdesk/repositories/ticket_repository.py index 52e515b..89ca620 100644 --- a/backend/helpdesk/repositories/ticket_repository.py +++ b/backend/helpdesk/repositories/ticket_repository.py @@ -1,11 +1,18 @@ +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) -> Ticket: - """ดึง Ticket ตาม ID""" - return Ticket.objects.select_related('creator', 'assigned_to').get(pk=ticket_id) + 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]: """ diff --git a/backend/helpdesk/services/ticket_service.py b/backend/helpdesk/services/ticket_service.py index 8b5121b..7b5a9d0 100644 --- a/backend/helpdesk/services/ticket_service.py +++ b/backend/helpdesk/services/ticket_service.py @@ -5,7 +5,7 @@ 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.""" @@ -47,16 +47,24 @@ class TicketService: 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) if ticket is None: 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: - 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): return self.ticket_repo.get_unified_inbox_list() @@ -77,7 +85,7 @@ class TicketService: } @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 ถ้ามี """ @@ -85,6 +93,13 @@ class TicketService: 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: @@ -93,6 +108,7 @@ class TicketService: user_id=assigned_user.id ) - return ticket + # คืน updated_ticket + return updated_ticket ticket_service = TicketService() \ No newline at end of file diff --git a/backend/helpdesk/tests/test_integration.py b/backend/helpdesk/tests/test_integration.py index e8e63ee..62e0cdd 100644 --- a/backend/helpdesk/tests/test_integration.py +++ b/backend/helpdesk/tests/test_integration.py @@ -53,7 +53,7 @@ class AssignmentNotificationIntegrationTest(TestCase): # ACT: มอบหมาย ticket updated_ticket = ticket_service.assign_ticket_to_user( ticket_id=self.ticket.id, - assigned_user=self.agent_2 + assignee_id=self.agent_2.id ) # ASSERT 1: ตรวจสอบว่า Ticket ถูกมอบหมาย diff --git a/backend/helpdesk/tests/test_services.py b/backend/helpdesk/tests/test_services.py index 0e4ae9d..d0211d0 100644 --- a/backend/helpdesk/tests/test_services.py +++ b/backend/helpdesk/tests/test_services.py @@ -55,5 +55,5 @@ class TicketServiceStatusTest(TestCase): 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() diff --git a/backend/helpdesk/tests/test_views.py b/backend/helpdesk/tests/test_views.py index 4d28827..a5c543d 100644 --- a/backend/helpdesk/tests/test_views.py +++ b/backend/helpdesk/tests/test_views.py @@ -55,3 +55,56 @@ class TicketViewSetFunctionalTest(APITestCase): 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']) diff --git a/backend/helpdesk/views/ticket_views.py b/backend/helpdesk/views/ticket_views.py index 1c058ee..bf87c9c 100644 --- a/backend/helpdesk/views/ticket_views.py +++ b/backend/helpdesk/views/ticket_views.py @@ -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.serializers.ticket_list_serializers import TicketListSerializer from rest_framework.permissions import IsAuthenticated @@ -7,13 +9,55 @@ 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']), + 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] # กำหนดให้เฉพาะผู้ที่ล็อกอินแล้วเท่านั้น + permission_classes = [IsAuthenticated] # 1. Viewset เรียก Service Layer def get_queryset(self): - return ticket_service.get_inbox_summary() \ No newline at end of file + 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) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 7eb66a8..acb10f7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -18,4 +18,5 @@ django-celery-email python-dotenv flower drf-nested-routers -channels_redis \ No newline at end of file +channels_redis +channels \ No newline at end of file