"""信息安全自评估 — Word(.docx) + PDF report rendering. Renders a finalized-or-draft cycle into a downloadable report that mirrors the legacy 平凯星辰 ISMS report layout: title + metadata, numbered control domains (each with 评估描述 + 评估结论 + auto-filled data table), an 整改措施 appendix, and the overall conclusion. PDF uses reportlab's built-in ``STSong-Light`` CID font for Chinese (no TTF bundling). Both formats are generated synchronously into a ``BytesIO`` and streamed — a report is small, so the async evidence-pack queue is overkill. """ from __future__ import annotations import io from html import escape from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.auditor.models import ( AuditorAssessmentCycle, AuditorAssessmentSection, ) DOCX_MEDIA_TYPE = ( "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ) PDF_MEDIA_TYPE = "application/pdf" _CONCLUSION_ZH = { "compliant": "符合", "partial": "部分符合", "non_compliant": "不符合", "not_assessed": "未评估", } _MEASURE_STATUS_ZH = { "pending": "待开展", "in_progress": "进行中", "done": "已完成", } async def _collect(db: AsyncSession, cycle_id) -> dict[str, Any]: from app.domains.auditor import assessment_service as svc cycle = await db.get(AuditorAssessmentCycle, cycle_id) if cycle is None: from app.core.exceptions import NotFoundError raise NotFoundError("assessment cycle not found") sections = (await db.execute( select(AuditorAssessmentSection) .where(AuditorAssessmentSection.cycle_id == cycle_id) .order_by(AuditorAssessmentSection.sort_order) )).scalars().all() # Build a numbered, hierarchical render list (top-level then children). top = [s for s in sections if not s.parent_key] children: dict[str, list[AuditorAssessmentSection]] = {} for s in sections: if s.parent_key: children.setdefault(s.parent_key, []).append(s) for lst in children.values(): lst.sort(key=lambda x: x.sort_order) items: list[dict[str, Any]] = [] def _emit(s: AuditorAssessmentSection, num: str, level: int) -> None: items.append({ "level": level, "num": num, "title": s.title, "narrative": s.narrative or "", "conclusion_zh": ( _CONCLUSION_ZH.get(s.conclusion, s.conclusion) if s.conclusion_applicable else None ), "columns": s.table_columns or [], "rows": s.data_table or [], }) n = 0 for s in sorted(top, key=lambda x: x.sort_order): n += 1 _emit(s, str(n), 1) for i, child in enumerate(children.get(s.section_key, []), 1): _emit(child, f"{n}.{i}", 2) measures = await svc.list_measures(db, include_archived=False) period = ( f"{cycle.period_start.isoformat()} ~ {cycle.period_end.isoformat()}" ) return { "title": cycle.title, "report_date": cycle.report_date.isoformat() if cycle.report_date else "", "period": period, "status": cycle.status, "overall_conclusion_zh": _CONCLUSION_ZH.get( cycle.overall_conclusion, cycle.overall_conclusion), "items": items, "measures": measures, "filename_stem": f"security_self_assessment_{cycle.period_key}", } # ════════════════════════════════════════════════════════════════════ # Word (.docx) # ════════════════════════════════════════════════════════════════════ async def build_docx(db: AsyncSession, cycle_id) -> tuple[io.BytesIO, str]: from docx import Document from docx.shared import Pt model = await _collect(db, cycle_id) doc = Document() doc.add_heading(model["title"], level=0) meta = doc.add_paragraph() meta.add_run(f"报告日期:{model['report_date']}\n") meta.add_run(f"评估时间范围:{model['period']}\n") meta.add_run(f"总体结论:{model['overall_conclusion_zh']}") for it in model["items"]: doc.add_heading(f"{it['num']} {it['title']}", level=1 if it["level"] == 1 else 2) if it["narrative"]: doc.add_paragraph(it["narrative"]) if it["conclusion_zh"]: p = doc.add_paragraph() run = p.add_run(f"评估结论:{it['conclusion_zh']}") run.bold = True if it["columns"] and it["rows"]: cols = it["columns"] table = doc.add_table(rows=1, cols=len(cols)) table.style = "Light Grid Accent 1" for j, col in enumerate(cols): table.rows[0].cells[j].text = str(col[1] if len(col) > 1 else col[0]) for row in it["rows"]: cells = table.add_row().cells for j, col in enumerate(cols): key = col[0] cells[j].text = str(row.get(key, "")) if model["measures"]: doc.add_heading("整改措施跟踪", level=1) cols = ["评估项", "主要内容", "具体举措", "落地负责人", "当前状态", "说明"] table = doc.add_table(rows=1, cols=len(cols)) table.style = "Light Grid Accent 1" for j, label in enumerate(cols): table.rows[0].cells[j].text = label for m in model["measures"]: cells = table.add_row().cells owner = (m.get("owner") or {}).get("display_name") or "" cells[0].text = m.get("item") or "" cells[1].text = m.get("content") or "" cells[2].text = m.get("measures") or "" cells[3].text = owner cells[4].text = _MEASURE_STATUS_ZH.get(m.get("status"), m.get("status") or "") cells[5].text = m.get("note") or "" for p in doc.paragraphs: for run in p.runs: run.font.size = run.font.size or Pt(11) buf = io.BytesIO() doc.save(buf) buf.seek(0) return buf, model["filename_stem"] # ════════════════════════════════════════════════════════════════════ # PDF (reportlab + STSong-Light CID font) # ════════════════════════════════════════════════════════════════════ _FONT_NAME = "STSong-Light" _font_registered = False def _ensure_font() -> None: global _font_registered if _font_registered: return from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.cidfonts import UnicodeCIDFont pdfmetrics.registerFont(UnicodeCIDFont(_FONT_NAME)) _font_registered = True async def build_pdf(db: AsyncSession, cycle_id) -> tuple[io.BytesIO, str]: from reportlab.lib import colors from reportlab.lib.enums import TA_LEFT from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet from reportlab.lib.units import mm from reportlab.platypus import ( Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) _ensure_font() model = await _collect(db, cycle_id) base = getSampleStyleSheet() title_style = ParagraphStyle( "CJKTitle", parent=base["Title"], fontName=_FONT_NAME, leading=24) h1 = ParagraphStyle( "CJKH1", parent=base["Heading1"], fontName=_FONT_NAME, leading=18) h2 = ParagraphStyle( "CJKH2", parent=base["Heading2"], fontName=_FONT_NAME, leading=16) body = ParagraphStyle( "CJKBody", parent=base["Normal"], fontName=_FONT_NAME, fontSize=10, leading=15, alignment=TA_LEFT) def P(text: str, style): # noqa: N802 return Paragraph(escape(str(text)).replace("\n", "
"), style) story: list[Any] = [P(model["title"], title_style), Spacer(1, 6)] story.append(P(f"报告日期:{model['report_date']}", body)) story.append(P(f"评估时间范围:{model['period']}", body)) story.append(P(f"总体结论:{model['overall_conclusion_zh']}", body)) story.append(Spacer(1, 10)) for it in model["items"]: story.append(P(f"{it['num']} {it['title']}", h1 if it["level"] == 1 else h2)) if it["narrative"]: story.append(P(it["narrative"], body)) if it["conclusion_zh"]: story.append(P(f"评估结论:{it['conclusion_zh']}", body)) if it["columns"] and it["rows"]: cols = it["columns"] header = [str(c[1] if len(c) > 1 else c[0]) for c in cols] data = [header] for row in it["rows"]: data.append([P(str(row.get(c[0], "")), body) for c in cols]) tbl = Table(data, repeatRows=1, hAlign="LEFT") tbl.setStyle(TableStyle([ ("FONTNAME", (0, 0), (-1, -1), _FONT_NAME), ("FONTSIZE", (0, 0), (-1, -1), 9), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4472C4")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("GRID", (0, 0), (-1, -1), 0.4, colors.grey), ("VALIGN", (0, 0), (-1, -1), "TOP"), ])) story.append(Spacer(1, 4)) story.append(tbl) story.append(Spacer(1, 8)) if model["measures"]: story.append(P("整改措施跟踪", h1)) cols = ["评估项", "主要内容", "具体举措", "负责人", "状态", "说明"] data = [cols] for m in model["measures"]: owner = (m.get("owner") or {}).get("display_name") or "" data.append([ P(m.get("item") or "", body), P(m.get("content") or "", body), P(m.get("measures") or "", body), P(owner, body), P(_MEASURE_STATUS_ZH.get(m.get("status"), m.get("status") or ""), body), P(m.get("note") or "", body), ]) tbl = Table(data, repeatRows=1, hAlign="LEFT", colWidths=[ 30 * mm, 35 * mm, 35 * mm, 22 * mm, 18 * mm, 30 * mm]) tbl.setStyle(TableStyle([ ("FONTNAME", (0, 0), (-1, -1), _FONT_NAME), ("FONTSIZE", (0, 0), (-1, -1), 8), ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#4472C4")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ("GRID", (0, 0), (-1, -1), 0.4, colors.grey), ("VALIGN", (0, 0), (-1, -1), "TOP"), ])) story.append(Spacer(1, 4)) story.append(tbl) buf = io.BytesIO() doc = SimpleDocTemplate( buf, pagesize=A4, topMargin=18 * mm, bottomMargin=18 * mm, leftMargin=18 * mm, rightMargin=18 * mm, title=model["title"], ) doc.build(story) buf.seek(0) return buf, model["filename_stem"]