# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return" """ Incident service — CRUD + stats + status + timeline + close. Phase 1 Step 4: extracted from ``compliance.api.incident_routes``. The workflow side (risk assessment, Art. 33/34 notifications, measures) lives in ``compliance.services.incident_workflow_service``. Module-level helpers (_calculate_risk_level, _is_notification_required, _calculate_72h_deadline, _incident_to_response, _measure_to_response, _parse_jsonb) are shared by both service modules and re-exported from ``compliance.api.incident_routes`` for legacy test imports. """ import json from datetime import datetime, timedelta, timezone from typing import Any, Optional from uuid import uuid4 from sqlalchemy import text from sqlalchemy.orm import Session from compliance.domain import NotFoundError, ValidationError from compliance.schemas.incident import ( CloseIncidentRequest, IncidentCreate, IncidentUpdate, StatusUpdate, TimelineEntryRequest, ) DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" # ============================================================================ # Module-level helpers (re-exported by compliance.api.incident_routes) # ============================================================================ def _calculate_risk_level(likelihood: int, impact: int) -> str: """Calculate risk level from likelihood * impact score.""" score = likelihood * impact if score >= 20: return "critical" if score >= 12: return "high" if score >= 6: return "medium" return "low" def _is_notification_required(risk_level: str) -> bool: """DSGVO Art. 33 — notification required for critical/high risk.""" return risk_level in ("critical", "high") def _calculate_72h_deadline(detected_at: datetime) -> str: """Calculate 72-hour DSGVO Art. 33 deadline.""" return (detected_at + timedelta(hours=72)).isoformat() def _parse_jsonb(val: Any) -> Any: """Parse a JSONB field — already dict/list from psycopg or a JSON string.""" if val is None: return None if isinstance(val, (dict, list)): return val if isinstance(val, str): try: return json.loads(val) except (json.JSONDecodeError, TypeError): return val return val def _incident_to_response(row: Any) -> dict[str, Any]: """Convert a DB row (RowMapping) to incident response dict.""" r = dict(row) for field in ( "risk_assessment", "authority_notification", "data_subject_notification", "timeline", "affected_data_categories", "affected_systems", ): if field in r: r[field] = _parse_jsonb(r[field]) for field in ("detected_at", "created_at", "updated_at", "closed_at"): if field in r and r[field] is not None and hasattr(r[field], "isoformat"): r[field] = r[field].isoformat() return r def _measure_to_response(row: Any) -> dict[str, Any]: """Convert a DB measure row to response dict.""" r = dict(row) for field in ("due_date", "completed_at", "created_at", "updated_at"): if field in r and r[field] is not None and hasattr(r[field], "isoformat"): r[field] = r[field].isoformat() return r def _append_timeline(db: Session, incident_id: str, entry: dict[str, Any]) -> None: """Append a timeline entry to the incident's timeline JSONB array.""" db.execute( text( "UPDATE incident_incidents " "SET timeline = COALESCE(timeline, '[]'::jsonb) || :entry::jsonb, " "updated_at = NOW() WHERE id = :id" ), {"id": incident_id, "entry": json.dumps(entry)}, ) # ============================================================================ # Service # ============================================================================ class IncidentService: """CRUD + stats + status + timeline + close.""" def __init__(self, db: Session) -> None: self.db = db def _require_exists(self, iid: str) -> None: row = self.db.execute( text("SELECT id FROM incident_incidents WHERE id = :id"), {"id": iid}, ).first() if not row: raise NotFoundError("incident not found") def create( self, tenant_id: str, user_id: str, body: IncidentCreate ) -> dict[str, Any]: incident_id = str(uuid4()) now = datetime.now(timezone.utc) detected_at = now if body.detected_at: try: parsed = datetime.fromisoformat(body.detected_at.replace("Z", "+00:00")) detected_at = parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) except (ValueError, AttributeError): detected_at = now deadline = detected_at + timedelta(hours=72) authority_notification = {"status": "pending", "deadline": deadline.isoformat()} data_subject_notification = {"required": False, "status": "not_required"} timeline = [{ "timestamp": now.isoformat(), "action": "incident_created", "user_id": user_id, "details": "Incident detected and reported", }] self.db.execute(text(""" INSERT INTO incident_incidents ( id, tenant_id, title, description, category, status, severity, detected_at, reported_by, affected_data_categories, affected_data_subject_count, affected_systems, authority_notification, data_subject_notification, timeline, created_at, updated_at ) VALUES ( :id, :tenant_id, :title, :description, :category, 'detected', :severity, :detected_at, :reported_by, CAST(:affected_data_categories AS jsonb), :affected_data_subject_count, CAST(:affected_systems AS jsonb), CAST(:authority_notification AS jsonb), CAST(:data_subject_notification AS jsonb), CAST(:timeline AS jsonb), :now, :now ) """), { "id": incident_id, "tenant_id": tenant_id, "title": body.title, "description": body.description or "", "category": body.category, "severity": body.severity, "detected_at": detected_at.isoformat(), "reported_by": user_id, "affected_data_categories": json.dumps(body.affected_data_categories or []), "affected_data_subject_count": body.affected_data_subject_count or 0, "affected_systems": json.dumps(body.affected_systems or []), "authority_notification": json.dumps(authority_notification), "data_subject_notification": json.dumps(data_subject_notification), "timeline": json.dumps(timeline), "now": now.isoformat(), }) self.db.commit() row = self.db.execute( text("SELECT * FROM incident_incidents WHERE id = :id"), {"id": incident_id}, ).mappings().first() incident_resp = _incident_to_response(row) if row else {} return { "incident": incident_resp, "authority_deadline": deadline.isoformat(), "hours_until_deadline": (deadline - now).total_seconds() / 3600, } def list_incidents( self, tenant_id: str, status: Optional[str], severity: Optional[str], category: Optional[str], limit: int, offset: int, ) -> dict[str, Any]: where = ["tenant_id = :tenant_id"] params: dict[str, Any] = { "tenant_id": tenant_id, "limit": limit, "offset": offset, } if status: where.append("status = :status") params["status"] = status if severity: where.append("severity = :severity") params["severity"] = severity if category: where.append("category = :category") params["category"] = category where_sql = " AND ".join(where) total = ( self.db.execute( text(f"SELECT COUNT(*) FROM incident_incidents WHERE {where_sql}"), params, ).scalar() or 0 ) rows = ( self.db.execute( text( f"SELECT * FROM incident_incidents WHERE {where_sql} " f"ORDER BY created_at DESC LIMIT :limit OFFSET :offset" ), params, ) .mappings() .all() ) return { "incidents": [_incident_to_response(r) for r in rows], "total": total, } def stats(self, tenant_id: str) -> dict[str, Any]: row: Any = ( self.db.execute( text(""" SELECT COUNT(*) AS total, SUM(CASE WHEN status != 'closed' THEN 1 ELSE 0 END) AS open, SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END) AS closed, SUM(CASE WHEN severity = 'critical' THEN 1 ELSE 0 END) AS critical, SUM(CASE WHEN severity = 'high' THEN 1 ELSE 0 END) AS high, SUM(CASE WHEN severity = 'medium' THEN 1 ELSE 0 END) AS medium, SUM(CASE WHEN severity = 'low' THEN 1 ELSE 0 END) AS low FROM incident_incidents WHERE tenant_id = :tenant_id """), {"tenant_id": tenant_id}, ) .mappings() .first() ) or {} return { "total": int(row["total"] or 0), "open": int(row["open"] or 0), "closed": int(row["closed"] or 0), "by_severity": { "critical": int(row["critical"] or 0), "high": int(row["high"] or 0), "medium": int(row["medium"] or 0), "low": int(row["low"] or 0), }, } def get(self, incident_id: str) -> dict[str, Any]: row = ( self.db.execute( text("SELECT * FROM incident_incidents WHERE id = :id"), {"id": incident_id}, ) .mappings() .first() ) if not row: raise NotFoundError("incident not found") incident = _incident_to_response(row) measures = [ _measure_to_response(m) for m in self.db.execute( text("SELECT * FROM incident_measures WHERE incident_id = :id ORDER BY created_at"), {"id": incident_id}, ) .mappings() .all() ] deadline_info = None auth_notif = ( _parse_jsonb(row["authority_notification"]) if "authority_notification" in row.keys() else None ) if auth_notif and isinstance(auth_notif, dict) and "deadline" in auth_notif: try: deadline_dt = datetime.fromisoformat( auth_notif["deadline"].replace("Z", "+00:00") ) now = datetime.now(timezone.utc) hours_remaining = (deadline_dt - now).total_seconds() / 3600 deadline_info = { "deadline": auth_notif["deadline"], "hours_remaining": hours_remaining, "overdue": hours_remaining < 0, } except (ValueError, TypeError): pass return {"incident": incident, "measures": measures, "deadline_info": deadline_info} def update(self, incident_id: str, body: IncidentUpdate) -> dict[str, Any]: self._require_exists(incident_id) updates: list[str] = [] params: dict[str, Any] = {"id": incident_id} for field in ("title", "description", "category", "status", "severity"): val = getattr(body, field, None) if val is not None: updates.append(f"{field} = :{field}") params[field] = val if body.affected_data_categories is not None: updates.append("affected_data_categories = CAST(:adc AS jsonb)") params["adc"] = json.dumps(body.affected_data_categories) if body.affected_data_subject_count is not None: updates.append("affected_data_subject_count = :adsc") params["adsc"] = body.affected_data_subject_count if body.affected_systems is not None: updates.append("affected_systems = CAST(:asys AS jsonb)") params["asys"] = json.dumps(body.affected_systems) if not updates: raise ValidationError("no fields to update") updates.append("updated_at = NOW()") self.db.execute( text(f"UPDATE incident_incidents SET {', '.join(updates)} WHERE id = :id"), params, ) self.db.commit() row = ( self.db.execute( text("SELECT * FROM incident_incidents WHERE id = :id"), {"id": incident_id}, ) .mappings() .first() ) return {"incident": _incident_to_response(row)} def delete(self, incident_id: str) -> dict[str, Any]: self._require_exists(incident_id) self.db.execute( text("DELETE FROM incident_measures WHERE incident_id = :id"), {"id": incident_id}, ) self.db.execute( text("DELETE FROM incident_incidents WHERE id = :id"), {"id": incident_id}, ) self.db.commit() return {"message": "incident deleted"} def update_status( self, incident_id: str, user_id: str, body: StatusUpdate ) -> dict[str, Any]: self._require_exists(incident_id) self.db.execute( text( "UPDATE incident_incidents SET status = :status, updated_at = NOW() " "WHERE id = :id" ), {"id": incident_id, "status": body.status}, ) _append_timeline(self.db, incident_id, { "timestamp": datetime.now(timezone.utc).isoformat(), "action": "status_changed", "user_id": user_id, "details": f"Status changed to {body.status}", }) self.db.commit() row = ( self.db.execute( text("SELECT * FROM incident_incidents WHERE id = :id"), {"id": incident_id}, ) .mappings() .first() ) return {"incident": _incident_to_response(row)} def add_timeline( self, incident_id: str, user_id: str, body: TimelineEntryRequest ) -> dict[str, Any]: self._require_exists(incident_id) now = datetime.now(timezone.utc) entry = { "timestamp": now.isoformat(), "action": body.action, "user_id": user_id, "details": body.details or "", } _append_timeline(self.db, incident_id, entry) self.db.commit() return {"timeline_entry": entry} def close( self, incident_id: str, user_id: str, body: CloseIncidentRequest ) -> dict[str, Any]: self._require_exists(incident_id) now = datetime.now(timezone.utc) self.db.execute( text(""" UPDATE incident_incidents SET status = 'closed', root_cause = :root_cause, lessons_learned = :lessons_learned, closed_at = :now, updated_at = :now WHERE id = :id """), { "id": incident_id, "root_cause": body.root_cause, "lessons_learned": body.lessons_learned or "", "now": now.isoformat(), }, ) _append_timeline(self.db, incident_id, { "timestamp": now.isoformat(), "action": "incident_closed", "user_id": user_id, "details": f"Incident closed. Root cause: {body.root_cause}", }) self.db.commit() return { "message": "incident closed", "root_cause": body.root_cause, "lessons_learned": body.lessons_learned or "", }