Push the rest

This commit is contained in:
2026-05-11 10:58:46 +02:00
parent adb5c1a439
commit 0031caf16c
94 changed files with 11777 additions and 3474 deletions

View File

174
backend/app/core/audit.py Normal file
View File

@@ -0,0 +1,174 @@
from __future__ import annotations
import logging
from datetime import datetime, timezone
from uuid import uuid4
from opentelemetry import trace
from sqlalchemy import DateTime, Integer, String, Text, JSON
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker, Session
LOGGER = logging.getLogger(__name__)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def current_span_context() -> tuple[str | None, str | None]:
ctx = trace.get_current_span().get_span_context()
if not ctx.is_valid:
return None, None
return f"{ctx.trace_id:032x}", f"{ctx.span_id:016x}"
class SharedBase(DeclarativeBase):
pass
class AuditLog(SharedBase):
__tablename__ = "audit_log"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, index=True)
action: Mapped[str] = mapped_column(String(100), index=True)
status: Mapped[str] = mapped_column(String(20), default="success")
actor_type: Mapped[str] = mapped_column(String(20), index=True)
actor_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
domain: Mapped[str] = mapped_column(String(50), index=True)
service: Mapped[str] = mapped_column(String(50), index=True)
entity_type: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True)
trace_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
span_id: Mapped[str | None] = mapped_column(String(16), nullable=True)
payload: Mapped[dict] = mapped_column(JSON, default=dict)
class JobExecution(SharedBase):
__tablename__ = "job_executions"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
job_name: Mapped[str] = mapped_column(String(100), index=True)
domain: Mapped[str] = mapped_column(String(50), index=True)
status: Mapped[str] = mapped_column(String(20), index=True)
records_processed: Mapped[int | None] = mapped_column(Integer, nullable=True)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
trace_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
span_id: Mapped[str | None] = mapped_column(String(16), nullable=True)
class ExportRecord(SharedBase):
__tablename__ = "export_records"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, index=True)
domain: Mapped[str] = mapped_column(String(50), index=True)
service: Mapped[str] = mapped_column(String(50))
source_view: Mapped[str] = mapped_column(String(100), index=True)
format: Mapped[str] = mapped_column(String(10))
filters_applied: Mapped[dict] = mapped_column(JSON, default=dict)
row_count: Mapped[int] = mapped_column(Integer)
file_size_bytes: Mapped[int] = mapped_column(Integer)
actor_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
trace_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
span_id: Mapped[str | None] = mapped_column(String(16), nullable=True)
def append_audit(
factory: sessionmaker[Session],
*,
action: str,
actor_type: str,
domain: str,
service: str,
entity_type: str | None = None,
actor_id: str | None = None,
status: str = "success",
payload: dict | None = None,
) -> None:
trace_id, span_id = current_span_context()
try:
with factory() as session:
session.add(AuditLog(
action=action,
actor_type=actor_type,
actor_id=actor_id,
domain=domain,
service=service,
entity_type=entity_type,
trace_id=trace_id,
span_id=span_id,
status=status,
payload=payload or {},
))
session.commit()
except Exception as exc: # noqa: BLE001
LOGGER.warning("Failed to write audit record (action=%s): %s", action, exc)
def record_job_start(
factory: sessionmaker[Session],
job_name: str,
domain: str,
trace_id: str | None,
span_id: str | None,
) -> str:
job_id = str(uuid4())
try:
with factory() as session:
session.add(JobExecution(
id=job_id,
job_name=job_name,
domain=domain,
status="running",
trace_id=trace_id,
span_id=span_id,
))
session.commit()
except Exception as exc: # noqa: BLE001
LOGGER.warning("Failed to record job start (job=%s): %s", job_name, exc)
return job_id
def record_job_complete(
factory: sessionmaker[Session],
job_id: str,
started_at: datetime,
records_processed: int,
) -> None:
now = datetime.now(timezone.utc)
duration_ms = int((now - started_at).total_seconds() * 1000)
try:
with factory() as session:
session.query(JobExecution).filter_by(id=job_id).update({
"status": "success",
"completed_at": now,
"records_processed": records_processed,
"duration_ms": duration_ms,
})
session.commit()
except Exception as exc: # noqa: BLE001
LOGGER.warning("Failed to record job completion (id=%s): %s", job_id, exc)
def record_job_failure(
factory: sessionmaker[Session],
job_id: str,
started_at: datetime,
error_message: str,
) -> None:
now = datetime.now(timezone.utc)
duration_ms = int((now - started_at).total_seconds() * 1000)
try:
with factory() as session:
session.query(JobExecution).filter_by(id=job_id).update({
"status": "failure",
"completed_at": now,
"duration_ms": duration_ms,
"error_message": error_message[:2000],
})
session.commit()
except Exception as exc: # noqa: BLE001
LOGGER.warning("Failed to record job failure (id=%s): %s", job_id, exc)

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
from functools import lru_cache
from urllib.parse import quote_plus
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -22,30 +21,20 @@ class Settings(BaseSettings):
api_port: int = 8000
cors_origins: str = "http://localhost:5173"
request_timeout_seconds: float = 20.0
mssql_host: str = "localhost"
mssql_port: int = 1433
mssql_username: str = "sa"
mssql_password: str = "Password!123"
mssql_driver: str = "ODBC Driver 18 for SQL Server"
mssql_trust_server_certificate: bool = False
# Go analytics service
analytics_service_url: str = "http://localhost:8080"
wwi_database: str = "WorldWideImporters"
aw_database: str = "AdventureWorks2022DWH"
wwi_connection_string: str | None = None
aw_connection_string: str | None = None
# PostgreSQL — write store for derived data
postgres_host: str = "localhost"
postgres_port: int = 5432
postgres_database: str = "otel_bi_app"
postgres_username: str = "otel_bi_app"
postgres_password: str = "otel_bi_app"
postgres_sslmode: str = "require"
postgres_database: str = "otel_bi"
postgres_username: str = "otel_bi"
postgres_password: str = "otel_bi"
postgres_sslmode: str = "prefer"
postgres_connection_string: str | None = None
postgres_required: bool = True
query_service_url: str = "http://localhost:8101"
analytics_service_url: str = "http://localhost:8102"
persistence_service_url: str = "http://localhost:8103"
# Frontend OIDC JWT validation
require_frontend_auth: bool = True
frontend_jwt_issuer_url: str = ""
frontend_jwt_audience: str = ""
@@ -53,18 +42,21 @@ class Settings(BaseSettings):
frontend_jwt_algorithm: str = "RS256"
frontend_required_scopes: str = ""
frontend_clock_skew_seconds: int = Field(default=30, ge=0, le=300)
internal_service_auth_enabled: bool = True
internal_service_shared_secret: str = "change-me"
internal_service_token_ttl_seconds: int = Field(default=120, ge=30, le=900)
internal_service_token_audience: str = "bi-internal"
internal_service_allowed_issuers: str = "api-gateway"
internal_token_clock_skew_seconds: int = Field(default=15, ge=0, le=120)
# Frontend OIDC client config (served via /api/config)
frontend_oidc_client_id: str = ""
frontend_oidc_scope: str = "openid profile email"
# OpenTelemetry
otel_service_name: str = "otel-bi-backend"
otel_service_namespace: str = "final-thesis"
otel_collector_endpoint: str = "http://localhost:4318"
otel_export_timeout_ms: int = 10000
# Report output — points at the K8s CSI / SMB mountpoint in production
report_output_dir: str = "/tmp/otel-bi-reports"
# Analytics defaults (forwarded to Go service as query params)
forecast_horizon_days: int = Field(default=30, ge=7, le=180)
default_history_days: int = Field(default=365, ge=30, le=1460)
ranking_default_top_n: int = Field(default=10, ge=3, le=100)
@@ -72,58 +64,22 @@ class Settings(BaseSettings):
@property
def cors_origins_list(self) -> list[str]:
return [
origin.strip() for origin in self.cors_origins.split(",") if origin.strip()
]
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@property
def frontend_required_scopes_list(self) -> list[str]:
return [
scope.strip()
for scope in self.frontend_required_scopes.split(" ")
if scope.strip()
]
@property
def internal_service_allowed_issuers_list(self) -> list[str]:
return [
issuer.strip()
for issuer in self.internal_service_allowed_issuers.split(",")
if issuer.strip()
]
def _build_mssql_connection_url(self, database: str) -> str:
driver = quote_plus(self.mssql_driver)
user = quote_plus(self.mssql_username)
password = quote_plus(self.mssql_password)
trust_cert = "yes" if self.mssql_trust_server_certificate else "no"
return (
f"mssql+pyodbc://{user}:{password}@{self.mssql_host}:{self.mssql_port}/{database}"
f"?driver={driver}&TrustServerCertificate={trust_cert}&ApplicationIntent=ReadOnly"
)
@property
def wwi_connection_url(self) -> str:
return self.wwi_connection_string or self._build_mssql_connection_url(
self.wwi_database
)
@property
def aw_connection_url(self) -> str:
return self.aw_connection_string or self._build_mssql_connection_url(
self.aw_database
)
return [s.strip() for s in self.frontend_required_scopes.split(" ") if s.strip()]
@property
def postgres_connection_url(self) -> str:
if self.postgres_connection_string:
return self.postgres_connection_string
from urllib.parse import quote_plus
user = quote_plus(self.postgres_username)
password = quote_plus(self.postgres_password)
return (
f"postgresql+psycopg://{user}:{password}@{self.postgres_host}:{self.postgres_port}/"
f"{self.postgres_database}?sslmode={self.postgres_sslmode}"
f"postgresql+psycopg://{user}:{password}@{self.postgres_host}:{self.postgres_port}"
f"/{self.postgres_database}?sslmode={self.postgres_sslmode}"
)

27
backend/app/core/db.py Normal file
View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker, Session
from app.core.config import settings
def create_postgres_engine() -> Engine:
return create_engine(
settings.postgres_connection_url,
pool_pre_ping=True,
pool_recycle=1800,
pool_size=5,
max_overflow=10,
future=True,
)
def create_session_factory(engine: Engine) -> sessionmaker[Session]:
return sessionmaker(
bind=engine,
autoflush=False,
autocommit=False,
expire_on_commit=False,
)

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
import os
from concurrent.futures import ThreadPoolExecutor
# Shared executor for CPU-bound analytics (pandas/sklearn) and sync MSSQL I/O
# (pyodbc is inherently synchronous and blocks the event loop if called directly).
#
# Workers are capped at 8 to avoid overwhelming the MSSQL connection pools.
# In K8s: set ANALYTICS_WORKERS to match the pod's CPU limit.
_WORKERS = min(8, int(os.environ.get("ANALYTICS_WORKERS", "0")) or (os.cpu_count() or 2) * 2)
_executor: ThreadPoolExecutor | None = None
def get_executor() -> ThreadPoolExecutor:
global _executor
if _executor is None:
_executor = ThreadPoolExecutor(max_workers=_WORKERS, thread_name_prefix="analytics")
return _executor
def shutdown_executor() -> None:
global _executor
if _executor is not None:
_executor.shutdown(wait=False)
_executor = None

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
import io
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
_PAGE_W, _ = landscape(A4)
_MARGIN = 1.5 * cm
_HEADER_BG = colors.HexColor("#1a56db")
_ROW_BG = colors.HexColor("#eef2ff")
def _pdf_table(rows: list[dict]) -> Table:
if not rows:
table_data: list[list] = [["No data available"]]
n_cols = 1
else:
headers = list(rows[0].keys())
n_cols = len(headers)
table_data = [headers] + [
[str(row.get(h, "")) for h in headers] for row in rows
]
col_w = (_PAGE_W - 2 * _MARGIN) / n_cols
t = Table(table_data, colWidths=[col_w] * n_cols, repeatRows=1)
style: list = [
("BACKGROUND", (0, 0), (-1, 0), _HEADER_BG),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -1), 7),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d1d5db")),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
]
for i in range(1, len(table_data)):
bg = _ROW_BG if i % 2 == 1 else colors.white
style.append(("BACKGROUND", (0, i), (-1, i), bg))
t.setStyle(TableStyle(style))
return t
def to_pdf_bytes(rows: list[dict], title: str, subtitle: str = "") -> bytes:
"""Serialise *rows* to a single-sheet PDF and return the raw bytes."""
buf = io.BytesIO()
styles = getSampleStyleSheet()
story = []
story.append(Paragraph(title, styles["Title"]))
if subtitle:
story.append(Spacer(1, 0.2 * cm))
story.append(Paragraph(subtitle, styles["Normal"]))
story.append(Spacer(1, 0.5 * cm))
story.append(_pdf_table(rows))
doc = SimpleDocTemplate(
buf,
pagesize=landscape(A4),
leftMargin=_MARGIN,
rightMargin=_MARGIN,
topMargin=_MARGIN,
bottomMargin=_MARGIN,
)
doc.build(story)
return buf.getvalue()

View File

@@ -7,24 +7,27 @@ from typing import Any
from fastapi import FastAPI
from opentelemetry import metrics, trace
from opentelemetry.baggage.propagation import W3CBaggagePropagator
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.logging import LoggingInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.propagate import set_global_textmap
from opentelemetry.propagators.composite import CompositePropagator
from opentelemetry.sdk._logs import LoggerProvider
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from opentelemetry._logs import set_logger_provider
try:
from opentelemetry.instrumentation.system_metrics import SystemMetricsInstrumentor
except ImportError: # pragma: no cover - defensive fallback for minimal envs
except ImportError:
SystemMetricsInstrumentor = None # type: ignore[assignment]
from app.core.config import Settings
@@ -36,12 +39,14 @@ LOGGER = logging.getLogger(__name__)
class TelemetryProviders:
tracer_provider: TracerProvider
meter_provider: MeterProvider
logger_provider: LoggerProvider
def configure_otel(settings: Settings) -> TelemetryProviders:
set_global_textmap(
CompositePropagator([TraceContextTextMapPropagator(), W3CBaggagePropagator()])
)
resource = Resource.create(
{
"service.name": settings.otel_service_name,
@@ -50,34 +55,54 @@ def configure_otel(settings: Settings) -> TelemetryProviders:
}
)
trace_exporter = OTLPSpanExporter(
endpoint=f"{settings.otel_collector_endpoint}/v1/traces",
timeout=settings.otel_export_timeout_ms / 1000,
)
tracer_provider = TracerProvider(resource=resource)
tracer_provider.add_span_processor(BatchSpanProcessor(trace_exporter))
tracer_provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
endpoint=f"{settings.otel_collector_endpoint}/v1/traces",
timeout=settings.otel_export_timeout_ms / 1000,
)
)
)
trace.set_tracer_provider(tracer_provider)
metric_reader = PeriodicExportingMetricReader(
exporter=OTLPMetricExporter(
endpoint=f"{settings.otel_collector_endpoint}/v1/metrics",
timeout=settings.otel_export_timeout_ms / 1000,
),
export_interval_millis=10000,
meter_provider = MeterProvider(
resource=resource,
metric_readers=[
PeriodicExportingMetricReader(
exporter=OTLPMetricExporter(
endpoint=f"{settings.otel_collector_endpoint}/v1/metrics",
timeout=settings.otel_export_timeout_ms / 1000,
),
export_interval_millis=10_000,
)
],
)
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
metrics.set_meter_provider(meter_provider)
logger_provider = LoggerProvider(resource=resource)
logger_provider.add_log_record_processor(
BatchLogRecordProcessor(
OTLPLogExporter(
endpoint=f"{settings.otel_collector_endpoint}/v1/logs",
timeout=settings.otel_export_timeout_ms / 1000,
)
)
)
set_logger_provider(logger_provider)
LoggingInstrumentor().instrument(set_logging_format=True)
if SystemMetricsInstrumentor is not None:
SystemMetricsInstrumentor().instrument()
else:
LOGGER.warning(
"System metrics instrumentor not available, runtime host metrics disabled."
)
LOGGER.info("OpenTelemetry providers configured")
LOGGER.warning("SystemMetricsInstrumentor not available — skipping.")
LOGGER.info("OTel providers configured", extra={"service.name": settings.otel_service_name})
return TelemetryProviders(
tracer_provider=tracer_provider, meter_provider=meter_provider
tracer_provider=tracer_provider,
meter_provider=meter_provider,
logger_provider=logger_provider,
)
@@ -85,19 +110,15 @@ def instrument_fastapi(app: FastAPI) -> None:
FastAPIInstrumentor.instrument_app(app)
def instrument_sqlalchemy_engines(engines: dict[str, Any]) -> None:
def instrument_sqlalchemy(engines: dict[str, Any]) -> None:
for engine in engines.values():
SQLAlchemyInstrumentor().instrument(engine=engine)
def instrument_httpx_clients() -> None:
HTTPXClientInstrumentor().instrument()
def shutdown_otel(providers: TelemetryProviders) -> None:
HTTPXClientInstrumentor().uninstrument()
if SystemMetricsInstrumentor is not None:
SystemMetricsInstrumentor().uninstrument()
LoggingInstrumentor().uninstrument()
providers.meter_provider.shutdown()
providers.tracer_provider.shutdown()
providers.logger_provider.shutdown()

187
backend/app/core/reports.py Normal file
View File

@@ -0,0 +1,187 @@
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from pathlib import Path
import openpyxl
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import cm
from reportlab.platypus import (
PageBreak,
Paragraph,
SimpleDocTemplate,
Spacer,
Table,
TableStyle,
)
_PAGE_W, _ = landscape(A4)
_MARGIN = 1.5 * cm
_HEADER_BG = colors.HexColor("#1a56db")
_ROW_BG = colors.HexColor("#eef2ff")
def _normalise(rows: list[dict] | dict) -> list[dict]:
if isinstance(rows, dict):
return [rows]
return rows or []
# ---------------------------------------------------------------------------
# XLSX
# ---------------------------------------------------------------------------
def _save_xlsx(data: dict, path: str, report_id: str, generated_at: str) -> None:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Metadata"
ws.append(["Field", "Value"])
ws.append(["Generated At (UTC)", generated_at])
ws.append(["Report ID", report_id])
sheets = [
("AW Sales KPIs", _normalise(data.get("aw_sales_kpis", {}))),
("AW Sales History", _normalise(data.get("aw_sales_history", []))),
("AW Sales Forecast", _normalise(data.get("aw_sales_forecast", []))),
("AW Rep Scores", _normalise(data.get("aw_rep_scores", []))),
("AW Product Demand", _normalise(data.get("aw_product_demand", []))),
("WWI Sales KPIs", _normalise(data.get("wwi_sales_kpis", {}))),
("WWI Stock Recs", _normalise(data.get("wwi_stock_recommendations", []))),
("WWI Supplier Scores", _normalise(data.get("wwi_supplier_scores", []))),
("WWI Business Events", _normalise(data.get("wwi_business_events", []))),
]
for sheet_name, rows in sheets:
ws = wb.create_sheet(title=sheet_name)
if rows:
ws.append(list(rows[0].keys()))
for row in rows:
ws.append([str(v) if v is not None else "" for v in row.values()])
else:
ws.append(["No data"])
wb.save(path)
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
def _pdf_table(rows: list[dict] | dict) -> Table:
data = _normalise(rows)
if not data:
table_data: list[list] = [["No data available"]]
n_cols = 1
else:
headers = list(data[0].keys())
n_cols = len(headers)
table_data = [headers] + [
[str(row.get(h, "")) for h in headers] for row in data
]
col_w = (_PAGE_W - 2 * _MARGIN) / n_cols
t = Table(table_data, colWidths=[col_w] * n_cols, repeatRows=1)
style: list = [
("BACKGROUND", (0, 0), (-1, 0), _HEADER_BG),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, 0), 8),
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
("FONTSIZE", (0, 1), (-1, -1), 7),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d1d5db")),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
]
for i in range(1, len(table_data)):
bg = _ROW_BG if i % 2 == 1 else colors.white
style.append(("BACKGROUND", (0, i), (-1, i), bg))
t.setStyle(TableStyle(style))
return t
def _section(story: list, title: str, rows: list[dict] | dict, styles) -> None:
story.append(Paragraph(title, styles["Heading2"]))
story.append(Spacer(1, 0.25 * cm))
story.append(_pdf_table(rows))
story.append(Spacer(1, 0.5 * cm))
def _save_pdf(data: dict, path: str, report_id: str, generated_at: str) -> None:
styles = getSampleStyleSheet()
story: list = []
story.append(Paragraph("OTel BI Platform — Generated Report", styles["Title"]))
story.append(Spacer(1, 0.2 * cm))
story.append(Paragraph(
f"Report ID: {report_id}   |   Generated: {generated_at}",
styles["Normal"],
))
story.append(Spacer(1, 0.6 * cm))
story.append(Paragraph("AdventureWorks DW", styles["Heading1"]))
story.append(Spacer(1, 0.3 * cm))
_section(story, "Sales KPIs", data.get("aw_sales_kpis", {}), styles)
_section(story, "Sales History", data.get("aw_sales_history", []), styles)
story.append(PageBreak())
_section(story, "Sales Forecast", data.get("aw_sales_forecast", []), styles)
_section(story, "Rep Scores", data.get("aw_rep_scores", []), styles)
_section(story, "Product Demand", data.get("aw_product_demand", []), styles)
story.append(PageBreak())
story.append(Paragraph("WideWorldImporters DW", styles["Heading1"]))
story.append(Spacer(1, 0.3 * cm))
_section(story, "Sales KPIs", data.get("wwi_sales_kpis", {}), styles)
_section(story, "Stock Recommendations", data.get("wwi_stock_recommendations", []), styles)
story.append(PageBreak())
_section(story, "Supplier Scores", data.get("wwi_supplier_scores", []), styles)
_section(story, "Business Events", data.get("wwi_business_events", []), styles)
doc = SimpleDocTemplate(
path,
pagesize=landscape(A4),
leftMargin=_MARGIN,
rightMargin=_MARGIN,
topMargin=_MARGIN,
bottomMargin=_MARGIN,
)
doc.build(story)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def save_report(data: dict, output_dir: str) -> dict:
"""Generate XLSX and PDF reports from aggregated BI data and write both to *output_dir*."""
now = datetime.now(timezone.utc)
ts = now.strftime("%Y%m%d_%H%M%S")
uid = uuid.uuid4().hex[:6]
report_id = f"{ts}_{uid}"
generated_at = now.isoformat()
out = Path(output_dir)
out.mkdir(parents=True, exist_ok=True)
base = f"otel_bi_report_{report_id}"
xlsx_path = str(out / f"{base}.xlsx")
pdf_path = str(out / f"{base}.pdf")
_save_xlsx(data, xlsx_path, report_id, generated_at)
_save_pdf(data, pdf_path, report_id, generated_at)
return {
"report_id": report_id,
"generated_at": generated_at,
"xlsx": {"filename": f"{base}.xlsx", "path": xlsx_path},
"pdf": {"filename": f"{base}.pdf", "path": pdf_path},
}

View File

@@ -2,11 +2,9 @@ from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
from time import time
from uuid import uuid4
import jwt
from fastapi import Depends, Header, HTTPException, status
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import InvalidTokenError, PyJWKClient
@@ -23,14 +21,6 @@ class FrontendPrincipal:
token: str
@dataclass
class InternalPrincipal:
subject: str
scopes: list[str]
claims: dict
token: str
class FrontendJWTVerifier:
@property
def jwks_url(self) -> str:
@@ -66,7 +56,6 @@ class FrontendJWTVerifier:
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="FRONTEND_JWT_AUDIENCE is not configured.",
)
try:
signing_key = self._jwks_client().get_signing_key_from_jwt(token).key
claims = jwt.decode(
@@ -92,103 +81,13 @@ class FrontendJWTVerifier:
scopes = self._extract_scopes(claims)
required = settings.frontend_required_scopes_list
missing = [scope for scope in required if scope not in scopes]
missing = [s for s in required if s not in scopes]
if missing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing required scope(s): {', '.join(missing)}",
)
return FrontendPrincipal(
subject=subject, scopes=scopes, claims=claims, token=token
)
class InternalTokenManager:
token_type = "internal-service"
@staticmethod
def _assert_secret() -> str:
secret = settings.internal_service_shared_secret
if not secret or secret == "change-me":
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="INTERNAL_SERVICE_SHARED_SECRET must be configured.",
)
if len(secret.encode("utf-8")) < 32:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=(
"INTERNAL_SERVICE_SHARED_SECRET must be at least 32 bytes for "
"HS256 token signing."
),
)
return secret
def mint(
self,
*,
subject: str,
scopes: list[str],
source_service: str,
) -> str:
now = int(time())
payload = {
"sub": subject,
"scope": " ".join(scopes),
"iss": source_service,
"aud": settings.internal_service_token_audience,
"typ": self.token_type,
"iat": now,
"nbf": now,
"exp": now + settings.internal_service_token_ttl_seconds,
"jti": str(uuid4()),
}
return jwt.encode(payload, self._assert_secret(), algorithm="HS256")
def verify(self, token: str) -> InternalPrincipal:
try:
claims = jwt.decode(
token,
self._assert_secret(),
algorithms=["HS256"],
audience=settings.internal_service_token_audience,
options={
"require": ["sub", "iss", "aud", "exp", "iat", "nbf", "jti", "typ"]
},
leeway=settings.internal_token_clock_skew_seconds,
)
except InvalidTokenError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid internal service token.",
) from exc
subject = str(claims.get("sub") or "")
if not subject:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Internal token missing subject.",
)
issuer = str(claims.get("iss") or "")
if issuer not in settings.internal_service_allowed_issuers_list:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Internal token issuer is not allowed.",
)
token_type = str(claims.get("typ") or "")
if token_type != self.token_type:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Internal token type is invalid.",
)
scope = claims.get("scope")
scopes = [item for item in str(scope).split(" ") if item] if scope else []
return InternalPrincipal(
subject=subject, scopes=scopes, claims=claims, token=token
)
return FrontendPrincipal(subject=subject, scopes=scopes, claims=claims, token=token)
@lru_cache(maxsize=1)
@@ -196,36 +95,14 @@ def get_frontend_verifier() -> FrontendJWTVerifier:
return FrontendJWTVerifier()
@lru_cache(maxsize=1)
def get_internal_token_manager() -> InternalTokenManager:
return InternalTokenManager()
def require_frontend_principal(
credentials: HTTPAuthorizationCredentials | None = Depends(BEARER_SCHEME),
) -> FrontendPrincipal:
if not settings.require_frontend_auth:
return FrontendPrincipal(subject="anonymous", scopes=[], claims={}, token="")
if credentials is None or credentials.scheme.lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing bearer token.",
)
return get_frontend_verifier().verify(credentials.credentials)
def require_internal_principal(
internal_token: str | None = Header(default=None, alias="x-internal-service-token"),
) -> InternalPrincipal:
if not settings.internal_service_auth_enabled:
return InternalPrincipal(
subject="internal-unauth", scopes=[], claims={}, token=""
)
if not internal_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing x-internal-service-token header.",
)
return get_internal_token_manager().verify(internal_token)