From 3fc5cfb76e21f7c4334ee8a78d8301fac669eff7 Mon Sep 17 00:00:00 2001 From: ITQ Date: Mon, 23 Feb 2026 11:47:18 +0300 Subject: [PATCH] feat(notifications): added notifications presentation layer --- src/backend/api/v1/notifications/__init__.py | 0 src/backend/api/v1/notifications/apps.py | 6 + src/backend/api/v1/notifications/endpoints.py | 226 ++++++++++++++++ src/backend/api/v1/notifications/schemas.py | 133 ++++++++++ .../api/v1/notifications/tests/__init__.py | 0 .../tests/test_notifications_api.py | 251 ++++++++++++++++++ src/backend/api/v1/router.py | 18 ++ 7 files changed, 634 insertions(+) create mode 100644 src/backend/api/v1/notifications/__init__.py create mode 100644 src/backend/api/v1/notifications/apps.py create mode 100644 src/backend/api/v1/notifications/endpoints.py create mode 100644 src/backend/api/v1/notifications/schemas.py create mode 100644 src/backend/api/v1/notifications/tests/__init__.py create mode 100644 src/backend/api/v1/notifications/tests/test_notifications_api.py diff --git a/src/backend/api/v1/notifications/__init__.py b/src/backend/api/v1/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/notifications/apps.py b/src/backend/api/v1/notifications/apps.py new file mode 100644 index 0000000..3457394 --- /dev/null +++ b/src/backend/api/v1/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsApiConfig(AppConfig): + name = "api.v1.notifications" + label = "api_v1_notifications" diff --git a/src/backend/api/v1/notifications/endpoints.py b/src/backend/api/v1/notifications/endpoints.py new file mode 100644 index 0000000..92fc498 --- /dev/null +++ b/src/backend/api/v1/notifications/endpoints.py @@ -0,0 +1,226 @@ +from http import HTTPStatus +from uuid import UUID + +from django.http import Http404, HttpRequest +from ninja import Router + +from api.v1.notifications.schemas import ( + ChannelCreateIn, + ChannelOut, + ChannelUpdateIn, + FlushResultOut, + LogOut, + RuleCreateIn, + RuleOut, + RuleUpdateIn, +) +from apps.experiments.models import Experiment +from apps.notifications.models import NotificationRule +from apps.notifications.services import ( + channel_create, + channel_delete, + channel_get, + channel_list, + channel_update, + flush_pending_notifications, + log_list, + rule_create, + rule_delete, + rule_list, + rule_update, +) +from apps.users.auth.bearer import jwt_bearer + +router = Router(tags=["notifications"], auth=jwt_bearer) + + +@router.post( + "/notification-channels", + response={HTTPStatus.CREATED: ChannelOut}, + summary="Create a notification channel", +) +def create_channel( + request: HttpRequest, + payload: ChannelCreateIn, +) -> tuple[int, ChannelOut]: + ch = channel_create( + channel_type=payload.channel_type, + name=payload.name, + config=payload.config, + ) + return HTTPStatus.CREATED, ChannelOut.model_validate(ch) + + +@router.get( + "/notification-channels", + response={HTTPStatus.OK: list[ChannelOut]}, + summary="List notification channels", +) +def list_channels( + request: HttpRequest, +) -> tuple[int, list[ChannelOut]]: + channels = channel_list() + return HTTPStatus.OK, [ChannelOut.model_validate(c) for c in channels] + + +@router.get( + "/notification-channels/{channel_id}", + response={HTTPStatus.OK: ChannelOut}, + summary="Get notification channel", +) +def get_channel( + request: HttpRequest, + channel_id: UUID, +) -> tuple[int, ChannelOut]: + ch = channel_get(channel_id) + if not ch: + raise Http404 + return HTTPStatus.OK, ChannelOut.model_validate(ch) + + +@router.patch( + "/notification-channels/{channel_id}", + response={HTTPStatus.OK: ChannelOut}, + summary="Update notification channel", +) +def update_channel( + request: HttpRequest, + channel_id: UUID, + payload: ChannelUpdateIn, +) -> tuple[int, ChannelOut]: + ch = channel_get(channel_id) + if not ch: + raise Http404 + ch = channel_update( + channel=ch, + **payload.dict(exclude_unset=True), + ) + return HTTPStatus.OK, ChannelOut.model_validate(ch) + + +@router.delete( + "/notification-channels/{channel_id}", + response={HTTPStatus.NO_CONTENT: None}, + summary="Delete notification channel", +) +def delete_channel( + request: HttpRequest, + channel_id: UUID, +) -> tuple[int, None]: + ch = channel_get(channel_id) + if not ch: + raise Http404 + channel_delete(channel=ch) + return HTTPStatus.NO_CONTENT, None + + +@router.post( + "/notification-rules", + response={HTTPStatus.CREATED: RuleOut}, + summary="Create a notification rule", +) +def create_rule( + request: HttpRequest, + payload: RuleCreateIn, +) -> tuple[int, RuleOut]: + ch = channel_get(payload.channel_id) + if not ch: + raise Http404 + + experiment = None + if payload.experiment_id: + try: + experiment = Experiment.objects.get(pk=payload.experiment_id) + except Experiment.DoesNotExist: + raise Http404 from Experiment.DoesNotExist + + r = rule_create( + event_type=payload.event_type, + channel=ch, + experiment=experiment, + ) + r = NotificationRule.objects.select_related("channel", "experiment").get( + pk=r.pk + ) + return HTTPStatus.CREATED, RuleOut.from_rule(r) + + +@router.get( + "/notification-rules", + response={HTTPStatus.OK: list[RuleOut]}, + summary="List notification rules", +) +def list_rules( + request: HttpRequest, + channel_id: UUID | None = None, +) -> tuple[int, list[RuleOut]]: + rules = rule_list(channel_id=channel_id) + return HTTPStatus.OK, [RuleOut.from_rule(r) for r in rules] + + +@router.patch( + "/notification-rules/{rule_id}", + response={HTTPStatus.OK: RuleOut}, + summary="Update notification rule", +) +def update_rule( + request: HttpRequest, + rule_id: UUID, + payload: RuleUpdateIn, +) -> tuple[int, RuleOut]: + try: + r = NotificationRule.objects.select_related( + "channel", "experiment" + ).get(pk=rule_id) + except NotificationRule.DoesNotExist: + raise Http404 from NotificationRule.DoesNotExist + r = rule_update( + rule=r, + **payload.dict(exclude_unset=True), + ) + r = NotificationRule.objects.select_related("channel", "experiment").get( + pk=r.pk + ) + return HTTPStatus.OK, RuleOut.from_rule(r) + + +@router.delete( + "/notification-rules/{rule_id}", + response={HTTPStatus.NO_CONTENT: None}, + summary="Delete notification rule", +) +def delete_rule( + request: HttpRequest, + rule_id: UUID, +) -> tuple[int, None]: + try: + r = NotificationRule.objects.get(pk=rule_id) + except NotificationRule.DoesNotExist: + raise Http404 from NotificationRule.DoesNotExist + rule_delete(rule=r) + return HTTPStatus.NO_CONTENT, None + + +@router.get( + "/notification-logs", + response={HTTPStatus.OK: list[LogOut]}, + summary="List notification logs", +) +def list_logs( + request: HttpRequest, + status: str | None = None, +) -> tuple[int, list[LogOut]]: + logs = log_list(status=status) + return HTTPStatus.OK, [LogOut.from_log(lg) for lg in logs] + + +@router.post( + "/notifications/flush", + response={HTTPStatus.OK: FlushResultOut}, + summary="Manually flush pending notifications", +) +def flush_notifications( + request: HttpRequest, +) -> tuple[int, FlushResultOut]: + results = flush_pending_notifications() + return HTTPStatus.OK, FlushResultOut(**results) diff --git a/src/backend/api/v1/notifications/schemas.py b/src/backend/api/v1/notifications/schemas.py new file mode 100644 index 0000000..4ea4471 --- /dev/null +++ b/src/backend/api/v1/notifications/schemas.py @@ -0,0 +1,133 @@ +from typing import ClassVar +from uuid import UUID + +from ninja import ModelSchema, Schema + +from apps.notifications.models import ( + ChannelType, + NotificationChannel, + NotificationEventType, + NotificationLog, + NotificationRule, +) + + +class ChannelCreateIn(Schema): + channel_type: ChannelType + name: str + config: dict = {} + + +class ChannelUpdateIn(Schema): + name: str | None = None + config: dict | None = None + is_active: bool | None = None + + +class ChannelOut(ModelSchema): + class Meta: + model = NotificationChannel + fields: ClassVar[tuple[str, ...]] = ( + NotificationChannel.id.field.name, + NotificationChannel.channel_type.field.name, + NotificationChannel.name.field.name, + NotificationChannel.config.field.name, + NotificationChannel.is_active.field.name, + NotificationChannel.created_at.field.name, + NotificationChannel.updated_at.field.name, + ) + + +class RuleCreateIn(Schema): + event_type: NotificationEventType + channel_id: UUID + experiment_id: UUID | None = None + + +class RuleUpdateIn(Schema): + event_type: NotificationEventType | None = None + is_active: bool | None = None + + +class ChannelBriefOut(Schema): + id: UUID + name: str + channel_type: str + + +class ExperimentBriefOut(Schema): + id: UUID + name: str + + +class RuleOut(ModelSchema): + channel: ChannelBriefOut + experiment: ExperimentBriefOut | None = None + + class Meta: + model = NotificationRule + fields: ClassVar[tuple[str, ...]] = ( + NotificationRule.id.field.name, + NotificationRule.event_type.field.name, + NotificationRule.is_active.field.name, + NotificationRule.created_at.field.name, + NotificationRule.updated_at.field.name, + ) + + @classmethod + def from_rule(cls, r: NotificationRule) -> "RuleOut": + experiment_brief = None + if r.experiment: + experiment_brief = ExperimentBriefOut( + id=r.experiment.pk, + name=r.experiment.name, + ) + return cls( + id=r.pk, + event_type=r.event_type, + channel=ChannelBriefOut( + id=r.channel.pk, + name=r.channel.name, + channel_type=r.channel.channel_type, + ), + experiment=experiment_brief, + is_active=r.is_active, + created_at=r.created_at, + updated_at=r.updated_at, + ) + + +class LogOut(ModelSchema): + channel_name: str | None = None + + class Meta: + model = NotificationLog + fields: ClassVar[tuple[str, ...]] = ( + NotificationLog.id.field.name, + NotificationLog.event_type.field.name, + NotificationLog.event_key.field.name, + NotificationLog.payload.field.name, + NotificationLog.status.field.name, + NotificationLog.error.field.name, + NotificationLog.created_at.field.name, + NotificationLog.sent_at.field.name, + ) + + @classmethod + def from_log(cls, log: NotificationLog) -> "LogOut": + return cls( + id=log.pk, + event_type=log.event_type, + event_key=log.event_key, + payload=log.payload, + status=log.status, + error=log.error, + created_at=log.created_at, + sent_at=log.sent_at, + channel_name=log.channel.name if log.channel else None, + ) + + +class FlushResultOut(Schema): + sent: int + failed: int diff --git a/src/backend/api/v1/notifications/tests/__init__.py b/src/backend/api/v1/notifications/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/api/v1/notifications/tests/test_notifications_api.py b/src/backend/api/v1/notifications/tests/test_notifications_api.py new file mode 100644 index 0000000..6179ad8 --- /dev/null +++ b/src/backend/api/v1/notifications/tests/test_notifications_api.py @@ -0,0 +1,251 @@ +import json +import uuid +from typing import override + +from django.test import Client, TestCase +from django.urls import reverse + +from apps.notifications.models import ( + ChannelType, + NotificationEventType, + NotificationLog, + NotificationStatus, +) +from apps.notifications.services import channel_create, rule_create +from apps.reviews.tests.helpers import make_admin +from apps.users.tests.helpers import auth_header + + +class ChannelAPITest(TestCase): + @override + def setUp(self) -> None: + self.client = Client() + self.admin = make_admin("_nch") + self.auth = auth_header(self.admin) + + def _create_channel(self, **overrides) -> dict: + payload = { + "channel_type": ChannelType.TELEGRAM, + "name": "Test Channel", + "config": {"bot_token": "tok", "chat_id": "-100"}, + } + payload.update(overrides) + return self.client.post( + reverse("api-1:create_channel"), + data=json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=self.auth, + ) + + def test_create_channel(self) -> None: + resp = self._create_channel() + self.assertEqual(resp.status_code, 201) + data = resp.json() + self.assertEqual(data["channel_type"], ChannelType.TELEGRAM) + self.assertEqual(data["name"], "Test Channel") + self.assertTrue(data["is_active"]) + + def test_create_smtp_channel(self) -> None: + resp = self._create_channel( + channel_type=ChannelType.SMTP, + name="Email", + config={"recipient": "a@b.com"}, + ) + self.assertEqual(resp.status_code, 201) + self.assertEqual(resp.json()["channel_type"], ChannelType.SMTP) + + def test_list_channels(self) -> None: + self._create_channel() + resp = self.client.get( + reverse("api-1:list_channels"), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.json()), 1) + + def test_get_channel(self) -> None: + create_resp = self._create_channel() + ch_id = create_resp.json()["id"] + resp = self.client.get( + reverse("api-1:get_channel", args=[ch_id]), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["id"], ch_id) + + def test_get_channel_not_found(self) -> None: + resp = self.client.get( + reverse("api-1:get_channel", args=[uuid.uuid4()]), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 404) + + def test_update_channel(self) -> None: + create_resp = self._create_channel() + ch_id = create_resp.json()["id"] + resp = self.client.patch( + reverse("api-1:update_channel", args=[ch_id]), + data=json.dumps({"name": "Updated"}), + content_type="application/json", + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json()["name"], "Updated") + + def test_delete_channel(self) -> None: + create_resp = self._create_channel() + ch_id = create_resp.json()["id"] + resp = self.client.delete( + reverse("api-1:delete_channel", args=[ch_id]), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 204) + get_resp = self.client.get( + reverse("api-1:get_channel", args=[ch_id]), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(get_resp.status_code, 404) + + def test_delete_channel_not_found(self) -> None: + resp = self.client.delete( + reverse("api-1:delete_channel", args=[uuid.uuid4()]), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 404) + + +class RuleAPITest(TestCase): + @override + def setUp(self) -> None: + self.client = Client() + self.admin = make_admin("_nrul") + self.auth = auth_header(self.admin) + self.channel = channel_create( + channel_type=ChannelType.TELEGRAM, + name="Rule Test Channel", + config={"bot_token": "tok", "chat_id": "-100"}, + ) + + def _create_rule(self, **overrides) -> dict: + payload = { + "event_type": NotificationEventType.GUARDRAIL_TRIGGERED, + "channel_id": str(self.channel.pk), + } + payload.update(overrides) + return self.client.post( + reverse("api-1:create_rule"), + data=json.dumps(payload), + content_type="application/json", + HTTP_AUTHORIZATION=self.auth, + ) + + def test_create_rule(self) -> None: + resp = self._create_rule() + self.assertEqual(resp.status_code, 201) + data = resp.json() + self.assertEqual( + data["event_type"], NotificationEventType.GUARDRAIL_TRIGGERED + ) + self.assertEqual(data["channel"]["id"], str(self.channel.pk)) + self.assertIsNone(data["experiment"]) + + def test_create_rule_nonexistent_channel(self) -> None: + resp = self._create_rule(channel_id=str(uuid.uuid4())) + self.assertEqual(resp.status_code, 404) + + def test_list_rules(self) -> None: + self._create_rule() + resp = self.client.get( + reverse("api-1:list_rules"), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.json()), 1) + + def test_update_rule(self) -> None: + create_resp = self._create_rule() + rule_id = create_resp.json()["id"] + resp = self.client.patch( + reverse("api-1:update_rule", args=[rule_id]), + data=json.dumps({"is_active": False}), + content_type="application/json", + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertFalse(resp.json()["is_active"]) + + def test_update_rule_not_found(self) -> None: + resp = self.client.patch( + reverse("api-1:update_rule", args=[uuid.uuid4()]), + data=json.dumps({"is_active": False}), + content_type="application/json", + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 404) + + def test_delete_rule(self) -> None: + create_resp = self._create_rule() + rule_id = create_resp.json()["id"] + resp = self.client.delete( + reverse("api-1:delete_rule", args=[rule_id]), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 204) + + def test_delete_rule_not_found(self) -> None: + resp = self.client.delete( + reverse("api-1:delete_rule", args=[uuid.uuid4()]), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 404) + + +class LogAndFlushAPITest(TestCase): + @override + def setUp(self) -> None: + self.client = Client() + self.admin = make_admin("_nlog") + self.auth = auth_header(self.admin) + self.channel = channel_create( + channel_type=ChannelType.TELEGRAM, + name="Log Channel", + config={"bot_token": "tok", "chat_id": "-100"}, + ) + + def test_list_logs_empty(self) -> None: + resp = self.client.get( + reverse("api-1:list_logs"), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), []) + + def test_list_logs_with_data(self) -> None: + rule = rule_create( + event_type=NotificationEventType.EXPERIMENT_STARTED, + channel=self.channel, + ) + NotificationLog.objects.create( + rule=rule, + channel=self.channel, + event_type=NotificationEventType.EXPERIMENT_STARTED, + event_key="test:1", + payload={"title": "Test"}, + status=NotificationStatus.SENT, + ) + resp = self.client.get( + reverse("api-1:list_logs"), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.json()), 1) + + def test_flush_no_pending(self) -> None: + resp = self.client.post( + reverse("api-1:flush_notifications"), + HTTP_AUTHORIZATION=self.auth, + ) + self.assertEqual(resp.status_code, 200) + data = resp.json() + self.assertEqual(data["sent"], 0) + self.assertEqual(data["failed"], 0) diff --git a/src/backend/api/v1/router.py b/src/backend/api/v1/router.py index f150d7d..605aebe 100644 --- a/src/backend/api/v1/router.py +++ b/src/backend/api/v1/router.py @@ -9,12 +9,15 @@ from ninja.renderers import BaseRenderer from api.v1 import handlers from api.v1.auth.endpoints import router as auth_router +from api.v1.conflicts.endpoints import router as conflicts_router from api.v1.decision.endpoints import router as decision_router from api.v1.events.endpoints import router as events_router from api.v1.experiments.endpoints import router as experiments_router from api.v1.flags.endpoints import router as flags_router from api.v1.guardrails.endpoints import router as guardrails_router +from api.v1.learnings.endpoints import router as learnings_router from api.v1.metrics.endpoints import router as metrics_router +from api.v1.notifications.endpoints import router as notifications_router from api.v1.reports.endpoints import router as reports_router from api.v1.reviews.endpoints import router as reviews_router from api.v1.users.endpoints import router as users_router @@ -96,5 +99,20 @@ router.add_router( guardrails_router, ) +router.add_router( + "", + notifications_router, +) + +router.add_router( + "", + learnings_router, +) + +router.add_router( + "conflicts", + conflicts_router, +) + for exception, handler in handlers.exception_handlers: router.add_exception_handler(exception, partial(handler, router=router))