feat: prepare ARR for controlled public deployment
This commit is contained in:
226
monthly_reports/service.py
Normal file
226
monthly_reports/service.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Orchestration and privacy-minimized results for monthly channel reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Mapping, Optional, Protocol, Tuple
|
||||
|
||||
from monthly_reports.contracts import (
|
||||
RESULT_SCHEMA_VERSION,
|
||||
ErrorCode,
|
||||
MonthlyReport,
|
||||
MonthlyReportError,
|
||||
)
|
||||
from monthly_reports.core import build_monthly_report, validate_as_of
|
||||
from monthly_reports.publishing import (
|
||||
BuildError,
|
||||
BuiltWorkbook,
|
||||
PublicationError,
|
||||
PublicationOutcome,
|
||||
private_staging_directory,
|
||||
)
|
||||
from monthly_reports.repository import ReportRepository, RepositoryError, ReservedReport
|
||||
|
||||
|
||||
class WorkbookBuilder(Protocol):
|
||||
def build(self, report: MonthlyReport, work_dir: Path) -> BuiltWorkbook:
|
||||
...
|
||||
|
||||
|
||||
class ReportPublisher(Protocol):
|
||||
def publish(
|
||||
self,
|
||||
report: MonthlyReport,
|
||||
reservation: ReservedReport,
|
||||
built: BuiltWorkbook,
|
||||
repository: ReportRepository,
|
||||
work_dir: Path,
|
||||
) -> PublicationOutcome:
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunRequest:
|
||||
report_year: int
|
||||
report_month: int
|
||||
as_of_date: date
|
||||
|
||||
def validate(self) -> None:
|
||||
validate_as_of(self.report_year, self.report_month, self.as_of_date)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunResult:
|
||||
request: RunRequest
|
||||
status: str
|
||||
row_count: int = 0
|
||||
channel_manifest: Tuple[Tuple[str, int, int], ...] = tuple()
|
||||
report_version_id: Optional[int] = None
|
||||
version_no: Optional[int] = None
|
||||
artifact: Optional[Mapping[str, Any]] = None
|
||||
error_code: Optional[str] = None
|
||||
error_stage: Optional[str] = None
|
||||
|
||||
@property
|
||||
def exit_code(self) -> int:
|
||||
if self.status == "success":
|
||||
return 0
|
||||
if self.error_code in {
|
||||
ErrorCode.SOURCE_INVALID,
|
||||
ErrorCode.SOURCE_SNAPSHOT_STALE,
|
||||
}:
|
||||
return 2
|
||||
return 4
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"schema_version": RESULT_SCHEMA_VERSION,
|
||||
"status": self.status,
|
||||
"report_year": self.request.report_year,
|
||||
"report_month": self.request.report_month,
|
||||
"as_of_date": self.request.as_of_date.isoformat(),
|
||||
"row_count": self.row_count,
|
||||
"channel_manifest": [
|
||||
{
|
||||
"worksheet": worksheet,
|
||||
"worksheet_order": order,
|
||||
"row_count": row_count,
|
||||
}
|
||||
for worksheet, order, row_count in self.channel_manifest
|
||||
],
|
||||
"warnings": [],
|
||||
"errors": [],
|
||||
}
|
||||
if self.report_version_id is not None:
|
||||
payload["report_version_id"] = self.report_version_id
|
||||
if self.version_no is not None:
|
||||
payload["version_no"] = self.version_no
|
||||
if self.artifact is not None:
|
||||
payload["artifact"] = dict(self.artifact)
|
||||
if self.error_code is not None:
|
||||
payload["errors"] = [
|
||||
{
|
||||
"code": self.error_code,
|
||||
"stage": self.error_stage or "internal",
|
||||
}
|
||||
]
|
||||
return payload
|
||||
|
||||
|
||||
class MonthlyReportService:
|
||||
def __init__(
|
||||
self,
|
||||
repository: ReportRepository,
|
||||
builder: WorkbookBuilder,
|
||||
publisher: ReportPublisher,
|
||||
staging_root: Path,
|
||||
) -> None:
|
||||
self._repository = repository
|
||||
self._builder = builder
|
||||
self._publisher = publisher
|
||||
self._staging_root = staging_root
|
||||
|
||||
@staticmethod
|
||||
def _failed(
|
||||
request: RunRequest,
|
||||
code: str,
|
||||
stage: str,
|
||||
report: Optional[MonthlyReport] = None,
|
||||
) -> RunResult:
|
||||
return RunResult(
|
||||
request=request,
|
||||
status="failed",
|
||||
row_count=report.row_count if report else 0,
|
||||
channel_manifest=report.channel_manifest if report else tuple(),
|
||||
error_code=code,
|
||||
error_stage=stage,
|
||||
)
|
||||
|
||||
def run(self, request: RunRequest) -> RunResult:
|
||||
request.validate()
|
||||
try:
|
||||
snapshot = self._repository.load_snapshot(
|
||||
request.report_year,
|
||||
request.report_month,
|
||||
request.as_of_date,
|
||||
)
|
||||
except RepositoryError as error:
|
||||
return self._failed(request, error.code, "source")
|
||||
try:
|
||||
report = build_monthly_report(
|
||||
request.report_year,
|
||||
request.report_month,
|
||||
request.as_of_date,
|
||||
snapshot,
|
||||
)
|
||||
except MonthlyReportError as error:
|
||||
return self._failed(request, error.code, "source")
|
||||
|
||||
reservation: Optional[ReservedReport] = None
|
||||
try:
|
||||
reservation = self._repository.reserve_report(report)
|
||||
with private_staging_directory(self._staging_root) as temp_dir:
|
||||
work_dir = Path(temp_dir)
|
||||
built = self._builder.build(report, work_dir)
|
||||
outcome = self._publisher.publish(
|
||||
report,
|
||||
reservation,
|
||||
built,
|
||||
self._repository,
|
||||
work_dir,
|
||||
)
|
||||
return RunResult(
|
||||
request=request,
|
||||
status="success",
|
||||
row_count=report.row_count,
|
||||
channel_manifest=report.channel_manifest,
|
||||
report_version_id=reservation.report_version_id,
|
||||
version_no=reservation.version_no,
|
||||
artifact={
|
||||
"filename": report.filename,
|
||||
"storage_key": outcome.artifact.storage_key,
|
||||
"sha256": outcome.artifact.sha256,
|
||||
"semantic_sha256": str(built.summary.get("semantic_sha256", "")),
|
||||
},
|
||||
)
|
||||
except BuildError as error:
|
||||
if reservation is not None:
|
||||
try:
|
||||
self._repository.mark_failed(reservation, error.code, error.safe_message)
|
||||
except Exception:
|
||||
pass
|
||||
return self._failed(request, error.code, "xlsx", report)
|
||||
except (PublicationError, RepositoryError) as error:
|
||||
return self._failed(request, error.code, "publish", report)
|
||||
except Exception:
|
||||
if reservation is not None:
|
||||
try:
|
||||
self._repository.mark_failed(
|
||||
reservation,
|
||||
ErrorCode.INTERNAL_ERROR,
|
||||
"monthly report processing failed",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return self._failed(request, ErrorCode.INTERNAL_ERROR, "internal", report)
|
||||
|
||||
|
||||
def write_run_result(path: Path, result: RunResult) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
temporary = path.parent / f".{path.name}.tmp-{uuid.uuid4().hex}"
|
||||
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
json.dump(result.to_dict(), handle, ensure_ascii=False, sort_keys=True, indent=2)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
Reference in New Issue
Block a user