diff --git a/backend/apps/solutions/__init__.py b/backend/apps/solutions/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/solutions/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/solutions/api.py b/backend/apps/solutions/api.py new file mode 100644 index 0000000..c930fdd --- /dev/null +++ b/backend/apps/solutions/api.py @@ -0,0 +1,66 @@ +from ninja import Router + +from apps.solutions.models import Solution, TicketSolutionLink +from apps.solutions.schemas import ( + SolutionIn, + SolutionOut, + TicketSolutionLinkIn, + TicketSolutionLinkOut, +) +from apps.tickets.models import Ticket + +router = Router(tags=["solutions"]) + + +@router.post("", response=SolutionOut) +def create_solution(request, payload: SolutionIn): + return Solution.objects.create( + title=payload.title, + problem_signature=payload.problem_signature, + related_system=payload.related_system, + environment_scope=payload.environment_scope, + root_cause=payload.root_cause, + solution=payload.solution, + risk_note=payload.risk_note, + status=payload.status, + created_by=request.user if request.user.is_authenticated else None, + ) + + +@router.get("", response=list[SolutionOut]) +def list_solutions(request, status: str | None = None): + queryset = Solution.objects.all() + if status: + queryset = queryset.filter(status=status) + return queryset[:100] + + +@router.get("/{solution_id}", response=SolutionOut) +def get_solution(request, solution_id: int): + return Solution.objects.get(id=solution_id) + + +@router.post("/{solution_id}/link-ticket", response=TicketSolutionLinkOut) +def link_solution_to_ticket(request, solution_id: int, payload: TicketSolutionLinkIn): + solution = Solution.objects.get(id=solution_id) + ticket = Ticket.objects.get(id=payload.ticket_id) + link, _ = TicketSolutionLink.objects.get_or_create( + ticket=ticket, + solution=solution, + link_type=payload.link_type, + defaults={ + "agent_run_id": payload.agent_run_id, + "created_by": request.user if request.user.is_authenticated else None, + }, + ) + if payload.link_type == "adopted": + solution.usage_count = solution.ticket_links.filter(link_type="adopted").count() + solution.save(update_fields=["usage_count", "updated_at"]) + return { + "id": link.id, + "ticket_id": ticket.id, + "solution_id": solution.id, + "link_type": link.link_type, + "solution_usage_count": solution.usage_count, + "created_at": link.created_at, + } diff --git a/backend/apps/solutions/apps.py b/backend/apps/solutions/apps.py new file mode 100644 index 0000000..6aba44e --- /dev/null +++ b/backend/apps/solutions/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SolutionsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.solutions" diff --git a/backend/apps/solutions/migrations/0001_initial.py b/backend/apps/solutions/migrations/0001_initial.py new file mode 100644 index 0000000..1a074d8 --- /dev/null +++ b/backend/apps/solutions/migrations/0001_initial.py @@ -0,0 +1,144 @@ +# Generated by Django 5.2.15 on 2026-06-30 10:43 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("tickets", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Solution", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("title", models.CharField(max_length=200)), + ("problem_signature", models.TextField()), + ("related_system", models.CharField(blank=True, max_length=120)), + ("environment_scope", models.CharField(blank=True, max_length=120)), + ("root_cause", models.TextField()), + ("solution", models.TextField()), + ("risk_note", models.TextField(blank=True)), + ("status", models.CharField(default="draft", max_length=40)), + ("usage_count", models.PositiveIntegerField(default=0)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "category", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="tickets.ticketcategory", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-updated_at"], + }, + ), + migrations.CreateModel( + name="TicketResolution", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("resolution_type", models.CharField(default="resolved", max_length=80)), + ("root_cause", models.TextField(blank=True)), + ("solution_text", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "confirmed_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "linked_solution", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="solutions.solution", + ), + ), + ( + "ticket", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="resolution", + to="tickets.ticket", + ), + ), + ], + ), + migrations.CreateModel( + name="TicketSolutionLink", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("link_type", models.CharField(default="referenced", max_length=40)), + ("agent_run_id", models.BigIntegerField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "solution", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ticket_links", + to="solutions.solution", + ), + ), + ( + "ticket", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="solution_links", + to="tickets.ticket", + ), + ), + ], + options={ + "unique_together": {("ticket", "solution", "link_type")}, + }, + ), + ] diff --git a/backend/apps/solutions/migrations/__init__.py b/backend/apps/solutions/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/solutions/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/solutions/models.py b/backend/apps/solutions/models.py new file mode 100644 index 0000000..89012ef --- /dev/null +++ b/backend/apps/solutions/models.py @@ -0,0 +1,64 @@ +from django.conf import settings +from django.db import models + +from apps.tickets.models import Ticket, TicketCategory + + +class Solution(models.Model): + title = models.CharField(max_length=200) + problem_signature = models.TextField() + related_system = models.CharField(max_length=120, blank=True) + environment_scope = models.CharField(max_length=120, blank=True) + category = models.ForeignKey(TicketCategory, null=True, blank=True, on_delete=models.SET_NULL) + root_cause = models.TextField() + solution = models.TextField() + risk_note = models.TextField(blank=True) + status = models.CharField(max_length=40, default="draft") + usage_count = models.PositiveIntegerField(default=0) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["-updated_at"] + + def __str__(self) -> str: + return self.title + + +class TicketResolution(models.Model): + ticket = models.OneToOneField(Ticket, on_delete=models.CASCADE, related_name="resolution") + resolution_type = models.CharField(max_length=80, default="resolved") + root_cause = models.TextField(blank=True) + solution_text = models.TextField() + linked_solution = models.ForeignKey(Solution, null=True, blank=True, on_delete=models.SET_NULL) + confirmed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class TicketSolutionLink(models.Model): + ticket = models.ForeignKey(Ticket, on_delete=models.CASCADE, related_name="solution_links") + solution = models.ForeignKey(Solution, on_delete=models.CASCADE, related_name="ticket_links") + link_type = models.CharField(max_length=40, default="referenced") + agent_run_id = models.BigIntegerField(null=True, blank=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + on_delete=models.SET_NULL, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = [("ticket", "solution", "link_type")] diff --git a/backend/apps/solutions/schemas.py b/backend/apps/solutions/schemas.py new file mode 100644 index 0000000..3e2f432 --- /dev/null +++ b/backend/apps/solutions/schemas.py @@ -0,0 +1,44 @@ +from datetime import datetime + +from ninja import Schema + + +class SolutionIn(Schema): + title: str + problem_signature: str + related_system: str = "" + environment_scope: str = "" + root_cause: str + solution: str + risk_note: str = "" + status: str = "draft" + + +class SolutionOut(Schema): + id: int + title: str + problem_signature: str + related_system: str + environment_scope: str + root_cause: str + solution: str + risk_note: str + status: str + usage_count: int + created_at: datetime + updated_at: datetime + + +class TicketSolutionLinkIn(Schema): + ticket_id: int + link_type: str = "referenced" + agent_run_id: int | None = None + + +class TicketSolutionLinkOut(Schema): + id: int + ticket_id: int + solution_id: int + link_type: str + solution_usage_count: int + created_at: datetime diff --git a/backend/apps/solutions/tests/__init__.py b/backend/apps/solutions/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/backend/apps/solutions/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/backend/apps/solutions/tests/test_solution_api.py b/backend/apps/solutions/tests/test_solution_api.py new file mode 100644 index 0000000..1f893f6 --- /dev/null +++ b/backend/apps/solutions/tests/test_solution_api.py @@ -0,0 +1,41 @@ +from apps.tickets.models import Ticket + + +def test_create_and_list_solution(client, db): + response = client.post( + "/api/solutions", + data={ + "title": "Restart callback worker", + "problem_signature": "HTTP 500 from callback", + "root_cause": "Worker connection pool stale", + "solution": "Restart callback worker and verify queue depth.", + "status": "published", + }, + content_type="application/json", + ) + + assert response.status_code == 200 + solution_id = response.json()["id"] + + response = client.get("/api/solutions") + assert response.status_code == 200 + assert response.json()[0]["id"] == solution_id + + +def test_link_solution_to_ticket_increments_usage(client, db): + ticket = Ticket.objects.create(ticket_no="TK202606300020", title="A", description="A") + solution = client.post( + "/api/solutions", + data={"title": "S", "problem_signature": "P", "root_cause": "R", "solution": "Do S"}, + content_type="application/json", + ).json() + + response = client.post( + f"/api/solutions/{solution['id']}/link-ticket", + data={"ticket_id": ticket.id, "link_type": "adopted"}, + content_type="application/json", + ) + + assert response.status_code == 200 + assert response.json()["link_type"] == "adopted" + assert response.json()["solution_usage_count"] == 1 diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py index 22f9b75..e9256f4 100644 --- a/backend/config/settings/base.py +++ b/backend/config/settings/base.py @@ -19,6 +19,7 @@ INSTALLED_APPS = [ "apps.audit", "apps.system", "apps.tickets", + "apps.solutions", ] MIDDLEWARE = [ diff --git a/backend/config/urls.py b/backend/config/urls.py index d9af2b1..d11b49f 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -3,6 +3,7 @@ from django.urls import path from ninja import NinjaAPI from apps.audit.api import router as audit_router +from apps.solutions.api import router as solutions_router from apps.system.api import router as system_router from apps.tickets.api import router as tickets_router @@ -10,6 +11,7 @@ api = NinjaAPI(title="AI Ticket Platform API", version="0.1.0") api.add_router("/system", system_router) api.add_router("/audit", audit_router) api.add_router("/tickets", tickets_router) +api.add_router("/solutions", solutions_router) urlpatterns = [ path("admin/", admin.site.urls),