feat: archive visible wordclouds into products
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""Transactional product-version archives for generated word clouds."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import shutil
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from .product_archive import (
|
||||
WordcloudSource,
|
||||
copy_word_locations_snapshot,
|
||||
find_visible_wordcloud_sources,
|
||||
)
|
||||
from .product_archive_store import ProductArchiveStore
|
||||
from .schemas import ProductVersionRecord
|
||||
|
||||
|
||||
class ProductPendingCleanupError(ValueError):
|
||||
"""Raised when a soft-deleted product is changed before restoration."""
|
||||
|
||||
|
||||
class ProductArchiveService:
|
||||
"""Materialize a self-contained design preview and word-location snapshots."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: ProductArchiveStore,
|
||||
storage: Any,
|
||||
load_asset_meta: Callable[[str], dict[str, Any]],
|
||||
resolve_job_status: Callable[[str], Any],
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.storage = storage
|
||||
self.load_asset_meta = load_asset_meta
|
||||
self.resolve_job_status = resolve_job_status
|
||||
self.archive_root = store.root.parent
|
||||
|
||||
@staticmethod
|
||||
def _validate_preview(preview_bytes: bytes) -> None:
|
||||
if not preview_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
raise ValueError("preview must be a PNG image")
|
||||
try:
|
||||
with Image.open(io.BytesIO(preview_bytes)) as image:
|
||||
image.verify()
|
||||
if image.format != "PNG":
|
||||
raise ValueError("preview must be a PNG image")
|
||||
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
||||
raise ValueError("preview must be a valid PNG image") from exc
|
||||
|
||||
def _source_db_paths(self, sources: list[WordcloudSource]) -> list[tuple[WordcloudSource, Path]]:
|
||||
resolved: list[tuple[WordcloudSource, Path]] = []
|
||||
for source in sources:
|
||||
status = self.resolve_job_status(source.source_job_id)
|
||||
if status is None or getattr(status, "status", "") != "success":
|
||||
raise ValueError(f"wordcloud source job is not successful: {source.source_job_id}")
|
||||
artifacts = getattr(status, "artifacts", {}) or {}
|
||||
raw_path = artifacts.get("db", "")
|
||||
if not raw_path:
|
||||
raise ValueError(f"wordcloud source database is unavailable: {source.source_job_id}")
|
||||
db_path = Path(raw_path)
|
||||
if not db_path.is_file():
|
||||
raise ValueError(f"wordcloud source database is unavailable: {source.source_job_id}")
|
||||
resolved.append((source, db_path))
|
||||
return resolved
|
||||
|
||||
def _rollback_metadata(self, version_id: str) -> None:
|
||||
# ProductArchiveStore deliberately keeps its public API small. These rows
|
||||
# all belong to the just-created version and can be removed safely here.
|
||||
self.store._execute("DELETE FROM product_wordcloud_archives WHERE version_id = ?", (version_id,))
|
||||
self.store._execute("DELETE FROM product_images WHERE version_id = ?", (version_id,))
|
||||
self.store._execute("DELETE FROM product_versions WHERE version_id = ?", (version_id,))
|
||||
|
||||
def archive_version(
|
||||
self,
|
||||
product_id: str,
|
||||
document: dict[str, Any],
|
||||
preview_bytes: bytes,
|
||||
now: datetime | None = None,
|
||||
) -> ProductVersionRecord:
|
||||
"""Archive visible, successful word clouds without trusting client job IDs."""
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("document must be a JSON object")
|
||||
product = self.store.get_product(product_id)
|
||||
if product.status == "pending_cleanup":
|
||||
raise ProductPendingCleanupError("product is pending cleanup")
|
||||
if product.status != "active":
|
||||
raise ValueError("product is not active")
|
||||
|
||||
self._validate_preview(preview_bytes)
|
||||
sources = find_visible_wordcloud_sources(document, self.load_asset_meta)
|
||||
source_dbs = self._source_db_paths(sources)
|
||||
|
||||
product_dir = self.archive_root / product_id
|
||||
staging_dir = product_dir / f".{uuid.uuid4().hex}.staging"
|
||||
version: ProductVersionRecord | None = None
|
||||
final_dir: Path | None = None
|
||||
try:
|
||||
staging_dir.mkdir(parents=True, exist_ok=False)
|
||||
preview_path = staging_dir / "design-preview.png"
|
||||
preview_path.write_bytes(preview_bytes)
|
||||
|
||||
snapshots: list[tuple[WordcloudSource, Path]] = []
|
||||
for index, (source, source_db) in enumerate(source_dbs, start=1):
|
||||
snapshot_path = (
|
||||
staging_dir
|
||||
/ "wordclouds"
|
||||
/ f"{index:02d}-{source.source_job_id}"
|
||||
/ "word_locations.sqlite"
|
||||
)
|
||||
copy_word_locations_snapshot(source_db, snapshot_path)
|
||||
snapshots.append((source, snapshot_path))
|
||||
|
||||
version = self.store.create_version(
|
||||
product_id,
|
||||
metadata={
|
||||
"document": document,
|
||||
"wordcloud_count": len(snapshots),
|
||||
"wordcloud_sources": [
|
||||
{"asset_id": source.asset_id, "source_job_id": source.source_job_id}
|
||||
for source, _ in snapshots
|
||||
],
|
||||
},
|
||||
now=now,
|
||||
)
|
||||
target_dir = product_dir / version.version_id
|
||||
staging_dir.replace(target_dir)
|
||||
final_dir = target_dir
|
||||
|
||||
final_preview = final_dir / "design-preview.png"
|
||||
self.store.add_image(
|
||||
product_id, version.version_id, image_path=str(final_preview), image_type="design_preview", now=now
|
||||
)
|
||||
for _, snapshot_path in snapshots:
|
||||
final_snapshot = final_dir / snapshot_path.relative_to(staging_dir)
|
||||
self.store.add_wordcloud_archive(
|
||||
product_id, version.version_id, archive_path=str(final_snapshot), now=now
|
||||
)
|
||||
return version
|
||||
except Exception:
|
||||
if version is not None:
|
||||
self._rollback_metadata(version.version_id)
|
||||
shutil.rmtree(final_dir or staging_dir, ignore_errors=True)
|
||||
raise
|
||||
Reference in New Issue
Block a user