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.
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user