feat(incidents): add incident lifecycle api and tests
This commit is contained in:
103
app/api/v1/incidents.py
Normal file
103
app/api/v1/incidents.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Incident API endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
|
||||
from app.api.deps import CurrentUser, get_current_user, require_role
|
||||
from app.schemas.common import PaginatedResponse
|
||||
from app.schemas.incident import (
|
||||
CommentRequest,
|
||||
IncidentEventResponse,
|
||||
IncidentResponse,
|
||||
IncidentStatus,
|
||||
TransitionRequest,
|
||||
IncidentCreate,
|
||||
)
|
||||
from app.services import IncidentService
|
||||
|
||||
|
||||
router = APIRouter(tags=["incidents"])
|
||||
incident_service = IncidentService()
|
||||
|
||||
|
||||
@router.get("/incidents", response_model=PaginatedResponse[IncidentResponse])
|
||||
async def list_incidents(
|
||||
status: IncidentStatus | None = Query(default=None),
|
||||
cursor: datetime | None = Query(default=None, description="Cursor (created_at)"),
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PaginatedResponse[IncidentResponse]:
|
||||
"""List incidents for the active organization."""
|
||||
|
||||
return await incident_service.get_incidents(
|
||||
current_user,
|
||||
status=status,
|
||||
cursor=cursor,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/services/{service_id}/incidents",
|
||||
response_model=IncidentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_incident(
|
||||
service_id: UUID,
|
||||
payload: IncidentCreate,
|
||||
current_user: CurrentUser = Depends(require_role("member")),
|
||||
) -> IncidentResponse:
|
||||
"""Create a new incident for the given service (member+)."""
|
||||
|
||||
return await incident_service.create_incident(current_user, service_id, payload)
|
||||
|
||||
|
||||
@router.get("/incidents/{incident_id}", response_model=IncidentResponse)
|
||||
async def get_incident(
|
||||
incident_id: UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> IncidentResponse:
|
||||
"""Fetch a single incident by ID."""
|
||||
|
||||
return await incident_service.get_incident(current_user, incident_id)
|
||||
|
||||
|
||||
@router.get("/incidents/{incident_id}/events", response_model=list[IncidentEventResponse])
|
||||
async def get_incident_events(
|
||||
incident_id: UUID,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[IncidentEventResponse]:
|
||||
"""Get the event timeline for an incident."""
|
||||
|
||||
return await incident_service.get_incident_events(current_user, incident_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/incidents/{incident_id}/transition",
|
||||
response_model=IncidentResponse,
|
||||
)
|
||||
async def transition_incident(
|
||||
incident_id: UUID,
|
||||
payload: TransitionRequest,
|
||||
current_user: CurrentUser = Depends(require_role("member")),
|
||||
) -> IncidentResponse:
|
||||
"""Transition an incident status (member+)."""
|
||||
|
||||
return await incident_service.transition_incident(current_user, incident_id, payload)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/incidents/{incident_id}/comment",
|
||||
response_model=IncidentEventResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_comment(
|
||||
incident_id: UUID,
|
||||
payload: CommentRequest,
|
||||
current_user: CurrentUser = Depends(require_role("member")),
|
||||
) -> IncidentEventResponse:
|
||||
"""Add a comment to the incident timeline (member+)."""
|
||||
|
||||
return await incident_service.add_comment(current_user, incident_id, payload)
|
||||
72
app/api/v1/org.py
Normal file
72
app/api/v1/org.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Organization API endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
|
||||
from app.api.deps import CurrentUser, get_current_user, require_role
|
||||
from app.schemas.org import (
|
||||
MemberResponse,
|
||||
NotificationTargetCreate,
|
||||
NotificationTargetResponse,
|
||||
OrgResponse,
|
||||
ServiceCreate,
|
||||
ServiceResponse,
|
||||
)
|
||||
from app.services import OrgService
|
||||
|
||||
|
||||
router = APIRouter(prefix="/org", tags=["org"])
|
||||
org_service = OrgService()
|
||||
|
||||
|
||||
@router.get("", response_model=OrgResponse)
|
||||
async def get_org(current_user: CurrentUser = Depends(get_current_user)) -> OrgResponse:
|
||||
"""Return the active organization summary for the authenticated user."""
|
||||
|
||||
return await org_service.get_current_org(current_user)
|
||||
|
||||
|
||||
@router.get("/members", response_model=list[MemberResponse])
|
||||
async def list_members(current_user: CurrentUser = Depends(require_role("admin"))) -> list[MemberResponse]:
|
||||
"""List members of the current organization (admin only)."""
|
||||
|
||||
return await org_service.get_members(current_user)
|
||||
|
||||
|
||||
@router.get("/services", response_model=list[ServiceResponse])
|
||||
async def list_services(current_user: CurrentUser = Depends(get_current_user)) -> list[ServiceResponse]:
|
||||
"""List services for the current organization."""
|
||||
|
||||
return await org_service.get_services(current_user)
|
||||
|
||||
|
||||
@router.post("/services", response_model=ServiceResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_service(
|
||||
payload: ServiceCreate,
|
||||
current_user: CurrentUser = Depends(require_role("member")),
|
||||
) -> ServiceResponse:
|
||||
"""Create a new service within the current organization (member+)."""
|
||||
|
||||
return await org_service.create_service(current_user, payload)
|
||||
|
||||
|
||||
@router.get("/notification-targets", response_model=list[NotificationTargetResponse])
|
||||
async def list_notification_targets(
|
||||
current_user: CurrentUser = Depends(require_role("admin")),
|
||||
) -> list[NotificationTargetResponse]:
|
||||
"""List notification targets for the current organization (admin only)."""
|
||||
|
||||
return await org_service.get_notification_targets(current_user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notification-targets",
|
||||
response_model=NotificationTargetResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_notification_target(
|
||||
payload: NotificationTargetCreate,
|
||||
current_user: CurrentUser = Depends(require_role("admin")),
|
||||
) -> NotificationTargetResponse:
|
||||
"""Create a notification target for the current organization (admin only)."""
|
||||
|
||||
return await org_service.create_notification_target(current_user, payload)
|
||||
@@ -6,7 +6,7 @@ from typing import AsyncGenerator
|
||||
from fastapi import FastAPI
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
|
||||
from app.api.v1 import auth, health
|
||||
from app.api.v1 import auth, health, incidents, org
|
||||
from app.config import settings
|
||||
from app.db import db, redis_client
|
||||
|
||||
@@ -35,6 +35,8 @@ app = FastAPI(
|
||||
|
||||
app.openapi_tags = [
|
||||
{"name": "auth", "description": "Registration, login, token lifecycle"},
|
||||
{"name": "org", "description": "Organization membership, services, and notifications"},
|
||||
{"name": "incidents", "description": "Incident lifecycle and timelines"},
|
||||
{"name": "health", "description": "Service health probes"},
|
||||
]
|
||||
|
||||
@@ -67,4 +69,6 @@ app.openapi = custom_openapi # type: ignore[assignment]
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix=settings.api_v1_prefix)
|
||||
app.include_router(incidents.router, prefix=settings.api_v1_prefix)
|
||||
app.include_router(org.router, prefix=settings.api_v1_prefix)
|
||||
app.include_router(health.router, prefix=settings.api_v1_prefix, tags=["health"])
|
||||
|
||||
@@ -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