feat: add versioned skill configuration
This commit is contained in:
176
backend/apps/skills/api.py
Normal file
176
backend/apps/skills/api.py
Normal file
@@ -0,0 +1,176 @@
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.utils import timezone
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from apps.accounts.api import require_permission
|
||||
from apps.audit.models import OperationAuditLog
|
||||
from apps.skills.models import SkillDefinition, SkillVersion
|
||||
from apps.skills.schemas import (
|
||||
SkillCreateIn,
|
||||
SkillDefinitionOut,
|
||||
SkillDefinitionUpdateIn,
|
||||
SkillDetailOut,
|
||||
SkillDraftUpdateIn,
|
||||
SkillVersionOut,
|
||||
)
|
||||
from apps.skills.services import SkillValidationError, ensure_draft, validate_skill_version
|
||||
|
||||
router = Router(tags=["skills"])
|
||||
|
||||
|
||||
def serialize_definition(definition: SkillDefinition, detailed: bool = False) -> dict:
|
||||
draft = definition.versions.filter(status="draft").first()
|
||||
payload = {
|
||||
"id": definition.id,
|
||||
"code": definition.code,
|
||||
"name": definition.name,
|
||||
"description": definition.description,
|
||||
"active_version": definition.active_version.version
|
||||
if definition.active_version_id
|
||||
else None,
|
||||
"draft_version": draft.version if draft else None,
|
||||
"updated_at": definition.updated_at,
|
||||
}
|
||||
if detailed:
|
||||
payload["versions"] = list(definition.versions.all())
|
||||
return payload
|
||||
|
||||
|
||||
def audit(user, action: str, definition: SkillDefinition, snapshot: dict) -> None:
|
||||
OperationAuditLog.objects.create(
|
||||
user=user,
|
||||
module="skills",
|
||||
action=action,
|
||||
target_type="skill",
|
||||
target_id=str(definition.id),
|
||||
request_snapshot=snapshot,
|
||||
result="success",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response=list[SkillDefinitionOut])
|
||||
def list_skills(request):
|
||||
require_permission(request, "skill.manage")
|
||||
queryset = SkillDefinition.objects.select_related("active_version").prefetch_related("versions")
|
||||
return [serialize_definition(item) for item in queryset]
|
||||
|
||||
|
||||
@router.post("", response=SkillDetailOut)
|
||||
def create_skill(request, payload: SkillCreateIn):
|
||||
require_permission(request, "skill.manage")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
definition = SkillDefinition.objects.create(
|
||||
code=payload.code,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
created_by=request.user,
|
||||
)
|
||||
SkillVersion.objects.create(
|
||||
definition=definition,
|
||||
version=1,
|
||||
status="draft",
|
||||
prompt_template=payload.prompt_template,
|
||||
input_schema=payload.input_schema,
|
||||
output_schema=payload.output_schema,
|
||||
example_input=payload.example_input,
|
||||
created_by=request.user,
|
||||
)
|
||||
except IntegrityError as error:
|
||||
raise HttpError(400, "Skill code already exists") from error
|
||||
audit(request.user, "create", definition, {"code": definition.code})
|
||||
return serialize_definition(definition, detailed=True)
|
||||
|
||||
|
||||
@router.get("/{skill_id}", response=SkillDetailOut)
|
||||
def get_skill(request, skill_id: int):
|
||||
require_permission(request, "skill.manage")
|
||||
definition = SkillDefinition.objects.select_related("active_version").get(id=skill_id)
|
||||
return serialize_definition(definition, detailed=True)
|
||||
|
||||
|
||||
@router.patch("/{skill_id}", response=SkillDetailOut)
|
||||
def update_skill(request, skill_id: int, payload: SkillDefinitionUpdateIn):
|
||||
require_permission(request, "skill.manage")
|
||||
definition = SkillDefinition.objects.select_related("active_version").get(id=skill_id)
|
||||
values = payload.dict(exclude_unset=True)
|
||||
for field, value in values.items():
|
||||
setattr(definition, field, value)
|
||||
definition.save()
|
||||
audit(request.user, "update", definition, {"fields": sorted(values)})
|
||||
return serialize_definition(definition, detailed=True)
|
||||
|
||||
|
||||
@router.post("/{skill_id}/draft", response=SkillVersionOut)
|
||||
def create_skill_draft(request, skill_id: int):
|
||||
require_permission(request, "skill.manage")
|
||||
definition = SkillDefinition.objects.select_related("active_version").get(id=skill_id)
|
||||
return ensure_draft(definition, request.user)
|
||||
|
||||
|
||||
@router.get("/{skill_id}/draft", response=SkillVersionOut)
|
||||
def get_skill_draft(request, skill_id: int):
|
||||
require_permission(request, "skill.manage")
|
||||
return SkillVersion.objects.get(definition_id=skill_id, status="draft")
|
||||
|
||||
|
||||
@router.patch("/{skill_id}/draft", response=SkillVersionOut)
|
||||
def update_skill_draft(request, skill_id: int, payload: SkillDraftUpdateIn):
|
||||
require_permission(request, "skill.manage")
|
||||
definition = SkillDefinition.objects.select_related("active_version").get(id=skill_id)
|
||||
draft = ensure_draft(definition, request.user)
|
||||
values = payload.dict(exclude_unset=True)
|
||||
for field, value in values.items():
|
||||
setattr(draft, field, value)
|
||||
try:
|
||||
validate_skill_version(draft)
|
||||
except SkillValidationError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
draft.save()
|
||||
audit(request.user, "update_draft", definition, {"version": draft.version})
|
||||
return draft
|
||||
|
||||
|
||||
@router.get("/{skill_id}/versions", response=list[SkillVersionOut])
|
||||
def list_skill_versions(request, skill_id: int):
|
||||
require_permission(request, "skill.manage")
|
||||
return SkillVersion.objects.filter(definition_id=skill_id)
|
||||
|
||||
|
||||
@router.post("/{skill_id}/publish", response=SkillVersionOut)
|
||||
def publish_skill(request, skill_id: int):
|
||||
require_permission(request, "skill.manage")
|
||||
with transaction.atomic():
|
||||
definition = SkillDefinition.objects.select_for_update().get(id=skill_id)
|
||||
draft = SkillVersion.objects.select_for_update().get(
|
||||
definition=definition,
|
||||
status="draft",
|
||||
)
|
||||
try:
|
||||
validate_skill_version(draft)
|
||||
except SkillValidationError as error:
|
||||
raise HttpError(400, str(error)) from error
|
||||
draft.status = "published"
|
||||
draft.published_by = request.user
|
||||
draft.published_at = timezone.now()
|
||||
draft.save(update_fields=["status", "published_by", "published_at", "updated_at"])
|
||||
definition.active_version = draft
|
||||
definition.save(update_fields=["active_version", "updated_at"])
|
||||
audit(request.user, "publish", definition, {"version": draft.version})
|
||||
return draft
|
||||
|
||||
|
||||
@router.post("/{skill_id}/rollback/{version}", response=SkillDetailOut)
|
||||
def rollback_skill(request, skill_id: int, version: int):
|
||||
require_permission(request, "skill.manage")
|
||||
definition = SkillDefinition.objects.select_related("active_version").get(id=skill_id)
|
||||
target = SkillVersion.objects.get(
|
||||
definition=definition,
|
||||
version=version,
|
||||
status="published",
|
||||
)
|
||||
definition.active_version = target
|
||||
definition.save(update_fields=["active_version", "updated_at"])
|
||||
audit(request.user, "rollback", definition, {"version": target.version})
|
||||
return serialize_definition(definition, detailed=True)
|
||||
Reference in New Issue
Block a user