From 1565859a0cdd2dbd644f9c34fb5cad5e794d6705 Mon Sep 17 00:00:00 2001 From: Flook Date: Sun, 30 Nov 2025 09:06:19 +0700 Subject: [PATCH] docs: update README.md for Unified Inbox + Chat feature --- README.md | 107 +++++++++--------- backend/api/views/audit_viewset.py | 6 + backend/chat/__init__.py | 0 backend/chat/admin.py | 17 +++ backend/chat/apps.py | 6 + backend/chat/consumers.py | 92 +++++++++++++++ backend/chat/migrations/0001_initial.py | 32 ++++++ backend/chat/migrations/__init__.py | 0 backend/chat/models.py | 38 +++++++ backend/chat/repositories/__init__.py | 0 .../chat/repositories/message_repository.py | 11 ++ backend/chat/routing.py | 7 ++ backend/chat/serializers/__init__.py | 0 .../chat/serializers/message_serializers.py | 22 ++++ backend/chat/services/__init__.py | 0 backend/chat/services/message_service.py | 53 +++++++++ backend/chat/tests/__init__.py | 0 backend/chat/tests/test_integration.py | 105 +++++++++++++++++ backend/chat/tests/test_repositories.py | 50 ++++++++ backend/chat/tests/test_services.py | 69 +++++++++++ backend/chat/tests/test_views.py | 93 +++++++++++++++ backend/chat/views.py | 3 + backend/chat/views/__init__.py | 0 backend/chat/views/message_views.py | 34 ++++++ backend/core/asgi.py | 22 ++-- backend/core/settings.py | 20 +++- backend/core/spectacular_hooks.py | 1 + backend/core/urls.py | 15 ++- backend/helpdesk/__init__.py | 0 backend/helpdesk/admin.py | 13 +++ backend/helpdesk/apps.py | 6 + backend/helpdesk/migrations/0001_initial.py | 35 ++++++ backend/helpdesk/migrations/__init__.py | 0 backend/helpdesk/models.py | 52 +++++++++ backend/helpdesk/repositories/__init__.py | 0 .../repositories/ticket_repository.py | 22 ++++ backend/helpdesk/serializers/__init__.py | 0 .../serializers/ticket_list_serializers.py | 15 +++ .../helpdesk/serializers/user_serializers.py | 7 ++ backend/helpdesk/services/__init__.py | 0 backend/helpdesk/services/ticket_service.py | 98 ++++++++++++++++ backend/helpdesk/tests/__init__.py | 0 backend/helpdesk/tests/test_integration.py | 66 +++++++++++ backend/helpdesk/tests/test_repositories.py | 55 +++++++++ backend/helpdesk/tests/test_services.py | 59 ++++++++++ backend/helpdesk/tests/test_views.py | 57 ++++++++++ backend/helpdesk/views/__init__.py | 0 backend/helpdesk/views/ticket_views.py | 19 ++++ backend/requirements.txt | 4 +- 49 files changed, 1236 insertions(+), 75 deletions(-) create mode 100644 backend/chat/__init__.py create mode 100644 backend/chat/admin.py create mode 100644 backend/chat/apps.py create mode 100644 backend/chat/consumers.py create mode 100644 backend/chat/migrations/0001_initial.py create mode 100644 backend/chat/migrations/__init__.py create mode 100644 backend/chat/models.py create mode 100644 backend/chat/repositories/__init__.py create mode 100644 backend/chat/repositories/message_repository.py create mode 100644 backend/chat/routing.py create mode 100644 backend/chat/serializers/__init__.py create mode 100644 backend/chat/serializers/message_serializers.py create mode 100644 backend/chat/services/__init__.py create mode 100644 backend/chat/services/message_service.py create mode 100644 backend/chat/tests/__init__.py create mode 100644 backend/chat/tests/test_integration.py create mode 100644 backend/chat/tests/test_repositories.py create mode 100644 backend/chat/tests/test_services.py create mode 100644 backend/chat/tests/test_views.py create mode 100644 backend/chat/views.py create mode 100644 backend/chat/views/__init__.py create mode 100644 backend/chat/views/message_views.py create mode 100644 backend/helpdesk/__init__.py create mode 100644 backend/helpdesk/admin.py create mode 100644 backend/helpdesk/apps.py create mode 100644 backend/helpdesk/migrations/0001_initial.py create mode 100644 backend/helpdesk/migrations/__init__.py create mode 100644 backend/helpdesk/models.py create mode 100644 backend/helpdesk/repositories/__init__.py create mode 100644 backend/helpdesk/repositories/ticket_repository.py create mode 100644 backend/helpdesk/serializers/__init__.py create mode 100644 backend/helpdesk/serializers/ticket_list_serializers.py create mode 100644 backend/helpdesk/serializers/user_serializers.py create mode 100644 backend/helpdesk/services/__init__.py create mode 100644 backend/helpdesk/services/ticket_service.py create mode 100644 backend/helpdesk/tests/__init__.py create mode 100644 backend/helpdesk/tests/test_integration.py create mode 100644 backend/helpdesk/tests/test_repositories.py create mode 100644 backend/helpdesk/tests/test_services.py create mode 100644 backend/helpdesk/tests/test_views.py create mode 100644 backend/helpdesk/views/__init__.py create mode 100644 backend/helpdesk/views/ticket_views.py diff --git a/README.md b/README.md index 1c37087..02db1c1 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,65 @@ -# 🚀 Unified Help Desk Platform (MVP) +# Feature Branch: Unified Inbox Chat -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -![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 āđāļĨāļ°āļĨāļđāļāļ„āđ‰āļēāļŠāļēāļĄāļēāļĢāļ–āļŠāļ·āđˆāļ­āļŠāļēāļĢāļāļąāļ™āđ„āļ”āđ‰ +**Branch Name:** `feature/unified-inbox-chat` +**Base Branch:** `develop` --- -## 📅 āđāļœāļ™āļāļēāļĢāļžāļąāļ’āļ™āļēāđāļĨāļ°āđ„āļ—āļĄāđŒāđ„āļĨāļ™āđŒ (5-Day Sprint Plan) +## 📝 āļŠāļĢāļļāļ›āļœāļĨāļāļēāļĢāļ”āļģāđ€āļ™āļīāļ™āļāļēāļĢ -āļāļēāļĢāļžāļąāļ’āļ™āļēāļ‚āļąāđ‰āļ™āļ•āđ‰āļ™āļ™āļĩāđ‰āļ–āļđāļāļšāļĩāļšāļ­āļąāļ”āđƒāļŦāđ‰āđ€āļŦāļĨāļ·āļ­āđ€āļžāļĩāļĒāļ‡ **5 āļ§āļąāļ™** āđ‚āļ”āļĒāļāļēāļĢāļ•āļąāļ”āļŸāļąāļ‡āļāđŒāļŠāļąāļ™āļ—āļĩāđˆāļ‹āļąāļšāļ‹āđ‰āļ­āļ™ (āđ€āļŠāđˆāļ™ Real-time Messaging, Task Queues) āļ­āļ­āļāļ—āļąāđ‰āļ‡āļŦāļĄāļ”: +āļˆāļēāļāđ‚āļ„āđ‰āļ”āđāļĨāļ° Test Coverage āļ›āļąāļˆāļˆāļļāļšāļąāļ™ āļŠāļĢāļļāļ›āļ āļēāļžāļĢāļ§āļĄāļāļēāļĢāļĢāļ­āļ‡āļĢāļąāļšāļĢāļ°āļšāļš Ticketing / Shared Inbox āđāļĨāļ°āļŸāļĩāđ€āļˆāļ­āļĢāđŒāđ€āļ—āļĩāļĒāļšāļāļąāļš Chatwoot/Freshdesk āđ„āļ”āđ‰āļ”āļąāļ‡āļ™āļĩāđ‰: -### 1. āđāļœāļ™āļ āļēāļžāļĢāļ§āļĄ (5-Day POC Breakdown) +### 1. āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡āļĢāļ°āļšāļšāļ›āļąāļˆāļˆāļļāļšāļąāļ™ +**Ticketing System** āļĢāļ­āļ‡āļĢāļąāļšāđ€āļ•āđ‡āļĄāļĢāļđāļ›āđāļšāļš āļ”āļąāļ‡āļ™āļĩāđ‰: +- āļŠāļĢāđ‰āļēāļ‡ Ticket (`Ticket` model) +- Assign Ticket (`TicketService.assign_ticket_to_user`) +- Update Status (`TicketService.update_ticket_status`) +- āļ•āļīāļ”āļ•āļēāļĄ Last Message (`Ticket.last_message_content`, `last_message_at`) +- Test āļ„āļĢāļ­āļšāļ„āļĨāļļāļĄāļ—āļąāđ‰āļ‡ Unit + Integration + Functional Tests āļ‹āļķāđˆāļ‡āļ—āļģāļ‡āļēāļ™āđ„āļ”āđ‰āļ„āļĢāļšāļ–āđ‰āļ§āļ™ -| # | āļāļīāļˆāļāļĢāļĢāļĄāļŦāļĨāļąāļ | āļĢāļ°āļĒāļ°āđ€āļ§āļĨāļē | āļŠāļĢāļļāļ›āđ€āļ›āđ‰āļēāļŦāļĄāļēāļĒ (POC Focus) | -| :--- | :--- | :--- | :--- | -| **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 āļŠāļģāđ€āļĢāđ‡āļˆ** | +**Shared Inbox / Multi-channel** āļĒāļąāļ‡āļˆāļģāļāļąāļ” āļ”āļąāļ‡āļ™āļĩāđ‰: +- āļ›āļąāļˆāļˆāļļāļšāļąāļ™ Inbox āđ€āļ›āđ‡āļ™ Unified Inbox āđāļ•āđˆāļĒāļąāļ‡āļĢāļ­āļ‡āļĢāļąāļšāđ€āļ‰āļžāļēāļ° internal messages (Ticket + Chat messages) +- āļĒāļąāļ‡āđ„āļĄāđˆāļĄāļĩ integration āļāļąāļš external channels (Email, Live Chat, Line, WhatsApp, āļ­āļ·āđˆāļ™ āđ†) -### 2. āđ€āļ—āļ„āđ‚āļ™āđ‚āļĨāļĒāļĩāđāļĨāļ°āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡āļ—āļĩāđˆāđƒāļŠāđ‰ (Leveraged from Template) +**āļŠāļĢāļļāļ›:** āļĢāļ°āļšāļšāļŠāļēāļĄāļēāļĢāļ–āļ—āļģ Ticketing + Inbox āļ‚āļ­āļ‡āļ•āļąāļ§āđ€āļ­āļ‡āđ„āļ”āđ‰ āđāļ•āđˆāļĒāļąāļ‡āđ„āļĄāđˆāđƒāļŠāđˆ “Shared Inbox” āđāļšāļš multi-channel āđ€āļŦāļĄāļ·āļ­āļ™ Chatwoot -* **Backend & Infrastructure:** - * **Authentication:** āļĢāļ°āļšāļšāļˆāļąāļ”āļāļēāļĢāļœāļđāđ‰āđƒāļŠāđ‰āđāļĨāļ°āļŠāļīāļ—āļ˜āļīāđŒ **JWT** (āļ•āļąāđ‰āļ‡āļ„āđˆāļēāđ€āļŠāļĢāđ‡āļˆāđāļĨāđ‰āļ§) - * **Database HA:** āļāļēāļ™āļ‚āđ‰āļ­āļĄāļđāļĨāļ—āļĩāđˆāļĄāļĩāļ„āļ§āļēāļĄāļžāļĢāđ‰āļ­āļĄāđƒāļŠāđ‰āļ‡āļēāļ™āļŠāļđāļ‡ (CockroachDB) - * **Task Queue:** āđ‚āļ„āļĢāļ‡āļŠāļĢāđ‰āļēāļ‡ **Celery / Redis** āļ•āļąāđ‰āļ‡āļ„āđˆāļēāđ„āļ§āđ‰āļžāļĢāđ‰āļ­āļĄāđƒāļŠāđ‰āļ‡āļēāļ™ (āđāļ•āđˆāđ„āļĄāđˆāđ„āļ”āđ‰āđƒāļŠāđ‰āđƒāļ™ POC āļ™āļĩāđ‰) -* **Frontend Base:** React Hooks / Redux Toolkit / TanStack Query +### 2. āļŠāđˆāļ­āļ‡āļ—āļēāļ‡āļŠāļ·āđˆāļ­āļŠāļēāļĢ +- āļ›āļąāļˆāļˆāļļāļšāļąāļ™āļĢāļ­āļ‡āļĢāļąāļš Internal Ticket + Chat message +- āļ‚āļēāļ” Multi-channel (Email/Line/WhatsApp/Facebook/Twitter/SMS) +- āļĒāļąāļ‡āđ„āļĄāđˆāļĄāļĩāļŦāļ™āđ‰āļē Customer Portal āđƒāļŦāđ‰āļĨāļđāļāļ„āđ‰āļēāļŠāđˆāļ‡ Ticket āļŦāļĢāļ·āļ­āļ”āļđāļŠāļ–āļēāļ™āļ° + +**āļŠāļĢāļļāļ›:** āļĢāļ°āļšāļšāļˆāļ°āļĢāļ­āļ‡āļĢāļąāļšāļāļēāļĢāļ—āļģāļ‡āļēāļ™āđ„āļ”āđ‰āļ”āļĩāđ€āļ‰āļžāļēāļ°āļ āļēāļĒāđƒāļ™āļ­āļ‡āļ„āđŒāļāļĢ āđāļ•āđˆāļĒāļąāļ‡āđ„āļĄāđˆāļ„āļĢāļšāļ—āļļāļāļŠāđˆāļ­āļ‡āļ—āļēāļ‡ + +### 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) diff --git a/backend/api/views/audit_viewset.py b/backend/api/views/audit_viewset.py index 61bd6b0..504f975 100644 --- a/backend/api/views/audit_viewset.py +++ b/backend/api/views/audit_viewset.py @@ -10,6 +10,12 @@ from api.serializers.audit_serializer import InferenceAuditLogSerializer # āļ™āļģāđ€āļ‚āđ‰āļē Permission āļ—āļĩāđˆāļˆāļģāđ€āļ›āđ‡āļ™ from permissions.permission_classes import IsAdminOrOperator, IsViewerOrHigher +from drf_spectacular.utils import extend_schema, extend_schema_view + +@extend_schema_view( + list=extend_schema(tags=['2. Application Service']), + create=extend_schema(tags=['2. Application Service']), +) class AuditLogViewSet(viewsets.ReadOnlyModelViewSet): """ API āļŠāļģāļŦāļĢāļąāļšāļāļēāļĢāđ€āļ‚āđ‰āļēāļ–āļķāļ‡ Inference Audit Log āđāļĨāļ°āļŠāļ–āļīāļ•āļīāļĢāļ§āļĄ (āļĢāļ§āļĄāļāļēāļĢāļ”āļķāļ‡ Summary āļ”āđ‰āļ§āļĒ) diff --git a/backend/chat/__init__.py b/backend/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/chat/admin.py b/backend/chat/admin.py new file mode 100644 index 0000000..6ba02ab --- /dev/null +++ b/backend/chat/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from .models import Message + +class MessageInline(admin.TabularInline): + model = Message + extra = 0 + readonly_fields = ('timestamp',) + fields = ('sender', 'content', 'message_type', 'timestamp') + show_change_link = True + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + list_display = ('id', 'ticket', 'sender', 'message_type', 'timestamp') + list_filter = ('message_type', 'sender') + search_fields = ('content', 'ticket__title', 'sender__username') + ordering = ('timestamp',) + readonly_fields = ('timestamp',) diff --git a/backend/chat/apps.py b/backend/chat/apps.py new file mode 100644 index 0000000..2fe899a --- /dev/null +++ b/backend/chat/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'chat' diff --git a/backend/chat/consumers.py b/backend/chat/consumers.py new file mode 100644 index 0000000..0281e5a --- /dev/null +++ b/backend/chat/consumers.py @@ -0,0 +1,92 @@ +import json +from channels.generic.websocket import AsyncWebsocketConsumer +from channels.db import database_sync_to_async +from helpdesk.services.ticket_service import TicketService # āļ•āļĢāļ§āļˆāļŠāļ­āļš Ticket + +class ChatConsumer(AsyncWebsocketConsumer): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.ticket_id = None + self.ticket_group_name = None + + @database_sync_to_async + def check_ticket_and_auth(self, ticket_id, user): + """āļ•āļĢāļ§āļˆāļŠāļ­āļšāļŠāļīāļ—āļ˜āļīāđŒāđāļĨāļ°āļŠāļ–āļēāļ™āļ°āļ‚āļ­āļ‡ Ticket āđƒāļ™ DB""" + if not user.is_authenticated: + return False + + # Business Logic Authorization: + # āđƒāļŠāđ‰ TicketService āđ€āļžāļ·āđˆāļ­āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļœāļđāđ‰āđƒāļŠāđ‰āļĄāļĩāļŠāļīāļ—āļ˜āļīāđŒāđ€āļ‚āđ‰āļēāļ–āļķāļ‡ Ticket āļ™āļĩāđ‰āļŦāļĢāļ·āļ­āđ„āļĄāđˆ + if not TicketService().is_user_authorized(ticket_id, user): + return False + + # āļŠāļģāļŦāļĢāļąāļšāļ•āļąāļ§āļ­āļĒāđˆāļēāļ‡āļ™āļĩāđ‰āđƒāļŦāđ‰āđāļ„āđˆāļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē Ticket āļĄāļĩāļ­āļĒāļđāđˆāļˆāļĢāļīāļ‡āļŦāļĢāļ·āļ­āđ„āļĄāđˆ + from helpdesk.models import Ticket + return Ticket.objects.filter(pk=ticket_id).exists() + + async def connect(self): + self.ticket_id = self.scope['url_route']['kwargs']['ticket_id'] + self.ticket_group_name = f'ticket_{self.ticket_id}' + user = self.scope['user'] + + # 1. āļ•āļĢāļ§āļˆāļŠāļ­āļšāļŠāļīāļ—āļ˜āļīāđŒāļāđˆāļ­āļ™āđ€āļŠāļ·āđˆāļ­āļĄāļ•āđˆāļ­ + is_allowed = await self.check_ticket_and_auth(self.ticket_id, user) + + if is_allowed: + # Join room group + await self.channel_layer.group_add( + self.ticket_group_name, + self.channel_name + ) + await self.accept() + else: + await self.close() + + async def disconnect(self, close_code): + if self.ticket_group_name: + # Leave room group + await self.channel_layer.group_discard( + self.ticket_group_name, + self.channel_name + ) + + # Receive message from WebSocket (āđ„āļ„āļĨāđ€āļ­āļ™āļ•āđŒāļŠāđˆāļ‡āļ‚āđ‰āļ­āļ„āļ§āļēāļĄāļĄāļē) + async def receive(self, text_data=None, bytes_data=None): + # Consumer āđ„āļĄāđˆāļ„āļ§āļĢāļĄāļĩ Business Logic + # āļ‚āđ‰āļ­āļĄāļđāļĨāļ‚āļēāđ€āļ‚āđ‰āļēāļ„āļ§āļĢāļ–āļđāļāļŠāđˆāļ‡āđ„āļ›āđƒāļŦāđ‰ Service Layer āļ›āļĢāļ°āļĄāļ§āļĨāļœāļĨ + text_data_json = json.loads(text_data) + content = text_data_json.get('content') + + # āđ€āļĢāļĩāļĒāļ Service āđ€āļžāļ·āđˆāļ­āļŠāļĢāđ‰āļēāļ‡āđāļĨāļ°āļŠāđˆāļ‡āļ‚āđ‰āļ­āļ„āļ§āļēāļĄ (āļ•āđ‰āļ­āļ‡āļ—āļģāđāļšāļš sync) + # āđ€āļ™āļ·āđˆāļ­āļ‡āļˆāļēāļ Consumer āđ€āļ›āđ‡āļ™ async, āđ€āļĢāļēāļˆāļ°āļŠāđˆāļ‡āđ„āļ›āđƒāļŦāđ‰ Service āđāļšāļš sync/async + await self.save_message_and_broadcast(self.ticket_id, self.scope['user'], content) + + # āđ€āļĄāļ˜āļ­āļ”āļ—āļĩāđˆ Service Layer āđ€āļĢāļĩāļĒāļāļœāđˆāļēāļ™ Channel Layer (group_send) + async def chat_message(self, event): + """āļŠāđˆāļ‡āļ‚āđ‰āļ­āļ„āļ§āļēāļĄāļ—āļĩāđˆāļ–āļđāļ broadcast āļāļĨāļąāļšāđ„āļ›āļĒāļąāļ‡ WebSocket Client""" + message = event['message'] + + # Send message to WebSocket + await self.send(text_data=json.dumps({ + 'type': 'new_message', + 'message': message + })) + + @database_sync_to_async + def save_message_and_broadcast(self, ticket_id, user, content): + """ + āđ€āļĄāļ˜āļ­āļ” sync āļ—āļĩāđˆāđ€āļĢāļĩāļĒāļ Service Layer āđ€āļžāļ·āđˆāļ­āļ—āļģ Business Logic + āđāļĨāļ°āļŠāđˆāļ‡ broadcast āļāļĨāļąāļšāļĄāļē (āļœāđˆāļēāļ™ _send_realtime_update āđƒāļ™ Service) + """ + from chat.services.message_service import chat_svc # āļ•āđ‰āļ­āļ‡āđāļ™āđˆāđƒāļˆāļ§āđˆāļē import āļ–āļđāļāļ•āđ‰āļ­āļ‡ + if not content: + return + + # Service āļˆāļ°āļŠāļĢāđ‰āļēāļ‡āļ‚āđ‰āļ­āļ„āļ§āļēāļĄāđƒāļ™ DB, āļ­āļąāļ›āđ€āļ”āļ• Ticket, āđāļĨāļ°āļŠāđˆāļ‡ broadcast + chat_svc.create_and_send_message( + ticket_id=ticket_id, + sender=user, + content=content, + message_type='T' + ) \ No newline at end of file diff --git a/backend/chat/migrations/0001_initial.py b/backend/chat/migrations/0001_initial.py new file mode 100644 index 0000000..c29c00d --- /dev/null +++ b/backend/chat/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.8 on 2025-11-28 22:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('helpdesk', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField()), + ('message_type', models.CharField(choices=[('T', 'Ticket Message (Public)'), ('P', 'Private Note (Internal)')], default='T', max_length=1)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('sender', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_messages', to=settings.AUTH_USER_MODEL)), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='helpdesk.ticket')), + ], + options={ + 'ordering': ['timestamp'], + }, + ), + ] diff --git a/backend/chat/migrations/__init__.py b/backend/chat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/chat/models.py b/backend/chat/models.py new file mode 100644 index 0000000..1a015e1 --- /dev/null +++ b/backend/chat/models.py @@ -0,0 +1,38 @@ +from django.db import models +from django.conf import settings +from helpdesk.models import Ticket # āļ­āđ‰āļēāļ‡āļ­āļīāļ‡āļ‚āđ‰āļēāļĄāđ‚āļ”āđ€āļĄāļ™ + +class Message(models.Model): + """āđ‚āļĄāđ€āļ”āļĨāļŠāļģāļŦāļĢāļąāļšāđ€āļāđ‡āļšāļ‚āđ‰āļ­āļ„āļ§āļēāļĄāļŠāļ™āļ—āļ™āļēāđƒāļ™āđāļ•āđˆāļĨāļ° Ticket""" + + TICKET_MESSAGE = 'T' + PRIVATE_NOTE = 'P' + MESSAGE_TYPES = [ + (TICKET_MESSAGE, 'Ticket Message (Public)'), + (PRIVATE_NOTE, 'Private Note (Internal)') + ] + + ticket = models.ForeignKey( + Ticket, + on_delete=models.CASCADE, + related_name='messages' # āđ€āļŠāļ·āđˆāļ­āļĄāđ‚āļĒāļ‡āļāļąāļš Ticket āļ—āļĩāđˆāđ€āļžāļīāđˆāļ‡āļŠāļĢāđ‰āļēāļ‡ + ) + sender = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='sent_messages' + ) + content = models.TextField() + message_type = models.CharField( + max_length=1, + choices=MESSAGE_TYPES, + default=TICKET_MESSAGE + ) + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['timestamp'] + + def __str__(self): + return f"Msg {self.id} on TKT {self.ticket_id}" \ No newline at end of file diff --git a/backend/chat/repositories/__init__.py b/backend/chat/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/chat/repositories/message_repository.py b/backend/chat/repositories/message_repository.py new file mode 100644 index 0000000..1959dc3 --- /dev/null +++ b/backend/chat/repositories/message_repository.py @@ -0,0 +1,11 @@ +from chat.models import Message +from django.db.models import QuerySet + +class MessageRepository: + + def get_messages_by_ticket(self, ticket_id: int) -> QuerySet[Message]: + return Message.objects.filter(ticket_id=ticket_id).select_related('sender') + + def create_message(self, **data): + # data = {ticket_id, sender, content, message_type} + return Message.objects.create(**data) \ No newline at end of file diff --git a/backend/chat/routing.py b/backend/chat/routing.py new file mode 100644 index 0000000..b6407a8 --- /dev/null +++ b/backend/chat/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path +from . import consumers + +websocket_urlpatterns = [ + # ws://localhost:8000/ws/ticket// + re_path(r'ws/ticket/(?P\d+)/$', consumers.ChatConsumer.as_asgi()), +] \ No newline at end of file diff --git a/backend/chat/serializers/__init__.py b/backend/chat/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/chat/serializers/message_serializers.py b/backend/chat/serializers/message_serializers.py new file mode 100644 index 0000000..7bd329e --- /dev/null +++ b/backend/chat/serializers/message_serializers.py @@ -0,0 +1,22 @@ +from rest_framework import serializers +from chat.models import Message +from helpdesk.models import Ticket +from helpdesk.serializers.user_serializers import SimpleUserSerializer + +class MessageSerializer(serializers.ModelSerializer): + sender = SimpleUserSerializer(read_only=True) + + class Meta: + model = Message + fields = ['id', 'ticket', 'sender', 'content', 'message_type', 'timestamp'] + read_only_fields = fields + +class MessageCreateSerializer(serializers.ModelSerializer): + class Meta: + model = Message + fields = ['ticket', 'content', 'message_type'] + + def validate_content(self, value): + if not value.strip(): + raise serializers.ValidationError("Content cannot be empty.") + return value diff --git a/backend/chat/services/__init__.py b/backend/chat/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/chat/services/message_service.py b/backend/chat/services/message_service.py new file mode 100644 index 0000000..7f369fd --- /dev/null +++ b/backend/chat/services/message_service.py @@ -0,0 +1,53 @@ +from chat.repositories.message_repository import MessageRepository +from helpdesk.services.ticket_service import ticket_service +from chat.serializers.message_serializers import MessageSerializer +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer +from django.db import transaction + +from helpdesk.services.ticket_service import ticket_service as helpdesk_ticket_service + +class MessageService: + def __init__(self, message_repo=None, ticket_service=None): + # āļĢāļ­āļ‡āļĢāļąāļš DI āļŠāļģāļŦāļĢāļąāļš unit test + self.message_repo = message_repo or MessageRepository() + self.ticket_service = ticket_service # āđ„āļĄāđˆāđƒāļŠāđˆ global āļ­āļĩāļāļ•āđˆāļ­āđ„āļ› + + def get_ticket_messages(self, ticket_id: int): + return self.message_repo.get_messages_by_ticket(ticket_id) + + @transaction.atomic + def create_and_send_message(self, ticket_id, sender, content, message_type): + sender_is_agent = sender.is_staff + + ticket_ref = self.ticket_service.get_ticket_ref(ticket_id) + + new_message = self.message_repo.create_message( + ticket_id=ticket_ref["id"], + sender=sender, + content=content, + message_type=message_type + ) + + self.ticket_service.update_ticket_on_new_message( + ticket_id=ticket_ref["id"], + content=new_message.content, + timestamp=new_message.timestamp, + sender_is_agent=sender_is_agent + ) + + self._send_realtime_update(new_message) + return new_message + + def _send_realtime_update(self, message): + channel_layer = get_channel_layer() + data = MessageSerializer(message).data + ticket_group_name = f'ticket_{message.ticket_id}' + + async_to_sync(channel_layer.group_send)( + ticket_group_name, + {'type': 'chat.message', 'message': data} + ) + +# āļŠāļĢāđ‰āļēāļ‡ instance āļŠāļģāļŦāļĢāļąāļšāđƒāļŠāđ‰āļ‡āļēāļ™āļˆāļĢāļīāļ‡ +message_service = MessageService(ticket_service=helpdesk_ticket_service) \ No newline at end of file diff --git a/backend/chat/tests/__init__.py b/backend/chat/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/chat/tests/test_integration.py b/backend/chat/tests/test_integration.py new file mode 100644 index 0000000..0018f4d --- /dev/null +++ b/backend/chat/tests/test_integration.py @@ -0,0 +1,105 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.utils import timezone +from unittest.mock import patch + +from helpdesk.models import Ticket + +from chat.services.message_service import MessageService +from helpdesk.services.ticket_service import ticket_service as global_ticket_service + +UserModel = get_user_model() + +class MessageTicketIntegrationTest(TestCase): + + def setUp(self): + # āļœāļđāđ‰āđƒāļŠāđ‰ Agent (āļ•āđ‰āļ­āļ‡āđƒāļŠāđˆ email āđƒāļŦāđ‰āđ„āļĄāđˆāļ‹āđ‰āļģ) + self.user = UserModel.objects.create_user( + username='tester', + email='tester@example.com', + password='testpassword', + is_staff=True + ) + + # Ticket āđ€āļĢāļīāđˆāļĄāļ•āđ‰āļ™ (āđ€āļāđ‡āļšāđ€āļ§āļĨāļēāđ„āļ§āđ‰āđ€āļžāļ·āđˆāļ­āđƒāļŠāđ‰āđ€āļ›āļĢāļĩāļĒāļšāđ€āļ—āļĩāļĒāļš) + self.ticket = Ticket.objects.create( + creator=self.user, + title="Ticket for Chat Integration Test", + last_message_at=timezone.now() + ) + + # āđ€āļāđ‡āļšāļ„āđˆāļē datetime āđ€āļ”āļīāļĄāđ„āļ§āđ‰ (āļāđˆāļ­āļ™āļĄāļĩāļ‚āđ‰āļ­āļ„āļ§āļēāļĄāđƒāļŦāļĄāđˆ) + self.original_last_message_at = self.ticket.last_message_at + + self.message_service = MessageService() + self.message_service.ticket_service = global_ticket_service + + # āļ›āļīāļ” Realtime āļ”āđ‰āļ§āļĒ mock (āđ€āļžāļ·āđˆāļ­āļ•āļąāļ” websocket āļ­āļ­āļāļˆāļēāļ test) + @patch('chat.services.message_service.MessageService._send_realtime_update') + def test_message_creation_updates_ticket_data(self, mock_send_realtime): + """ + GIVEN: āļĄāļĩ Ticket āļ­āļĒāļđāđˆāļāđˆāļ­āļ™āđāļĨāđ‰āļ§ + WHEN: Agent āļŠāđˆāļ‡āļ‚āđ‰āļ­āļ„āļ§āļēāļĄāđƒāļŦāļĄāđˆāļœāđˆāļēāļ™ MessageService + THEN: last_message_content, last_message_at, is_read āļ•āđ‰āļ­āļ‡āļ–āļđāļāļ­āļąāļ›āđ€āļ”āļ•āļ–āļđāļāļ•āđ‰āļ­āļ‡ + """ + new_content = "This is the newest message content from Agent." + + # ACT + new_message = self.message_service.create_and_send_message( + ticket_id=self.ticket.id, + sender=self.user, + content=new_content, + message_type='T' + ) + + # ASSERT 1 Message āļ–āļđāļāļŠāļĢāđ‰āļēāļ‡āđƒāļ™āļāļēāļ™āļ‚āđ‰āļ­āļĄāļđāļĨ + self.assertIsNotNone(new_message.id) + + # āļ”āļķāļ‡ Ticket āđƒāļŦāļĄāđˆāļˆāļēāļ DB + self.ticket.refresh_from_db() + + # ASSERT 2 āđ€āļ™āļ·āđ‰āļ­āļŦāļēāļ‚āđ‰āļ­āļ„āļ§āļēāļĄāļĨāđˆāļēāļŠāļļāļ”āļ–āļđāļāļ­āļąāļ›āđ€āļ”āļ• + self.assertEqual(self.ticket.last_message_content, new_content) + + # ASSERT 3 Agent āļŠāđˆāļ‡ â†’ is_read āļ•āđ‰āļ­āļ‡āđ€āļ›āđ‡āļ™ True + self.assertEqual(self.ticket.is_read, True) + + # ASSERT 4 āđ€āļ§āļĨāļē last_message_at āļ•āđ‰āļ­āļ‡āđƒāļŦāļĄāđˆāļāļ§āđˆāļēāđ€āļ§āļĨāļēāļ‚āļ­āļ‡ ticket āđ€āļ”āļīāļĄ + self.assertGreater(self.ticket.last_message_at, self.original_last_message_at) + + # ASSERT 5 realtime broadcast āļ–āļđāļāđ€āļĢāļĩāļĒāļ (āđ€āļžāļĢāļēāļ° mock) + mock_send_realtime.assert_called_once() + + + @patch('chat.services.message_service.MessageService._send_realtime_update') + def test_message_creation_handles_non_agent_message_correctly(self, mock_send_realtime): + """ + GIVEN: āļœāļđāđ‰āļŠāđˆāļ‡āđ€āļ›āđ‡āļ™ customer (is_staff=False) + WHEN: āļŠāđˆāļ‡āļ‚āđ‰āļ­āļ„āļ§āļēāļĄāđƒāļŦāļĄāđˆāđ€āļ‚āđ‰āļēāļĄāļē + THEN: Ticket.is_read āļ•āđ‰āļ­āļ‡āđ€āļ›āđ‡āļ™ False (āļĢāļ­ Agent āļ­āđˆāļēāļ™) + """ + customer = UserModel.objects.create_user( + username='customer', + email='customer@example.com', # āļ•āđ‰āļ­āļ‡āđƒāļŠāđˆ email āđ€āļžāļ·āđˆāļ­āđ„āļĄāđˆāđƒāļŦāđ‰āļ‹āđ‰āļģ + password='p', + is_staff=False + ) + + new_content = "Customer: I need help." + + # ACT + self.message_service.create_and_send_message( + ticket_id=self.ticket.id, + sender=customer, + content=new_content, + message_type='T' + ) + + # ASSERT: āļ”āļķāļ‡āļ‚āđ‰āļ­āļĄāļđāļĨāļĨāđˆāļēāļŠāļļāļ”āļˆāļēāļ DB + self.ticket.refresh_from_db() + + # āđ€āļ™āļ·āđ‰āļ­āļŦāļēāļ•āđ‰āļ­āļ‡āļ•āļĢāļ‡āļāļąāļšāļ‚āđ‰āļ­āļ„āļ§āļēāļĄāđƒāļŦāļĄāđˆ + self.assertEqual(self.ticket.last_message_content, new_content) + + # āļĨāļđāļāļ„āđ‰āļēāļŠāđˆāļ‡ â†’ Agent āļĒāļąāļ‡āđ„āļĄāđˆāđ„āļ”āđ‰āļ­āđˆāļēāļ™ â†’ is_read = False + self.assertEqual(self.ticket.is_read, False) diff --git a/backend/chat/tests/test_repositories.py b/backend/chat/tests/test_repositories.py new file mode 100644 index 0000000..7bba3de --- /dev/null +++ b/backend/chat/tests/test_repositories.py @@ -0,0 +1,50 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model + +from chat.repositories.message_repository import MessageRepository +from helpdesk.models import Ticket # āļˆāļģāđ€āļ›āđ‡āļ™āļ•āđ‰āļ­āļ‡āļŠāļĢāđ‰āļēāļ‡ Ticket āļāđˆāļ­āļ™āļŠāļĢāđ‰āļēāļ‡ Message +from chat.models import Message + +UserModel = get_user_model() + +class MessageRepositoryTest(TestCase): + + def setUp(self): + self.repo = MessageRepository() + self.user = UserModel.objects.create_user( + username='msg_user', + email='msg_user@test.com', + password='p' + ) + self.ticket = Ticket.objects.create( + creator=self.user, + title="Test Ticket for Message", + ) + + def test_create_message_saves_data_correctly(self): + """ + WHEN: āđ€āļĢāļĩāļĒāļ create_message āļ”āđ‰āļ§āļĒ data āļ—āļĩāđˆāļ–āļđāļāļ•āđ‰āļ­āļ‡ + THEN: Message āļ•āđ‰āļ­āļ‡āļ–āļđāļāļšāļąāļ™āļ—āļķāļāđƒāļ™ DB āđāļĨāļ°āļĄāļĩ content āļ•āļĢāļ‡āļ•āļēāļĄāļ—āļĩāđˆāļŠāđˆāļ‡ + """ + content = "Testing message content storage." + message_type = 'P' # Private Note + + data = { + 'ticket': self.ticket, + 'sender': self.user, + 'content': content, + 'message_type': message_type + } + + # ACT + new_message = self.repo.create_message(**data) + + # ASSERT 1 āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē Message āļ–āļđāļāļŠāļĢāđ‰āļēāļ‡āđƒāļ™ DB + self.assertIsNotNone(new_message.id) + self.assertEqual(Message.objects.count(), 1) + + # ASSERT 2 āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ„āļ§āļēāļĄāļ–āļđāļāļ•āđ‰āļ­āļ‡āļ‚āļ­āļ‡āļ‚āđ‰āļ­āļĄāļđāļĨāļ—āļĩāđˆāļ–āļđāļāļšāļąāļ™āļ—āļķāļ + retrieved_message = Message.objects.get(pk=new_message.id) + self.assertEqual(retrieved_message.content, content) + self.assertEqual(retrieved_message.message_type, message_type) + self.assertEqual(retrieved_message.ticket_id, self.ticket.id) \ No newline at end of file diff --git a/backend/chat/tests/test_services.py b/backend/chat/tests/test_services.py new file mode 100644 index 0000000..d52d90c --- /dev/null +++ b/backend/chat/tests/test_services.py @@ -0,0 +1,69 @@ +from django.test import TestCase +from unittest.mock import MagicMock, patch +from django.contrib.auth import get_user_model +from django.utils import timezone + +from chat.services.message_service import MessageService +from chat.models import Message +from helpdesk.services.ticket_service import TicketService # Service āļ—āļĩāđˆāļ–āļđāļāđ€āļĢāļĩāļĒāļ + +UserModel = get_user_model() + +class MessageServiceTest(TestCase): + + def setUp(self): + # Mock Repository + self.mock_repo = MagicMock() + # Mock TicketService + self.mock_ticket_service = MagicMock(spec=TicketService) + self.mock_ticket_service.get_ticket_ref.return_value = { + "id": 100, + "last_message_at": timezone.now(), + "last_message_content": "", + "is_read": False + } + + # Inject Mock āđ€āļ‚āđ‰āļēāđ„āļ›āđƒāļ™ Service + self.service = MessageService(message_repo=self.mock_repo) + self.service.ticket_service = self.mock_ticket_service + + # User āļˆāļģāļĨāļ­āļ‡ + self.user = UserModel.objects.create_user( + username='svc_tester', email='svc_tester@test.com', password='p', is_staff=True + ) + + # Message Data + self.message_data = { + 'ticket_id': 100, + 'sender': self.user, + 'content': 'Test Message Content', + 'message_type': 'T' + } + + # Mock create_message āđƒāļŦāđ‰āļ„āļ·āļ™āļ„āđˆāļē Message + self.mock_repo.create_message.return_value = Message( + id=1, **self.message_data, timestamp=timezone.now() + ) + + @patch('chat.services.message_service.MessageService._send_realtime_update') + def test_create_and_send_message_calls_ticket_service_contract(self, mock_send_realtime): + # ACT + new_message = self.service.create_and_send_message( + ticket_id=self.message_data['ticket_id'], + sender=self.message_data['sender'], + content=self.message_data['content'], + message_type=self.message_data['message_type'] + ) + + # ASSERT 1 Repository āļ–āļđāļāđ€āļĢāļĩāļĒāļāđ€āļžāļ·āđˆāļ­āļšāļąāļ™āļ—āļķāļ Message + self.mock_repo.create_message.assert_called_once_with(**self.message_data) + + # ASSERT 2 TicketService āļ–āļđāļāđ€āļĢāļĩāļĒāļāļ–āļđāļāļ•āđ‰āļ­āļ‡ + self.mock_ticket_service.update_ticket_on_new_message.assert_called_once() + args, kwargs = self.mock_ticket_service.update_ticket_on_new_message.call_args + self.assertEqual(kwargs['ticket_id'], self.message_data['ticket_id']) + self.assertIsInstance(kwargs['timestamp'], timezone.datetime) + self.assertEqual(kwargs['sender_is_agent'], self.user.is_staff) + + # ASSERT 3 Realtime Broadcast āļ–āļđāļāđ€āļĢāļĩāļĒāļ + mock_send_realtime.assert_called_once() \ No newline at end of file diff --git a/backend/chat/tests/test_views.py b/backend/chat/tests/test_views.py new file mode 100644 index 0000000..951859e --- /dev/null +++ b/backend/chat/tests/test_views.py @@ -0,0 +1,93 @@ +# chat/tests/test_views.py +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from django.utils import timezone + +from helpdesk.models import Ticket +from chat.models import Message + +UserModel = get_user_model() + +class MessageViewSetFunctionalTest(APITestCase): + """Functional Test āļŠāļģāļŦāļĢāļąāļš MessageViewSet""" + + def setUp(self): + # 1. āļŠāļĢāđ‰āļēāļ‡ Users + self.agent = UserModel.objects.create_user( + username="agent1", + email="agent1@example.com", + password="password123", + is_staff=True + ) + self.customer = UserModel.objects.create_user( + username="customer1", + email="customer1@example.com", + password="password123", + is_staff=False + ) + + # 2. āļŠāļĢāđ‰āļēāļ‡ APIClient āđāļĨāļ° force_authenticate + self.client_agent = APIClient() + self.client_agent.force_authenticate(user=self.agent) + + self.client_customer = APIClient() + self.client_customer.force_authenticate(user=self.customer) + + # 3. āļŠāļĢāđ‰āļēāļ‡ Ticket āļ•āļąāļ§āļ­āļĒāđˆāļēāļ‡ + self.ticket = Ticket.objects.create( + creator=self.customer, + title="Chat Functional Test Ticket", + last_message_at=timezone.now() + ) + + def test_message_list_and_create(self): + """Test GET /messages/?ticket_id= āđāļĨāļ° POST /messages/""" + url = reverse('chat-messages-list') # āļ•āđ‰āļ­āļ‡āļ•āļĢāļ‡āļāļąāļš router āļ‚āļ­āļ‡ MessageViewSet + + # GET messages → āļ•āđ‰āļ­āļ‡āļ§āđˆāļēāļ‡āļāđˆāļ­āļ™āļŠāļĢāđ‰āļēāļ‡ + response = self.client_agent.get(url, {"ticket_id": self.ticket.id}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 0) + + # POST āļ‚āđ‰āļ­āļ„āļ§āļēāļĄ Agent āļŠāđˆāļ‡ + post_data_agent = { + "ticket": self.ticket.id, + "content": "Hello from Agent", + "message_type": "T" + } + + response = self.client_agent.post(url, post_data_agent, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['content'], post_data_agent['content']) + + # āļ•āļĢāļ§āļˆāļŠāļ­āļš Ticket.is_read → Agent āļŠāđˆāļ‡āđ€āļ­āļ‡ â†’ āļ•āđ‰āļ­āļ‡ True + self.ticket.refresh_from_db() + self.assertTrue(self.ticket.is_read) + + # POST āļ‚āđ‰āļ­āļ„āļ§āļēāļĄ Customer āļŠāđˆāļ‡ + post_data_customer = { + "ticket": self.ticket.id, + "content": "Customer: I need help!", + "message_type": "T" + } + response = self.client_customer.post(url, post_data_customer, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # āļ•āļĢāļ§āļˆāļŠāļ­āļš Ticket.is_read → Customer āļŠāđˆāļ‡ â†’ āļ•āđ‰āļ­āļ‡ False + self.ticket.refresh_from_db() + self.assertFalse(self.ticket.is_read) + + # GET messages → āļ•āđ‰āļ­āļ‡āļĄāļĩ 2 āļ‚āđ‰āļ­āļ„āļ§āļēāļĄ ---- + response = self.client_agent.get(url, {"ticket_id": self.ticket.id}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 2) + self.assertEqual(response.data[0]['content'], "Hello from Agent") + self.assertEqual(response.data[1]['content'], "Customer: I need help!") + + # āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē Message āđƒāļ™ DB āļ–āļđāļāļŠāļĢāđ‰āļēāļ‡āļ„āļĢāļš ---- + messages = Message.objects.filter(ticket=self.ticket).order_by('timestamp') + self.assertEqual(messages.count(), 2) + self.assertEqual(messages[0].content, "Hello from Agent") + self.assertEqual(messages[1].content, "Customer: I need help!") diff --git a/backend/chat/views.py b/backend/chat/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/backend/chat/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/backend/chat/views/__init__.py b/backend/chat/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/chat/views/message_views.py b/backend/chat/views/message_views.py new file mode 100644 index 0000000..c282f0b --- /dev/null +++ b/backend/chat/views/message_views.py @@ -0,0 +1,34 @@ +from rest_framework import viewsets, status +from rest_framework.response import Response +from chat.services.message_service import message_service +from chat.serializers.message_serializers import MessageSerializer, MessageCreateSerializer +from rest_framework.permissions import IsAuthenticated +from drf_spectacular.utils import extend_schema, extend_schema_view + +@extend_schema_view( + list=extend_schema(tags=['2. Application Service']), + create=extend_schema(tags=['2. Application Service']), +) +class MessageViewSet(viewsets.GenericViewSet): + """View āļŠāļģāļŦāļĢāļąāļšāļˆāļąāļ”āļāļēāļĢāļ‚āđ‰āļ­āļ„āļ§āļēāļĄāļ—āļĩāđˆāļ­āļĒāļđāđˆāļ āļēāļĒāđƒāļ•āđ‰ Ticket""" + permission_classes = [IsAuthenticated] + + def list(self, request): + ticket_id = request.query_params.get('ticket_id') + messages = message_service.get_ticket_messages(ticket_id=ticket_id) + serializer = MessageSerializer(messages, many=True) + return Response(serializer.data) + + def create(self, request): + serializer = MessageCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + new_message = message_service.create_and_send_message( + ticket_id=serializer.validated_data['ticket'].id, + sender=request.user, + content=serializer.validated_data['content'], + message_type=serializer.validated_data['message_type'] + ) + response_serializer = MessageSerializer(new_message) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + diff --git a/backend/core/asgi.py b/backend/core/asgi.py index e36e2c8..ee20241 100644 --- a/backend/core/asgi.py +++ b/backend/core/asgi.py @@ -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 - 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') -application = get_asgi_application() +application = ProtocolTypeRouter({ + "http": get_asgi_application(), + "websocket": AuthMiddlewareStack( + URLRouter( + chat.routing.websocket_urlpatterns # āļ•āđ‰āļ­āļ‡āļŠāļĢāđ‰āļēāļ‡ chat/routing.py āđāļĨāļ°āļāļģāļŦāļ™āļ” websocket_urlpatterns + ) + ), +}) \ No newline at end of file diff --git a/backend/core/settings.py b/backend/core/settings.py index 7c809e5..f38cc8a 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -57,6 +57,7 @@ THIRD_PARTY_APPS = [ 'drf_spectacular', 'drf_spectacular_sidecar', 'djcelery_email', + 'channels', ] LOCAL_APPS = [ @@ -64,12 +65,12 @@ LOCAL_APPS = [ 'accounts', 'user_profile', 'permissions', - #'model_registry' + 'chat', + 'helpdesk' ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS - MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', # āļŠāļģāļ„āļąāļāļĄāļēāļāļŠāļģāļŦāļĢāļąāļš Frontend 'django.middleware.security.SecurityMiddleware', @@ -351,4 +352,17 @@ EMAIL_USE_TLS = os.getenv('MAILJET_SMTP_TLS', 'True') == 'True' EMAIL_HOST_USER = os.getenv('MAILJET_API_KEY') # API Key āđ€āļ›āđ‡āļ™ Username EMAIL_HOST_PASSWORD = os.getenv('MAILJET_SECRET_KEY') # Secret Key āđ€āļ›āđ‡āļ™ Password DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL') # āļ­āļĩāđ€āļĄāļĨāļœāļđāđ‰āļŠāđˆāļ‡ -SERVER_EMAIL = DEFAULT_FROM_EMAIL # āļ­āļĩāđ€āļĄāļĨāļŠāļģāļŦāļĢāļąāļšāđāļˆāđ‰āļ‡āđ€āļ•āļ·āļ­āļ™ Server \ No newline at end of file +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)], + }, + }, +} diff --git a/backend/core/spectacular_hooks.py b/backend/core/spectacular_hooks.py index 5a992e4..9d34ac4 100644 --- a/backend/core/spectacular_hooks.py +++ b/backend/core/spectacular_hooks.py @@ -11,3 +11,4 @@ def rename_djoser_tags(result, generator, request, public): if 'v1' in tags: operation['tags'] = ['1. Authentication & User Management'] return result + diff --git a/backend/core/urls.py b/backend/core/urls.py index 4056f04..206ec27 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -27,16 +27,21 @@ from api.views.audit_viewset import AuditLogViewSet from accounts.views import CustomTokenObtainPairView +from chat.views.message_views import MessageViewSet +from helpdesk.views.ticket_views import TicketViewSet + # 1. āļāļģāļŦāļ™āļ”āļ•āļąāļ§āđāļ›āļĢ router āļāđˆāļ­āļ™āđƒāļŠāđ‰āļ‡āļēāļ™ router = DefaultRouter() # 2. āļĨāļ‡āļ—āļ°āđ€āļšāļĩāļĒāļ™ API ViewSets (Project-Level Routing) # URL: /api/v1/audit/ (AuditLogViewSet) -router.register( - r'audit', - AuditLogViewSet, - basename='auditlog', -) +router.register(r'audit', AuditLogViewSet, basename='auditlog') + +# Chat APIs +router.register(r'chat/messages', MessageViewSet, basename='chat-messages') + +# Helpdesk APIs +router.register(r'helpdesk/tickets', TicketViewSet, basename='helpdesk-tickets') # 3. āļĨāļ‡āļ—āļ°āđ€āļšāļĩāļĒāļ™ ViewSet āļ­āļ·āđˆāļ™ āđ† urlpatterns = [ diff --git a/backend/helpdesk/__init__.py b/backend/helpdesk/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/helpdesk/admin.py b/backend/helpdesk/admin.py new file mode 100644 index 0000000..4e12083 --- /dev/null +++ b/backend/helpdesk/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from .models import Ticket +from chat.models import Message + +class MessageInline(admin.TabularInline): + model = Message + extra = 0 + readonly_fields = ('sender', 'content', 'message_type', 'timestamp') + +@admin.register(Ticket) +class TicketAdmin(admin.ModelAdmin): + list_display = ('id', 'title', 'creator', 'assigned_to', 'status', 'last_message_at') + inlines = [MessageInline] diff --git a/backend/helpdesk/apps.py b/backend/helpdesk/apps.py new file mode 100644 index 0000000..6f09e9e --- /dev/null +++ b/backend/helpdesk/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HelpdeskConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'helpdesk' diff --git a/backend/helpdesk/migrations/0001_initial.py b/backend/helpdesk/migrations/0001_initial.py new file mode 100644 index 0000000..83930af --- /dev/null +++ b/backend/helpdesk/migrations/0001_initial.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.8 on 2025-11-28 22:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Ticket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('status', models.CharField(choices=[('OPEN', 'Open'), ('IN_PROGRESS', 'In Progress'), ('RESOLVED', 'Resolved'), ('CLOSED', 'Closed')], default='OPEN', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('last_message_at', models.DateTimeField(blank=True, null=True)), + ('last_message_content', models.CharField(blank=True, max_length=255, null=True)), + ('is_read', models.BooleanField(default=False)), + ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL)), + ('creator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_tickets', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-updated_at'], + }, + ), + ] diff --git a/backend/helpdesk/migrations/__init__.py b/backend/helpdesk/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/helpdesk/models.py b/backend/helpdesk/models.py new file mode 100644 index 0000000..8828c72 --- /dev/null +++ b/backend/helpdesk/models.py @@ -0,0 +1,52 @@ +from django.db import models +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +# Constants āļŠāļģāļŦāļĢāļąāļšāļŠāļ–āļēāļ™āļ° +class TicketStatus(models.TextChoices): + OPEN = 'OPEN', _('Open') + IN_PROGRESS = 'IN_PROGRESS', _('In Progress') + RESOLVED = 'RESOLVED', _('Resolved') + CLOSED = 'CLOSED', _('Closed') + +class Ticket(models.Model): + """āđ‚āļĄāđ€āļ”āļĨāļŦāļĨāļąāļāļŠāļģāļŦāļĢāļąāļš Ticket/Case""" + + # āļāļēāļ™āļ‚āđ‰āļ­āļĄāļđāļĨāļˆāļ°āļ­āđ‰āļēāļ‡āļ­āļīāļ‡ User model āļŦāļĨāļąāļāļ‚āļ­āļ‡ Django (CustomUser) + creator = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name='created_tickets' + ) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, blank=True, + related_name='assigned_tickets' + ) + title = models.CharField(max_length=255) + status = models.CharField( + max_length=20, + choices=TicketStatus.choices, + default=TicketStatus.OPEN + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # āļŸāļīāļĨāļ”āđŒāļŠāļģāļ„āļąāļāļŠāļģāļŦāļĢāļąāļšāļāļēāļĢāļĢāļ§āļĄāđ€āļ›āđ‡āļ™ Unified Inbox + last_message_at = models.DateTimeField( + null=True, blank=True + ) + last_message_content = models.CharField( + max_length=255, null=True, blank=True + ) + is_read = models.BooleanField( + default=False # āļŠāļ–āļēāļ™āļ°āļ­āđˆāļēāļ™āļĨāđˆāļēāļŠāļļāļ”āđ‚āļ”āļĒ Agent + ) + + class Meta: + ordering = ['-updated_at'] # āļˆāļąāļ”āđ€āļĢāļĩāļĒāļ‡āļ•āļēāļĄāļāļēāļĢāļ­āļąāļ›āđ€āļ”āļ•āļĨāđˆāļēāļŠāļļāļ” + + def __str__(self): + return f"TKT-{self.id}: {self.title}" \ No newline at end of file diff --git a/backend/helpdesk/repositories/__init__.py b/backend/helpdesk/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/helpdesk/repositories/ticket_repository.py b/backend/helpdesk/repositories/ticket_repository.py new file mode 100644 index 0000000..52e515b --- /dev/null +++ b/backend/helpdesk/repositories/ticket_repository.py @@ -0,0 +1,22 @@ +from helpdesk.models import Ticket +from django.db.models import QuerySet + +class TicketRepository: + + def get_ticket_by_id(self, ticket_id: int) -> Ticket: + """āļ”āļķāļ‡ Ticket āļ•āļēāļĄ ID""" + return Ticket.objects.select_related('creator', 'assigned_to').get(pk=ticket_id) + + def get_unified_inbox_list(self) -> QuerySet[Ticket]: + """ + āļ”āļķāļ‡āļĢāļēāļĒāļāļēāļĢ Ticket āļ—āļąāđ‰āļ‡āļŦāļĄāļ” āļˆāļąāļ”āđ€āļĢāļĩāļĒāļ‡āļ•āļēāļĄāļāļēāļĢāļ­āļąāļ›āđ€āļ”āļ•āļĨāđˆāļēāļŠāļļāļ” + (āļŠāļģāļŦāļĢāļąāļšāļŦāļ™āđ‰āļē Unified Inbox) + """ + return Ticket.objects.all().select_related('creator', 'assigned_to').order_by('-last_message_at') + + def update_ticket(self, ticket: Ticket, update_data: dict) -> Ticket: + """āļ­āļąāļ›āđ€āļ”āļ•āļŸāļīāļĨāļ”āđŒāļ—āļĩāđˆāļ•āđ‰āļ­āļ‡āļāļēāļĢāļ‚āļ­āļ‡ Ticket instance""" + for key, value in update_data.items(): + setattr(ticket, key, value) + ticket.save() + return ticket \ No newline at end of file diff --git a/backend/helpdesk/serializers/__init__.py b/backend/helpdesk/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/helpdesk/serializers/ticket_list_serializers.py b/backend/helpdesk/serializers/ticket_list_serializers.py new file mode 100644 index 0000000..683d7ef --- /dev/null +++ b/backend/helpdesk/serializers/ticket_list_serializers.py @@ -0,0 +1,15 @@ +from rest_framework import serializers +from helpdesk.models import Ticket +from .user_serializers import SimpleUserSerializer + +class TicketListSerializer(serializers.ModelSerializer): + """āđƒāļŠāđ‰āļŠāļģāļŦāļĢāļąāļšāđāļŠāļ”āļ‡āļĢāļēāļĒāļāļēāļĢ Ticket āđƒāļ™ Unified Inbox""" + creator = SimpleUserSerializer(read_only=True) + assigned_to = SimpleUserSerializer(read_only=True) + + class Meta: + model = Ticket + fields = [ + 'id', 'title', 'status', 'creator', 'assigned_to', + 'last_message_at', 'last_message_content', 'is_read' + ] \ No newline at end of file diff --git a/backend/helpdesk/serializers/user_serializers.py b/backend/helpdesk/serializers/user_serializers.py new file mode 100644 index 0000000..ff9ebae --- /dev/null +++ b/backend/helpdesk/serializers/user_serializers.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + +class SimpleUserSerializer(serializers.Serializer): + """Serializer āļžāļ·āđ‰āļ™āļāļēāļ™āļŠāļģāļŦāļĢāļąāļšāļœāļđāđ‰āđƒāļŠāđ‰āļ‡āļēāļ™""" + id = serializers.IntegerField() + username = serializers.CharField(source='get_username') + # āļŠāļēāļĄāļēāļĢāļ–āđ€āļžāļīāđˆāļĄāļŸāļīāļĨāļ”āđŒāļ—āļĩāđˆāļ•āđ‰āļ­āļ‡āļāļēāļĢāđāļŠāļ”āļ‡āđ„āļ”āđ‰ āđ€āļŠāđˆāļ™ email, first_name \ No newline at end of file diff --git a/backend/helpdesk/services/__init__.py b/backend/helpdesk/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/helpdesk/services/ticket_service.py b/backend/helpdesk/services/ticket_service.py new file mode 100644 index 0000000..8b5121b --- /dev/null +++ b/backend/helpdesk/services/ticket_service.py @@ -0,0 +1,98 @@ +from typing import Optional +from datetime import datetime + +from django.db.transaction import atomic + +from helpdesk.repositories.ticket_repository import TicketRepository +from helpdesk.models import Ticket, TicketStatus + + +class TicketNotFoundError(Exception): + """Raised when the requested Ticket is not found.""" + pass + +class NotificationService: + """Placeholder āļŠāļģāļŦāļĢāļąāļš NotificationService""" + def send_assignment_notification(self, ticket_id, user_id): + pass + +class TicketService: + def __init__(self, ticket_repo: Optional[TicketRepository] = None, notification_service=None): + # āļĢāļ­āļ‡āļĢāļąāļš DI āđƒāļ™āļāļēāļĢāļ—āļ”āļŠāļ­āļš â€” āļŦāļēāļāđ„āļĄāđˆāļŠāđˆāļ‡āļĄāļē āļˆāļ°āļŠāļĢāđ‰āļēāļ‡ repo āđƒāļŦāļĄāđˆāļŠāļģāļŦāļĢāļąāļš production + self.ticket_repo = ticket_repo or TicketRepository() + self.notification_service = notification_service + + @atomic + def update_ticket_on_new_message( + self, + ticket_id: int, + content: str, + timestamp: datetime, + sender_is_agent: bool, + ) -> Ticket: + """ + āļ­āļąāļ›āđ€āļ”āļ• Ticket āđ€āļĄāļ·āđˆāļ­āļĄāļĩāļ‚āđ‰āļ­āļ„āļ§āļēāļĄāđƒāļŦāļĄāđˆāđ€āļ‚āđ‰āļēāļĄāļēāđƒāļ™ Chat + - āļ–āđ‰āļēāđ„āļĄāđˆāļžāļš Ticket āļˆāļ°āđ‚āļĒāļ™ TicketNotFoundError + """ + ticket = self.ticket_repo.get_ticket_by_id(ticket_id) + + if ticket is None: + raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.") + + update_data = { + "last_message_at": timestamp, + "last_message_content": content, + "is_read": True if sender_is_agent else False, + } + + return self.ticket_repo.update_ticket(ticket, update_data) + + def update_ticket_status(self, ticket_id: int, new_status: TicketStatus) -> Ticket: + ticket = self.ticket_repo.get_ticket_by_id(ticket_id) + if ticket is None: + raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.") + + # Business Rule: āļŦāđ‰āļēāļĄāđ€āļ›āļĨāļĩāđˆāļĒāļ™āļˆāļēāļ CLOSED -> OPEN + if ticket.status == TicketStatus.CLOSED and new_status == TicketStatus.OPEN: + raise ValueError("cannot transition from CLOSED to OPEN") + + return self.ticket_repo.update_ticket(ticket, {"status": new_status}) + + def get_inbox_summary(self): + return self.ticket_repo.get_unified_inbox_list() + + def get_ticket_detail(self, ticket_id: int) -> Ticket: + ticket = self.ticket_repo.get_ticket_by_id(ticket_id) + if ticket is None: + raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.") + return ticket + + def get_ticket_ref(self, ticket_id: int) -> dict: + ticket = self.ticket_repo.get_ticket_by_id(ticket_id) + return { + "id": ticket.id, + "last_message_at": ticket.last_message_at, + "last_message_content": ticket.last_message_content, + "is_read": ticket.is_read + } + + @atomic + def assign_ticket_to_user(self, ticket_id: int, assigned_user): + """ + āļĄāļ­āļšāļŦāļĄāļēāļĒ Ticket āđƒāļŦāđ‰āļāļąāļšāļœāļđāđ‰āđƒāļŠāđ‰ (Agent) āđāļĨāļ°āđ€āļĢāļĩāļĒāļ NotificationService āļ–āđ‰āļēāļĄāļĩ + """ + ticket = self.ticket_repo.get_ticket_by_id(ticket_id) + if not ticket: + raise TicketNotFoundError(f"Ticket with id={ticket_id} not found.") + + updated_ticket = self.ticket_repo.update_ticket(ticket, {"assigned_to": assigned_user}) + + if self.notification_service: + self.notification_service.send_assignment_notification( + ticket_id=updated_ticket.id, + user_id=assigned_user.id + ) + + return ticket + +ticket_service = TicketService() \ No newline at end of file diff --git a/backend/helpdesk/tests/__init__.py b/backend/helpdesk/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/helpdesk/tests/test_integration.py b/backend/helpdesk/tests/test_integration.py new file mode 100644 index 0000000..e8e63ee --- /dev/null +++ b/backend/helpdesk/tests/test_integration.py @@ -0,0 +1,66 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from unittest.mock import patch + +from helpdesk.models import Ticket +from helpdesk.services.ticket_service import TicketService +from helpdesk.repositories.ticket_repository import TicketRepository +from unittest import skip + +UserModel = get_user_model() + +class AssignmentNotificationIntegrationTest(TestCase): + + def setUp(self): + # 1. Setup Users (Agents) + self.agent_1 = UserModel.objects.create_user( + username='agent_one', + email='agent1@test.com', + password='p', + is_staff=True + ) + self.agent_2 = UserModel.objects.create_user( + username='agent_two', + email='agent2@test.com', + password='p', + is_staff=True + ) + + # 2. Setup Ticket + self.ticket = Ticket.objects.create( + creator=self.agent_1, + title="Ticket for Assignment Test" + ) + + # 3. Setup Repository + self.repo = TicketRepository() + + @patch("helpdesk.services.ticket_service.NotificationService") # mock class + def test_assignment_triggers_notification_call(self, MockNotificationService): + """ + Integration Test: āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē assign_ticket_to_user() āđ€āļĢāļĩāļĒāļ + NotificationService.send_assignment_notification() + """ + # Mock instance āļ‚āļ­āļ‡ NotificationService + mock_notification_instance = MockNotificationService.return_value + + # Inject mock instance āđ€āļ‚āđ‰āļēāđ„āļ›āđƒāļ™ TicketService + ticket_service = TicketService( + ticket_repo=self.repo, + notification_service=mock_notification_instance + ) + + # ACT: āļĄāļ­āļšāļŦāļĄāļēāļĒ ticket + updated_ticket = ticket_service.assign_ticket_to_user( + ticket_id=self.ticket.id, + assigned_user=self.agent_2 + ) + + # ASSERT 1: āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē Ticket āļ–āļđāļāļĄāļ­āļšāļŦāļĄāļēāļĒ + self.assertEqual(updated_ticket.assigned_to, self.agent_2) + + # ASSERT 2: āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļē NotificationService āļ–āļđāļāđ€āļĢāļĩāļĒāļ + mock_notification_instance.send_assignment_notification.assert_called_once_with( + ticket_id=self.ticket.id, + user_id=self.agent_2.id + ) diff --git a/backend/helpdesk/tests/test_repositories.py b/backend/helpdesk/tests/test_repositories.py new file mode 100644 index 0000000..cd3fff7 --- /dev/null +++ b/backend/helpdesk/tests/test_repositories.py @@ -0,0 +1,55 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from django.utils import timezone + +from helpdesk.repositories.ticket_repository import TicketRepository +from helpdesk.models import Ticket, TicketStatus + +UserModel = get_user_model() + +class TicketRepositoryTest(TestCase): + + def setUp(self): + self.repo = TicketRepository() + + # āļŠāļĢāđ‰āļēāļ‡ Users + self.creator = UserModel.objects.create_user( + username='repo_creator', + email='repo_creator@test.com', + password='p' + ) + + self.assigned_user = UserModel.objects.create_user( + username='repo_assignee', + email='repo_assignee@test.com', + password='p' + ) + + # āļŠāļĢāđ‰āļēāļ‡ Tickets + self.ticket1 = Ticket.objects.create( + creator=self.creator, + assigned_to=self.assigned_user, + title="Test Ticket 1", + last_message_at=timezone.now(), + last_message_content="Initial message 1", + status=TicketStatus.OPEN, + is_read=True, + ) + + self.ticket2 = Ticket.objects.create( + creator=self.creator, + title="Test Ticket 2", + last_message_at=timezone.now(), + last_message_content="Initial message 2", + status=TicketStatus.OPEN, + is_read=True, + ) + + def test_get_unified_inbox_list_uses_select_related(self): + """āļ•āļĢāļ§āļˆāļŠāļ­āļšāļ§āđˆāļēāļāļēāļĢ Query āđƒāļŠāđ‰ select_related() āđ€āļžāļ·āđˆāļ­āļĨāļ”āļˆāļģāļ™āļ§āļ™ Query (āđāļāđ‰āļ›āļąāļāļŦāļē N+1)""" + with self.assertNumQueries(1): + tickets = list(self.repo.get_unified_inbox_list()) + for ticket in tickets: + _ = ticket.creator.username + + self.assertEqual(len(tickets), 2) diff --git a/backend/helpdesk/tests/test_services.py b/backend/helpdesk/tests/test_services.py new file mode 100644 index 0000000..0e4ae9d --- /dev/null +++ b/backend/helpdesk/tests/test_services.py @@ -0,0 +1,59 @@ +from django.test import TestCase +from unittest.mock import MagicMock +from django.utils import timezone +from helpdesk.services.ticket_service import TicketService +from helpdesk.models import TicketStatus, Ticket +from django.contrib.auth import get_user_model + +UserModel = get_user_model() + +class TicketServiceStatusTest(TestCase): + + def setUp(self): + self.mock_repo = MagicMock() + self.service = TicketService(ticket_repo=self.mock_repo) + + # Mock Ticket Object + self.mock_ticket = Ticket( + id=1, + title="Test Ticket", + status=TicketStatus.OPEN, + last_message_at=timezone.now(), + last_message_content="Initial message", + is_read=True, + ) + + self.mock_repo.get_ticket_by_id.return_value = self.mock_ticket + + def test_allow_status_change_from_open_to_inprogress(self): + new_status = TicketStatus.IN_PROGRESS + + # Mock update_ticket return value + updated_ticket = Ticket( + id=1, + title="Test Ticket", + status=new_status, + last_message_at=timezone.now(), + last_message_content="Initial message", + is_read=True, + ) + self.mock_repo.update_ticket.return_value = updated_ticket + + # ACT + result = self.service.update_ticket_status(1, new_status) + + # ASSERT + self.assertEqual(result.status, new_status) + self.mock_repo.update_ticket.assert_called_once() + + def test_prevent_status_change_from_closed_to_open(self): + self.mock_ticket.status = TicketStatus.CLOSED + + with self.assertRaises(ValueError) as ctx: + self.service.update_ticket_status( + ticket_id=1, + new_status=TicketStatus.OPEN + ) + + self.assertIn("cannot transition from CLOSED to OPEN", str(ctx.exception)) + self.mock_repo.update_ticket.assert_not_called() diff --git a/backend/helpdesk/tests/test_views.py b/backend/helpdesk/tests/test_views.py new file mode 100644 index 0000000..4d28827 --- /dev/null +++ b/backend/helpdesk/tests/test_views.py @@ -0,0 +1,57 @@ +from django.urls import reverse +from rest_framework.test import APITestCase, APIClient +from rest_framework import status +from django.contrib.auth import get_user_model +from django.utils import timezone + +from helpdesk.models import Ticket + +UserModel = get_user_model() + +class TicketViewSetFunctionalTest(APITestCase): + + def setUp(self): + # āļŠāļĢāđ‰āļēāļ‡ Agent User + self.agent = UserModel.objects.create_user( + username="agent1", + email="agent1@example.com", + password="password123", + is_staff=True + ) + + # āļŠāļĢāđ‰āļēāļ‡ Customer User + self.customer = UserModel.objects.create_user( + username="customer1", + email="customer1@example.com", + password="password123", + is_staff=False + ) + + # Client āļŠāļģāļŦāļĢāļąāļš Agent + self.client_agent = APIClient() + self.client_agent.force_authenticate(user=self.agent) + + # Client āļŠāļģāļŦāļĢāļąāļš Customer + self.client_customer = APIClient() + self.client_customer.force_authenticate(user=self.customer) + + # āļŠāļĢāđ‰āļēāļ‡ Ticket āļ•āļąāļ§āļ­āļĒāđˆāļēāļ‡ + self.ticket = Ticket.objects.create( + creator=self.customer, + title="Functional Test Ticket", + last_message_at=timezone.now() + ) + + def test_ticket_list_authenticated(self): + """GET /tickets/ āļ•āđ‰āļ­āļ‡ return 200 āļŠāļģāļŦāļĢāļąāļšāļœāļđāđ‰āđƒāļŠāđ‰āļĨāđ‡āļ­āļāļ­āļīāļ™""" + url = reverse('helpdesk-tickets-list') # āļ•āđ‰āļ­āļ‡āļ•āļĢāļ‡āļāļąāļš router name + response = self.client_agent.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(isinstance(response.data, list)) + + def test_ticket_list_unauthenticated(self): + """GET /tickets/ āļ•āđ‰āļ­āļ‡ return 401 āļŠāļģāļŦāļĢāļąāļšāļœāļđāđ‰āđƒāļŠāđ‰āđ„āļĄāđˆāļĨāđ‡āļ­āļāļ­āļīāļ™""" + client = APIClient() # client āđ„āļĄāđˆāļĨāđ‡āļ­āļāļ­āļīāļ™ + url = reverse('helpdesk-tickets-list') + response = client.get(url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/backend/helpdesk/views/__init__.py b/backend/helpdesk/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/helpdesk/views/ticket_views.py b/backend/helpdesk/views/ticket_views.py new file mode 100644 index 0000000..1c058ee --- /dev/null +++ b/backend/helpdesk/views/ticket_views.py @@ -0,0 +1,19 @@ +from rest_framework import viewsets +from helpdesk.services.ticket_service import ticket_service +from helpdesk.serializers.ticket_list_serializers import TicketListSerializer +from rest_framework.permissions import IsAuthenticated + +from drf_spectacular.utils import extend_schema, extend_schema_view + +@extend_schema_view( + list=extend_schema(tags=['2. Application Service']), + create=extend_schema(tags=['2. Application Service']), +) +class TicketViewSet(viewsets.ReadOnlyModelViewSet): + """View āļŠāļģāļŦāļĢāļąāļšāļ”āļķāļ‡āļĢāļēāļĒāļāļēāļĢ Ticket āļ—āļąāđ‰āļ‡āļŦāļĄāļ” (Unified Inbox List)""" + serializer_class = TicketListSerializer + permission_classes = [IsAuthenticated] # āļāļģāļŦāļ™āļ”āđƒāļŦāđ‰āđ€āļ‰āļžāļēāļ°āļœāļđāđ‰āļ—āļĩāđˆāļĨāđ‡āļ­āļāļ­āļīāļ™āđāļĨāđ‰āļ§āđ€āļ—āđˆāļēāļ™āļąāđ‰āļ™ + + # 1. Viewset āđ€āļĢāļĩāļĒāļ Service Layer + def get_queryset(self): + return ticket_service.get_inbox_summary() \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt index 0aad1bb..7eb66a8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,4 +16,6 @@ drf-spectacular drf-spectacular-sidecar django-celery-email python-dotenv -flower \ No newline at end of file +flower +drf-nested-routers +channels_redis \ No newline at end of file