From 1eeeb88142191412d4e09242b921a9188ec44d3d Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sun, 17 May 2026 12:53:19 -0400 Subject: [PATCH 1/5] feat(skills): add routing join tables and proficiency constraints (#51) Introduces SkillRoutingTag/SkillRoutingDepartment, skill.description, and AgentSkill proficiency default 3 with a 1-5 check constraint. Co-authored-by: Cursor --- .../0022_skills_management_routing.py | 152 ++++++++++++++++++ escalated/models.py | 66 +++++++- 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 escalated/migrations/0022_skills_management_routing.py diff --git a/escalated/migrations/0022_skills_management_routing.py b/escalated/migrations/0022_skills_management_routing.py new file mode 100644 index 0000000..5312548 --- /dev/null +++ b/escalated/migrations/0022_skills_management_routing.py @@ -0,0 +1,152 @@ +import django +import django.db.models.deletion +from django.conf import settings +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import migrations, models + +from escalated.conf import get_table_name + +_check_kwargs = {"condition": models.Q(proficiency__gte=1, proficiency__lte=5)} +if django.VERSION < (5, 0): + _check_kwargs = {"check": models.Q(proficiency__gte=1, proficiency__lte=5)} + + +def backfill_agent_skill_proficiency(apps, schema_editor): + AgentSkill = apps.get_model("escalated", "AgentSkill") + AgentSkill.objects.filter(proficiency__lt=1).update(proficiency=3) + AgentSkill.objects.filter(proficiency__gt=5).update(proficiency=3) + + +def noop_reverse(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("escalated", "0021_backfill_contact_id_on_tickets"), + ] + + operations = [ + migrations.AddField( + model_name="skill", + name="description", + field=models.TextField(blank=True, null=True), + ), + migrations.CreateModel( + name="SkillRoutingTag", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "skill", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="skill_routing_tag_links", + to="escalated.skill", + ), + ), + ( + "tag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="skill_routing_tag_links", + to="escalated.tag", + ), + ), + ], + options={ + "db_table": get_table_name("skill_routing_tags"), + }, + ), + migrations.CreateModel( + name="SkillRoutingDepartment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "skill", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="skill_routing_department_links", + to="escalated.skill", + ), + ), + ( + "department", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="skill_routing_department_links", + to="escalated.department", + ), + ), + ], + options={ + "db_table": get_table_name("skill_routing_departments"), + }, + ), + migrations.AddField( + model_name="skill", + name="routing_departments", + field=models.ManyToManyField( + blank=True, + related_name="skill_routing_departments", + through="escalated.SkillRoutingDepartment", + to="escalated.department", + ), + ), + migrations.AddField( + model_name="skill", + name="routing_tags", + field=models.ManyToManyField( + blank=True, + related_name="skill_routing_tags", + through="escalated.SkillRoutingTag", + to="escalated.tag", + ), + ), + migrations.AddConstraint( + model_name="skillroutingdepartment", + constraint=models.UniqueConstraint( + fields=("skill", "department"), + name="escalated_skill_routing_dept_skill_dept_uniq", + ), + ), + migrations.AddConstraint( + model_name="skillroutingtag", + constraint=models.UniqueConstraint( + fields=("skill", "tag"), + name="escalated_skill_routing_tag_skill_tag_uniq", + ), + ), + migrations.AlterField( + model_name="agentskill", + name="proficiency", + field=models.PositiveIntegerField( + default=3, + validators=[MinValueValidator(1), MaxValueValidator(5)], + ), + ), + migrations.RunPython(backfill_agent_skill_proficiency, noop_reverse), + migrations.AddConstraint( + model_name="agentskill", + constraint=models.CheckConstraint( + **_check_kwargs, + name="escalated_agentskill_proficiency_1_5", + ), + ), + ] diff --git a/escalated/models.py b/escalated/models.py index 267ad50..39ab925 100644 --- a/escalated/models.py +++ b/escalated/models.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Q from django.utils import timezone @@ -1641,7 +1642,10 @@ class AgentSkill(models.Model): on_delete=models.CASCADE, related_name="agent_skills", ) - proficiency = models.PositiveIntegerField(default=1) + proficiency = models.PositiveIntegerField( + default=3, + validators=[MinValueValidator(1), MaxValueValidator(5)], + ) class Meta: db_table = get_table_name("agent_skill") @@ -1654,6 +1658,19 @@ def __str__(self): class Skill(models.Model): name = models.CharField(max_length=255) slug = models.SlugField(max_length=255, unique=True) + description = models.TextField(null=True, blank=True) + routing_tags = models.ManyToManyField( + "Tag", + through="SkillRoutingTag", + related_name="skill_routing_tags", + blank=True, + ) + routing_departments = models.ManyToManyField( + "Department", + through="SkillRoutingDepartment", + related_name="skill_routing_departments", + blank=True, + ) agents = models.ManyToManyField( settings.AUTH_USER_MODEL, through="AgentSkill", @@ -1677,6 +1694,53 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) +class SkillRoutingTag(models.Model): + skill = models.ForeignKey( + "Skill", + on_delete=models.CASCADE, + related_name="skill_routing_tag_links", + ) + tag = models.ForeignKey( + "Tag", + on_delete=models.CASCADE, + related_name="skill_routing_tag_links", + ) + + class Meta: + db_table = get_table_name("skill_routing_tags") + constraints = [ + models.UniqueConstraint(fields=["skill", "tag"], name="escalated_skill_routing_tag_skill_tag_uniq"), + ] + + def __str__(self): + return f"SkillRoutingTag({self.skill_id}, tag={self.tag_id})" + + +class SkillRoutingDepartment(models.Model): + skill = models.ForeignKey( + "Skill", + on_delete=models.CASCADE, + related_name="skill_routing_department_links", + ) + department = models.ForeignKey( + "Department", + on_delete=models.CASCADE, + related_name="skill_routing_department_links", + ) + + class Meta: + db_table = get_table_name("skill_routing_departments") + constraints = [ + models.UniqueConstraint( + fields=["skill", "department"], + name="escalated_skill_routing_dept_skill_dept_uniq", + ), + ] + + def __str__(self): + return f"SkillRoutingDepartment({self.skill_id}, dept={self.department_id})" + + class AgentCapacity(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, From 59c623d452fefa8ef2063cd9169157eeb078c9a2 Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sun, 17 May 2026 12:53:26 -0400 Subject: [PATCH 2/5] feat(skills): admin Inertia payloads and explicit routing (#51) Index counts, form context (camelCase props per shared UI), store/update/destroy routes, transactional saves, and SkillRoutingService overlap + intersection logic ordered by proficiency sum then load. Co-authored-by: Cursor --- escalated/serializers.py | 34 ++ escalated/services/skill_routing_service.py | 53 ++- escalated/urls.py | 3 + escalated/views/admin.py | 336 +++++++++++++++++++- 4 files changed, 392 insertions(+), 34 deletions(-) diff --git a/escalated/serializers.py b/escalated/serializers.py index e08595e..b15eb72 100644 --- a/escalated/serializers.py +++ b/escalated/serializers.py @@ -673,12 +673,46 @@ def serialize_list(profiles): class SkillSerializer: + @staticmethod + def serialize_index_row(skill): + return { + "id": skill.pk, + "name": skill.name, + "agents_count": getattr(skill, "agents_count", skill.agents.count()), + "routing_tags_count": getattr(skill, "routing_tags_count", skill.routing_tags.count()), + "routing_departments_count": getattr( + skill, + "routing_departments_count", + skill.routing_departments.count(), + ), + "updated_at": _format_dt(skill.updated_at), + } + + @staticmethod + def serialize_index_list(skills): + return [SkillSerializer.serialize_index_row(s) for s in skills] + + @staticmethod + def serialize_for_form(skill): + return { + "id": skill.pk, + "name": skill.name, + "description": skill.description, + "routing_tag_ids": list(skill.routing_tags.order_by("pk").values_list("pk", flat=True)), + "routing_department_ids": list(skill.routing_departments.order_by("pk").values_list("pk", flat=True)), + "agents": [ + {"user_id": row.user_id, "proficiency": row.proficiency} + for row in skill.agent_skills.order_by("user_id", "pk") + ], + } + @staticmethod def serialize(skill): data = { "id": skill.pk, "name": skill.name, "slug": skill.slug, + "description": getattr(skill, "description", None), "created_at": _format_dt(skill.created_at), "updated_at": _format_dt(skill.updated_at), } diff --git a/escalated/services/skill_routing_service.py b/escalated/services/skill_routing_service.py index 236a1a8..bf5198f 100644 --- a/escalated/services/skill_routing_service.py +++ b/escalated/services/skill_routing_service.py @@ -1,39 +1,58 @@ class SkillRoutingService: def find_matching_agents(self, ticket): - """Find agents with skills matching ticket tags, sorted by current load. + """Find agents eligible for every skill required by the ticket's tags and department. - Maps ticket tags to skills by name, then finds agents who have those skills, - ordered by current open ticket count (ascending). + Required skills are those with a routing tag overlapping the ticket's tags, or a + routing department matching the ticket's department. Eligible agents hold an + AgentSkill row for every required skill. Results are ordered by sum of matched + proficiencies (desc), then by open ticket load (asc). """ from django.contrib.auth import get_user_model - from django.db.models import Count, Q + from django.db.models import Count, Q, Sum from escalated.models import AgentSkill, Skill User = get_user_model() - tag_names = list(ticket.tags.values_list("name", flat=True)) - if not tag_names: + + tag_ids = list(ticket.tags.values_list("id", flat=True)) + department_id = ticket.department_id + + skill_parts = [] + if tag_ids: + skill_parts.append(Q(routing_tags__in=tag_ids)) + if department_id: + skill_parts.append(Q(routing_departments=department_id)) + if not skill_parts: return User.objects.none() - skill_ids = list(Skill.objects.filter(name__in=tag_names).values_list("id", flat=True)) - if not skill_ids: + combined = skill_parts[0] + for part in skill_parts[1:]: + combined |= part + + required_skill_ids = list(Skill.objects.filter(combined).distinct().values_list("id", flat=True)) + required_count = len(required_skill_ids) + if required_count == 0: return User.objects.none() - # Find user IDs who have these skills - agent_user_ids = list( - AgentSkill.objects.filter(skill_id__in=skill_ids).values_list("user_id", flat=True).distinct() + eligible_user_ids = ( + AgentSkill.objects.filter(skill_id__in=required_skill_ids) + .values("user_id") + .annotate(matched_skills=Count("skill_id", distinct=True)) + .filter(matched_skills=required_count) + .values_list("user_id", flat=True) ) - if not agent_user_ids: - return User.objects.none() - # Return agents sorted by open ticket count return ( - User.objects.filter(pk__in=agent_user_ids) + User.objects.filter(pk__in=eligible_user_ids) .annotate( + matched_proficiency_sum=Sum( + "agentskill__proficiency", + filter=Q(agentskill__skill_id__in=required_skill_ids), + ), open_tickets_count=Count( "escalated_assigned_tickets", filter=~Q(escalated_assigned_tickets__status__in=["resolved", "closed"]), - ) + ), ) - .order_by("open_tickets_count") + .order_by("-matched_proficiency_sum", "open_tickets_count") ) diff --git a/escalated/urls.py b/escalated/urls.py index 8230227..7cfb5a0 100644 --- a/escalated/urls.py +++ b/escalated/urls.py @@ -211,8 +211,11 @@ # Skills path("admin/skills/", admin.skills_index, name="admin_skills_index"), path("admin/skills/create/", admin.skills_create, name="admin_skills_create"), + path("admin/skills/store/", admin.skills_store, name="admin_skills_store"), path("admin/skills//edit/", admin.skills_edit, name="admin_skills_edit"), + path("admin/skills//update/", admin.skills_update, name="admin_skills_update"), path("admin/skills//delete/", admin.skills_delete, name="admin_skills_delete"), + path("admin/skills//", admin.skills_destroy, name="admin_skills_destroy"), # Capacity path("admin/capacity/", admin.capacity_index, name="admin_capacity_index"), path("admin/capacity//update/", admin.capacity_update, name="admin_capacity_update"), diff --git a/escalated/views/admin.py b/escalated/views/admin.py index ed5440b..c3a18e3 100644 --- a/escalated/views/admin.py +++ b/escalated/views/admin.py @@ -5,8 +5,9 @@ from django.contrib.auth.decorators import login_required from django.core.cache import cache from django.core.paginator import Paginator +from django.db import transaction from django.db.models import Avg, Count, Max, Q -from django.http import HttpResponseForbidden, HttpResponseNotFound, JsonResponse +from django.http import HttpResponseForbidden, HttpResponseNotAllowed, HttpResponseNotFound, JsonResponse from django.shortcuts import redirect from django.utils import timezone from django.utils.text import slugify @@ -15,6 +16,7 @@ from escalated.kb_guards import require_kb_enabled from escalated.models import ( AgentCapacity, + AgentSkill, Article, ArticleCategory, AuditLog, @@ -3076,14 +3078,252 @@ def kb_categories_delete(request, category_id): # --------------------------------------------------------------------------- +def _skill_form_user_filter_q(): + """ORM Q for users with is_agent or is_admin when those fields exist on the user model.""" + field_names = {f.name for f in User._meta.get_fields()} + parts = [] + if "is_agent" in field_names: + parts.append(Q(is_agent=True)) + if "is_admin" in field_names: + parts.append(Q(is_admin=True)) + if not parts: + return None + combined = parts[0] + for p in parts[1:]: + combined |= p + return combined + + +def _skills_eligible_user_queryset(): + """Users shown in the skill form agent picker (is_agent or is_admin when present).""" + qs = User.objects.filter(is_active=True) + q_or = _skill_form_user_filter_q() + if q_or is not None: + qs = qs.filter(q_or) + return qs.order_by("first_name", "last_name", "username") + + +def _skills_serialize_available_agents(qs): + return [{"id": u.pk, "name": u.get_full_name() or u.username, "email": getattr(u, "email", "") or ""} for u in qs] + + +def _skills_form_shared_props(): + return { + "availableAgents": _skills_serialize_available_agents(_skills_eligible_user_queryset()), + "availableTags": TagSerializer.serialize_list(Tag.objects.order_by("name")), + "availableDepartments": [ + {"id": d.pk, "name": d.name} for d in Department.objects.filter(is_active=True).order_by("name") + ], + } + + +def _normalize_skills_payload_dict(payload): + name = (payload.get("name") or "").strip() + description = payload.get("description") + if description is not None and str(description).strip() != "": + description = str(description).strip() + else: + description = None + + def collect_int_ids(key): + out = [] + val = payload.get(key) + if val is None: + return out + if isinstance(val, (list, tuple)): + for item in val: + try: + if item is None or item == "": + continue + out.append(int(item)) + except (TypeError, ValueError): + continue + else: + try: + if val == "": + return out + out.append(int(val)) + except (TypeError, ValueError): + pass + return list(dict.fromkeys(out)) + + routing_tag_ids = collect_int_ids("routing_tag_ids") + routing_department_ids = collect_int_ids("routing_department_ids") + + agents_out = [] + for row in payload.get("agents") or []: + if not isinstance(row, dict): + continue + uid = row.get("user_id") + if uid is None: + continue + try: + uid = int(uid) + except (TypeError, ValueError): + continue + prof = row.get("proficiency", 3) + try: + prof = int(prof) + except (TypeError, ValueError): + prof = 3 + prof = max(1, min(5, prof)) + agents_out.append({"user_id": uid, "proficiency": prof}) + + return { + "name": name, + "description": description, + "routing_tag_ids": routing_tag_ids, + "routing_department_ids": routing_department_ids, + "agents": agents_out, + } + + +def _skills_parse_agents_bracket_notation(data): + agents = [] + idx = 0 + while True: + uid = data.get(f"agents[{idx}][user_id]") + if uid is None or str(uid).strip() == "": + break + prof = data.get(f"agents[{idx}][proficiency]", "3") + try: + agents.append({"user_id": int(uid), "proficiency": int(prof)}) + except (TypeError, ValueError): + break + idx += 1 + return agents + + +def _skills_post_to_payload_dict(data): + """Build a normalised payload dict from a QueryDict (Inertia form posts).""" + name = (data.get("name") or "").strip() + description = data.get("description") + if description is not None: + description = str(description).strip() or None + + tag_ids = [] + for key in ("routing_tag_ids[]", "routing_tag_ids"): + tag_ids.extend(data.getlist(key)) + + dept_ids = [] + for key in ("routing_department_ids[]", "routing_department_ids"): + dept_ids.extend(data.getlist(key)) + + agents = [] + agents_raw = data.get("agents") + if agents_raw: + try: + parsed = json.loads(agents_raw) + if isinstance(parsed, list): + agents = parsed + except (json.JSONDecodeError, TypeError): + pass + if not agents: + agents = _skills_parse_agents_bracket_notation(data) + + tag_ids_clean = [] + seen = set() + for x in tag_ids: + try: + i = int(x) + if i not in seen: + seen.add(i) + tag_ids_clean.append(i) + except (TypeError, ValueError): + continue + + dept_ids_clean = [] + seen_d = set() + for x in dept_ids: + try: + i = int(x) + if i not in seen_d: + seen_d.add(i) + dept_ids_clean.append(i) + except (TypeError, ValueError): + continue + + return _normalize_skills_payload_dict( + { + "name": name, + "description": description, + "routing_tag_ids": tag_ids_clean, + "routing_department_ids": dept_ids_clean, + "agents": agents, + } + ) + + +def _skills_parse_request(request): + if request.body: + try: + raw = json.loads(request.body) + if isinstance(raw, dict) and raw: + return _normalize_skills_payload_dict(raw) + except (json.JSONDecodeError, TypeError, UnicodeDecodeError): + pass + return _skills_post_to_payload_dict(request.POST) + + +def _skills_apply_payload(skill, payload): + name = payload["name"] + if not name: + raise ValueError("name_required") + + tag_ids = set(Tag.objects.filter(pk__in=payload["routing_tag_ids"]).values_list("pk", flat=True)) + dept_ids = set(Department.objects.filter(pk__in=payload["routing_department_ids"]).values_list("pk", flat=True)) + allowed_users = _skills_eligible_user_queryset().filter(pk__in=[a["user_id"] for a in payload["agents"]]) + valid_user_ids = set(allowed_users.values_list("pk", flat=True)) + + with transaction.atomic(): + skill.name = name + skill.description = payload.get("description") + skill.save() + skill.routing_tags.set(sorted(tag_ids)) + skill.routing_departments.set(sorted(dept_ids)) + AgentSkill.objects.filter(skill=skill).delete() + bulk = [] + seen_user = set() + for row in payload["agents"]: + uid = row["user_id"] + if uid not in valid_user_ids or uid in seen_user: + continue + seen_user.add(uid) + bulk.append( + AgentSkill( + user_id=uid, + skill=skill, + proficiency=row["proficiency"], + ) + ) + if bulk: + AgentSkill.objects.bulk_create(bulk) + + +def _skills_method_is_update(request): + if request.method in ("PUT", "PATCH"): + return True + if request.method == "POST": + spoofed = request.POST.get("_method", "").upper() + if spoofed in ("PUT", "PATCH", ""): + return True + return False + + @login_required def skills_index(request): check = _require_admin(request) if check: return check - skills = Skill.objects.annotate(agents_count=Count("agents")).order_by("name") + skills = Skill.objects.annotate( + agents_count=Count("agents", distinct=True), + routing_tags_count=Count("routing_tags", distinct=True), + routing_departments_count=Count("routing_departments", distinct=True), + ).order_by("name") return render_page( - request, "Escalated/Admin/Skills/Index", props={"skills": SkillSerializer.serialize_list(skills)} + request, + "Escalated/Admin/Skills/Index", + props={"skills": SkillSerializer.serialize_index_list(skills)}, ) @@ -3092,15 +3332,30 @@ def skills_create(request): check = _require_admin(request) if check: return check - if request.method == "POST": - name = request.POST.get("name", "").strip() - if not name: - return render_page( - request, "Escalated/Admin/Skills/Form", props={"errors": {"name": _("Name is required.")}} - ) - Skill.objects.create(name=name) - return redirect("escalated:admin_skills_index") - return render_page(request, "Escalated/Admin/Skills/Form", props={}) + if request.method != "GET": + return HttpResponseNotAllowed(["GET"]) + props = {"skill": None, **_skills_form_shared_props()} + return render_page(request, "Escalated/Admin/Skills/Form", props=props) + + +@login_required +def skills_store(request): + check = _require_admin(request) + if check: + return check + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + payload = _skills_parse_request(request) + if not payload["name"]: + props = {**_skills_form_shared_props(), "skill": None, "errors": {"name": _("Name is required.")}} + return render_page(request, "Escalated/Admin/Skills/Form", props=props) + skill = Skill() + try: + _skills_apply_payload(skill, payload) + except ValueError: + props = {**_skills_form_shared_props(), "skill": None, "errors": {"name": _("Name is required.")}} + return render_page(request, "Escalated/Admin/Skills/Form", props=props) + return redirect("escalated:admin_skills_index") @login_required @@ -3112,11 +3367,44 @@ def skills_edit(request, skill_id): skill = Skill.objects.get(pk=skill_id) except Skill.DoesNotExist: return HttpResponseNotFound(_("Skill not found")) - if request.method == "POST": - skill.name = request.POST.get("name", skill.name) - skill.save() - return redirect("escalated:admin_skills_index") - return render_page(request, "Escalated/Admin/Skills/Form", props={"skill": SkillSerializer.serialize(skill)}) + if request.method != "GET": + return HttpResponseNotAllowed(["GET"]) + props = { + "skill": SkillSerializer.serialize_for_form(skill), + **_skills_form_shared_props(), + } + return render_page(request, "Escalated/Admin/Skills/Form", props=props) + + +@login_required +def skills_update(request, skill_id): + check = _require_admin(request) + if check: + return check + if not _skills_method_is_update(request): + return HttpResponseNotAllowed(["PUT", "PATCH", "POST"]) + try: + skill = Skill.objects.get(pk=skill_id) + except Skill.DoesNotExist: + return HttpResponseNotFound(_("Skill not found")) + payload = _skills_parse_request(request) + if not payload["name"]: + props = { + "skill": SkillSerializer.serialize_for_form(skill), + **_skills_form_shared_props(), + "errors": {"name": _("Name is required.")}, + } + return render_page(request, "Escalated/Admin/Skills/Form", props=props) + try: + _skills_apply_payload(skill, payload) + except ValueError: + props = { + "skill": SkillSerializer.serialize_for_form(skill), + **_skills_form_shared_props(), + "errors": {"name": _("Name is required.")}, + } + return render_page(request, "Escalated/Admin/Skills/Form", props=props) + return redirect("escalated:admin_skills_index") @login_required @@ -3133,6 +3421,20 @@ def skills_delete(request, skill_id): return redirect("escalated:admin_skills_index") +@login_required +def skills_destroy(request, skill_id): + check = _require_admin(request) + if check: + return check + if request.method != "DELETE": + return HttpResponseNotAllowed(["DELETE"]) + try: + Skill.objects.get(pk=skill_id).delete() + except Skill.DoesNotExist: + pass + return redirect("escalated:admin_skills_index") + + # --------------------------------------------------------------------------- # Capacity # --------------------------------------------------------------------------- From 76cf44ede6b4480890c8f06a10a3e0523c98da96 Mon Sep 17 00:00:00 2001 From: Matt Gros Date: Sun, 17 May 2026 12:53:29 -0400 Subject: [PATCH 3/5] test(skills): CRUD, routing mapping, and agent picker filters (#51) Covers store/update JSON, DELETE destroy, explicit tag routing vs name match, ordering, departments, and form user-filter Q construction. Co-authored-by: Cursor --- tests/factories.py | 10 +++ .../integration/test_phase3_5_admin_views.py | 75 ++++++++++++++---- tests/unit/test_skill_routing_service.py | 77 +++++++++++++++++++ tests/unit/test_skills_admin_helpers.py | 36 +++++++++ 4 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 tests/unit/test_skill_routing_service.py create mode 100644 tests/unit/test_skills_admin_helpers.py diff --git a/tests/factories.py b/tests/factories.py index 64fbe62..48b5a85 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -8,6 +8,7 @@ from escalated.models import ( AgentCapacity, AgentProfile, + AgentSkill, ApiToken, Article, ArticleCategory, @@ -397,6 +398,15 @@ class Meta: slug = factory.Sequence(lambda n: f"skill-{n}") +class AgentSkillFactory(factory.django.DjangoModelFactory): + class Meta: + model = AgentSkill + + user = factory.SubFactory(UserFactory) + skill = factory.SubFactory(SkillFactory) + proficiency = 3 + + class AgentCapacityFactory(factory.django.DjangoModelFactory): class Meta: model = AgentCapacity diff --git a/tests/integration/test_phase3_5_admin_views.py b/tests/integration/test_phase3_5_admin_views.py index 4e5d1d8..2c6ba0c 100644 --- a/tests/integration/test_phase3_5_admin_views.py +++ b/tests/integration/test_phase3_5_admin_views.py @@ -8,7 +8,9 @@ Automation, CustomObject, CustomObjectRecord, + Department, Skill, + Tag, TwoFactor, Webhook, ) @@ -36,6 +38,8 @@ def _make_admin_request(rf, method, path, data=None, user=None, content_type=Non user = UserFactory(username="admin_p35", is_staff=True, is_superuser=True) if method == "GET": request = rf.get(path) + elif method == "DELETE": + request = rf.delete(path) elif content_type == "application/json": request = rf.post( path, @@ -71,36 +75,77 @@ def test_index_returns_skills(self, mock_render, rf): assert args[0][1] == "Escalated/Admin/Skills/Index" props = args[1]["props"] if "props" in args[1] else args[0][2] assert "skills" in props + assert "routing_tags_count" in props["skills"][0] - def test_create_post(self, rf): + @patch("escalated.views.admin.render_page") + def test_create_get_includes_form_context(self, mock_render, rf): + mock_render.return_value = MagicMock(status_code=200) + request = _make_admin_request(rf, "GET", "/admin/skills/create/") + admin.skills_create(request) + _args, kwargs = mock_render.call_args + props = kwargs.get("props") + if props is None and len(_args) > 2: + props = _args[2] + assert props["skill"] is None + assert "availableAgents" in props + assert "availableTags" in props + assert "availableDepartments" in props + + def test_store_post_json(self, rf): + tag = Tag.objects.create(name="route-tag", slug="route-tag", color="#000") + dept = Department.objects.create(name="Dept A", slug="dept-a") + agent = UserFactory(username="skill_agent") + payload = { + "name": "Routing Skill", + "description": "desc", + "routing_tag_ids": [tag.pk], + "routing_department_ids": [dept.pk], + "agents": [{"user_id": agent.pk, "proficiency": 4}], + } request = _make_admin_request( rf, "POST", - "/admin/skills/create/", - data={ - "name": "Networking", - }, + "/admin/skills/store/", + data=payload, + content_type="application/json", ) - response = admin.skills_create(request) + response = admin.skills_store(request) assert response.status_code == 302 - assert Skill.objects.filter(name="Networking").exists() - - def test_edit_post(self, rf): - skill = SkillFactory(name="Old Skill", slug="old-skill") + skill = Skill.objects.get(name="Routing Skill") + assert skill.description == "desc" + assert list(skill.routing_tags.values_list("pk", flat=True)) == [tag.pk] + assert list(skill.routing_departments.values_list("pk", flat=True)) == [dept.pk] + assert skill.agent_skills.filter(user=agent, proficiency=4).exists() + + def test_update_post_json(self, rf): + skill = SkillFactory(name="Updatable", slug="updatable") + tag = Tag.objects.create(name="t2", slug="t2", color="#000") request = _make_admin_request( rf, "POST", - f"/admin/skills/{skill.pk}/edit/", + f"/admin/skills/{skill.pk}/update/", data={ - "name": "New Skill", + "name": "Updated Name", + "routing_tag_ids": [tag.pk], + "routing_department_ids": [], + "agents": [], }, + content_type="application/json", ) - response = admin.skills_edit(request, skill.pk) + response = admin.skills_update(request, skill.pk) assert response.status_code == 302 skill.refresh_from_db() - assert skill.name == "New Skill" + assert skill.name == "Updated Name" + assert list(skill.routing_tags.values_list("pk", flat=True)) == [tag.pk] - def test_delete(self, rf): + def test_destroy_delete(self, rf): + skill = SkillFactory(slug="skill-del-destroy") + request = _make_admin_request(rf, "DELETE", f"/admin/skills/{skill.pk}/") + response = admin.skills_destroy(request, skill.pk) + assert response.status_code == 302 + assert not Skill.objects.filter(pk=skill.pk).exists() + + def test_delete_post_legacy(self, rf): skill = SkillFactory(slug="skill-to-del") request = _make_admin_request(rf, "POST", f"/admin/skills/{skill.pk}/delete/") response = admin.skills_delete(request, skill.pk) diff --git a/tests/unit/test_skill_routing_service.py b/tests/unit/test_skill_routing_service.py new file mode 100644 index 0000000..834f003 --- /dev/null +++ b/tests/unit/test_skill_routing_service.py @@ -0,0 +1,77 @@ +import pytest + +from escalated.models import SkillRoutingDepartment, SkillRoutingTag +from escalated.services.skill_routing_service import SkillRoutingService +from tests.factories import ( + AgentSkillFactory, + DepartmentFactory, + SkillFactory, + TagFactory, + TicketFactory, + UserFactory, +) + + +@pytest.mark.django_db +def test_routing_matches_via_explicit_tag_mapping_not_skill_name(): + """Skill name differs from tag name; routing uses SkillRoutingTag only.""" + tag_bug = TagFactory(name="bug", slug="bug") + + skill = SkillFactory(name="Networking Expert", slug="networking-expert") + SkillRoutingTag.objects.create(skill=skill, tag=tag_bug) + + # Name-match legacy would look for Skill named "bug"; none exists. + assert not skill.name.lower() == tag_bug.name.lower() + + user_match = UserFactory(username="has_skill") + user_no = UserFactory(username="no_skill") + AgentSkillFactory(user=user_match, skill=skill, proficiency=5) + + ticket = TicketFactory() + ticket.tags.add(tag_bug) + + svc = SkillRoutingService() + agents = list(svc.find_matching_agents(ticket)) + assert agents == [user_match] + assert user_no not in agents + + +@pytest.mark.django_db +def test_routing_requires_all_skills_and_orders_by_proficiency_sum(): + dept = DepartmentFactory() + tag = TagFactory() + s_a = SkillFactory(name="Skill A", slug="skill-a") + s_b = SkillFactory(name="Skill B", slug="skill-b") + SkillRoutingTag.objects.create(skill=s_a, tag=tag) + SkillRoutingTag.objects.create(skill=s_b, tag=tag) + + strong = UserFactory(username="strong") + weak = UserFactory(username="weak") + AgentSkillFactory(user=strong, skill=s_a, proficiency=5) + AgentSkillFactory(user=strong, skill=s_b, proficiency=5) + AgentSkillFactory(user=weak, skill=s_a, proficiency=2) + AgentSkillFactory(user=weak, skill=s_b, proficiency=1) + + ticket = TicketFactory(department=dept) + ticket.tags.add(tag) + + svc = SkillRoutingService() + agents = list(svc.find_matching_agents(ticket)) + assert agents[0] == strong + assert agents[1] == weak + + +@pytest.mark.django_db +def test_routing_matches_via_department_mapping(): + dept = DepartmentFactory() + skill = SkillFactory(name="Billing", slug="billing") + SkillRoutingDepartment.objects.create(skill=skill, department=dept) + + user = UserFactory() + AgentSkillFactory(user=user, skill=skill, proficiency=3) + + ticket = TicketFactory(department=dept) + + svc = SkillRoutingService() + agents = list(svc.find_matching_agents(ticket)) + assert agents == [user] diff --git a/tests/unit/test_skills_admin_helpers.py b/tests/unit/test_skills_admin_helpers.py new file mode 100644 index 0000000..3c596b5 --- /dev/null +++ b/tests/unit/test_skills_admin_helpers.py @@ -0,0 +1,36 @@ +from types import SimpleNamespace +from unittest.mock import patch + +import pytest +from django.db.models import Q + +from escalated.views import admin + + +@pytest.mark.parametrize( + "field_names,expect_or", + [ + ([], None), + (["username"], None), + (["is_agent"], "is_agent"), + (["is_admin"], "is_admin"), + (["is_agent", "is_admin"], "both"), + ], +) +def test_skill_form_user_filter_q(field_names, expect_or): + mocks = [SimpleNamespace(name=n) for n in field_names] + + with patch("escalated.views.admin.User._meta.get_fields", return_value=mocks): + q = admin._skill_form_user_filter_q() + + if expect_or is None: + assert q is None + return + + assert q is not None + if expect_or == "is_agent": + assert q == Q(is_agent=True) + elif expect_or == "is_admin": + assert q == Q(is_admin=True) + else: + assert q == Q(is_agent=True) | Q(is_admin=True) From 60155ace2f49b39801085eb15e503e97a56efe00 Mon Sep 17 00:00:00 2001 From: mpge Date: Sun, 17 May 2026 12:58:28 -0400 Subject: [PATCH 4/5] fix(skills): CheckConstraint kwarg renamed to condition= in Django 5.1, not 5.0 --- escalated/migrations/0022_skills_management_routing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/escalated/migrations/0022_skills_management_routing.py b/escalated/migrations/0022_skills_management_routing.py index 5312548..dd81139 100644 --- a/escalated/migrations/0022_skills_management_routing.py +++ b/escalated/migrations/0022_skills_management_routing.py @@ -6,8 +6,10 @@ from escalated.conf import get_table_name +# Django 5.1 renamed `check=` to `condition=` on CheckConstraint. +# Both 4.2 and 5.0 still use the old `check=` kwarg. _check_kwargs = {"condition": models.Q(proficiency__gte=1, proficiency__lte=5)} -if django.VERSION < (5, 0): +if django.VERSION < (5, 1): _check_kwargs = {"check": models.Q(proficiency__gte=1, proficiency__lte=5)} From 55eb55ea0b20fa2c4af9ac5049ac5f2bc5720500 Mon Sep 17 00:00:00 2001 From: mpge Date: Sun, 17 May 2026 13:01:58 -0400 Subject: [PATCH 5/5] ci: remove FOSSA workflow (failing due to missing api key) FOSSA fails in CI because secrets.FOSSA_API_KEY isn't configured. Per the 2026-05-17 portfolio sweep, remove the workflow on every backend where it's red so the suite can go green. To restore: re-add this file (or copy from escalated-laravel / escalated where FOSSA is wired up correctly) and set the FOSSA_API_KEY secret on the repo. --- .github/workflows/fossa.yml | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 .github/workflows/fossa.yml diff --git a/.github/workflows/fossa.yml b/.github/workflows/fossa.yml deleted file mode 100644 index 769ba90..0000000 --- a/.github/workflows/fossa.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: fossa - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - workflow_dispatch: - -permissions: - contents: read - -jobs: - analyze: - runs-on: ubuntu-latest - name: FOSSA Analysis - - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Run FOSSA scan - uses: fossas/fossa-action@main - with: - api-key: ${{ secrets.FOSSA_API_KEY }} - branch: ${{ github.head_ref || github.ref_name }} - - - name: Run FOSSA policy test - uses: fossas/fossa-action@main - with: - api-key: ${{ secrets.FOSSA_API_KEY }} - run-tests: true