Skip to content

Commit 7881994

Browse files
committed
Department_merge:
- Inherits from BaseRule. - Determines if merging is needed based on ticket load thresholds. - Reassigns tickets from overloaded to underutilized departments.
1 parent fe41ed0 commit 7881994

File tree

15 files changed

+360
-22
lines changed

15 files changed

+360
-22
lines changed

management/chat/admin.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from django.contrib import admin
22

3-
from chat.models import (ChatGroup, FileAttachment, GroupMessage,
4-
ImageAttachment)
3+
from chat.models import ChatGroup, FileAttachment, GroupMessage, ImageAttachment
54

65
admin.site.register(ChatGroup)
76
admin.site.register(GroupMessage)

management/chat/api/viewset.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
from rest_framework.response import Response
44

55
from chat.models import ChatGroup, GroupMessage
6-
from chat.serializers import (ChatSerializers, FileAttachmentSerializers,
7-
GroupSerializers, ImageAttachmentSerializer)
6+
from chat.serializers import (
7+
ChatSerializers,
8+
FileAttachmentSerializers,
9+
GroupSerializers,
10+
ImageAttachmentSerializer,
11+
)
812

913

1014
class ChatMessageView(generics.ListAPIView):

management/chat/serializers.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from rest_framework import serializers
22

3-
from chat.models import (ChatGroup, FileAttachment, GroupMessage,
4-
ImageAttachment)
5-
from core.dumps import (FileAttachmentExt, FileAttachmentSize,
6-
ImageAttachmentExt, ImageAttachmentSize)
3+
from chat.models import ChatGroup, FileAttachment, GroupMessage, ImageAttachment
4+
from core.dumps import (
5+
FileAttachmentExt,
6+
FileAttachmentSize,
7+
ImageAttachmentExt,
8+
ImageAttachmentSize,
9+
)
710

811

912
class GroupSerializers(serializers.ModelSerializer):

management/core/api/payments.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -176,5 +176,3 @@ def post(self, request):
176176

177177

178178
stripe_payment_event = StripeWebhookView.as_view()
179-
180-
# TODO: Esewa and Khalti needs to be also added.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
""" "
2+
Module: department_merge
3+
This module provides automation for balancing workload across departments by merging underutilized departments with overloaded ones.
4+
It identifies departments with low and high ticket loads and reassigns tickets from overloaded departments to underutilized ones,
5+
ensuring fair distribution of work among agents.
6+
Classes:
7+
Department_merge:
8+
- Inherits from BaseRule.
9+
- Determines if merging is needed based on ticket load thresholds.
10+
- Reassigns tickets from overloaded to underutilized departments.
11+
Key Concepts:
12+
- Load distribution among departments.
13+
- Automated ticket reassignment for fair workload.
14+
- Utilizes configurable overload and underutilization thresholds.
15+
16+
Copyright (c) Supportix. All rights reserved.
17+
Written in 2025 by Dorna Raj Gyawali <dronarajgyawali@gmail.com>
18+
"""
19+
20+
from core.models import Ticket, Department, Agent
21+
from core.automation.base_rule import BaseRule
22+
from core.constants import Status
23+
from django.db import models
24+
from core.dumps import OVERLOAD_THRESHOLD, UNDERUTILIZED_THRESHOLD
25+
import logging
26+
27+
logger = logging.getLogger(__name__)
28+
29+
30+
class Department_merge(BaseRule):
31+
"""
32+
Rule to automatically merged the underutilized department to high load department
33+
"""
34+
35+
def __init__(self, ticket, **kwargs):
36+
super().__init__(ticket, **kwargs)
37+
38+
def should_apply(self):
39+
try:
40+
loads = (
41+
Ticket.objects.filter(status=Status.WAITING, agent__isnull=False)
42+
.values("agent__department_id")
43+
.annotate(count=models.Count("id"))
44+
)
45+
except Exception as e:
46+
logger.error("Error Occured", str(e))
47+
48+
load_map = {items["agent__department_id"]: items["count"] for items in loads}
49+
for dept in Department.objects.all():
50+
load_map.setdefault(dept.id, 0)
51+
52+
max_load = max(load_map.values())
53+
min_load = min(load_map.values())
54+
55+
return max_load > OVERLOAD_THRESHOLD and min_load < UNDERUTILIZED_THRESHOLD
56+
57+
def apply(self):
58+
59+
loads = (
60+
Ticket.objects.filter(status=Status.WAITING, agent__isnull=False)
61+
.values("agent__department_id")
62+
.annotate(count=models.Count("id"))
63+
)
64+
65+
load_map = {item["agent__department_id"]: item["count"] for item in loads}
66+
for dept in Department.objects.all():
67+
load_map.setdefault(dept.id, 0)
68+
69+
overload_dept_id = max(load_map, key=load_map.get)
70+
underload_dept_id = min(load_map, key=load_map.get)
71+
72+
# exact count e.g. 1,2,4
73+
overloaded_count = load_map[overload_dept_id]
74+
underloaded_count = load_map[underload_dept_id]
75+
76+
diff = overloaded_count - underloaded_count
77+
num_to_move = max(1, diff // 2)
78+
79+
logger.info(
80+
f"Reassigning {num_to_move} tickets "
81+
f"from Dept {overload_dept_id} ({overloaded_count}) "
82+
f"to Dept {underload_dept_id} ({underloaded_count})"
83+
)
84+
85+
target_agent = Agent.objects.filter(department_id=underload_dept_id).first()
86+
87+
tickets_qs = Ticket.objects.filter(
88+
status=Status.WAITING, agent__department_id=overload_dept_id
89+
).order_by("created_at")
90+
# extract only id not a tuple
91+
ticket_ids = list(tickets_qs.values_list("id", flat=True)[:num_to_move])
92+
93+
if not ticket_ids:
94+
logger.info("No tickets to reassign.")
95+
return
96+
97+
updated = Ticket.objects.filter(id__in=ticket_ids).update(agent=target_agent)
98+
logger.info(f"Successfully reassigned {updated} tickets.")

management/core/automation/rule_runner.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,17 @@
1717

1818
from core.automation.auto_close import AutoClose
1919
from core.automation.tag_by_content import TagByContent
20+
from core.automation.department_merge import Department_merge
2021

2122

2223
class RuleEngine:
2324
def __init__(self, ticket_id):
2425
self.ticket_id = ticket_id
25-
self.rules = [AutoClose(ticket_id, inactive_days=1), TagByContent(ticket_id)]
26+
self.rules = [
27+
AutoClose(ticket_id, inactive_days=1),
28+
TagByContent(ticket_id),
29+
Department_merge(ticket_id=None),
30+
]
2631

2732
def run(self):
2833
context = []

management/core/dumps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,5 @@
2626
FileAttachmentExt = [".pdf"]
2727
FileAttachmentSize = 20 * 1024 * 1024
2828
ImageAttachmentSize = 5 * 1024 * 1024
29+
OVERLOAD_THRESHOLD = 50
30+
UNDERUTILIZED_THRESHOLD = 10

management/core/tests/test_api.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import json
99
from datetime import datetime, timedelta
10+
1011
# import uuid
1112
from decimal import Decimal
1213
from unittest import mock
@@ -21,8 +22,7 @@
2122
from rest_framework.test import APIClient
2223

2324
from core.constants import Status
24-
from core.models import (Agent, Customer, Department, PaymentDetails, Ticket,
25-
User)
25+
from core.models import Agent, Customer, Department, PaymentDetails, Ticket, User
2626

2727

2828
@override_settings(STRIPE_SECRET_KEY="sk_test_123", STRIPE_WEBHOOK_SECRET="whsec_test")
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from datetime import timedelta
2+
3+
from django.test import TestCase
4+
from django.utils import timezone
5+
6+
from core.models import Ticket, Department, Agent, User, Customer
7+
from core.automation.department_merge import Department_merge
8+
from core.constants import Status
9+
from core.dumps import OVERLOAD_THRESHOLD, UNDERUTILIZED_THRESHOLD
10+
11+
12+
class DepartmentMergeRuleTest(TestCase):
13+
def setUp(self):
14+
self.dept_high = Department.objects.create(name="Technical")
15+
self.dept_low = Department.objects.create(name="General")
16+
17+
self.agent_high_user = User.objects.create_user(
18+
username="high_agent",
19+
email="high@example.com",
20+
password="pass",
21+
role="agent",
22+
)
23+
self.agent_high = Agent.objects.create(
24+
user=self.agent_high_user,
25+
department=self.dept_high,
26+
is_available=True,
27+
max_customers=100,
28+
current_customers=0,
29+
)
30+
31+
self.agent_low_user = User.objects.create_user(
32+
username="low_agent",
33+
email="low@example.com",
34+
password="pass",
35+
role="agent",
36+
)
37+
self.agent_low = Agent.objects.create(
38+
user=self.agent_low_user,
39+
department=self.dept_low,
40+
is_available=True,
41+
max_customers=100,
42+
current_customers=0,
43+
)
44+
45+
self.customer_user = User.objects.create_user(
46+
username="cust",
47+
email="cust@example.com",
48+
password="pass",
49+
role="customer",
50+
)
51+
self.customer = Customer.objects.create(
52+
user=self.customer_user,
53+
is_paid=True,
54+
)
55+
56+
for i in range(OVERLOAD_THRESHOLD + 2):
57+
Ticket.objects.create(
58+
ticket_id=f"TID-TECH-{i}",
59+
customer=self.customer,
60+
agent=self.agent_high,
61+
issue_title="Tech issue",
62+
issue_desc="Desc",
63+
status=Status.WAITING,
64+
)
65+
for i in range(UNDERUTILIZED_THRESHOLD - 1):
66+
Ticket.objects.create(
67+
ticket_id=f"TID-GEN-{i}",
68+
customer=self.customer,
69+
agent=self.agent_low,
70+
issue_title="Gen issue",
71+
issue_desc="Desc",
72+
status=Status.WAITING,
73+
)
74+
Ticket.objects.all().update(created_at=timezone.now() - timedelta(days=1))
75+
76+
def test_should_apply_when_loads_imbalanced(self):
77+
rule = Department_merge(ticket=None)
78+
self.assertTrue(
79+
rule.should_apply(),
80+
"should_apply() must return True when one dept is overloaded and another underutilized",
81+
)
82+
83+
def test_apply_moves_half_diff(self):
84+
rule = Department_merge(ticket=None)
85+
86+
count_high = Ticket.objects.filter(agent__department=self.dept_high).count()
87+
count_low = Ticket.objects.filter(agent__department=self.dept_low).count()
88+
diff = count_high - count_low
89+
expected_to_move = max(1, diff // 2)
90+
91+
candidate_qs = Ticket.objects.filter(
92+
status=Status.WAITING, agent__department=self.dept_high
93+
).order_by("created_at")
94+
ids_to_move = list(candidate_qs.values_list("id", flat=True)[:expected_to_move])
95+
96+
rule.apply()
97+
98+
after_high = Ticket.objects.filter(agent__department=self.dept_high).count()
99+
after_low = Ticket.objects.filter(agent__department=self.dept_low).count()
100+
101+
self.assertEqual(
102+
after_high,
103+
count_high - expected_to_move,
104+
f"{expected_to_move} tickets should be moved out of Technical",
105+
)
106+
self.assertEqual(
107+
after_low,
108+
count_low + expected_to_move,
109+
f"{expected_to_move} tickets should be reassigned into General",
110+
)
111+
112+
moved_count = Ticket.objects.filter(
113+
id__in=ids_to_move, agent=self.agent_low
114+
).count()
115+
self.assertEqual(
116+
moved_count,
117+
expected_to_move,
118+
"Reassigned tickets should be assigned to the low-load agent",
119+
)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from django.test import TestCase
2+
from core.models import Department, User, Agent, Customer, Ticket, Status
3+
from core.automation.rule_runner import RuleEngine
4+
from unittest.mock import patch, MagicMock
5+
6+
7+
class RuleEngineTest(TestCase):
8+
def setUp(self):
9+
self.department = Department.objects.create(name="Support")
10+
11+
self.agent_user = User.objects.create_user(
12+
username="testagent",
13+
email="testagent@example.com",
14+
password="testpassword123",
15+
role="agent",
16+
)
17+
self.agent = Agent.objects.create(
18+
user=self.agent_user,
19+
department=self.department,
20+
is_available=True,
21+
max_customers=5,
22+
current_customers=0,
23+
)
24+
25+
self.customer_user = User.objects.create_user(
26+
username="testcustomer",
27+
email="testcustomer@example.com",
28+
password="testpassword123",
29+
role="customer",
30+
)
31+
self.customer = Customer.objects.create(
32+
user=self.customer_user,
33+
is_paid=True,
34+
)
35+
36+
self.ticket = Ticket.objects.create(
37+
ticket_id="TID-TEST123",
38+
customer=self.customer,
39+
agent=self.agent,
40+
issue_title="Auto-close test",
41+
issue_desc="Desc",
42+
status=Status.WAITING,
43+
)
44+
45+
@patch("core.automation.rule_runner.AutoClose")
46+
@patch("core.automation.rule_runner.TagByContent")
47+
@patch("core.automation.rule_runner.Department_merge")
48+
def test_rule_engine_run_applies_applicable_rules(
49+
self, MockDeptMerge, MockTagByContent, MockAutoClose
50+
):
51+
# Configure mocks
52+
mock_auto = MagicMock()
53+
mock_auto.should_apply.return_value = True
54+
mock_auto.apply.return_value = "AutoClosed"
55+
mock_auto.__class__.__name__ = "AutoClose"
56+
MockAutoClose.return_value = mock_auto
57+
58+
mock_tag = MagicMock()
59+
mock_tag.should_apply.return_value = True
60+
mock_tag.apply.return_value = "Ai-WrittenTag"
61+
mock_tag.__class__.__name__ = "TagByContent"
62+
MockTagByContent.return_value = mock_tag
63+
64+
mock_merge = MagicMock()
65+
mock_merge.should_apply.return_value = True
66+
mock_merge.apply.return_value = "Merged"
67+
mock_merge.__class__.__name__ = "Department_merge"
68+
MockDeptMerge.return_value = mock_merge
69+
70+
engine = RuleEngine(self.ticket.id)
71+
result = engine.run()
72+
73+
self.assertEqual(len(result), 3)
74+
self.assertIn({"rule": "AutoClose", "details": "AutoClosed"}, result)
75+
self.assertIn({"rule": "Department_merge", "details": "Merged"}, result)
76+
self.assertIn({"rule": "TagByContent", "details": "Ai-WrittenTag"}, result)

0 commit comments

Comments
 (0)