feat(incidents): add incident lifecycle api and tests
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
"""Service layer entrypoints."""
|
||||
|
||||
from app.services.auth import AuthService
|
||||
from app.services.incident import IncidentService
|
||||
from app.services.org import OrgService
|
||||
|
||||
__all__ = ["AuthService"]
|
||||
__all__ = ["AuthService", "OrgService", "IncidentService"]
|
||||
|
||||
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"]
|
||||
115
app/services/org.py
Normal file
115
app/services/org.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Organization service providing org-scoped operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import asyncpg
|
||||
from asyncpg.pool import PoolConnectionProxy
|
||||
|
||||
from app.api.deps import CurrentUser
|
||||
from app.core import exceptions as exc
|
||||
from app.db import Database, db
|
||||
from app.repositories import NotificationRepository, OrgRepository, ServiceRepository
|
||||
from app.schemas.org import (
|
||||
MemberResponse,
|
||||
NotificationTargetCreate,
|
||||
NotificationTargetResponse,
|
||||
OrgResponse,
|
||||
ServiceCreate,
|
||||
ServiceResponse,
|
||||
)
|
||||
|
||||
|
||||
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 OrgService:
|
||||
"""Encapsulates organization-level operations within the active org context."""
|
||||
|
||||
def __init__(self, database: Database | None = None) -> None:
|
||||
self.db = database or db
|
||||
|
||||
async def get_current_org(self, current_user: CurrentUser) -> OrgResponse:
|
||||
"""Return the active organization summary for the current user."""
|
||||
|
||||
async with self.db.connection() as conn:
|
||||
org_repo = OrgRepository(_as_conn(conn))
|
||||
org = await org_repo.get_by_id(current_user.org_id)
|
||||
if org is None:
|
||||
raise exc.NotFoundError("Organization not found")
|
||||
return OrgResponse(**org)
|
||||
|
||||
async def get_members(self, current_user: CurrentUser) -> list[MemberResponse]:
|
||||
"""List members of the active organization."""
|
||||
|
||||
async with self.db.connection() as conn:
|
||||
org_repo = OrgRepository(_as_conn(conn))
|
||||
members = await org_repo.get_members(current_user.org_id)
|
||||
return [MemberResponse(**member) for member in members]
|
||||
|
||||
async def create_service(self, current_user: CurrentUser, data: ServiceCreate) -> ServiceResponse:
|
||||
"""Create a new service within the active organization."""
|
||||
|
||||
async with self.db.connection() as conn:
|
||||
service_repo = ServiceRepository(_as_conn(conn))
|
||||
|
||||
if await service_repo.slug_exists(current_user.org_id, data.slug):
|
||||
raise exc.ConflictError("Service slug already exists in this organization")
|
||||
|
||||
try:
|
||||
service = await service_repo.create(
|
||||
service_id=uuid4(),
|
||||
org_id=current_user.org_id,
|
||||
name=data.name,
|
||||
slug=data.slug,
|
||||
)
|
||||
except asyncpg.UniqueViolationError as err: # pragma: no cover - race protection
|
||||
raise exc.ConflictError("Service slug already exists in this organization") from err
|
||||
|
||||
return ServiceResponse(**service)
|
||||
|
||||
async def get_services(self, current_user: CurrentUser) -> list[ServiceResponse]:
|
||||
"""List services for the active organization."""
|
||||
|
||||
async with self.db.connection() as conn:
|
||||
service_repo = ServiceRepository(_as_conn(conn))
|
||||
services = await service_repo.get_by_org(current_user.org_id)
|
||||
return [ServiceResponse(**svc) for svc in services]
|
||||
|
||||
async def create_notification_target(
|
||||
self,
|
||||
current_user: CurrentUser,
|
||||
data: NotificationTargetCreate,
|
||||
) -> NotificationTargetResponse:
|
||||
"""Create a notification target for the active organization."""
|
||||
|
||||
if data.target_type == "webhook" and data.webhook_url is None:
|
||||
raise exc.BadRequestError("webhook_url is required for webhook targets")
|
||||
|
||||
async with self.db.connection() as conn:
|
||||
notification_repo = NotificationRepository(_as_conn(conn))
|
||||
target = await notification_repo.create_target(
|
||||
target_id=uuid4(),
|
||||
org_id=current_user.org_id,
|
||||
name=data.name,
|
||||
target_type=data.target_type,
|
||||
webhook_url=str(data.webhook_url) if data.webhook_url else None,
|
||||
enabled=data.enabled,
|
||||
)
|
||||
return NotificationTargetResponse(**target)
|
||||
|
||||
async def get_notification_targets(self, current_user: CurrentUser) -> list[NotificationTargetResponse]:
|
||||
"""List notification targets for the active organization."""
|
||||
|
||||
async with self.db.connection() as conn:
|
||||
notification_repo = NotificationRepository(_as_conn(conn))
|
||||
targets = await notification_repo.get_targets_by_org(current_user.org_id)
|
||||
return [NotificationTargetResponse(**target) for target in targets]
|
||||
|
||||
|
||||
__all__ = ["OrgService"]
|
||||
Reference in New Issue
Block a user