mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 15:21:30 +02:00
Choosing what a report contains required editing a template. That is the wrong place for a decision made per report, and the scan launch flow already established the alternative: a saved configuration seeds the run, the run may depart from it, and the saved configuration is never touched. Three declarations put sections on the same footing as stages. SectionRole separates the content a reader asked for from the furniture around it, so a picker can offer twenty-one choices and include the cover, the contents and the reference matter without being asked. Section.order fixes the reading order, which until now was however the directories happened to sort — the executive summary came after attack paths because "a" precedes "e". Section.launch_fields names the settings worth surfacing when the report is generated rather than only in the builder, mirroring Stage.launch_fields; the findings section declares its severities, its detail depth, its roll-up and its asset cap. The estimate had to become honest for any of this to be worth showing. It counted every finding in the scan whatever the section filtered to, so dropping three severities moved nothing. It now counts through the same severity filter the section applies and prices each tier separately, from the real per-severity issue counts rather than an assumed split: 28 pages against 26 rendered for a full template, 10 against 9 for critical and high alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AW5pitnosX8LEFEYxH8gFS
126 lines
3.6 KiB
Python
126 lines
3.6 KiB
Python
"""A section is a unit of the document: what it needs, what it configures, what it renders."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING, ClassVar
|
|
|
|
from reports.config import SectionConfig
|
|
from shared.definitions.reports import ReportSpec, SectionGroup, SectionRole
|
|
|
|
if TYPE_CHECKING:
|
|
from reports.analysis.brief import ReportBrief
|
|
from reports.data.source import ReportSource
|
|
from reports.narrate.base import Narrator
|
|
from shared.definitions.report_theme import ThemeTokens
|
|
|
|
|
|
@dataclass
|
|
class Counters:
|
|
figure: int = 0
|
|
table: int = 0
|
|
|
|
def next_figure(self) -> int:
|
|
self.figure += 1
|
|
return self.figure
|
|
|
|
def next_table(self) -> int:
|
|
self.table += 1
|
|
return self.table
|
|
|
|
|
|
@dataclass
|
|
class RenderContext:
|
|
spec: ReportSpec
|
|
theme: ThemeTokens
|
|
data: ReportSource
|
|
brief: ReportBrief
|
|
narrator: Narrator
|
|
now: datetime
|
|
counters: Counters = field(default_factory=Counters)
|
|
preview: bool = False
|
|
warnings: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def style(self):
|
|
return self.spec.style
|
|
|
|
@property
|
|
def branding(self):
|
|
return self.spec.branding
|
|
|
|
@property
|
|
def palette(self) -> dict[str, str]:
|
|
colour = self.theme.color
|
|
return {
|
|
"ink": colour.ink,
|
|
"ink_soft": colour.ink_soft,
|
|
"ink_faint": colour.ink_faint,
|
|
"surface": colour.surface,
|
|
"accent": colour.accent,
|
|
"accent_ink": colour.accent_ink,
|
|
"rule": colour.rule,
|
|
}
|
|
|
|
def sev(self, severity: str | None) -> str:
|
|
table = self.theme.color.severity
|
|
return table.get((severity or "unknown").lower(), self.theme.color.ink_faint)
|
|
|
|
def hue(self, index: int) -> str:
|
|
scale = self.theme.color.chart or [self.theme.color.accent]
|
|
return scale[index % len(scale)]
|
|
|
|
def warn(self, message: str) -> None:
|
|
if message not in self.warnings:
|
|
self.warnings.append(message)
|
|
|
|
|
|
@dataclass
|
|
class SectionOutput:
|
|
"""What one section contributes to the document."""
|
|
|
|
html: str
|
|
title: str = ""
|
|
in_toc: bool = True
|
|
anchor: str = ""
|
|
page_break: str = "auto"
|
|
bookmarks: list[tuple[str, str]] = field(default_factory=list)
|
|
|
|
|
|
class Section(ABC):
|
|
name: ClassVar[str]
|
|
title: ClassVar[str]
|
|
description: ClassVar[str] = ""
|
|
group: ClassVar[str] = SectionGroup.SUMMARY.value
|
|
role: ClassVar[str] = SectionRole.CONTENT.value
|
|
order: ClassVar[int] = 100
|
|
launch_fields: ClassVar[frozenset[str]] = frozenset()
|
|
requires: ClassVar[frozenset[str]] = frozenset()
|
|
repeatable: ClassVar[bool] = False
|
|
default_enabled: ClassVar[bool] = True
|
|
in_toc: ClassVar[bool] = True
|
|
page_break: ClassVar[str] = "auto"
|
|
template: ClassVar[str] = ""
|
|
config_model: ClassVar[type[SectionConfig]] = SectionConfig
|
|
|
|
@classmethod
|
|
def template_name(cls) -> str:
|
|
return cls.template or f"{cls.name}/section.html"
|
|
|
|
@classmethod
|
|
def defaults(cls) -> dict:
|
|
return cls.config_model().model_dump()
|
|
|
|
@classmethod
|
|
def schema(cls) -> dict:
|
|
return cls.config_model.model_json_schema()
|
|
|
|
def available(self, ctx: RenderContext) -> bool:
|
|
return not self.requires or bool(self.requires & ctx.data.covered_dimensions)
|
|
|
|
@abstractmethod
|
|
def build(self, ctx: RenderContext, cfg: SectionConfig) -> dict | None:
|
|
"""Template variables, or None when the section has nothing to say."""
|