# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return" """ Incident workflow service — risk assessment + Art. 33/34 notifications + measures. Phase 1 Step 4: extracted from ``compliance.api.incident_routes``. CRUD + stats + status + timeline + close live in ``compliance.services.incident_service``. """ import json from datetime import datetime, timedelta, timezone from typing import Any from uuid import uuid4 from sqlalchemy import text from sqlalchemy.orm import Session from compliance.domain import NotFoundError, ValidationError from compliance.schemas.incident import ( AuthorityNotificationRequest, DataSubjectNotificationRequest, MeasureCreate, MeasureUpdate, RiskAssessmentRequest, ) from compliance.services.incident_service import ( _append_timeline, _calculate_72h_deadline, _calculate_risk_level, _is_notification_required, _measure_to_response, _parse_jsonb, ) class IncidentWorkflowService: """Business logic for incident risk assessment, notifications, measures.""" def __init__(self, db: Session) -> None: self.db = db def _incident_row_or_raise(self, incident_id: str, columns: str) -> Any: row = ( self.db.execute( text(f"SELECT {columns} FROM incident_incidents WHERE id = :id"), {"id": incident_id}, ) .mappings() .first() ) if not row: raise NotFoundError("incident not found") return row # ------------------------------------------------------------------ # Risk assessment # ------------------------------------------------------------------ def assess_risk( self, incident_id: str, user_id: str, body: RiskAssessmentRequest ) -> dict[str, Any]: row = self._incident_row_or_raise( incident_id, "id, status, authority_notification" ) risk_level = _calculate_risk_level(body.likelihood, body.impact) notification_required = _is_notification_required(risk_level) now = datetime.now(timezone.utc) assessment = { "likelihood": body.likelihood, "impact": body.impact, "risk_level": risk_level, "assessed_at": now.isoformat(), "assessed_by": user_id, "notes": body.notes or "", } new_status = "assessment" if notification_required: new_status = "notification_required" auth = _parse_jsonb(row["authority_notification"]) or {} auth["status"] = "pending" self.db.execute( text( "UPDATE incident_incidents SET authority_notification = CAST(:an AS jsonb) " "WHERE id = :id" ), {"id": incident_id, "an": json.dumps(auth)}, ) self.db.execute( text(""" UPDATE incident_incidents SET risk_assessment = CAST(:ra AS jsonb), status = :status, updated_at = NOW() WHERE id = :id """), {"id": incident_id, "ra": json.dumps(assessment), "status": new_status}, ) _append_timeline(self.db, incident_id, { "timestamp": now.isoformat(), "action": "risk_assessed", "user_id": user_id, "details": f"Risk level: {risk_level} (likelihood={body.likelihood}, impact={body.impact})", }) self.db.commit() return { "risk_assessment": assessment, "notification_required": notification_required, "incident_status": new_status, } # ------------------------------------------------------------------ # Art. 33 authority notification # ------------------------------------------------------------------ def notify_authority( self, incident_id: str, user_id: str, body: AuthorityNotificationRequest ) -> dict[str, Any]: row = self._incident_row_or_raise( incident_id, "id, detected_at, authority_notification" ) now = datetime.now(timezone.utc) auth_existing = _parse_jsonb(row["authority_notification"]) or {} deadline_str = auth_existing.get("deadline") if not deadline_str and row["detected_at"]: detected = row["detected_at"] if hasattr(detected, "isoformat"): deadline_str = (detected + timedelta(hours=72)).isoformat() else: deadline_str = _calculate_72h_deadline( datetime.fromisoformat(str(detected).replace("Z", "+00:00")) ) notification = { "status": "sent", "deadline": deadline_str, "submitted_at": now.isoformat(), "authority_name": body.authority_name, "reference_number": body.reference_number or "", "contact_person": body.contact_person or "", "notes": body.notes or "", } self.db.execute( text(""" UPDATE incident_incidents SET authority_notification = CAST(:an AS jsonb), status = 'notification_sent', updated_at = NOW() WHERE id = :id """), {"id": incident_id, "an": json.dumps(notification)}, ) _append_timeline(self.db, incident_id, { "timestamp": now.isoformat(), "action": "authority_notified", "user_id": user_id, "details": f"Authority notification submitted to {body.authority_name}", }) self.db.commit() submitted_within_72h = True if deadline_str: try: deadline_dt = datetime.fromisoformat(deadline_str.replace("Z", "+00:00")) submitted_within_72h = now < deadline_dt except (ValueError, TypeError): pass return { "authority_notification": notification, "submitted_within_72h": submitted_within_72h, } # ------------------------------------------------------------------ # Art. 34 data subject notification # ------------------------------------------------------------------ def notify_subjects( self, incident_id: str, user_id: str, body: DataSubjectNotificationRequest ) -> dict[str, Any]: row = self._incident_row_or_raise( incident_id, "id, affected_data_subject_count" ) now = datetime.now(timezone.utc) affected_count = body.affected_count or row["affected_data_subject_count"] or 0 notification = { "required": True, "status": "sent", "sent_at": now.isoformat(), "affected_count": affected_count, "notification_text": body.notification_text, "channel": body.channel, } self.db.execute( text(""" UPDATE incident_incidents SET data_subject_notification = CAST(:dsn AS jsonb), updated_at = NOW() WHERE id = :id """), {"id": incident_id, "dsn": json.dumps(notification)}, ) _append_timeline(self.db, incident_id, { "timestamp": now.isoformat(), "action": "data_subjects_notified", "user_id": user_id, "details": f"Data subjects notified via {body.channel} ({affected_count} affected)", }) self.db.commit() return {"data_subject_notification": notification} # ------------------------------------------------------------------ # Measures # ------------------------------------------------------------------ def add_measure( self, incident_id: str, user_id: str, body: MeasureCreate ) -> dict[str, Any]: self._incident_row_or_raise(incident_id, "id") measure_id = str(uuid4()) now = datetime.now(timezone.utc) self.db.execute( text(""" INSERT INTO incident_measures ( id, incident_id, title, description, measure_type, status, responsible, due_date, created_at, updated_at ) VALUES ( :id, :incident_id, :title, :description, :measure_type, 'planned', :responsible, :due_date, :now, :now ) """), { "id": measure_id, "incident_id": incident_id, "title": body.title, "description": body.description or "", "measure_type": body.measure_type, "responsible": body.responsible or "", "due_date": body.due_date, "now": now.isoformat(), }, ) _append_timeline(self.db, incident_id, { "timestamp": now.isoformat(), "action": "measure_added", "user_id": user_id, "details": f"Measure added: {body.title} ({body.measure_type})", }) self.db.commit() measure = ( self.db.execute( text("SELECT * FROM incident_measures WHERE id = :id"), {"id": measure_id}, ) .mappings() .first() ) return {"measure": _measure_to_response(measure)} def update_measure( self, measure_id: str, body: MeasureUpdate ) -> dict[str, Any]: check = self.db.execute( text("SELECT id FROM incident_measures WHERE id = :id"), {"id": measure_id}, ).first() if not check: raise NotFoundError("measure not found") updates: list[str] = [] params: dict[str, Any] = {"id": measure_id} for field in ( "title", "description", "measure_type", "status", "responsible", "due_date", ): val = getattr(body, field, None) if val is not None: updates.append(f"{field} = :{field}") params[field] = val if not updates: raise ValidationError("no fields to update") updates.append("updated_at = NOW()") self.db.execute( text(f"UPDATE incident_measures SET {', '.join(updates)} WHERE id = :id"), params, ) self.db.commit() measure = ( self.db.execute( text("SELECT * FROM incident_measures WHERE id = :id"), {"id": measure_id}, ) .mappings() .first() ) return {"measure": _measure_to_response(measure)} def complete_measure(self, measure_id: str) -> dict[str, Any]: check = self.db.execute( text("SELECT id FROM incident_measures WHERE id = :id"), {"id": measure_id}, ).first() if not check: raise NotFoundError("measure not found") self.db.execute( text( "UPDATE incident_measures " "SET status = 'completed', completed_at = NOW(), updated_at = NOW() " "WHERE id = :id" ), {"id": measure_id}, ) self.db.commit() return {"message": "measure completed"}