Files
headquarter/apps/api/src/services/shared/notification_service.py
T
alex 37ccaa4fdc refactor: organize API routers and services into subpackages
Service organization (19 files moved into 6 subpackages):
- services/instance/ — event_bus, health_monitor, lifecycle_hooks
- services/config/ — config_profile_resolver
- services/git/ — clone, git_operations, git_service
- services/build/ — docker_build, manifest_compiler
- services/terminal/ — terminal_manager, terminal_session
- services/shared/ — correlation, file_service, notification_service,
  permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager

API router organization (16 files moved into 6 subpackages):
- api/tool/ — tool_instances, tool_types, tool_definitions,
  tool_types_validation, sessions (extracted from tool_instances)
- api/config/ — config_profiles, user_config
- api/workspace/ — workspaces, workspace_files, workspace_git,
  workspace_instances
- api/user/ — users, auth, ssh_keys
- api/project/ — projects, git_repositories
- api/system/ — health, events, notifications, dashboard, terminal,
  instance_proxy

Updated main.py imports and all __init__.py re-exports.
Sessions router extracted from tool_instances.py into api/tool/sessions.py.

Quality gates: py_compile passed, ruff passed.
2026-06-04 12:24:14 +02:00

273 lines
8.1 KiB
Python

"""Notification persistence service."""
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import Notification
class NotificationService:
"""Singleton notification persistence service.
All methods filter by user_id to enforce strict ownership isolation.
"""
async def create_notification(
self,
session: AsyncSession,
user_id: uuid.UUID,
*,
category: str,
severity: str,
title: str,
message: str | None = None,
source_type: str | None = None,
source_id: uuid.UUID | None = None,
metadata: dict[str, Any] | None = None,
) -> Notification:
"""Insert a new notification row.
Args:
session: Database session.
user_id: Owner of the notification.
category: Notification category (e.g., instance, system, health).
severity: Severity level (e.g., info, warning, error, success).
title: Short notification title.
message: Optional longer message body.
source_type: Optional source entity type.
source_id: Optional source entity UUID.
metadata: Optional JSON metadata dictionary.
Returns:
The newly created Notification instance.
"""
notification = Notification(
user_id=user_id,
category=category,
severity=severity,
title=title,
message=message,
source_type=source_type,
source_id=source_id,
notification_metadata=metadata or {},
)
session.add(notification)
await session.commit()
await session.refresh(notification)
return notification
async def list_notifications(
self,
session: AsyncSession,
user_id: uuid.UUID,
*,
limit: int = 20,
offset: int = 0,
unread_only: bool = False,
mute_categories: list[str] | None = None,
) -> tuple[list[Notification], int]:
"""Return paginated notifications for a user.
Excludes dismissed notifications and applies optional filtering.
Args:
session: Database session.
user_id: Owner of the notifications.
limit: Maximum number of items to return.
offset: Number of items to skip.
unread_only: If True, only return unread notifications.
mute_categories: Categories to exclude from results.
Returns:
A tuple of (items, total_count).
"""
where_clauses = [
Notification.user_id == user_id,
Notification.dismissed_at.is_(None),
]
if unread_only:
where_clauses.append(Notification.read_at.is_(None))
if mute_categories:
where_clauses.append(Notification.category.not_in(mute_categories))
total_stmt = (
select(func.count()).select_from(Notification).where(*where_clauses)
)
total_result = await session.execute(total_stmt)
total = total_result.scalar_one()
items_stmt = (
select(Notification)
.where(*where_clauses)
.order_by(Notification.created_at.desc())
.limit(limit)
.offset(offset)
)
items_result = await session.execute(items_stmt)
items = list(items_result.scalars().all())
return items, total
async def get_unread_count(
self,
session: AsyncSession,
user_id: uuid.UUID,
) -> int:
"""Count unread, non-dismissed notifications for a user.
Args:
session: Database session.
user_id: Owner of the notifications.
Returns:
Number of unread notifications.
"""
stmt = (
select(func.count())
.select_from(Notification)
.where(
Notification.user_id == user_id,
Notification.read_at.is_(None),
Notification.dismissed_at.is_(None),
)
)
result = await session.execute(stmt)
return result.scalar_one()
async def mark_read(
self,
session: AsyncSession,
notification_id: uuid.UUID,
user_id: uuid.UUID,
) -> Notification:
"""Mark a single notification as read.
Args:
session: Database session.
notification_id: UUID of the notification to mark.
user_id: Owner of the notification.
Returns:
The updated Notification instance.
Raises:
ValueError: If the notification does not exist or is not owned by the user.
"""
notification = await self._get_owned_notification(
session, notification_id, user_id
)
notification.read_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(notification)
return notification
async def mark_all_read(
self,
session: AsyncSession,
user_id: uuid.UUID,
) -> int:
"""Mark all unread notifications as read for a user.
Args:
session: Database session.
user_id: Owner of the notifications.
Returns:
Number of rows updated.
"""
stmt = (
update(Notification)
.where(
Notification.user_id == user_id,
Notification.read_at.is_(None),
Notification.dismissed_at.is_(None),
)
.values(read_at=datetime.now(timezone.utc))
)
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
await session.commit()
return result.rowcount or 0
async def dismiss_all(
self,
session: AsyncSession,
user_id: uuid.UUID,
) -> int:
"""Soft-delete all non-dismissed notifications for a user.
Args:
session: Database session.
user_id: Owner of the notifications.
Returns:
Number of rows updated.
"""
stmt = (
update(Notification)
.where(
Notification.user_id == user_id,
Notification.dismissed_at.is_(None),
)
.values(dismissed_at=datetime.now(timezone.utc))
)
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
await session.commit()
return result.rowcount or 0
async def dismiss(
self,
session: AsyncSession,
notification_id: uuid.UUID,
user_id: uuid.UUID,
) -> None:
"""Soft-delete a notification by setting dismissed_at.
Args:
session: Database session.
notification_id: UUID of the notification to dismiss.
user_id: Owner of the notification.
Raises:
ValueError: If the notification does not exist or is not owned by the user.
"""
notification = await self._get_owned_notification(
session, notification_id, user_id
)
notification.dismissed_at = datetime.now(timezone.utc)
await session.commit()
async def _get_owned_notification(
self,
session: AsyncSession,
notification_id: uuid.UUID,
user_id: uuid.UUID,
) -> Notification:
"""Fetch a notification and verify ownership.
Args:
session: Database session.
notification_id: UUID of the notification.
user_id: Expected owner.
Returns:
The Notification instance.
Raises:
ValueError: If the notification does not exist or is not owned.
"""
notification = await session.get(Notification, notification_id)
if notification is None or notification.user_id != user_id:
raise ValueError("Notification not found")
return notification
# Module-level singleton instance
notification_service = NotificationService()