feat(notifications): added notifications presentation layer

This commit is contained in:
ITQ
2026-02-23 11:47:18 +03:00
parent ca0c456862
commit 3fc5cfb76e
7 changed files with 634 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotificationsApiConfig(AppConfig):
name = "api.v1.notifications"
label = "api_v1_notifications"
@@ -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)
+133
View File
@@ -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
@@ -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)
+18
View File
@@ -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))