Files
rengine/api/app/services/report.py
T
Yogesh OjhaandClaude Opus 5.5 67242775de feat(reports): templates as a filtered table with default and custom badges
Templates tab in the /scans idiom: All/Default/Custom count tabs, search,
audience and scope filters, rows with a cover thumbnail, section badges with
a +N hover list, format badges and a Generate action that opens the dialog
on that template. Default templates offer View instead of Edit. "Shipped" is
"Default" across report, wordlist, rule and typeface copy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 19:07:57 +05:30

1115 lines
39 KiB
Python

"""Report CRUD, the builder catalog and the hand-off to the worker."""
from __future__ import annotations
from pathlib import Path
from uuid import UUID
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from reports.fonts import vendored
from reports.presets import PRESETS, Preset
from reports.registry import catalog as section_catalog
from reports.registry import section as lookup_section
from reports.theme import ThemeError, builtin_source, builtin_themes, theme_summary
from reports.theme import parse as parse_theme
from shared.definitions.ai import (
REPORT_TASKS,
TASK_OUTPUT_TOKENS,
price,
)
from shared.definitions.compliance import FRAMEWORKS
from shared.definitions.report_fonts import (
DEFAULT_WEIGHT,
FONT_ROLE_HELP,
FONT_ROLE_LABELS,
MAX_FACES,
MAX_FAMILIES,
FontOrigin,
FontRole,
)
from shared.definitions.report_theme import (
COVER_ART_LABELS,
COVER_LAYOUT_LABELS,
FINDING_STYLE_LABELS,
HEADING_STYLE_LABELS,
MAX_THEME_BYTES,
TABLE_STYLE_LABELS,
ThemeOrigin,
ThemeTokens,
)
from shared.definitions.reports import (
AUDIENCE_HELP,
AUDIENCE_LABELS,
DENSITY_LABELS,
DEPTH_LABELS,
FORMAT_LABELS,
MAX_SECTIONS,
PAGE_SIZE_LABELS,
REPORT_ROOT,
SCOPE_HELP,
SCOPE_LABELS,
SECTION_GROUP_LABELS,
SECTION_GROUP_ORDER,
SLOT_TOKENS,
NarrativeOptions,
ReportBranding,
ReportFormat,
ReportSpec,
ReportStatus,
ReportStyle,
SectionEntry,
coerce_scope,
)
from shared.definitions.vulnerabilities import SEVERITY_ORDER, Severity
from shared.models.instance_settings import InstanceSettings
from shared.models.report import (
FontFace,
FrameworkSummary,
Report,
ReportCatalog,
ReportCreate,
ReportDefaults,
ReportEstimate,
ReportFile,
ReportFont,
ReportFontRead,
ReportFontUpload,
ReportRead,
ReportTemplate,
ReportTemplateCreate,
ReportTemplateRead,
ReportTemplateUpdate,
ReportTheme,
ReportThemeRead,
ThemeSummary,
)
from shared.models.scan import Scan
from shared.models.target import Target
from shared.services.ai.config import load_config_async
from shared.services.celery_dispatch import dispatch_report
from shared.services.report_fonts import (
FontError,
clean_name,
decode,
delete_family,
slugify,
store_face,
)
from shared.utils.datetime import utc_now
from shared.utils.slug import generate_slug
_MAX_LIST = 200
class ReportService:
def __init__(self, session: AsyncSession):
self.session = session
# ---------- catalog ----------
async def catalog(self) -> ReportCatalog:
cfg = await load_config_async(self.session)
themes = await self._theme_summaries()
return ReportCatalog(
sections=section_catalog(),
groups=[
{"key": key, "label": SECTION_GROUP_LABELS[key]}
for key in SECTION_GROUP_ORDER
],
themes=themes,
presets=[_preset_payload(p) for p in PRESETS],
fonts=await self.fonts(),
font_roles=[
{"key": k, "label": v, "help": FONT_ROLE_HELP.get(k, "")}
for k, v in FONT_ROLE_LABELS.items()
],
page_sizes=[{"key": k, "label": v} for k, v in PAGE_SIZE_LABELS.items()],
formats=[{"key": k, "label": v} for k, v in FORMAT_LABELS.items()],
scopes=[
{"key": k, "label": v, "help": SCOPE_HELP.get(k, "")}
for k, v in SCOPE_LABELS.items()
],
slot_tokens=[{"token": t.token, "label": t.label} for t in SLOT_TOKENS],
frameworks=[
FrameworkSummary(
key=f.key,
name=f.name,
version=f.version,
description=f.description,
url=f.url,
scope_note=f.scope_note,
controls=[
{"id": c.id, "title": c.title, "note": c.note}
for c in f.controls
],
)
for f in FRAMEWORKS
],
cover_layouts=[
{"key": k, "label": v} for k, v in COVER_LAYOUT_LABELS.items()
],
cover_art=[{"key": k, "label": v} for k, v in COVER_ART_LABELS.items()],
table_styles=[
{"key": k, "label": v} for k, v in TABLE_STYLE_LABELS.items()
],
finding_styles=[
{"key": k, "label": v} for k, v in FINDING_STYLE_LABELS.items()
],
heading_styles=[
{"key": k, "label": v} for k, v in HEADING_STYLE_LABELS.items()
],
audiences=[
{"key": k, "label": v, "help": AUDIENCE_HELP.get(k, "")}
for k, v in AUDIENCE_LABELS.items()
],
depths=[{"key": k, "label": v} for k, v in DEPTH_LABELS.items()],
densities=[{"key": k, "label": v} for k, v in DENSITY_LABELS.items()],
ai_available=bool(cfg and cfg.allows("report_narrative")),
ai_model=cfg.model if cfg else "",
)
async def _theme_summaries(self) -> list[ThemeSummary]:
await self.sync_themes()
rows = (
(
await self.session.execute(
select(ReportTheme).order_by(ReportTheme.origin, ReportTheme.name)
)
)
.scalars()
.all()
)
out: list[ThemeSummary] = []
for row in rows:
try:
tokens = ThemeTokens.model_validate(row.tokens)
except ValueError:
continue
out.append(ThemeSummary(**theme_summary(tokens, row.origin)))
return out
async def sync_themes(self) -> None:
"""Shipped themes are indexed on read."""
changed = False
for slug, tokens in builtin_themes().items():
source = builtin_source(slug)
row = (
(
await self.session.execute(
select(ReportTheme).where(ReportTheme.slug == slug)
)
)
.scalars()
.first()
)
values = {
"name": tokens.name,
"description": tokens.description,
"author": tokens.author,
"version": tokens.version,
"origin": ThemeOrigin.BUILTIN.value,
"tokens": tokens.model_dump(),
"source": source,
"updated_at": utc_now(),
}
if row is None:
self.session.add(ReportTheme(slug=slug, **values))
changed = True
elif row.source != source:
for key, value in values.items():
setattr(row, key, value)
self.session.add(row)
changed = True
if changed:
await self.session.commit()
# ---------- themes ----------
async def themes(self) -> list[ReportThemeRead]:
await self.sync_themes()
rows = (
(
await self.session.execute(
select(ReportTheme).order_by(ReportTheme.origin, ReportTheme.name)
)
)
.scalars()
.all()
)
return [
ReportThemeRead.model_validate(row, from_attributes=True) for row in rows
]
async def theme_source(self, slug: str) -> str:
row = (
(
await self.session.execute(
select(ReportTheme).where(ReportTheme.slug == slug)
)
)
.scalars()
.first()
)
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Theme not found")
return row.source or builtin_source(slug)
async def upload_theme(
self, content: str, user_id: UUID, *, slug: str = ""
) -> ReportThemeRead:
if len(content.encode("utf-8", errors="ignore")) > MAX_THEME_BYTES:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Theme file exceeds {MAX_THEME_BYTES} bytes.",
)
try:
tokens = parse_theme(content, slug=slug)
except ThemeError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
existing = (
(
await self.session.execute(
select(ReportTheme).where(ReportTheme.slug == tokens.key)
)
)
.scalars()
.first()
)
if existing is not None and existing.origin == ThemeOrigin.BUILTIN.value:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"'{tokens.key}' is a default theme. Choose a different key.",
)
values = {
"name": tokens.name,
"description": tokens.description,
"author": tokens.author,
"version": tokens.version,
"origin": ThemeOrigin.CUSTOM.value,
"tokens": tokens.model_dump(),
"source": content,
"uploaded_by": user_id,
"updated_at": utc_now(),
}
if existing is None:
row = ReportTheme(slug=tokens.key, **values)
self.session.add(row)
else:
row = existing
for key, value in values.items():
setattr(row, key, value)
self.session.add(row)
await self.session.commit()
await self.session.refresh(row)
return ReportThemeRead.model_validate(row, from_attributes=True)
async def delete_theme(self, slug: str) -> None:
row = (
(
await self.session.execute(
select(ReportTheme).where(ReportTheme.slug == slug)
)
)
.scalars()
.first()
)
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Theme not found")
if row.origin == ThemeOrigin.BUILTIN.value:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Default themes are read-only."
)
await self.session.delete(row)
await self.session.commit()
# ---------- fonts ----------
async def fonts(self) -> list[ReportFontRead]:
rows = (
(await self.session.execute(select(ReportFont).order_by(ReportFont.name)))
.scalars()
.all()
)
return [*vendored(), *[_font_read(row) for row in rows]]
async def upload_font(
self, data: ReportFontUpload, user_id: UUID
) -> ReportFontRead:
if data.role not in tuple(r.value for r in FontRole):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Choose a sans, serif or monospaced role."
)
if len(data.faces) > MAX_FACES:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"A family may hold at most {MAX_FACES} faces.",
)
count = int(await self.session.scalar(select(func.count(ReportFont.id))) or 0)
try:
name = clean_name(data.name)
slug = slugify(name)
except FontError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
existing = (
(
await self.session.execute(
select(ReportFont).where(ReportFont.slug == slug)
)
)
.scalars()
.first()
)
if existing is None and count >= MAX_FAMILIES:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"The typeface limit is {MAX_FAMILIES}. Delete one first.",
)
if slug in {f.slug for f in vendored()}:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"'{slug}' is a default typeface. Choose a different name.",
)
faces: list[dict] = []
try:
for face in data.faces:
faces.append(
store_face(
slug,
decode(face.content),
weight=face.weight,
italic=face.italic,
)
)
except FontError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
values = {
"name": name,
"role": data.role,
"origin": FontOrigin.CUSTOM.value,
"note": data.note.strip(),
"faces": faces,
"bytes": sum(f["bytes"] for f in faces),
"uploaded_by": user_id,
"updated_at": utc_now(),
}
if existing is None:
row = ReportFont(slug=slug, **values)
self.session.add(row)
else:
row = existing
for key, value in values.items():
setattr(row, key, value)
self.session.add(row)
await self.session.commit()
await self.session.refresh(row)
return _font_read(row)
async def delete_font(self, slug: str) -> None:
row = (
(
await self.session.execute(
select(ReportFont).where(ReportFont.slug == slug)
)
)
.scalars()
.first()
)
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Typeface not found")
try:
delete_family(row.slug, row.origin)
except FontError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
await self.session.delete(row)
await self.session.commit()
# ---------- instance defaults ----------
async def _settings(self) -> InstanceSettings:
row = (
(await self.session.execute(select(InstanceSettings).limit(1)))
.scalars()
.first()
)
if row is None:
row = InstanceSettings()
self.session.add(row)
await self.session.commit()
await self.session.refresh(row)
return row
async def defaults(self) -> ReportDefaults:
row = await self._settings()
return ReportDefaults.model_validate(row.report_defaults or {})
async def set_defaults(self, data: ReportDefaults) -> ReportDefaults:
row = await self._settings()
row.report_defaults = data.model_dump()
row.updated_at = utc_now()
self.session.add(row)
await self.session.commit()
return data
# ---------- templates ----------
async def seed_templates(self, project_id: UUID) -> None:
count = await self.session.scalar(
select(func.count(ReportTemplate.id)).where(
ReportTemplate.project_id == project_id,
ReportTemplate.is_builtin.is_(True),
)
)
if count:
return
for preset in PRESETS:
style = ReportStyle(theme=preset.theme, density=preset.density)
narrative = NarrativeOptions(audience=preset.audience, depth=preset.depth)
self.session.add(
ReportTemplate(
project_id=project_id,
slug=preset.slug,
name=preset.name,
description=preset.description,
title=preset.title,
subtitle=preset.subtitle,
preset=preset.slug,
tags=list(preset.tags),
scope=preset.scope,
sections=[e.model_dump() for e in preset.entries()],
theme=preset.theme,
style=style.model_dump(),
branding=ReportBranding().model_dump(),
narrative=narrative.model_dump(),
formats=list(preset.formats),
is_builtin=True,
is_default=preset.default,
)
)
try:
await self.session.commit()
except IntegrityError:
await self.session.rollback()
async def templates(self, project_id: UUID) -> list[ReportTemplateRead]:
await self.seed_templates(project_id)
rows = (
(
await self.session.execute(
select(ReportTemplate)
.where(ReportTemplate.project_id == project_id)
.order_by(ReportTemplate.is_builtin.desc(), ReportTemplate.name)
)
)
.scalars()
.all()
)
return [_template_read(row) for row in rows]
async def template(self, template_id: UUID, project_id: UUID) -> ReportTemplate:
row = await self.session.get(ReportTemplate, template_id)
if row is None or row.project_id != project_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Report template not found")
return row
async def create_template(
self, data: ReportTemplateCreate, project_id: UUID, user_id: UUID
) -> ReportTemplateRead:
sections = data.sections
if data.clone_of is not None:
source = await self.template(data.clone_of, project_id)
sections = sections or [
SectionEntry.model_validate(s) for s in source.sections
]
base_style = ReportStyle.model_validate(source.style or {})
base_branding = ReportBranding.model_validate(source.branding or {})
base_narrative = NarrativeOptions.model_validate(source.narrative or {})
else:
base_style, base_branding, base_narrative = (
data.style,
data.branding,
data.narrative,
)
_validate_sections(sections)
row = ReportTemplate(
project_id=project_id,
slug=await self._unique_slug(project_id, data.name),
name=data.name,
description=data.description,
title=data.title,
subtitle=data.subtitle,
scope=coerce_scope(data.scope),
sections=[s.model_dump() for s in sections],
theme=data.theme or base_style.theme,
style=base_style.model_dump(),
branding=base_branding.model_dump(),
narrative=base_narrative.model_dump(),
formats=data.formats or [ReportFormat.PDF.value],
created_by=user_id,
)
self.session.add(row)
await self.session.commit()
await self.session.refresh(row)
return _template_read(row)
async def update_template(
self, template_id: UUID, project_id: UUID, data: ReportTemplateUpdate
) -> ReportTemplateRead:
row = await self.template(template_id, project_id)
if row.is_builtin:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Default templates are read-only. Duplicate the template to edit it.",
)
payload = data.model_dump(exclude_unset=True)
if data.sections is not None:
_validate_sections(data.sections)
row.sections = [s.model_dump() for s in data.sections]
payload.pop("sections", None)
for key in ("style", "branding", "narrative"):
value = payload.pop(key, None)
if value is not None:
setattr(row, key, value)
for key, value in payload.items():
if value is not None:
setattr(row, key, value)
if data.theme:
row.theme = data.theme
style = dict(row.style or {})
style["theme"] = data.theme
row.style = style
row.updated_at = utc_now()
self.session.add(row)
await self.session.commit()
await self.session.refresh(row)
return _template_read(row)
async def delete_template(self, template_id: UUID, project_id: UUID) -> None:
row = await self.template(template_id, project_id)
if row.is_builtin:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Shipped templates are read-only."
)
await self.session.delete(row)
await self.session.commit()
async def _unique_slug(self, project_id: UUID, name: str) -> str:
base = generate_slug(name)[:56] or "report"
slug = base
index = 2
while await self.session.scalar(
select(func.count(ReportTemplate.id)).where(
ReportTemplate.project_id == project_id, ReportTemplate.slug == slug
)
):
slug = f"{base}-{index}"
index += 1
return slug
# ---------- reports ----------
async def _subject(
self, data: ReportCreate, project_id: UUID
) -> tuple[Scan | None, Target]:
scan = None
target = None
if data.scan_id is not None:
scan = await self.session.get(Scan, data.scan_id)
if scan is None or scan.project_id != project_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Scan not found")
target = await self.session.get(Target, scan.target_id)
elif data.target_id is not None:
target = await self.session.get(Target, data.target_id)
if target is None or target.project_id != project_id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Choose a scan or a target for this report.",
)
return scan, target
async def _require_known_theme(self, slug: str | None) -> None:
"""Reject an unknown theme slug."""
if not slug:
return
known = set(builtin_themes())
rows = await self.session.execute(select(ReportTheme.slug))
known |= set(rows.scalars().all())
if slug not in known:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"No theme named '{slug}'. Available: {', '.join(sorted(known))}."
),
)
async def build_spec(
self, data: ReportCreate, project_id: UUID
) -> tuple[ReportSpec, ReportTemplate | None]:
template = None
if data.template_id is not None:
template = await self.template(data.template_id, project_id)
sections = data.sections
if sections is None and template is not None:
sections = [SectionEntry.model_validate(s) for s in template.sections]
if not sections:
preset = PRESETS[0]
sections = preset.entries()
_validate_sections(sections)
style = data.style or ReportStyle.model_validate(
(template.style if template else {}) or {}
)
if data.theme:
style.theme = data.theme
elif template is not None and template.theme:
style.theme = template.theme
branding = data.branding or ReportBranding.model_validate(
(template.branding if template else {}) or {}
)
defaults = await self.defaults()
branding = _fill_empty(branding, defaults.branding)
if not style.theme and defaults.theme:
style.theme = defaults.theme
await self._require_known_theme(style.theme)
narrative = data.narrative or NarrativeOptions.model_validate(
(template.narrative if template else {}) or {}
)
cfg = await load_config_async(self.session)
if narrative.ai_enabled and not (cfg and cfg.allows("report_narrative")):
narrative.ai_enabled = False
title = (
data.title
or (template.title if template else "")
or "Security Assessment Report"
)
subtitle = data.subtitle or (template.subtitle if template else "")
formats = (
data.formats
or (template.formats if template else None)
or [ReportFormat.PDF.value]
)
return (
ReportSpec(
title=title,
subtitle=subtitle,
scope=coerce_scope(
data.scope or (template.scope if template else None)
),
sections=sections,
style=style,
branding=branding,
narrative=narrative,
formats=[f for f in formats if f in FORMAT_LABELS],
),
template,
)
async def create(
self, data: ReportCreate, project_id: UUID, user_id: UUID
) -> ReportRead:
scan, target = await self._subject(data, project_id)
spec, template = await self.build_spec(data, project_id)
report = Report(
project_id=project_id,
template_id=template.id if template else None,
template_name=template.name if template else "Custom",
scope=spec.scope,
scan_id=scan.id if scan else None,
target_id=target.id,
subject=target.target_value,
title=spec.title,
spec=spec.model_dump(),
status=ReportStatus.QUEUED.value,
step="Queued",
created_by=user_id,
)
self.session.add(report)
if template is not None:
template.used_count += 1
template.last_used_at = utc_now()
self.session.add(template)
await self.session.commit()
await self.session.refresh(report)
dispatch_report(str(report.id))
return self.to_read(report)
async def _volumes(
self, scan: Scan | None, severities: list[str] | None = None
) -> tuple[int, int, int, dict[str, int]]:
"""Finding, issue and asset counts, filtered the way the sections filter."""
if scan is None:
return (0, 0, 0, {})
from shared.models.subdomain import Subdomain # noqa: PLC0415
from shared.models.vulnerability import Vulnerability # noqa: PLC0415
scoped = [Vulnerability.scan_id == scan.id]
if severities is not None:
scoped.append(Vulnerability.severity.in_(severities))
findings = int(
await self.session.scalar(
select(func.count(Vulnerability.id)).where(*scoped)
)
or 0
)
issues = int(
await self.session.scalar(
select(func.count(func.distinct(Vulnerability.template_id))).where(
*scoped
)
)
or 0
)
rows = (
await self.session.execute(
select(
Vulnerability.severity,
func.count(func.distinct(Vulnerability.template_id)),
)
.where(*scoped)
.group_by(Vulnerability.severity)
)
).all()
by_severity = {str(severity): int(count) for severity, count in rows}
assets = int(
await self.session.scalar(
select(func.count(Subdomain.id)).where(Subdomain.scan_id == scan.id)
)
or 0
)
return (findings, issues, assets, by_severity)
async def estimate(self, data: ReportCreate, project_id: UUID) -> ReportEstimate:
scan, _target = await self._subject(data, project_id)
spec, _ = await self.build_spec(data, project_id)
cfg = await load_config_async(self.session)
enabled = [s for s in spec.sections if s.enabled]
findings_cfg = _section_config(enabled, "findings_detail")
findings, issues, assets, by_severity = await self._volumes(
scan, findings_cfg.get("severities") if findings_cfg else None
)
estimate = ReportEstimate(
sections=len(enabled),
findings=findings,
assets=assets,
pages_estimated=_pages(
enabled,
issues=issues,
assets=assets,
by_severity=by_severity,
chapter_breaks=spec.style.chapter_breaks,
),
)
if spec.narrative.ai_enabled and cfg is not None:
calls = len(REPORT_TASKS)
if spec.narrative.explain_findings:
calls += min(spec.narrative.max_explained_issues, max(1, findings // 8))
estimate.ai_calls = calls
estimate.ai_input_tokens = calls * 3000
estimate.ai_output_tokens = (
sum(TASK_OUTPUT_TOKENS.get(t, 900) for t in REPORT_TASKS)
+ (calls - len(REPORT_TASKS)) * 500
)
cost = price(cfg.model, estimate.ai_input_tokens, estimate.ai_output_tokens)
estimate.ai_cost_usd = round(cost, 4) if cost else 0.0
if cost is None:
estimate.warnings.append(
"No per-token pricing for this provider. Cost not estimated."
)
for entry in enabled:
spec_row = lookup_section(entry.section)
if spec_row is None:
estimate.warnings.append(f"Unknown section '{entry.section}'. Skipped.")
return estimate
async def list(
self,
project_id: UUID,
*,
scan_id: UUID | None = None,
target_id: UUID | None = None,
) -> list[ReportRead]:
query = select(Report).where(Report.project_id == project_id)
if scan_id is not None:
query = query.where(Report.scan_id == scan_id)
if target_id is not None:
query = query.where(Report.target_id == target_id)
rows = (
(
await self.session.execute(
query.order_by(Report.created_at.desc()).limit(_MAX_LIST)
)
)
.scalars()
.all()
)
return [self.to_read(row) for row in rows]
async def get(self, report_id: UUID, project_id: UUID) -> Report:
row = await self.session.get(Report, report_id)
if row is None or row.project_id != project_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Report not found")
return row
async def delete(self, report_id: UUID, project_id: UUID) -> None:
row = await self.get(report_id, project_id)
root = Path(REPORT_ROOT) / str(row.id)
if root.exists():
for item in root.iterdir():
item.unlink(missing_ok=True)
root.rmdir()
await self.session.delete(row)
await self.session.commit()
async def retry(self, report_id: UUID, project_id: UUID) -> ReportRead:
row = await self.get(report_id, project_id)
row.status = ReportStatus.QUEUED.value
row.progress = 0
row.step = "Queued"
row.error = None
row.started_at = None
row.completed_at = None
self.session.add(row)
await self.session.commit()
await self.session.refresh(row)
dispatch_report(str(row.id))
return self.to_read(row)
def file_path(self, report: Report, fmt: str) -> tuple[Path, str]:
for entry in report.files or []:
if entry.get("format") == fmt:
path = Path(REPORT_ROOT) / str(report.id) / str(entry.get("filename"))
if path.is_file():
return path, str(entry.get("filename"))
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Format not generated for this report."
)
@staticmethod
def to_read(report: Report) -> ReportRead:
spec = report.spec or {}
style = spec.get("style") or {}
return ReportRead(
id=report.id,
project_id=report.project_id,
template_id=report.template_id,
template_name=report.template_name,
scope=report.scope,
scan_id=report.scan_id,
target_id=report.target_id,
subject=report.subject,
title=report.title,
status=report.status,
progress=report.progress,
step=report.step,
error=report.error,
files=[ReportFile.model_validate(f) for f in (report.files or [])],
page_count=report.page_count,
stats=report.stats or {},
theme=style.get("theme", ""),
formats=[f.get("format") for f in (report.files or [])]
or list(spec.get("formats") or []),
ai_used=report.ai_used,
ai_model=report.ai_model,
ai_calls=report.ai_calls,
ai_input_tokens=report.ai_input_tokens,
ai_output_tokens=report.ai_output_tokens,
ai_cached_calls=report.ai_cached_calls,
duration_seconds=report.duration_seconds,
created_by=report.created_by,
created_at=report.created_at,
started_at=report.started_at,
completed_at=report.completed_at,
expires_at=report.expires_at,
)
_ROWS_PER_PAGE = 42
_PAGES_PER_ISSUE = 0.9
_PAGES_PER_ISSUE_EVIDENCE = 1.8
_ROLLED_PER_PAGE = 26
_QUIET_SEVERITIES = frozenset({Severity.INFO.value, Severity.UNKNOWN.value})
_PAGE_SAVED_PER_RUN_ON = 0.4
_PAGE_TAIL_PER_CHAPTER = 0.3
def _font_read(row: ReportFont) -> ReportFontRead:
faces = list(row.faces or [])
return ReportFontRead(
id=row.id,
slug=row.slug,
name=row.name,
role=row.role,
origin=row.origin,
note=row.note,
faces=[FontFace.model_validate(f) for f in faces],
weights=sorted({int(f.get("weight", DEFAULT_WEIGHT)) for f in faces}),
bytes=row.bytes,
created_at=row.created_at,
)
def _section_config(sections: list[SectionEntry], name: str) -> dict | None:
entry = next((e for e in sections if e.section == name), None)
if entry is None:
return None
spec = lookup_section(name)
return {**(spec.defaults if spec else {}), **(entry.config or {})}
def _pages(
sections: list[SectionEntry],
*,
issues: int,
assets: int,
by_severity: dict[str, int] | None = None,
chapter_breaks: bool = True,
) -> int:
"""Estimated page count from each section's limits."""
total = 0.0
for entry in sections:
spec = lookup_section(entry.section)
if spec is None:
continue
config = {**spec.defaults, **(entry.config or {})}
name = spec.name
if name == "findings_detail":
weight = (
_PAGES_PER_ISSUE_EVIDENCE
if config.get("show_evidence", True)
else _PAGES_PER_ISSUE
)
counts = by_severity or {}
shown = min(issues, int(config.get("max_issues", 60)))
scale = shown / issues if issues else 0.0
quiet = (
sum(counts.get(s, 0) for s in _QUIET_SEVERITIES)
if config.get("roll_up_info", True)
else 0
)
cut = SEVERITY_ORDER.index(config.get("detail_from", Severity.MEDIUM.value))
brief = sum(
count
for severity, count in counts.items()
if severity in SEVERITY_ORDER
and severity not in _QUIET_SEVERITIES
and SEVERITY_ORDER.index(severity) > cut
)
full = max(0, issues - quiet - brief)
total += scale * (
full * weight + brief * _PAGES_PER_ISSUE + quiet / _ROLLED_PER_PAGE
)
elif name == "appendix_assets":
total += max(1.0, min(assets, int(config.get("max_rows", 1500))) / 260)
elif "max_rows" in config:
total += max(1.0, min(assets, int(config["max_rows"])) / _ROWS_PER_PAGE)
elif name == "screenshots":
per_page = max(1, int(config.get("columns", 2) or 2)) * 3
total += max(1.0, int(config.get("max_images", 12)) / per_page)
elif name == "cover":
total += 1
else:
total += 1
tails = max(0, len(sections) - 2)
total += (
_PAGE_TAIL_PER_CHAPTER if chapter_breaks else -_PAGE_SAVED_PER_RUN_ON
) * tails
return max(2, round(total))
def _fill_empty(branding: ReportBranding, defaults: ReportBranding) -> ReportBranding:
"""Instance defaults fill what a template left blank."""
merged = branding.model_dump()
for key, fallback in defaults.model_dump().items():
if not merged.get(key) and fallback:
merged[key] = fallback
return ReportBranding.model_validate(merged)
def _validate_sections(sections: list[SectionEntry]) -> None:
if len(sections) > MAX_SECTIONS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"A report may hold at most {MAX_SECTIONS} sections.",
)
seen: set[str] = set()
for entry in sections:
spec = lookup_section(entry.section)
if spec is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"Unknown section '{entry.section}'."
)
if not spec.repeatable and entry.section in seen:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"'{spec.title}' can only appear once in a report.",
)
seen.add(entry.section)
try:
spec.config(entry.config)
except ValueError as exc:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, f"{spec.title}: {exc}"
) from exc
def _template_read(row: ReportTemplate) -> ReportTemplateRead:
return ReportTemplateRead(
id=row.id,
project_id=row.project_id,
slug=row.slug,
name=row.name,
description=row.description,
title=row.title,
subtitle=row.subtitle,
preset=row.preset,
tags=list(row.tags or []),
scope=row.scope,
sections=[SectionEntry.model_validate(s) for s in (row.sections or [])],
theme=row.theme,
style=ReportStyle.model_validate(row.style or {}),
branding=ReportBranding.model_validate(row.branding or {}),
narrative=NarrativeOptions.model_validate(row.narrative or {}),
formats=list(row.formats or []),
is_builtin=row.is_builtin,
is_default=row.is_default,
used_count=row.used_count,
last_used_at=row.last_used_at,
created_at=row.created_at,
updated_at=row.updated_at,
)
def _preset_payload(preset: Preset) -> dict:
return {
"slug": preset.slug,
"name": preset.name,
"description": preset.description,
"scope": preset.scope,
"theme": preset.theme,
"title": preset.title,
"subtitle": preset.subtitle,
"audience": preset.audience,
"depth": preset.depth,
"density": preset.density,
"formats": list(preset.formats),
"tags": list(preset.tags),
"sections": [
{"section": name, "config": dict(config)}
for name, config in preset.sections
],
}