feat: notification center backend core (PR-1)
- Add notifications table with Alembic migration
- Notification model with user-scoped indexing and partial index on unread
- NotificationService singleton with create/list/count/mark-read/dismiss
- FastAPI router: GET /notifications, GET /unread, PATCH /{id}/read,
POST /mark-all-read, DELETE /{id}
- Mute categories filtering from UserConfig
- 13 unit tests for NotificationService
- 10 integration tests for API endpoints
- Updated test_models.py with new table registration
Quality gates: pytest 23 new passed, ruff clean
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.events import router as events_router
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.users import router as users_router
|
||||
|
||||
__all__ = ["auth_router", "events_router", "users_router"]
|
||||
__all__ = ["auth_router", "events_router", "notifications_router", "users_router"]
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Notification API endpoints."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.auth.dependencies import get_current_user, get_db_session
|
||||
from src.models.user import User
|
||||
from src.models.user_config import UserConfig
|
||||
from src.services.notification_service import notification_service
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
class NotificationItem(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
category: str
|
||||
severity: str
|
||||
title: str
|
||||
message: str | None
|
||||
source_type: str | None
|
||||
source_id: uuid.UUID | None
|
||||
notification_metadata: dict = Field(serialization_alias="metadata")
|
||||
read_at: datetime | None
|
||||
dismissed_at: datetime | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
items: list[NotificationItem]
|
||||
total: int
|
||||
limit: int
|
||||
offset: int
|
||||
|
||||
|
||||
class UnreadCountResponse(BaseModel):
|
||||
count: int
|
||||
|
||||
|
||||
class MarkAllReadResponse(BaseModel):
|
||||
marked_count: int
|
||||
|
||||
|
||||
async def _get_mute_categories(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[str]:
|
||||
"""Read notification mute categories from user config."""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(UserConfig).where(UserConfig.user_id == user_id)
|
||||
)
|
||||
config = result.scalar_one_or_none()
|
||||
if config is None:
|
||||
return []
|
||||
mute_categories = config.config.get("notification_mute_categories", [])
|
||||
if isinstance(mute_categories, list):
|
||||
return mute_categories
|
||||
return []
|
||||
|
||||
|
||||
@router.get("", response_model=NotificationListResponse)
|
||||
async def list_notifications(
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
unread_only: bool = Query(False),
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> NotificationListResponse:
|
||||
"""List notifications for the authenticated user."""
|
||||
mute_categories = await _get_mute_categories(session, user.id)
|
||||
items, total = await notification_service.list_notifications(
|
||||
session,
|
||||
user.id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
unread_only=unread_only,
|
||||
mute_categories=mute_categories,
|
||||
)
|
||||
return NotificationListResponse(
|
||||
items=[NotificationItem.model_validate(item) for item in items],
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread", response_model=UnreadCountResponse)
|
||||
async def get_unread_count(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> UnreadCountResponse:
|
||||
"""Get unread notification count for the authenticated user."""
|
||||
count = await notification_service.get_unread_count(session, user.id)
|
||||
return UnreadCountResponse(count=count)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=NotificationItem)
|
||||
async def mark_notification_read(
|
||||
notification_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> NotificationItem:
|
||||
"""Mark a single notification as read."""
|
||||
try:
|
||||
notification = await notification_service.mark_read(
|
||||
session, notification_id, user.id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found",
|
||||
) from exc
|
||||
return NotificationItem.model_validate(notification)
|
||||
|
||||
|
||||
@router.post("/mark-all-read", response_model=MarkAllReadResponse)
|
||||
async def mark_all_read(
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> MarkAllReadResponse:
|
||||
"""Mark all unread notifications as read."""
|
||||
marked = await notification_service.mark_all_read(session, user.id)
|
||||
return MarkAllReadResponse(marked_count=marked)
|
||||
|
||||
|
||||
@router.delete("/{notification_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def dismiss_notification(
|
||||
notification_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_db_session),
|
||||
) -> None:
|
||||
"""Soft-delete (dismiss) a notification."""
|
||||
try:
|
||||
await notification_service.dismiss(session, notification_id, user.id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found",
|
||||
) from exc
|
||||
@@ -21,9 +21,11 @@ from src.api.tool_definitions import router as tool_definitions_router
|
||||
from src.api.tool_instances import router as tool_instances_router
|
||||
from src.api.tool_instances import sessions_router
|
||||
from src.api.tool_types import router as tool_types_router
|
||||
from src.api.notifications import router as notifications_router
|
||||
from src.api.user_config import router as user_config_router
|
||||
from src.api.users import router as users_router
|
||||
from src.config import Settings
|
||||
from src.models.notification import Notification # noqa: F401 – Alembic model discovery
|
||||
from src.models.terminal_session import TerminalSessionModel # noqa: F401 – Alembic model discovery
|
||||
from src.database import init_database
|
||||
from src.logging_config import (
|
||||
@@ -156,4 +158,5 @@ app.include_router(sessions_router)
|
||||
app.include_router(instance_proxy_router)
|
||||
app.include_router(terminal_router)
|
||||
app.include_router(events_router)
|
||||
app.include_router(notifications_router)
|
||||
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
|
||||
|
||||
@@ -3,6 +3,7 @@ from src.models.config_profile import ConfigProfile, ConfigProfileInclude
|
||||
from src.models.git_repository import GitRepository
|
||||
from src.models.health_check import HealthCheck
|
||||
from src.models.instance_event import InstanceEvent
|
||||
from src.models.notification import Notification
|
||||
from src.models.project import Project
|
||||
from src.models.ssh_key import SSHKey
|
||||
from src.models.terminal_session import TerminalSessionModel
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"GitRepository",
|
||||
"HealthCheck",
|
||||
"InstanceEvent",
|
||||
"Notification",
|
||||
"Project",
|
||||
"SSHKey",
|
||||
"TerminalSessionModel",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Notification SQLAlchemy model."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, JSON, String, Text
|
||||
from sqlalchemy import Uuid as UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from src.models.base import Base, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class Notification(UUIDPrimaryKeyMixin, Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
category: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
severity: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
source_type: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), nullable=True
|
||||
)
|
||||
notification_metadata: Mapped[dict[str, Any]] = mapped_column(
|
||||
"metadata", JSON, nullable=False, default=dict
|
||||
)
|
||||
read_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
dismissed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""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.notification 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(
|
||||
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