feat: add reusable solution library
This commit is contained in:
1
backend/apps/solutions/__init__.py
Normal file
1
backend/apps/solutions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
66
backend/apps/solutions/api.py
Normal file
66
backend/apps/solutions/api.py
Normal file
@@ -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,
|
||||
}
|
||||
6
backend/apps/solutions/apps.py
Normal file
6
backend/apps/solutions/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class SolutionsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.solutions"
|
||||
144
backend/apps/solutions/migrations/0001_initial.py
Normal file
144
backend/apps/solutions/migrations/0001_initial.py
Normal file
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
1
backend/apps/solutions/migrations/__init__.py
Normal file
1
backend/apps/solutions/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
64
backend/apps/solutions/models.py
Normal file
64
backend/apps/solutions/models.py
Normal file
@@ -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")]
|
||||
44
backend/apps/solutions/schemas.py
Normal file
44
backend/apps/solutions/schemas.py
Normal file
@@ -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
|
||||
1
backend/apps/solutions/tests/__init__.py
Normal file
1
backend/apps/solutions/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
41
backend/apps/solutions/tests/test_solution_api.py
Normal file
41
backend/apps/solutions/tests/test_solution_api.py
Normal file
@@ -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
|
||||
@@ -19,6 +19,7 @@ INSTALLED_APPS = [
|
||||
"apps.audit",
|
||||
"apps.system",
|
||||
"apps.tickets",
|
||||
"apps.solutions",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user