feat(incidents): add incident lifecycle api and tests
This commit is contained in:
219
app/services/incident.py
Normal file
219
app/services/incident.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""Incident service implementing incident lifecycle operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import asyncpg
|
||||
from asyncpg.pool import PoolConnectionProxy
|
||||
|
||||
from app.api.deps import CurrentUser, ensure_org_access
|
||||
from app.core import exceptions as exc
|
||||
from app.db import Database, db
|
||||
from app.repositories import IncidentRepository, ServiceRepository
|
||||
from app.schemas.common import PaginatedResponse
|
||||
from app.schemas.incident import (
|
||||
CommentRequest,
|
||||
IncidentCreate,
|
||||
IncidentEventResponse,
|
||||
IncidentResponse,
|
||||
TransitionRequest,
|
||||
)
|
||||
|
||||
|
||||
_ALLOWED_TRANSITIONS: dict[str, set[str]] = {
|
||||
"triggered": {"acknowledged"},
|
||||
"acknowledged": {"mitigated"},
|
||||
"mitigated": {"resolved"},
|
||||
"resolved": set(),
|
||||
}
|
||||
|
||||
|
||||
def _as_conn(conn: asyncpg.Connection | PoolConnectionProxy) -> asyncpg.Connection:
|
||||
"""Helper to satisfy typing when a pool proxy is returned."""
|
||||
|
||||
return cast(asyncpg.Connection, conn)
|
||||
|
||||
|
||||
class IncidentService:
|
||||
"""Encapsulates incident lifecycle operations within an org context."""
|
||||
|
||||
def __init__(self, database: Database | None = None) -> None:
|
||||
self.db = database or db
|
||||
|
||||
async def create_incident(
|
||||
self,
|
||||
current_user: CurrentUser,
|
||||
service_id: UUID,
|
||||
data: IncidentCreate,
|
||||
) -> IncidentResponse:
|
||||
"""Create an incident for a service in the active org and record the creation event."""
|
||||
|
||||
async with self.db.transaction() as conn:
|
||||
db_conn = _as_conn(conn)
|
||||
service_repo = ServiceRepository(db_conn)
|
||||
incident_repo = IncidentRepository(db_conn)
|
||||
|
||||
service = await service_repo.get_by_id(service_id)
|
||||
if service is None:
|
||||
raise exc.NotFoundError("Service not found")
|
||||
ensure_org_access(service["org_id"], current_user)
|
||||
|
||||
incident_id = uuid4()
|
||||
incident = await incident_repo.create(
|
||||
incident_id=incident_id,
|
||||
org_id=current_user.org_id,
|
||||
service_id=service_id,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
severity=data.severity,
|
||||
)
|
||||
|
||||
await incident_repo.add_event(
|
||||
uuid4(),
|
||||
incident_id,
|
||||
"created",
|
||||
actor_user_id=current_user.user_id,
|
||||
payload={
|
||||
"title": data.title,
|
||||
"severity": data.severity,
|
||||
"description": data.description,
|
||||
},
|
||||
)
|
||||
|
||||
return IncidentResponse(**incident)
|
||||
|
||||
async def get_incidents(
|
||||
self,
|
||||
current_user: CurrentUser,
|
||||
*,
|
||||
status: str | None = None,
|
||||
cursor: datetime | None = None,
|
||||
limit: int = 20,
|
||||
) -> PaginatedResponse[IncidentResponse]:
|
||||
"""Return paginated incidents for the active organization."""
|
||||
|
||||
async with self.db.connection() as conn:
|
||||
incident_repo = IncidentRepository(_as_conn(conn))
|
||||
rows = await incident_repo.get_by_org(
|
||||
org_id=current_user.org_id,
|
||||
status=status,
|
||||
cursor=cursor,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
next_cursor = items[-1]["created_at"].isoformat() if has_more and items else None
|
||||
|
||||
incidents = [IncidentResponse(**row) for row in items]
|
||||
return PaginatedResponse[IncidentResponse](
|
||||
items=incidents,
|
||||
next_cursor=next_cursor,
|
||||
has_more=has_more,
|
||||
)
|
||||
|
||||
async def get_incident(self, current_user: CurrentUser, incident_id: UUID) -> IncidentResponse:
|
||||
"""Return a single incident, ensuring it belongs to the active org."""
|
||||
|
||||
async with self.db.connection() as conn:
|
||||
incident_repo = IncidentRepository(_as_conn(conn))
|
||||
incident = await incident_repo.get_by_id(incident_id)
|
||||
if incident is None:
|
||||
raise exc.NotFoundError("Incident not found")
|
||||
ensure_org_access(incident["org_id"], current_user)
|
||||
return IncidentResponse(**incident)
|
||||
|
||||
async def get_incident_events(
|
||||
self, current_user: CurrentUser, incident_id: UUID
|
||||
) -> list[IncidentEventResponse]:
|
||||
"""Return the timeline events for an incident in the active org."""
|
||||
|
||||
async with self.db.connection() as conn:
|
||||
incident_repo = IncidentRepository(_as_conn(conn))
|
||||
incident = await incident_repo.get_by_id(incident_id)
|
||||
if incident is None:
|
||||
raise exc.NotFoundError("Incident not found")
|
||||
ensure_org_access(incident["org_id"], current_user)
|
||||
|
||||
events = await incident_repo.get_events(incident_id)
|
||||
return [IncidentEventResponse(**event) for event in events]
|
||||
|
||||
async def transition_incident(
|
||||
self,
|
||||
current_user: CurrentUser,
|
||||
incident_id: UUID,
|
||||
data: TransitionRequest,
|
||||
) -> IncidentResponse:
|
||||
"""Transition an incident status with optimistic locking and event recording."""
|
||||
|
||||
async with self.db.transaction() as conn:
|
||||
db_conn = _as_conn(conn)
|
||||
incident_repo = IncidentRepository(db_conn)
|
||||
|
||||
incident = await incident_repo.get_by_id(incident_id)
|
||||
if incident is None:
|
||||
raise exc.NotFoundError("Incident not found")
|
||||
ensure_org_access(incident["org_id"], current_user)
|
||||
self._validate_transition(incident["status"], data.to_status)
|
||||
|
||||
updated = await incident_repo.update_status(
|
||||
incident_id,
|
||||
data.to_status,
|
||||
data.version,
|
||||
)
|
||||
if updated is None:
|
||||
raise exc.ConflictError("Incident version mismatch")
|
||||
|
||||
payload = {"from": incident["status"], "to": data.to_status}
|
||||
if data.note:
|
||||
payload["note"] = data.note
|
||||
|
||||
await incident_repo.add_event(
|
||||
uuid4(),
|
||||
incident_id,
|
||||
"status_changed",
|
||||
actor_user_id=current_user.user_id,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
return IncidentResponse(**updated)
|
||||
|
||||
async def add_comment(
|
||||
self,
|
||||
current_user: CurrentUser,
|
||||
incident_id: UUID,
|
||||
data: CommentRequest,
|
||||
) -> IncidentEventResponse:
|
||||
"""Add a comment event to the incident timeline."""
|
||||
|
||||
async with self.db.connection() as conn:
|
||||
incident_repo = IncidentRepository(_as_conn(conn))
|
||||
incident = await incident_repo.get_by_id(incident_id)
|
||||
if incident is None:
|
||||
raise exc.NotFoundError("Incident not found")
|
||||
ensure_org_access(incident["org_id"], current_user)
|
||||
|
||||
event = await incident_repo.add_event(
|
||||
uuid4(),
|
||||
incident_id,
|
||||
"comment_added",
|
||||
actor_user_id=current_user.user_id,
|
||||
payload={"content": data.content},
|
||||
)
|
||||
return IncidentEventResponse(**event)
|
||||
|
||||
def _validate_transition(self, current_status: str, to_status: str) -> None:
|
||||
"""Validate a requested status transition against the allowed state machine."""
|
||||
|
||||
if current_status == to_status:
|
||||
raise exc.BadRequestError("Incident is already in the requested status")
|
||||
|
||||
allowed = _ALLOWED_TRANSITIONS.get(current_status, set())
|
||||
if to_status not in allowed:
|
||||
raise exc.BadRequestError("Invalid incident status transition")
|
||||
|
||||
|
||||
__all__ = ["IncidentService"]
|
||||
Reference in New Issue
Block a user