feat(phase-18): add in-app notification system (T-128, T-129)
This commit is contained in:
46
backend/alembic/versions/b006_add_notifications_table.py
Normal file
46
backend/alembic/versions/b006_add_notifications_table.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""add_notifications_table
|
||||
|
||||
Revision ID: b006notifications
|
||||
Revises: b005v2indexes
|
||||
Create Date: 2026-02-09 11:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b006notifications'
|
||||
down_revision: Union[str, Sequence[str], None] = 'b005v2indexes'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create notifications table."""
|
||||
op.create_table(
|
||||
'notifications',
|
||||
sa.Column('id', UUID(as_uuid=True), primary_key=True, server_default=sa.text('gen_random_uuid()')),
|
||||
sa.Column('user_id', UUID(as_uuid=True), sa.ForeignKey('users.id'), nullable=False),
|
||||
sa.Column('type', sa.String(), nullable=False),
|
||||
sa.Column('title', sa.String(), nullable=False),
|
||||
sa.Column('message', sa.Text(), nullable=True),
|
||||
sa.Column('entity_type', sa.String(), nullable=True),
|
||||
sa.Column('entity_id', UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('read', sa.Boolean(), server_default='false'),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index('ix_notifications_user_id', 'notifications', ['user_id'])
|
||||
op.create_index('ix_notifications_read', 'notifications', ['read'])
|
||||
op.create_index('ix_notifications_created_at', 'notifications', ['created_at'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop notifications table."""
|
||||
op.drop_index('ix_notifications_created_at', table_name='notifications')
|
||||
op.drop_index('ix_notifications_read', table_name='notifications')
|
||||
op.drop_index('ix_notifications_user_id', table_name='notifications')
|
||||
op.drop_table('notifications')
|
||||
@@ -17,6 +17,7 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from app.database import SessionLocal
|
||||
from app.services.mitre_sync_service import sync_mitre
|
||||
from app.services.intel_service import scan_intel
|
||||
from app.services.notification_service import cleanup_old_notifications
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,6 +46,19 @@ def _run_mitre_sync() -> None:
|
||||
db.close()
|
||||
|
||||
|
||||
def _run_notification_cleanup() -> None:
|
||||
"""Clean up old read notifications."""
|
||||
logger.info("Scheduled notification cleanup job starting...")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
deleted = cleanup_old_notifications(db, days=90)
|
||||
logger.info("Notification cleanup finished — deleted %d old notifications", deleted)
|
||||
except Exception:
|
||||
logger.exception("Notification cleanup job failed")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _run_intel_scan() -> None:
|
||||
"""Execute an intel scan inside its own DB session."""
|
||||
logger.info("Scheduled intel scan job starting...")
|
||||
@@ -89,5 +103,13 @@ def start_scheduler() -> None:
|
||||
name="Intel scan (every 7d)",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.add_job(
|
||||
_run_notification_cleanup,
|
||||
trigger="interval",
|
||||
hours=24,
|
||||
id="notification_cleanup",
|
||||
name="Notification cleanup (daily)",
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
logger.info("Background scheduler started — mitre_sync (24h), intel_scan (7d)")
|
||||
logger.info("Background scheduler started — mitre_sync (24h), intel_scan (7d), notification_cleanup (24h)")
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.routers import system as system_router
|
||||
from app.routers import metrics as metrics_router
|
||||
from app.routers import users as users_router
|
||||
from app.routers import audit as audit_router
|
||||
from app.routers import notifications as notifications_router
|
||||
from app.storage import ensure_bucket_exists
|
||||
from app.jobs.mitre_sync_job import start_scheduler, scheduler
|
||||
|
||||
@@ -56,6 +57,7 @@ app.include_router(system_router.router, prefix="/api/v1")
|
||||
app.include_router(metrics_router.router, prefix="/api/v1")
|
||||
app.include_router(users_router.router, prefix="/api/v1")
|
||||
app.include_router(audit_router.router, prefix="/api/v1")
|
||||
app.include_router(notifications_router.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -6,10 +6,11 @@ from app.models.test_template import TestTemplate
|
||||
from app.models.evidence import Evidence
|
||||
from app.models.intel import IntelItem
|
||||
from app.models.audit import AuditLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
|
||||
|
||||
__all__ = [
|
||||
"User", "Technique", "Test", "TestTemplate", "Evidence",
|
||||
"IntelItem", "AuditLog",
|
||||
"IntelItem", "AuditLog", "Notification",
|
||||
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
||||
]
|
||||
|
||||
39
backend/app/models/notification.py
Normal file
39
backend/app/models/notification.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Notification model — in-app notifications for user actions."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
"""
|
||||
In-app notification for alerting users when they need to act.
|
||||
|
||||
Types include: test_assigned, validation_needed, test_rejected,
|
||||
test_validated, test_state_changed, etc.
|
||||
"""
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||
type = Column(String, nullable=False)
|
||||
title = Column(String, nullable=False)
|
||||
message = Column(Text, nullable=True)
|
||||
entity_type = Column(String, nullable=True)
|
||||
entity_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
read = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User")
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_notifications_user_id", "user_id"),
|
||||
Index("ix_notifications_read", "read"),
|
||||
Index("ix_notifications_created_at", "created_at"),
|
||||
)
|
||||
103
backend/app/routers/notifications.py
Normal file
103
backend/app/routers/notifications.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Notification endpoints.
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
GET /notifications — list user notifications (paginated)
|
||||
GET /notifications/unread-count — count of unread notifications
|
||||
PATCH /notifications/{id}/read — mark one notification as read
|
||||
POST /notifications/read-all — mark all as read
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.models.notification import Notification
|
||||
from app.models.user import User
|
||||
from app.schemas.notification import NotificationOut, UnreadCountOut
|
||||
from app.services.notification_service import (
|
||||
mark_as_read,
|
||||
mark_all_as_read,
|
||||
get_unread_count,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /notifications — list (paginated)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("", response_model=list[NotificationOut])
|
||||
def list_notifications(
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return paginated notifications for the current user, newest first."""
|
||||
notifs = (
|
||||
db.query(Notification)
|
||||
.filter(Notification.user_id == current_user.id)
|
||||
.order_by(Notification.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return notifs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /notifications/unread-count
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountOut)
|
||||
def unread_count(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Return the number of unread notifications for the current user."""
|
||||
count = get_unread_count(db, current_user.id)
|
||||
return UnreadCountOut(unread_count=count)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /notifications/{id}/read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=NotificationOut)
|
||||
def read_notification(
|
||||
notification_id: uuid.UUID,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark a single notification as read."""
|
||||
success = mark_as_read(db, notification_id, current_user.id)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Notification not found",
|
||||
)
|
||||
notif = db.query(Notification).filter(Notification.id == notification_id).first()
|
||||
return notif
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /notifications/read-all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
def read_all_notifications(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Mark all notifications for the current user as read."""
|
||||
count = mark_all_as_read(db, current_user.id)
|
||||
return {"detail": f"Marked {count} notifications as read"}
|
||||
28
backend/app/schemas/notification.py
Normal file
28
backend/app/schemas/notification.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Pydantic schemas for Notification endpoints."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class NotificationOut(BaseModel):
|
||||
"""Notification returned by the API."""
|
||||
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
type: str
|
||||
title: str
|
||||
message: str | None = None
|
||||
entity_type: str | None = None
|
||||
entity_id: uuid.UUID | None = None
|
||||
read: bool = False
|
||||
created_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class UnreadCountOut(BaseModel):
|
||||
"""Simple counter response."""
|
||||
|
||||
unread_count: int
|
||||
179
backend/app/services/notification_service.py
Normal file
179
backend/app/services/notification_service.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Notification service — create, read, and manage in-app notifications.
|
||||
|
||||
Provides helpers for generating notifications automatically when test
|
||||
state changes occur, plus CRUD for the notifications API.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from app.models.notification import Notification
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_notification(
|
||||
db: Session,
|
||||
user_id: uuid.UUID,
|
||||
type: str,
|
||||
title: str,
|
||||
message: str | None = None,
|
||||
entity_type: str | None = None,
|
||||
entity_id: uuid.UUID | None = None,
|
||||
) -> Notification:
|
||||
"""Create a single notification for a user."""
|
||||
notif = Notification(
|
||||
user_id=user_id,
|
||||
type=type,
|
||||
title=title,
|
||||
message=message,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
)
|
||||
db.add(notif)
|
||||
db.commit()
|
||||
db.refresh(notif)
|
||||
return notif
|
||||
|
||||
|
||||
def mark_as_read(db: Session, notification_id: uuid.UUID, user_id: uuid.UUID) -> bool:
|
||||
"""Mark a single notification as read. Returns True if updated."""
|
||||
notif = (
|
||||
db.query(Notification)
|
||||
.filter(Notification.id == notification_id, Notification.user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
if notif is None:
|
||||
return False
|
||||
notif.read = True
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
def mark_all_as_read(db: Session, user_id: uuid.UUID) -> int:
|
||||
"""Mark all unread notifications for a user as read. Returns count updated."""
|
||||
count = (
|
||||
db.query(Notification)
|
||||
.filter(Notification.user_id == user_id, Notification.read == False) # noqa: E712
|
||||
.update({"read": True})
|
||||
)
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
|
||||
def get_unread_count(db: Session, user_id: uuid.UUID) -> int:
|
||||
"""Return the number of unread notifications for a user."""
|
||||
return (
|
||||
db.query(func.count(Notification.id))
|
||||
.filter(Notification.user_id == user_id, Notification.read == False) # noqa: E712
|
||||
.scalar()
|
||||
) or 0
|
||||
|
||||
|
||||
def cleanup_old_notifications(db: Session, days: int = 90) -> int:
|
||||
"""Delete read notifications older than *days*. Returns count deleted."""
|
||||
cutoff = datetime.utcnow() - timedelta(days=days)
|
||||
count = (
|
||||
db.query(Notification)
|
||||
.filter(
|
||||
Notification.read == True, # noqa: E712
|
||||
Notification.created_at < cutoff,
|
||||
)
|
||||
.delete()
|
||||
)
|
||||
db.commit()
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Automatic notification dispatchers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def notify_test_state_change(db: Session, test, new_state: str) -> None:
|
||||
"""Dispatch notifications based on a test's new state.
|
||||
|
||||
Called by the workflow service after each state transition.
|
||||
|
||||
Rules:
|
||||
- red_executing -> notify creator (confirmation)
|
||||
- blue_evaluating -> notify all blue_tech users
|
||||
- in_review -> notify red_lead and blue_lead users
|
||||
- rejected -> notify creator
|
||||
- validated -> notify creator
|
||||
"""
|
||||
test_name = test.name
|
||||
test_id = test.id
|
||||
creator_id = test.created_by
|
||||
|
||||
if new_state == "red_executing" and creator_id:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=creator_id,
|
||||
type="test_state_changed",
|
||||
title="Test execution started",
|
||||
message=f'Your test "{test_name}" has moved to execution phase.',
|
||||
entity_type="test",
|
||||
entity_id=test_id,
|
||||
)
|
||||
|
||||
elif new_state == "blue_evaluating":
|
||||
# Notify all blue_tech users
|
||||
blue_users = db.query(User).filter(User.role == "blue_tech", User.is_active == True).all() # noqa: E712
|
||||
for user in blue_users:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=user.id,
|
||||
type="test_assigned",
|
||||
title="New test ready for blue evaluation",
|
||||
message=f'Test "{test_name}" needs blue team evaluation.',
|
||||
entity_type="test",
|
||||
entity_id=test_id,
|
||||
)
|
||||
|
||||
elif new_state == "in_review":
|
||||
# Notify red_lead and blue_lead users
|
||||
managers = (
|
||||
db.query(User)
|
||||
.filter(User.role.in_(["red_lead", "blue_lead"]), User.is_active == True) # noqa: E712
|
||||
.all()
|
||||
)
|
||||
for user in managers:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=user.id,
|
||||
type="validation_needed",
|
||||
title="Test ready for validation",
|
||||
message=f'Test "{test_name}" is awaiting your review.',
|
||||
entity_type="test",
|
||||
entity_id=test_id,
|
||||
)
|
||||
|
||||
elif new_state == "rejected" and creator_id:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=creator_id,
|
||||
type="test_rejected",
|
||||
title="Test rejected",
|
||||
message=f'Your test "{test_name}" has been rejected. Please review and resubmit.',
|
||||
entity_type="test",
|
||||
entity_id=test_id,
|
||||
)
|
||||
|
||||
elif new_state == "validated" and creator_id:
|
||||
create_notification(
|
||||
db,
|
||||
user_id=creator_id,
|
||||
type="test_validated",
|
||||
title="Test validated",
|
||||
message=f'Your test "{test_name}" has been validated successfully.',
|
||||
entity_type="test",
|
||||
entity_id=test_id,
|
||||
)
|
||||
@@ -20,6 +20,7 @@ from app.models.enums import TestState
|
||||
from app.models.test import Test
|
||||
from app.models.user import User
|
||||
from app.services.audit_service import log_action
|
||||
from app.services.notification_service import notify_test_state_change
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Valid transition map
|
||||
@@ -91,6 +92,12 @@ def transition_state(
|
||||
details=details,
|
||||
)
|
||||
|
||||
# Dispatch in-app notifications for the new state
|
||||
try:
|
||||
notify_test_state_change(db, test, target_state.value)
|
||||
except Exception:
|
||||
pass # Notifications are best-effort — don't block the workflow
|
||||
|
||||
return test
|
||||
|
||||
|
||||
@@ -250,9 +257,17 @@ def check_dual_validation(db: Session, test: Test) -> Test:
|
||||
if red_status == "rejected" or blue_status == "rejected":
|
||||
test.state = TestState.rejected
|
||||
db.commit()
|
||||
try:
|
||||
notify_test_state_change(db, test, "rejected")
|
||||
except Exception:
|
||||
pass
|
||||
elif red_status == "approved" and blue_status == "approved":
|
||||
test.state = TestState.validated
|
||||
db.commit()
|
||||
try:
|
||||
notify_test_state_change(db, test, "validated")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# One side hasn't voted yet — stay in_review, just flush
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user