feat(learnings): added learnings presentation logic
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class LearningsApiConfig(AppConfig):
|
||||||
|
name = "api.v1.learnings"
|
||||||
|
label = "api_v1_learnings"
|
||||||
@@ -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]
|
||||||
@@ -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(), [])
|
||||||
Reference in New Issue
Block a user