feat: add reusable solution library

This commit is contained in:
hailin
2026-06-30 18:46:30 +08:00
parent f8298632b6
commit 333dc76f18
11 changed files with 371 additions and 0 deletions

View File

@@ -0,0 +1 @@

View 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,
}

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class SolutionsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.solutions"

View 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")},
},
),
]

View File

@@ -0,0 +1 @@

View 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")]

View 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

View File

@@ -0,0 +1 @@

View 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

View File

@@ -19,6 +19,7 @@ INSTALLED_APPS = [
"apps.audit",
"apps.system",
"apps.tickets",
"apps.solutions",
]
MIDDLEWARE = [

View File

@@ -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),