"""信息安全自评估 — service (cycle lifecycle, sections, auto-fill, measures). A cycle covers one half-year. ``create_cycle`` materialises one section per :mod:`assessment_catalog` spec and auto-fills the data tables from live Helix tables via :mod:`assessment_data`. The auditor edits narrative + 评估 结论 (AI draft in PR3), then ``finalize_cycle`` locks it. The measures tracker is an evergreen 整改 worklist. Naive-UTC datetime discipline throughout. """ from __future__ import annotations import re import uuid from datetime import UTC, date, datetime from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import NotFoundError, ValidationError from app.domains.auditor import assessment_catalog as cat from app.domains.auditor import assessment_data from app.domains.auditor.models import ( AuditorAssessmentCycle, AuditorAssessmentMeasure, AuditorAssessmentSection, ) from app.identity.person_models import IdentityPerson # Sections whose 评估结论 can be informed by a GRC control's live status. CONTROL_BY_SECTION: dict[str, str] = { "authentication": "mfa_coverage", "periodic_review": "access_certification", "vuln_mgmt": "public_exposure_scan", "secret_mgmt": "secret_rotation", "periodic_self_check": "periodic_self_check", } _PERIOD_RE = re.compile(r"^(\d{4})-H([12])$") def _naive_now() -> datetime: return datetime.now(UTC).replace(tzinfo=None) def _to_naive(dt: datetime | None) -> datetime | None: if dt is None: return None if dt.tzinfo is not None: return dt.astimezone(UTC).replace(tzinfo=None) return dt def parse_period_key(period_key: str) -> tuple[date, date]: m = _PERIOD_RE.match((period_key or "").strip().upper()) if not m: raise ValidationError("period_key must look like '2026-H1' / '2026-H2'") year, half = int(m.group(1)), int(m.group(2)) if half == 1: return date(year, 1, 1), date(year, 6, 30) return date(year, 7, 1), date(year, 12, 31) def period_just_ended(ref: date) -> str: """The half-year that most recently ended as of ``ref``. Opened Jan 1 → prior-year H2; opened Jul 1 → this-year H1. """ if ref.month <= 6: return f"{ref.year - 1}-H2" return f"{ref.year}-H1" # ════════════════════════════════════════════════════════════════════ # Person enrichment # ════════════════════════════════════════════════════════════════════ async def _persons_index( db: AsyncSession, person_ids: set[uuid.UUID], ) -> dict[uuid.UUID, IdentityPerson]: ids = [pid for pid in person_ids if pid] if not ids: return {} rows = (await db.execute( select(IdentityPerson).where(IdentityPerson.id.in_(ids)) )).scalars().all() return {p.id: p for p in rows} def _person_brief(p: IdentityPerson | None) -> dict[str, Any] | None: if p is None: return None return { "id": str(p.id), "display_name": p.display_name, "employee_no": p.employee_no, "work_email": p.work_email, "employment_status": p.employment_status, } # ════════════════════════════════════════════════════════════════════ # Cycle lifecycle # ════════════════════════════════════════════════════════════════════ async def create_cycle( db: AsyncSession, *, period_key: str | None = None, created_by_person_id: uuid.UUID | None = None, ref: date | None = None, ) -> AuditorAssessmentCycle: """Materialise a cycle for ``period_key`` (default = just-ended half). Instantiates every catalog section and auto-fills data tables. Idempotent failure: raises if the period already has a cycle. """ ref = ref or _naive_now().date() period_key = (period_key or period_just_ended(ref)).strip().upper() ps, pe = parse_period_key(period_key) existing = (await db.execute( select(AuditorAssessmentCycle).where( AuditorAssessmentCycle.period_key == period_key, ) )).scalar_one_or_none() if existing is not None: raise ValidationError(f"assessment cycle {period_key} already exists") cycle = AuditorAssessmentCycle( template_key=cat.TEMPLATE_KEY, period_key=period_key, period_start=ps, period_end=pe, report_date=ref, title=f"{ps.year}年{'上' if period_key.endswith('H1') else '下'}半年信息安全自评估报告", status="draft", overall_conclusion="not_assessed", created_by_person_id=created_by_person_id, ) db.add(cycle) await db.flush() now = _naive_now() for spec in cat.SECTIONS: agg = await assessment_data.aggregate(db, spec.get("auto_source"), ps, pe) section = AuditorAssessmentSection( cycle_id=cycle.id, section_key=spec["key"], parent_key=spec.get("parent_key"), title=spec["title"], sort_order=spec.get("sort_order", 0), has_table=bool(spec.get("has_table")), conclusion="not_assessed", conclusion_applicable=bool(spec.get("conclusion_applicable", True)), table_columns=spec.get("table_columns") or None, data_table=(agg or {}).get("table") if agg else None, auto_source=spec.get("auto_source"), auto_data=( {"summary": agg["summary"], "numbers": agg["numbers"]} if agg else None ), auto_refreshed_at=now if agg else None, control_key=CONTROL_BY_SECTION.get(spec["key"]), ) db.add(section) await db.flush() return cycle async def get_cycle_row( db: AsyncSession, cycle_id: uuid.UUID, ) -> AuditorAssessmentCycle: cycle = await db.get(AuditorAssessmentCycle, cycle_id) if cycle is None: raise NotFoundError("assessment cycle not found") return cycle def _cycle_brief(c: AuditorAssessmentCycle) -> dict[str, Any]: return { "id": str(c.id), "period_key": c.period_key, "title": c.title, "status": c.status, "period_start": c.period_start.isoformat() if c.period_start else None, "period_end": c.period_end.isoformat() if c.period_end else None, "report_date": c.report_date.isoformat() if c.report_date else None, "overall_conclusion": c.overall_conclusion, "finalized_at": ( _to_naive(c.finalized_at).isoformat() if c.finalized_at else None ), "created_at": _to_naive(c.created_at).isoformat() if c.created_at else None, } async def list_cycles(db: AsyncSession) -> list[dict[str, Any]]: rows = (await db.execute( select(AuditorAssessmentCycle) .order_by(AuditorAssessmentCycle.period_key.desc()) )).scalars().all() return [_cycle_brief(c) for c in rows] def _section_view(s: AuditorAssessmentSection) -> dict[str, Any]: return { "id": str(s.id), "section_key": s.section_key, "parent_key": s.parent_key, "title": s.title, "sort_order": s.sort_order, "has_table": bool(s.has_table), "narrative": s.narrative, "conclusion": s.conclusion, "conclusion_applicable": bool(s.conclusion_applicable), "table_columns": s.table_columns, "data_table": s.data_table, "auto_source": s.auto_source, "auto_summary": (s.auto_data or {}).get("summary") if s.auto_data else None, "auto_refreshed_at": ( _to_naive(s.auto_refreshed_at).isoformat() if s.auto_refreshed_at else None ), "control_key": s.control_key, "ai_drafted": bool(s.ai_drafted), } async def get_cycle_detail(db: AsyncSession, cycle_id: uuid.UUID) -> dict[str, Any]: cycle = await get_cycle_row(db, cycle_id) sections = (await db.execute( select(AuditorAssessmentSection) .where(AuditorAssessmentSection.cycle_id == cycle_id) .order_by(AuditorAssessmentSection.sort_order) )).scalars().all() out = _cycle_brief(cycle) out["intro_narrative"] = cycle.intro_narrative out["conclusion_narrative"] = cycle.conclusion_narrative out["sections"] = [_section_view(s) for s in sections] return out async def update_cycle( db: AsyncSession, cycle_id: uuid.UUID, *, payload: dict[str, Any], ) -> AuditorAssessmentCycle: cycle = await get_cycle_row(db, cycle_id) if cycle.status == "finalized": raise ValidationError("a finalized cycle is locked; reopen it first") if "title" in payload and payload["title"]: cycle.title = payload["title"] if "intro_narrative" in payload: cycle.intro_narrative = payload["intro_narrative"] if "conclusion_narrative" in payload: cycle.conclusion_narrative = payload["conclusion_narrative"] if "overall_conclusion" in payload and payload["overall_conclusion"]: if payload["overall_conclusion"] not in cat.CONCLUSIONS: raise ValidationError("invalid overall_conclusion") cycle.overall_conclusion = payload["overall_conclusion"] if "report_date" in payload and payload["report_date"]: cycle.report_date = payload["report_date"] await db.flush() return cycle async def finalize_cycle( db: AsyncSession, cycle_id: uuid.UUID, *, finalized_by_person_id: uuid.UUID | None, ) -> AuditorAssessmentCycle: cycle = await get_cycle_row(db, cycle_id) if cycle.status == "finalized": raise ValidationError("cycle already finalized") cycle.status = "finalized" cycle.finalized_at = _naive_now() cycle.finalized_by_person_id = finalized_by_person_id await db.flush() return cycle async def reopen_cycle( db: AsyncSession, cycle_id: uuid.UUID, ) -> AuditorAssessmentCycle: cycle = await get_cycle_row(db, cycle_id) cycle.status = "draft" cycle.finalized_at = None cycle.finalized_by_person_id = None await db.flush() return cycle # ════════════════════════════════════════════════════════════════════ # Sections # ════════════════════════════════════════════════════════════════════ async def get_section( db: AsyncSession, section_id: uuid.UUID, ) -> AuditorAssessmentSection: s = await db.get(AuditorAssessmentSection, section_id) if s is None: raise NotFoundError("assessment section not found") return s async def _guard_section_editable( db: AsyncSession, section: AuditorAssessmentSection, ) -> None: cycle = await db.get(AuditorAssessmentCycle, section.cycle_id) if cycle is not None and cycle.status == "finalized": raise ValidationError("the cycle is finalized; reopen it to edit") async def update_section( db: AsyncSession, section_id: uuid.UUID, *, payload: dict[str, Any], ) -> AuditorAssessmentSection: section = await get_section(db, section_id) await _guard_section_editable(db, section) if "narrative" in payload: section.narrative = payload["narrative"] if "conclusion" in payload and payload["conclusion"]: if payload["conclusion"] not in cat.CONCLUSIONS: raise ValidationError("invalid conclusion") if not section.conclusion_applicable: raise ValidationError("this section carries prose only") section.conclusion = payload["conclusion"] if "data_table" in payload: section.data_table = payload["data_table"] await db.flush() return section async def refresh_section_auto_data( db: AsyncSession, section_id: uuid.UUID, ) -> AuditorAssessmentSection: section = await get_section(db, section_id) await _guard_section_editable(db, section) if not section.auto_source: raise ValidationError("this section has no auto data source") cycle = await get_cycle_row(db, section.cycle_id) agg = await assessment_data.aggregate( db, section.auto_source, cycle.period_start, cycle.period_end, ) if agg is not None: section.data_table = agg["table"] section.auto_data = {"summary": agg["summary"], "numbers": agg["numbers"]} section.auto_refreshed_at = _naive_now() await db.flush() return section async def refresh_all_auto_data( db: AsyncSession, cycle_id: uuid.UUID, ) -> int: cycle = await get_cycle_row(db, cycle_id) if cycle.status == "finalized": raise ValidationError("the cycle is finalized; reopen it to refresh") sections = (await db.execute( select(AuditorAssessmentSection).where( AuditorAssessmentSection.cycle_id == cycle_id, AuditorAssessmentSection.auto_source.is_not(None), ) )).scalars().all() refreshed = 0 for s in sections: agg = await assessment_data.aggregate( db, s.auto_source, cycle.period_start, cycle.period_end, ) if agg is not None: s.data_table = agg["table"] s.auto_data = {"summary": agg["summary"], "numbers": agg["numbers"]} s.auto_refreshed_at = _naive_now() refreshed += 1 await db.flush() return refreshed # ════════════════════════════════════════════════════════════════════ # Measures tracker (evergreen) # ════════════════════════════════════════════════════════════════════ async def list_measures( db: AsyncSession, *, include_archived: bool = False, ) -> list[dict[str, Any]]: stmt = select(AuditorAssessmentMeasure) if not include_archived: stmt = stmt.where(AuditorAssessmentMeasure.archived.is_(False)) stmt = stmt.order_by( AuditorAssessmentMeasure.sort_order, AuditorAssessmentMeasure.created_at, ) rows = (await db.execute(stmt)).scalars().all() persons = await _persons_index( db, {r.owner_person_id for r in rows if r.owner_person_id}) return [ { "id": str(r.id), "item": r.item, "content": r.content, "measures": r.measures, "status": r.status, "note": r.note, "sort_order": r.sort_order, "archived": bool(r.archived), "owner": _person_brief(persons.get(r.owner_person_id)) if r.owner_person_id else None, } for r in rows ] async def create_measure( db: AsyncSession, *, payload: dict[str, Any], ) -> AuditorAssessmentMeasure: if not (payload.get("item") or "").strip(): raise ValidationError("item is required") status = payload.get("status", "pending") if status not in cat.MEASURE_STATUSES: raise ValidationError(f"status must be one of {cat.MEASURE_STATUSES}") m = AuditorAssessmentMeasure( item=payload["item"].strip(), content=payload.get("content"), measures=payload.get("measures"), owner_person_id=payload.get("owner_person_id"), status=status, note=payload.get("note"), sort_order=int(payload.get("sort_order", 0)), ) db.add(m) await db.flush() return m async def update_measure( db: AsyncSession, measure_id: uuid.UUID, *, payload: dict[str, Any], ) -> AuditorAssessmentMeasure: m = await db.get(AuditorAssessmentMeasure, measure_id) if m is None: raise NotFoundError("measure not found") if "item" in payload and payload["item"]: m.item = payload["item"].strip() if "content" in payload: m.content = payload["content"] if "measures" in payload: m.measures = payload["measures"] if "owner_person_id" in payload: m.owner_person_id = payload["owner_person_id"] if "status" in payload and payload["status"]: if payload["status"] not in cat.MEASURE_STATUSES: raise ValidationError(f"status must be one of {cat.MEASURE_STATUSES}") m.status = payload["status"] if "note" in payload: m.note = payload["note"] if "sort_order" in payload and payload["sort_order"] is not None: m.sort_order = int(payload["sort_order"]) if "archived" in payload and payload["archived"] is not None: m.archived = bool(payload["archived"]) await db.flush() return m async def compare_previous( db: AsyncSession, cycle_id: uuid.UUID, ) -> dict[str, Any]: """Diff this cycle's conclusions against the previous period's cycle.""" cycle = await get_cycle_row(db, cycle_id) prev = (await db.execute( select(AuditorAssessmentCycle) .where(AuditorAssessmentCycle.period_key < cycle.period_key) .order_by(AuditorAssessmentCycle.period_key.desc()) .limit(1) )).scalar_one_or_none() if prev is None: return {"previous": None, "section_changes": [], "overall_from": None, "overall_to": cycle.overall_conclusion} cur_sections = { s.section_key: s for s in (await db.execute( select(AuditorAssessmentSection).where( AuditorAssessmentSection.cycle_id == cycle.id) )).scalars().all() } prev_sections = { s.section_key: s for s in (await db.execute( select(AuditorAssessmentSection).where( AuditorAssessmentSection.cycle_id == prev.id) )).scalars().all() } changes: list[dict[str, Any]] = [] for key, cur in cur_sections.items(): if not cur.conclusion_applicable: continue prev_s = prev_sections.get(key) prev_c = prev_s.conclusion if prev_s else None if prev_c != cur.conclusion: changes.append({ "section_key": key, "title": cur.title, "from": prev_c, "to": cur.conclusion, }) return { "previous": {"period_key": prev.period_key, "overall_conclusion": prev.overall_conclusion}, "section_changes": changes, "overall_from": prev.overall_conclusion, "overall_to": cycle.overall_conclusion, } async def delete_measure(db: AsyncSession, measure_id: uuid.UUID) -> None: m = await db.get(AuditorAssessmentMeasure, measure_id) if m is None: raise NotFoundError("measure not found") await db.delete(m) await db.flush()