feat(learnings): added learnings presentation logic

This commit is contained in:
ITQ
2026-02-23 10:58:03 +03:00
parent e9e64a7ce5
commit 85923f11fc
6 changed files with 524 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class LearningsApiConfig(AppConfig):
name = "api.v1.learnings"
label = "api_v1_learnings"
+161
View File
@@ -0,0 +1,161 @@
from http import HTTPStatus
from uuid import UUID
from django.http import Http404, HttpRequest
from ninja import Router
from api.v1.learnings.schemas import (
EditOut,
LearningCreateIn,
LearningOut,
LearningUpdateIn,
SimilarLearningOut,
)
from apps.experiments.models import Experiment
from apps.learnings.services import (
find_similar_learnings,
learning_create,
learning_delete,
learning_edit_list,
learning_get,
learning_list,
learning_update,
)
from apps.users.auth.bearer import jwt_bearer
router = Router(tags=["learnings"], auth=jwt_bearer)
@router.post(
"/learnings",
response={HTTPStatus.CREATED: LearningOut},
summary="Create a learning record",
)
def create_learning(
request: HttpRequest,
payload: LearningCreateIn,
) -> tuple[int, LearningOut]:
try:
experiment = Experiment.objects.select_related("flag").get(pk=payload.experiment_id)
except Experiment.DoesNotExist:
raise Http404 from Experiment.DoesNotExist
l = learning_create(
experiment=experiment,
hypothesis=payload.hypothesis,
findings=payload.findings,
tags=payload.tags,
context_summary=payload.context_summary,
user=request.auth,
)
l = learning_get(l.pk)
return HTTPStatus.CREATED, LearningOut.from_learning(l)
@router.get(
"/learnings",
response={HTTPStatus.OK: list[LearningOut]},
summary="List learnings",
)
def list_learnings(
request: HttpRequest,
flag_id: UUID | None = None,
owner_id: UUID | None = None,
outcome: str | None = None,
tag: str | None = None,
search: str | None = None,
) -> tuple[int, list[LearningOut]]:
learnings = learning_list(
flag_id=flag_id,
owner_id=owner_id,
outcome=outcome,
tag=tag,
search=search,
)
return HTTPStatus.OK, [LearningOut.from_learning(l) for l in learnings]
@router.get(
"/learnings/{learning_id}",
response={HTTPStatus.OK: LearningOut},
summary="Get learning by ID",
)
def get_learning(
request: HttpRequest,
learning_id: UUID,
) -> tuple[int, LearningOut]:
l = learning_get(learning_id)
if not l:
raise Http404
return HTTPStatus.OK, LearningOut.from_learning(l)
@router.patch(
"/learnings/{learning_id}",
response={HTTPStatus.OK: LearningOut},
summary="Update a learning record",
)
def update_learning(
request: HttpRequest,
learning_id: UUID,
payload: LearningUpdateIn,
) -> tuple[int, LearningOut]:
l = learning_get(learning_id)
if not l:
raise Http404
l = learning_update(
learning=l,
user=request.auth,
**payload.dict(exclude_unset=True),
)
l = learning_get(l.pk)
return HTTPStatus.OK, LearningOut.from_learning(l)
@router.delete(
"/learnings/{learning_id}",
response={HTTPStatus.NO_CONTENT: None},
summary="Delete a learning record",
)
def delete_learning(
request: HttpRequest,
learning_id: UUID,
) -> tuple[int, None]:
l = learning_get(learning_id)
if not l:
raise Http404
learning_delete(learning=l)
return HTTPStatus.NO_CONTENT, None
@router.get(
"/learnings/{learning_id}/edits",
response={HTTPStatus.OK: list[EditOut]},
summary="List edit history for a learning",
)
def list_learning_edits(
request: HttpRequest,
learning_id: UUID,
) -> tuple[int, list[EditOut]]:
l = learning_get(learning_id)
if not l:
raise Http404
edits = learning_edit_list(learning_id)
return HTTPStatus.OK, [EditOut.from_edit(e) for e in edits]
@router.get(
"/experiments/{experiment_id}/similar-learnings",
response={HTTPStatus.OK: list[SimilarLearningOut]},
summary="Find similar learnings for an experiment",
)
def similar_learnings(
request: HttpRequest,
experiment_id: UUID,
limit: int = 5,
) -> tuple[int, list[SimilarLearningOut]]:
results = find_similar_learnings(
experiment_id=experiment_id,
limit=limit,
)
return HTTPStatus.OK, [SimilarLearningOut(**r) for r in results]
+117
View File
@@ -0,0 +1,117 @@
from datetime import datetime
from typing import Any, ClassVar
from uuid import UUID
from ninja import ModelSchema, Schema
from apps.learnings.models import Learning, LearningEdit
class LearningCreateIn(Schema):
experiment_id: UUID
hypothesis: str
findings: str
tags: list[str] = []
context_summary: str = ""
class LearningUpdateIn(Schema):
hypothesis: str | None = None
findings: str | None = None
tags: list[str] | None = None
context_summary: str | None = None
class ExperimentBriefOut(Schema):
id: UUID
name: str
status: str
flag_key: str
class CreatedByOut(Schema):
id: UUID
username: str
class LearningOut(ModelSchema):
experiment: ExperimentBriefOut
created_by: CreatedByOut | None = None
class Meta:
model = Learning
fields: ClassVar[tuple[str, ...]] = (
Learning.id.field.name,
Learning.hypothesis.field.name,
Learning.findings.field.name,
Learning.tags.field.name,
Learning.context_summary.field.name,
Learning.created_at.field.name,
Learning.updated_at.field.name,
)
@classmethod
def from_learning(cls, l: Learning) -> "LearningOut":
created_by_out = None
if l.created_by:
created_by_out = CreatedByOut(
id=l.created_by.pk,
username=l.created_by.username,
)
return cls(
id=l.pk,
hypothesis=l.hypothesis,
findings=l.findings,
tags=l.tags,
context_summary=l.context_summary,
created_at=l.created_at,
updated_at=l.updated_at,
experiment=ExperimentBriefOut(
id=l.experiment.pk,
name=l.experiment.name,
status=l.experiment.status,
flag_key=l.experiment.flag.key,
),
created_by=created_by_out,
)
class EditOut(ModelSchema):
edited_by: CreatedByOut | None = None
class Meta:
model = LearningEdit
fields: ClassVar[tuple[str, ...]] = (
LearningEdit.id.field.name,
LearningEdit.changes.field.name,
LearningEdit.created_at.field.name,
)
@classmethod
def from_edit(cls, e: LearningEdit) -> "EditOut":
edited_by_out = None
if e.edited_by:
edited_by_out = CreatedByOut(
id=e.edited_by.pk,
username=e.edited_by.username,
)
return cls(
id=e.pk,
changes=e.changes,
created_at=e.created_at,
edited_by=edited_by_out,
)
class SimilarLearningOut(Schema):
learning_id: str
experiment_id: str
experiment_name: str
flag_key: str
hypothesis: str
findings: str
tags: list[str]
outcome: dict[str, Any] | None = None
had_guardrail_triggers: bool
guardrail_trigger_count: int
similarity_score: float
@@ -0,0 +1,240 @@
import json
import uuid
from typing import override
from django.test import Client, TestCase
from django.urls import reverse
from apps.experiments.tests.helpers import make_experiment, make_flag
from apps.learnings.services import learning_create
from apps.reviews.tests.helpers import make_admin
from apps.users.tests.helpers import auth_header
class LearningAPITest(TestCase):
@override
def setUp(self) -> None:
self.client = Client()
self.admin = make_admin("_lapi")
self.auth = auth_header(self.admin)
self.flag = make_flag(suffix="_lapi")
self.exp = make_experiment(
flag=self.flag,
owner=self.admin,
suffix="_lapi",
)
def _create_learning(self, **overrides) -> ...:
payload = {
"experiment_id": str(self.exp.pk),
"hypothesis": "Test hypothesis",
"findings": "Test findings",
"tags": ["test", "api"],
"context_summary": "API test context",
}
payload.update(overrides)
return self.client.post(
reverse("api-1:create_learning"),
data=json.dumps(payload),
content_type="application/json",
HTTP_AUTHORIZATION=self.auth,
)
def test_create_learning(self) -> None:
resp = self._create_learning()
self.assertEqual(resp.status_code, 201)
data = resp.json()
self.assertEqual(data["hypothesis"], "Test hypothesis")
self.assertEqual(data["findings"], "Test findings")
self.assertEqual(data["tags"], ["test", "api"])
self.assertEqual(data["experiment"]["id"], str(self.exp.pk))
def test_create_learning_invalid_experiment(self) -> None:
resp = self._create_learning(experiment_id=str(uuid.uuid4()))
self.assertEqual(resp.status_code, 404)
def test_list_learnings(self) -> None:
self._create_learning()
resp = self.client.get(
reverse("api-1:list_learnings"),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertGreaterEqual(len(data), 1)
def test_list_learnings_filter_by_flag(self) -> None:
self._create_learning()
resp = self.client.get(
reverse("api-1:list_learnings"),
{"flag_id": str(self.flag.pk)},
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 200)
self.assertGreaterEqual(len(resp.json()), 1)
def test_list_learnings_filter_by_tag(self) -> None:
self._create_learning()
resp = self.client.get(
reverse("api-1:list_learnings"),
{"tag": "test"},
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 200)
self.assertGreaterEqual(len(resp.json()), 1)
def test_get_learning(self) -> None:
create_resp = self._create_learning()
learning_id = create_resp.json()["id"]
resp = self.client.get(
reverse("api-1:get_learning", args=[learning_id]),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["id"], learning_id)
def test_get_learning_not_found(self) -> None:
resp = self.client.get(
reverse("api-1:get_learning", args=[uuid.uuid4()]),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 404)
def test_update_learning(self) -> None:
create_resp = self._create_learning()
learning_id = create_resp.json()["id"]
resp = self.client.patch(
reverse("api-1:update_learning", args=[learning_id]),
data=json.dumps({"hypothesis": "Updated hypothesis"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json()["hypothesis"], "Updated hypothesis")
def test_delete_learning(self) -> None:
create_resp = self._create_learning()
learning_id = create_resp.json()["id"]
resp = self.client.delete(
reverse("api-1:delete_learning", args=[learning_id]),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 204)
get_resp = self.client.get(
reverse("api-1:get_learning", args=[learning_id]),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(get_resp.status_code, 404)
def test_delete_learning_not_found(self) -> None:
resp = self.client.delete(
reverse("api-1:delete_learning", args=[uuid.uuid4()]),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 404)
class LearningEditsAPITest(TestCase):
@override
def setUp(self) -> None:
self.client = Client()
self.admin = make_admin("_ledit")
self.auth = auth_header(self.admin)
self.exp = make_experiment(suffix="_ledit", owner=self.admin)
self.learning = learning_create(
experiment=self.exp,
hypothesis="Original",
findings="Original findings",
user=self.admin,
)
def test_list_edits_empty(self) -> None:
resp = self.client.get(
reverse("api-1:list_learning_edits", args=[self.learning.pk]),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(len(resp.json()), 0)
def test_list_edits_after_update(self) -> None:
self.client.patch(
reverse("api-1:update_learning", args=[self.learning.pk]),
data=json.dumps({"hypothesis": "Updated"}),
content_type="application/json",
HTTP_AUTHORIZATION=self.auth,
)
resp = self.client.get(
reverse("api-1:list_learning_edits", args=[self.learning.pk]),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertEqual(len(data), 1)
self.assertIn("hypothesis", data[0]["changes"])
def test_list_edits_not_found(self) -> None:
resp = self.client.get(
reverse("api-1:list_learning_edits", args=[uuid.uuid4()]),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 404)
class SimilarLearningsAPITest(TestCase):
@override
def setUp(self) -> None:
self.client = Client()
self.admin = make_admin("_simapi")
self.auth = auth_header(self.admin)
self.flag = make_flag(suffix="_simapi")
self.exp1 = make_experiment(
flag=self.flag,
owner=self.admin,
suffix="_simapi1",
)
self.exp2 = make_experiment(
flag=self.flag,
owner=self.admin,
suffix="_simapi2",
name="Sim Exp 2",
)
learning_create(
experiment=self.exp1,
hypothesis="Button color test",
findings="Blue won",
tags=["ui", "checkout"],
)
learning_create(
experiment=self.exp2,
hypothesis="Button size test",
findings="Large won",
tags=["ui", "checkout"],
)
def test_find_similar(self) -> None:
resp = self.client.get(
reverse("api-1:similar_learnings", args=[self.exp1.pk]),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 200)
data = resp.json()
self.assertGreaterEqual(len(data), 1)
self.assertIn("similarity_score", data[0])
self.assertIn("flag_key", data[0])
def test_find_similar_with_limit(self) -> None:
resp = self.client.get(
reverse("api-1:similar_learnings", args=[self.exp1.pk]),
{"limit": 1},
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 200)
self.assertLessEqual(len(resp.json()), 1)
def test_find_similar_nonexistent(self) -> None:
resp = self.client.get(
reverse("api-1:similar_learnings", args=[uuid.uuid4()]),
HTTP_AUTHORIZATION=self.auth,
)
self.assertEqual(resp.status_code, 200)
self.assertEqual(resp.json(), [])