diff --git a/src/backend/api/v1/learnings/__init__.py b/src/backend/api/v1/learnings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/learnings/apps.py b/src/backend/api/v1/learnings/apps.py new file mode 100644 index 0000000..bceb0a8 --- /dev/null +++ b/src/backend/api/v1/learnings/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LearningsApiConfig(AppConfig): + name = "api.v1.learnings" + label = "api_v1_learnings" diff --git a/src/backend/api/v1/learnings/endpoints.py b/src/backend/api/v1/learnings/endpoints.py new file mode 100644 index 0000000..c3f25f8 --- /dev/null +++ b/src/backend/api/v1/learnings/endpoints.py @@ -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] diff --git a/src/backend/api/v1/learnings/schemas.py b/src/backend/api/v1/learnings/schemas.py new file mode 100644 index 0000000..c1df152 --- /dev/null +++ b/src/backend/api/v1/learnings/schemas.py @@ -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 diff --git a/src/backend/api/v1/learnings/tests/__init__.py b/src/backend/api/v1/learnings/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/learnings/tests/test_learnings_api.py b/src/backend/api/v1/learnings/tests/test_learnings_api.py new file mode 100644 index 0000000..2359533 --- /dev/null +++ b/src/backend/api/v1/learnings/tests/test_learnings_api.py @@ -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(), [])