Files
rengine/worker/app/tasks/reports.py
T
Yogesh OjhaandClaude Opus 5 ed5f8f3f16 feat(notifications): a notification is an interrupt, the activity log is the ledger
The in-app feed was a second copy of the activity log with a red badge on it.
A scan published up to six notifications (started, completed, new subdomains,
new services, new vulnerabilities, coverage), every one of them already an
activity_logs row and every one of them toasted for five seconds. A nightly
twenty-target schedule left forty to a hundred and twenty unread rows.

If it does not want a human to do something, it is not a notification.

Deleted scan_started, scan_completed and scan_cancelled (both the API-side echo
of the user's own Cancel click and the finalize one). whois/ripestat
enrichment_complete became enrichment_incomplete and return None unless
something actually failed.

One digest replaces the five scan templates. scan_digest(...) -> dict | None
takes a ScanDeltas and derives its severity, title, type and deep-link tab from
the worst thing inside it, and returns None when nothing changed, so a quiet
scheduled run is silent. Severity now encodes risk rather than process:
scan_failed is warning, not error, because the bell takes its colour from the
highest unread severity and one flaky run should not read like a KEV.

Toasts are interrupts only: warning and error toast, success and info land
silently in the bell. A toast with a url finally gets a View action; it used to
require an action_label no template ever set.

notifications.project_id scopes the bell to the active project (nullable and
indexed, no FK so a transient row can never block a project delete). The list,
stats, read-all and clear-all endpoints take ?project_id=, and the store drops
SSE events from other projects.

app.tasks.notifications.cleanup runs on beat every six hours. expires_at had
been decorative since nothing ever called cleanup_expired, and the inbox modal
fetched stats.total rows in a single request.

Three bugs this uncovered:

_count_new_subdomains had no baseline gate while services and vulnerabilities
did, so a target's first scan announced its whole inventory as new: the first
gov.np run would have reported 24022 new subdomains. Baselines are now per
dimension, and a run with none reports the census instead.

The delta counts ignored census_only, so a focused rescan both counted as a
baseline and had its own rows announced. Every comparison now joins
scans bs ... scope = 'full' AND bs.started_at < :started.

Measuring the deltas took 16.8s on a 13,528-host scan: the scan-level baseline
was a correlated EXISTS re-evaluated once per row and could not use
ix_subdomains_target_name_discovered, having no name predicate. Hoisting it to
one scan-level check and rewriting the per-row test as a hash anti-join over a
WITH seen AS (SELECT DISTINCT ...) CTE gives the identical answer in 52ms,
verified against a plain EXCEPT query.

Not done, deliberately: is_read is still one global flag, so on a shared
instance one analyst's Mark all as read still applies to everyone; that needs a
notification_reads table. And notifier.wants() still governs external channels
only, the bell having no preference layer, because volume reduction was the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCPTXCr5qPESCt3LxR3329
2026-09-06 18:13:08 +05:30

248 lines
7.9 KiB
Python

"""Report generation runs here so the api never blocks on a render."""
from __future__ import annotations
import time
from datetime import timedelta
from pathlib import Path
from uuid import UUID
from celery import shared_task
from app.config import settings
from app.database import get_sync_session
from reports import theme_store
from reports.pipeline import generate
from shared.definitions.reports import (
FORMAT_EXTENSIONS,
REPORT_ROOT,
RETENTION_DAYS,
ReportSpec,
ReportStatus,
)
from shared.enums.notification import NotificationSeverity, NotificationType
from shared.enums.sse import SSEChannel, SSEEventType
from shared.logging import get_logger
from shared.models.report import Report
from shared.models.scan import Scan
from shared.models.target import Target
from shared.services.event_publisher import SyncEventPublisher
from shared.services.notification_sync import SyncNotificationPublisher
from shared.utils.datetime import utc_now
from shared.utils.slug import generate_slug
logger = get_logger(__name__)
def _root(report_id: UUID) -> Path:
path = Path(REPORT_ROOT) / str(report_id)
path.mkdir(parents=True, exist_ok=True)
return path
def _publisher() -> SyncEventPublisher | None:
try:
return SyncEventPublisher(settings.celery_broker_url)
except Exception:
logger.warning("report events unavailable")
return None
def _announce(publisher, report: Report) -> None:
if publisher is None:
return
payload = {
"id": str(report.id),
"status": report.status,
"progress": report.progress,
"step": report.step,
"error": report.error,
"page_count": report.page_count,
}
publisher.publish(
SSEChannel.report(str(report.id)), SSEEventType.REPORT.value, payload
)
publisher.publish(
SSEChannel.project(str(report.project_id)), SSEEventType.REPORT.value, payload
)
@shared_task(bind=True, name="app.tasks.reports.generate", max_retries=0)
def generate_report(self, report_id: str) -> dict:
started = time.monotonic()
publisher = _publisher()
with get_sync_session() as session:
report = session.get(Report, UUID(report_id))
if report is None:
logger.warning("report missing", report_id=report_id)
return {"status": "missing"}
report.status = ReportStatus.RUNNING.value
report.started_at = utc_now()
report.task_id = self.request.id
report.progress = 5
report.step = "Starting"
report.error = None
session.add(report)
session.commit()
_announce(publisher, report)
def progress(percent: int, label: str) -> None:
report.progress = percent
report.step = label
session.add(report)
session.commit()
_announce(publisher, report)
try:
spec = ReportSpec.model_validate(report.spec or {})
scan, target = _subject(session, report)
theme_store.sync_builtin(session)
output = generate(
session,
spec,
scan=scan,
target=target,
progress=progress,
)
stem = (
generate_slug(f"{spec.title or 'report'}-{target.target_value}")[:80]
or "report"
)
files = _write_files(report, output, stem)
_finish(report, output, files, round(time.monotonic() - started, 2))
session.add(report)
session.commit()
_announce(publisher, report)
_notify(session, report, ok=True)
logger.info(
"report ready",
report_id=report_id,
pages=output.pages,
seconds=report.duration_seconds,
)
return {"status": report.status, "pages": output.pages, "files": len(files)}
except Exception as exc:
_fail(
session, publisher, report_id, exc, round(time.monotonic() - started, 2)
)
logger.exception("report failed", report_id=report_id)
raise
def _fail(session, publisher, report_id: str, exc: Exception, seconds: float) -> None:
session.rollback()
report = session.get(Report, UUID(report_id))
if report is None:
return
report.status = ReportStatus.FAILED.value
report.error = str(exc)[:2000]
report.step = "Failed"
report.completed_at = utc_now()
report.duration_seconds = seconds
session.add(report)
session.commit()
_announce(publisher, report)
_notify(session, report, ok=False)
def _subject(session, report: Report) -> tuple[Scan | None, Target]:
target = session.get(Target, report.target_id) if report.target_id else None
scan = session.get(Scan, report.scan_id) if report.scan_id else None
if target is None and scan is not None:
target = session.get(Target, scan.target_id)
if target is None:
msg = "The report has no target to describe."
raise ValueError(msg)
return scan, target
def _write_files(report: Report, output, stem: str) -> list[dict]:
root = _root(report.id)
files: list[dict] = []
for fmt, data in output.files.items():
name = f"{stem}.{FORMAT_EXTENSIONS.get(fmt, fmt)}"
(root / name).write_bytes(data)
files.append(
{
"format": fmt,
"filename": name,
"bytes": len(data),
"pages": output.pages if fmt == "pdf" else None,
}
)
return files
def _finish(report: Report, output, files: list[dict], seconds: float) -> None:
report.files = files
report.page_count = output.pages
report.stats = output.stats
report.ai_used = output.ai_used
report.ai_provider = output.ai_provider or None
report.ai_model = output.ai_model or None
report.ai_calls = output.usage.calls
report.ai_cached_calls = output.usage.cached
report.ai_input_tokens = output.usage.input_tokens
report.ai_output_tokens = output.usage.output_tokens
report.status = ReportStatus.COMPLETED.value
report.progress = 100
report.step = "Ready"
report.completed_at = utc_now()
report.duration_seconds = seconds
report.expires_at = utc_now() + timedelta(days=RETENTION_DAYS)
def _notify(session, report: Report, *, ok: bool) -> None:
name = report.title or report.template_name or "Report"
body = (
f"{name} for {report.subject} is ready to download."
if ok
else f"{name} for {report.subject} could not be generated: {report.error}"
)
try:
SyncNotificationPublisher(settings.celery_broker_url).publish(
session,
NotificationType.SYSTEM,
NotificationSeverity.SUCCESS if ok else NotificationSeverity.ERROR,
"Report ready" if ok else "Report failed",
body,
project_id=report.project_id,
)
except Exception:
logger.debug("report notification skipped", exc_info=True)
@shared_task(name="app.tasks.reports.cleanup")
def cleanup() -> dict:
"""Drop generated files past their retention date."""
removed = 0
with get_sync_session() as session:
now = utc_now()
rows = (
session.query(Report)
.filter(Report.expires_at.is_not(None), Report.expires_at < now)
.limit(500)
.all()
)
for report in rows:
root = Path(REPORT_ROOT) / str(report.id)
if root.exists():
for item in root.iterdir():
item.unlink(missing_ok=True)
root.rmdir()
report.files = []
report.status = ReportStatus.FAILED.value
report.error = (
"The generated files passed their retention date and were removed."
)
session.add(report)
removed += 1
if removed:
session.commit()
return {"removed": removed}