Files
leocrm/app/core/notifications.py
T
Agent Zero 1baa9481a2
Check Cross-Plugin Imports / check (push) Has been cancelled
feat(B-NOTIF): Notification→Message Konsolidierung — System-Channel, post_system_message(), Migration
B-NOTIF-SYS: CommConversation um is_system Feld erweitert, get_or_create_system_channel()
B-NOTIF-EVT: post_system_message() in kommunikation/services.py — erstellt CommMessage im System-Channel
- create_notification() als deprecated Wrapper delegiert auf post_system_message()
- Text-Block + action_card Block mit Deep-Link, Metadata: notification_type/severity/entity_ref
B-NOTIF-PREF: NotificationPreference als Routing-Konfiguration, stumm/disabled respektiert
B-NOTIF-MIG: Migration 0120 — Notifications → CommMessages migriert, notifications_legacy View
B-NOTIF-DEPREC: Notification-Routes als deprecated markiert, keine Routes entfernt
B-NOTIF-TEST: 19 Tests in test_notification_migration.py — alle grün
- System-Channel, post_system_message, create_notification Wrapper, Preferences, Unread-Badge, Migration Mapping, Sensitive Fields
2026-08-13 21:19:41 +02:00

207 lines
5.8 KiB
Python

"""Notification service — create and manage user notifications.
As of B-NOTIF-EVT, the primary entry point is post_system_message() which posts
to the Communication system channel. create_notification() is retained as a
deprecated backward-compat wrapper that delegates to post_system_message().
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC
from typing import Any
from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.notification import (
Notification,
NotificationPreference,
NotificationType,
)
logger = logging.getLogger(__name__)
# Re-export post_system_message from kommunikation services for convenience
async def post_system_message(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
message_type: str,
title: str,
body: str | None = None,
entity_type: str | None = None,
entity_id: uuid.UUID | None = None,
severity: str = "info",
):
"""Post a typed system message to the tenant system channel.
Delegates to kommunikation.services.post_system_message.
Returns the created CommMessage, or None if the user has muted this type.
"""
from app.plugins.builtins.kommunikation.services import post_system_message as _post
return await _post(
db, tenant_id, user_id, message_type, title, body,
entity_type, entity_id, severity,
)
async def create_notification(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
type: str,
title: str,
body: str | None = None,
entity_type: str | None = None,
entity_id: uuid.UUID | None = None,
) -> Notification | None:
"""Create a new notification for a user if they have not disabled this type.
.. deprecated:: B-NOTIF-EVT
Use post_system_message() instead. This wrapper delegates to
post_system_message() and also creates a legacy Notification record
for backward compatibility with existing routes and frontend.
Returns None if the user has opted out of this notification type.
"""
# Delegate to post_system_message for the comm channel
comm_msg = await post_system_message(
db, tenant_id, user_id, type, title, body,
entity_type, entity_id, severity="info",
)
if comm_msg is None:
# User has muted this type — don't create legacy record either
return None
# Also create legacy Notification record for backward compat
notif = Notification(
tenant_id=tenant_id,
user_id=user_id,
type=type,
title=title,
body=body,
entity_type=entity_type,
entity_id=entity_id,
)
db.add(notif)
await db.flush()
# Publish notification.created event (backward compat)
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
await event_bus.publish("notification.created", {
"notification_id": str(notif.id),
"tenant_id": str(tenant_id),
"user_id": str(user_id),
"type": type,
"title": title,
"entity_type": entity_type,
"entity_id": str(entity_id) if entity_id else None,
"comm_message_id": str(comm_msg.id),
})
return notif
async def list_notifications(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
page: int = 1,
page_size: int = 25,
) -> dict[str, Any]:
"""List notifications for a user, unread first, then by created_at desc."""
offset = (page - 1) * page_size
count_q = (
select(func.count())
.select_from(Notification)
.where(
Notification.tenant_id == tenant_id,
Notification.user_id == user_id,
)
)
total = (await db.execute(count_q)).scalar() or 0
q = (
select(Notification)
.where(
Notification.tenant_id == tenant_id,
Notification.user_id == user_id,
)
.order_by(
Notification.read_at.isnot(None),
Notification.created_at.desc(),
)
.offset(offset)
.limit(page_size)
)
result = await db.execute(q)
items = result.scalars().all()
return {
"items": [_notification_to_dict(n) for n in items],
"total": total,
"page": page,
"page_size": page_size,
}
async def mark_notification_read(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
notification_id: uuid.UUID,
) -> Notification | None:
"""Mark a notification as read."""
from datetime import datetime
q = (
update(Notification)
.where(
Notification.id == notification_id,
Notification.tenant_id == tenant_id,
Notification.user_id == user_id,
)
.values(read_at=datetime.now(UTC))
.returning(Notification)
)
result = await db.execute(q)
row = result.scalar_one_or_none()
return row
async def get_unread_count(
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> int:
"""Get unread notification count for a user."""
q = (
select(func.count())
.select_from(Notification)
.where(
Notification.tenant_id == tenant_id,
Notification.user_id == user_id,
Notification.read_at.is_(None),
)
)
result = await db.execute(q)
return result.scalar() or 0
def _notification_to_dict(n: Notification) -> dict[str, Any]:
"""Convert a notification to a dict."""
return {
"id": str(n.id),
"type": n.type,
"title": n.title,
"body": n.body,
"entity_type": n.entity_type,
"entity_id": str(n.entity_id) if n.entity_id else None,
"read_at": n.read_at.isoformat() if n.read_at else None,
"created_at": n.created_at.isoformat() if n.created_at else None,
}