from __future__ import annotations import hashlib import json import os import re import shutil import zipfile from dataclasses import dataclass from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict, Iterable, List, Optional from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from .. import database from ..config import settings from ..models import ( AuthUserORM, DinsarResultORM, ResultAssetORM, ResultDeliveryItemORM, ResultDeliveryRequestORM, ResultProductORM, ) from .dinsar_read_service import dinsar_read_service from .job_queue_service import job_queue_service from .task_service import task_service JOB_TYPE_RESULT_DELIVERY_BUILD = "RESULT_DELIVERY_BUILD" TASK_TYPE_RESULT_DELIVERY_BUILD = "RESULT_DELIVERY_BUILD" DELIVERY_STATUS_PENDING = "PENDING" DELIVERY_STATUS_RUNNING = "RUNNING" DELIVERY_STATUS_READY = "READY" DELIVERY_STATUS_FAILED = "FAILED" DELIVERY_STATUS_CANCELLED = "CANCELLED" DELIVERY_STATUS_EXPIRED = "EXPIRED" ITEM_STATUS_PENDING = "PENDING" ITEM_STATUS_COPIED = "COPIED" ITEM_STATUS_FAILED = "FAILED" ITEM_STATUS_SKIPPED = "SKIPPED" CHANNEL_DINSAR = "dinsar" SUPPORTED_READY_CHANNELS = {CHANNEL_DINSAR} PACKAGE_MODE_DIRECTORY = "directory" PACKAGE_MODE_ZIP = "zip" _ASSOCIATED_EXTENSIONS = ("", ".hdr", ".sml", ".xml", ".aux.xml", ".prj") _INVALID_SEGMENT_RE = re.compile(r'[<>:"/\\|?*\x00-\x1F]+') _SAFE_ID_RE = re.compile(r"[^0-9A-Za-z_-]+") @dataclass(frozen=True) class DeliverySource: product: ResultProductORM compat_row: Optional[DinsarResultORM] display_name: str source_path: str source_asset_id: Optional[int] = None def _new_session() -> AsyncSession: if database.AsyncSessionLocal is None: database.init_db() if database.AsyncSessionLocal is None: raise RuntimeError("Database session factory is not initialized.") return database.AsyncSessionLocal() def _utcnow() -> datetime: return datetime.utcnow() def _normalize_channel(value: str) -> str: channel = str(value or "").strip().lower() if not channel: raise ValueError("channel is required") return channel def _normalize_package_mode(value: str) -> str: mode = str(value or PACKAGE_MODE_DIRECTORY).strip().lower() if mode not in {PACKAGE_MODE_DIRECTORY, PACKAGE_MODE_ZIP}: raise ValueError("package_mode must be directory or zip") return mode def _normalize_int_ids(values: Optional[Iterable[Any]], *, max_count: int) -> List[int]: normalized: List[int] = [] seen = set() for raw in values or []: try: value = int(raw) except (TypeError, ValueError): continue if value <= 0 or value in seen: continue seen.add(value) normalized.append(value) if len(normalized) > max_count: raise ValueError(f"selected item count exceeds max limit ({max_count})") return normalized def _sanitize_segment(value: Any, *, default: str = "item", max_len: int = 120) -> str: text = str(value or "").strip() text = _INVALID_SEGMENT_RE.sub("_", text).strip(" .") text = _SAFE_ID_RE.sub("_", text) return (text or default)[:max_len] def _same_file_size(left: str, right: str) -> bool: try: return os.path.getsize(left) == os.path.getsize(right) except OSError: return False def _file_size(path: str) -> int: try: return int(os.path.getsize(path)) except OSError: return 0 def _sha256_file(path: str) -> str: digest = hashlib.sha256() with open(path, "rb") as stream: for chunk in iter(lambda: stream.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _write_json(path: str, payload: Dict[str, Any]) -> None: os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as stream: json.dump(payload, stream, ensure_ascii=False, indent=2, default=str) def _path_within(parent: str, child: str) -> bool: parent_path = Path(parent).resolve() child_path = Path(child).resolve() try: child_path.relative_to(parent_path) return True except ValueError: return False def _iter_associated_files(source_path: str) -> List[str]: source = os.path.normpath(source_path) if os.path.isdir(source): files: List[str] = [] for root, _dirs, names in os.walk(source): for name in names: files.append(os.path.join(root, name)) return sorted(files) if not os.path.isfile(source): return [] src_dir = os.path.dirname(source) base_name = os.path.basename(source) files: List[str] = [] for ext in _ASSOCIATED_EXTENSIONS: candidate = os.path.join(src_dir, base_name + ext) if ext else source if os.path.isfile(candidate) and candidate not in files: files.append(candidate) return files def _delivery_root() -> str: root = str(settings.RESULT_DELIVERY_ROOT or "").strip() if not root: raise ValueError("RESULT_DELIVERY_ROOT is not configured") return os.path.normpath(os.path.abspath(root)) def _owner_dir(root: str, username: str) -> str: return os.path.join(root, _sanitize_segment(username, default="user", max_len=64)) def _make_delivery_id(username: str) -> str: stamp = _utcnow().strftime("%Y%m%d%H%M%S") digest = hashlib.sha1(f"{username}|{stamp}|{os.urandom(8).hex()}".encode("utf-8")).hexdigest()[:10] return f"rd_{stamp}_{digest}" def _resolve_source_path(product: ResultProductORM, assets: List[ResultAssetORM]) -> tuple[str, Optional[int]]: primary_assets = [ asset for asset in assets if asset.exists_flag and (asset.is_primary or str(asset.asset_role or "").lower() in {"disp", "primary_geotiff"}) ] primary_assets.sort(key=lambda item: (not item.is_primary, item.id)) for asset in primary_assets: path = str(asset.absolute_path or "").strip() if path: return path, asset.id for path in (product.primary_asset_path, product.source_primary_path, product.publish_dir): text = str(path or "").strip() if text: return text, None return "", None class ResultDeliveryService: def channels(self) -> List[Dict[str, Any]]: return [ { "key": CHANNEL_DINSAR, "group": "InSAR 成果", "label": "D-InSAR 结果", "state": "ready", "state_text": "可交付", "description": "已登记 D-InSAR catalog,可创建后台交付包并下载到本地。", }, { "key": "sbas", "group": "InSAR 成果", "label": "SBAS-InSAR 结果", "state": "planned", "state_text": "目录可查", "description": "SBAS 结果 catalog 已有基础能力,本阶段暂不开放交付打包。", }, { "key": "lt1_ortho", "group": "正射成果", "label": "LT-1 正射结果", "state": "placeholder", "state_text": "待接入", "description": "陆探一正射生产将由 LandSAR 生产链注册后接入统一交付。", }, { "key": "s1_ortho", "group": "正射成果", "label": "Sentinel-1 正射结果", "state": "placeholder", "state_text": "待接入", "description": "Sentinel-1 正射生产尚未接入,当前只保留交付通道占位。", }, { "key": "gf3_ortho", "group": "正射成果", "label": "GF3 SARscape _geo", "state": "placeholder", "state_text": "待接入", "description": "GF3 外部生产成果登记后再接入统一交付。", }, ] async def _resolve_dinsar_sources( self, db: AsyncSession, *, product_ids: List[int], compat_result_ids: List[int], ) -> List[DeliverySource]: sources: List[DeliverySource] = [] seen_products = set() if product_ids: result = await db.execute( select(ResultProductORM) .options(selectinload(ResultProductORM.assets)) .where( ResultProductORM.id.in_(product_ids), ResultProductORM.catalog_name == CHANNEL_DINSAR, ResultProductORM.status.in_(["READY", "PUBLISHED", "OK"]), ) .order_by(ResultProductORM.id.asc()) ) for product in result.scalars().unique().all(): assets = list(product.assets or []) source_path, source_asset_id = _resolve_source_path(product, assets) if not source_path: continue seen_products.add(int(product.id)) sources.append( DeliverySource( product=product, compat_row=None, display_name=dinsar_read_service.get_display_name(product), source_path=source_path, source_asset_id=source_asset_id, ) ) if compat_result_ids: records = await dinsar_read_service.list_compat_records_by_ids( db, compat_result_ids=compat_result_ids, ) product_lookup_ids = [record.product.id for record in records if record.product is not None] assets_by_product: Dict[int, List[ResultAssetORM]] = {} if product_lookup_ids: asset_result = await db.execute( select(ResultAssetORM) .where(ResultAssetORM.product_ref_id.in_(product_lookup_ids)) .order_by(ResultAssetORM.id.asc()) ) for asset in asset_result.scalars().all(): assets_by_product.setdefault(int(asset.product_ref_id), []).append(asset) for record in records: product = record.product if int(product.id) in seen_products: continue assets = assets_by_product.get(int(product.id), []) source_path, source_asset_id = _resolve_source_path(product, assets) if not source_path and record.compat_row is not None: source_path = str(record.compat_row.file_path or "").strip() if not source_path: continue seen_products.add(int(product.id)) sources.append( DeliverySource( product=product, compat_row=record.compat_row, display_name=record.display_name, source_path=source_path, source_asset_id=source_asset_id, ) ) return sources async def create_delivery( self, db: AsyncSession, *, user: AuthUserORM, channel: str, product_ids: Optional[List[int]] = None, compat_result_ids: Optional[List[int]] = None, package_mode: str = PACKAGE_MODE_DIRECTORY, include_checksums: Optional[bool] = None, ) -> ResultDeliveryRequestORM: channel = _normalize_channel(channel) if channel not in SUPPORTED_READY_CHANNELS: raise ValueError(f"{channel} delivery is not connected yet") package_mode = _normalize_package_mode(package_mode) max_items = max(1, int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)) normalized_product_ids = _normalize_int_ids(product_ids, max_count=max_items) normalized_compat_ids = _normalize_int_ids(compat_result_ids, max_count=max_items) if not normalized_product_ids and not normalized_compat_ids: raise ValueError("select at least one result") if len(normalized_product_ids) + len(normalized_compat_ids) > max_items: raise ValueError(f"selected item count exceeds max limit ({max_items})") if channel == CHANNEL_DINSAR: sources = await self._resolve_dinsar_sources( db, product_ids=normalized_product_ids, compat_result_ids=normalized_compat_ids, ) else: sources = [] if not sources: raise ValueError("selected results do not have deliverable files") if len(sources) > max_items: raise ValueError(f"resolved result count exceeds max limit ({max_items})") delivery_root = _delivery_root() delivery_id = _make_delivery_id(str(user.username or "user")) delivery_dir = os.path.join(_owner_dir(delivery_root, str(user.username or "user")), delivery_id) expires_at = _utcnow() + timedelta(days=max(1, int(settings.RESULT_DELIVERY_RETENTION_DAYS or 7))) request_json = { "channel": channel, "product_ids": normalized_product_ids, "compat_result_ids": normalized_compat_ids, "package_mode": package_mode, "include_checksums": ( bool(settings.RESULT_DELIVERY_CHECKSUM_ENABLED) if include_checksums is None else bool(include_checksums) ), } delivery = ResultDeliveryRequestORM( delivery_id=delivery_id, owner_user_id=user.id, owner_username=str(user.username or "unknown"), channel=channel, status=DELIVERY_STATUS_PENDING, package_mode=package_mode, item_count=len(sources), total_bytes=0, copied_bytes=0, delivery_root=delivery_root, delivery_dir=delivery_dir, zip_path=(f"{delivery_dir}.zip" if package_mode == PACKAGE_MODE_ZIP else None), expires_at=expires_at, request_json=request_json, summary_json={ "source_count": len(sources), "created_message": "Delivery package queued.", }, ) db.add(delivery) await db.flush() task_type = f"{TASK_TYPE_RESULT_DELIVERY_BUILD}_{delivery_id}" task_id = await task_service.create_task( task_type, f"成果交付包生成: {delivery_id}", params={ "delivery_id": delivery_id, "channel": channel, "item_count": len(sources), "package_mode": package_mode, }, db=db, ) job_id = await job_queue_service.create_job( JOB_TYPE_RESULT_DELIVERY_BUILD, payload={"delivery_id": delivery_id}, task_id=task_id, max_attempts=1, db=db, ) delivery.task_id = task_id delivery.job_id = job_id await db.flush() return delivery async def list_deliveries( self, db: AsyncSession, *, user: AuthUserORM, include_all: bool = False, include_items: bool = False, item_limit: Optional[int] = None, limit: int = 50, offset: int = 0, ) -> Dict[str, Any]: safe_limit = min(200, max(1, int(limit or 50))) safe_offset = max(0, int(offset or 0)) stmt = select(ResultDeliveryRequestORM) if include_items: stmt = stmt.options(selectinload(ResultDeliveryRequestORM.items)) count_stmt = select(func.count(ResultDeliveryRequestORM.id)) if not include_all: owner_filter = ResultDeliveryRequestORM.owner_user_id == user.id stmt = stmt.where(owner_filter) count_stmt = count_stmt.where(owner_filter) stmt = ( stmt.order_by(ResultDeliveryRequestORM.created_at.desc(), ResultDeliveryRequestORM.id.desc()) .offset(safe_offset) .limit(safe_limit) ) result = await db.execute(stmt) total_result = await db.execute(count_stmt) deliveries = result.scalars().unique().all() if include_items else result.scalars().all() items = [ self.serialize_delivery(item, include_items=include_items, item_limit=item_limit) for item in deliveries ] total = int(total_result.scalar_one() or 0) return { "items": items, "total": total, "limit": safe_limit, "offset": safe_offset, "has_more": safe_offset + len(items) < total, } async def get_delivery( self, db: AsyncSession, *, delivery_id: str, user: AuthUserORM, include_items: bool = True, ) -> Optional[ResultDeliveryRequestORM]: stmt = select(ResultDeliveryRequestORM).where( ResultDeliveryRequestORM.delivery_id == str(delivery_id or "").strip() ) if include_items: stmt = stmt.options(selectinload(ResultDeliveryRequestORM.items)) if str(user.role or "").lower() != "admin": stmt = stmt.where(ResultDeliveryRequestORM.owner_user_id == user.id) result = await db.execute(stmt) return result.scalars().unique().one_or_none() def serialize_delivery( self, delivery: ResultDeliveryRequestORM, *, include_items: bool = False, item_limit: Optional[int] = None, ) -> Dict[str, Any]: items = list(delivery.items or []) if include_items else [] if item_limit is not None: items = items[: max(0, int(item_limit))] summary = delivery.summary_json if isinstance(delivery.summary_json, dict) else {} payload = { "id": delivery.id, "delivery_id": delivery.delivery_id, "owner_user_id": delivery.owner_user_id, "owner_username": delivery.owner_username, "channel": delivery.channel, "status": delivery.status, "package_mode": delivery.package_mode, "item_count": delivery.item_count, "total_bytes": delivery.total_bytes, "copied_bytes": delivery.copied_bytes, "delivery_dir": delivery.delivery_dir, "zip_path": delivery.zip_path, "manifest_path": delivery.manifest_path, "expires_at": delivery.expires_at, "task_id": delivery.task_id, "job_id": delivery.job_id, "error_message": delivery.error_message, "summary": summary, "created_at": delivery.created_at, "updated_at": delivery.updated_at, "started_at": delivery.started_at, "completed_at": delivery.completed_at, "download_urls": { "manifest": f"/api/result-deliveries/{delivery.delivery_id}/manifest", "archive": ( f"/api/result-deliveries/{delivery.delivery_id}/archive/download" if delivery.zip_path else None ), }, } if include_items: payload["items"] = [self.serialize_item(item, delivery_id=delivery.delivery_id) for item in items] return payload def serialize_item(self, item: ResultDeliveryItemORM, *, delivery_id: str) -> Dict[str, Any]: return { "id": item.id, "delivery_id": item.delivery_id, "source_product_id": item.source_product_id, "source_result_id": item.source_result_id, "source_asset_id": item.source_asset_id, "display_name": item.display_name, "relative_path": item.relative_path, "file_size": item.file_size, "checksum_sha256": item.checksum_sha256, "status": item.status, "error_message": item.error_message, "download_url": ( f"/api/result-deliveries/{delivery_id}/files/{item.id}/download" if item.status == ITEM_STATUS_COPIED else None ), } async def build_delivery(self, delivery_id: str) -> Dict[str, Any]: async with _new_session() as db: result = await db.execute( select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id) ) delivery = result.scalar_one_or_none() if delivery is None: raise ValueError(f"delivery not found: {delivery_id}") if delivery.status in {DELIVERY_STATUS_READY, DELIVERY_STATUS_CANCELLED, DELIVERY_STATUS_EXPIRED}: return self.serialize_delivery(delivery) delivery.status = DELIVERY_STATUS_RUNNING delivery.started_at = _utcnow() delivery.error_message = None await db.commit() try: summary = await self._build_delivery_files(delivery_id) except Exception as exc: async with _new_session() as db: result = await db.execute( select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id) ) delivery = result.scalar_one_or_none() if delivery is not None: delivery.status = DELIVERY_STATUS_FAILED delivery.error_message = str(exc) delivery.completed_at = _utcnow() delivery.summary_json = { **(delivery.summary_json if isinstance(delivery.summary_json, dict) else {}), "error": str(exc), } await db.commit() if delivery.task_id: await task_service.update_task(delivery.task_id, status="FAILED", progress=100, message=str(exc)) raise async with _new_session() as db: result = await db.execute( select(ResultDeliveryRequestORM) .options(selectinload(ResultDeliveryRequestORM.items)) .where(ResultDeliveryRequestORM.delivery_id == delivery_id) ) delivery = result.scalars().unique().one_or_none() if delivery is None: raise ValueError(f"delivery not found: {delivery_id}") available_files = int(summary.get("copied_files", 0) or 0) + int(summary.get("skipped_files", 0) or 0) delivery.status = DELIVERY_STATUS_READY if available_files > 0 else DELIVERY_STATUS_FAILED delivery.completed_at = _utcnow() delivery.error_message = summary.get("error_message") delivery.summary_json = summary await db.commit() if delivery.task_id: if delivery.status == DELIVERY_STATUS_READY: await task_service.update_task( delivery.task_id, status="COMPLETED", progress=100, message=( f"成果交付包已生成: files={summary.get('copied_files', 0)}, " f"bytes={summary.get('copied_bytes', 0)}" ), ) else: await task_service.update_task( delivery.task_id, status="FAILED", progress=100, message=summary.get("error_message") or "成果交付包生成失败", ) return self.serialize_delivery(delivery, include_items=True) async def _build_delivery_files(self, delivery_id: str) -> Dict[str, Any]: async with _new_session() as db: result = await db.execute( select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id) ) delivery = result.scalar_one() request_json = delivery.request_json if isinstance(delivery.request_json, dict) else {} if delivery.channel == CHANNEL_DINSAR: sources = await self._resolve_dinsar_sources( db, product_ids=_normalize_int_ids(request_json.get("product_ids"), max_count=int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)), compat_result_ids=_normalize_int_ids(request_json.get("compat_result_ids"), max_count=int(settings.RESULT_DELIVERY_MAX_ITEMS or 500)), ) else: sources = [] task_id = delivery.task_id if not sources: raise ValueError("delivery has no deliverable sources") include_checksums = bool( request_json.get("include_checksums") if "include_checksums" in request_json else settings.RESULT_DELIVERY_CHECKSUM_ENABLED ) delivery_dir = os.path.normpath(os.path.abspath(delivery.delivery_dir)) root = os.path.normpath(os.path.abspath(delivery.delivery_root)) if not _path_within(root, delivery_dir): raise ValueError("delivery directory is outside RESULT_DELIVERY_ROOT") os.makedirs(delivery_dir, exist_ok=True) copied_files = 0 skipped_files = 0 failed_items = 0 copied_bytes = 0 total_bytes = 0 checksum_lines: List[str] = [] item_payloads: List[Dict[str, Any]] = [] manifest_items: List[Dict[str, Any]] = [] total_sources = len(sources) for source_index, source in enumerate(sources, start=1): folder = _sanitize_segment( source.product.task_alias or source.product.task_name or source.display_name or source.product.product_id, default=f"product_{source_index}", ) source_files = _iter_associated_files(source.source_path) if not source_files: failed_items += 1 item_payloads.append( { "delivery_id": delivery_id, "source_product_id": source.product.id, "source_result_id": source.compat_row.id if source.compat_row else None, "source_asset_id": source.source_asset_id, "display_name": source.display_name, "source_path": source.source_path, "relative_path": None, "file_size": 0, "checksum_sha256": None, "status": ITEM_STATUS_FAILED, "error_message": "source file not found", } ) continue for file_index, source_file in enumerate(source_files, start=1): if os.path.isdir(source.source_path): relative_under_source = os.path.relpath(source_file, source.source_path) relative_path = os.path.join(folder, relative_under_source) else: relative_path = os.path.join(folder, os.path.basename(source_file)) target_path = os.path.join(delivery_dir, relative_path) size = _file_size(source_file) total_bytes += size status = ITEM_STATUS_COPIED error_message = None checksum = None try: os.makedirs(os.path.dirname(target_path), exist_ok=True) if os.path.isfile(target_path) and _same_file_size(source_file, target_path): skipped_files += 1 else: shutil.copy2(source_file, target_path) copied_files += 1 copied_bytes += size if include_checksums and os.path.isfile(target_path): checksum = _sha256_file(target_path) checksum_lines.append(f"{checksum} {relative_path.replace(os.sep, '/')}") except OSError as exc: status = ITEM_STATUS_FAILED error_message = str(exc) failed_items += 1 if task_id and (copied_files + skipped_files + failed_items) % 10 == 0: progress = min(95, int(((source_index - 1) / max(1, total_sources)) * 100)) await task_service.update_task( task_id, progress=progress, message=f"成果交付包生成中: {source_index}/{total_sources}", ) item_payload = { "delivery_id": delivery_id, "source_product_id": source.product.id, "source_result_id": source.compat_row.id if source.compat_row else None, "source_asset_id": source.source_asset_id if file_index == 1 else None, "display_name": source.display_name, "source_path": source_file, "relative_path": relative_path, "file_size": size, "checksum_sha256": checksum, "status": status, "error_message": error_message, } item_payloads.append(item_payload) manifest_items.append( { "display_name": source.display_name, "product_id": source.product.product_id, "product_ref_id": source.product.id, "source_result_id": source.compat_row.id if source.compat_row else None, "relative_path": relative_path.replace(os.sep, "/"), "file_size": size, "checksum_sha256": checksum, "status": status, "error_message": error_message, } ) checksums_path = os.path.join(delivery_dir, "checksums.sha256") if include_checksums: with open(checksums_path, "w", encoding="utf-8") as stream: stream.write("\n".join(checksum_lines)) if checksum_lines: stream.write("\n") manifest_path = os.path.join(delivery_dir, "manifest.json") summary = { "delivery_id": delivery_id, "channel": delivery.channel, "package_mode": delivery.package_mode, "source_count": total_sources, "file_count": len(item_payloads), "copied_files": copied_files, "skipped_files": skipped_files, "failed_items": failed_items, "total_bytes": total_bytes, "copied_bytes": copied_bytes, "include_checksums": include_checksums, "manifest_path": manifest_path, "checksums_path": checksums_path if include_checksums else None, "created_at": _utcnow().isoformat(timespec="seconds"), } if failed_items: summary["error_message"] = f"{failed_items} files failed during delivery build" _write_json( manifest_path, { "schema_version": "insar.result-delivery/v1", "summary": summary, "items": manifest_items, }, ) zip_path = None if delivery.package_mode == PACKAGE_MODE_ZIP: if total_bytes > int(settings.RESULT_DELIVERY_ZIP_MAX_BYTES or 0): raise ValueError( f"delivery size exceeds zip limit: {total_bytes} > {settings.RESULT_DELIVERY_ZIP_MAX_BYTES}" ) zip_path = str(delivery.zip_path or f"{delivery_dir}.zip") with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive: for root_dir, _dirs, files in os.walk(delivery_dir): for name in files: path = os.path.join(root_dir, name) archive.write(path, os.path.relpath(path, delivery_dir)) async with _new_session() as db: result = await db.execute( select(ResultDeliveryRequestORM).where(ResultDeliveryRequestORM.delivery_id == delivery_id) ) delivery_row = result.scalar_one() await db.execute( ResultDeliveryItemORM.__table__.delete().where( ResultDeliveryItemORM.delivery_id == delivery_id ) ) for payload in item_payloads: db.add(ResultDeliveryItemORM(**payload)) delivery_row.total_bytes = total_bytes delivery_row.copied_bytes = copied_bytes delivery_row.item_count = len(item_payloads) delivery_row.manifest_path = manifest_path delivery_row.zip_path = zip_path or delivery_row.zip_path await db.commit() return summary async def resolve_manifest_path( self, db: AsyncSession, *, delivery_id: str, user: AuthUserORM, ) -> str: delivery = await self.get_delivery(db, delivery_id=delivery_id, user=user, include_items=False) if delivery is None: raise ValueError("delivery not found") if delivery.status != DELIVERY_STATUS_READY: raise ValueError("delivery is not ready") path = str(delivery.manifest_path or "").strip() if not path or not os.path.isfile(path): raise ValueError("manifest file is missing") if not _path_within(delivery.delivery_root, path): raise ValueError("manifest path is outside delivery root") return path async def resolve_archive_path( self, db: AsyncSession, *, delivery_id: str, user: AuthUserORM, ) -> str: delivery = await self.get_delivery(db, delivery_id=delivery_id, user=user, include_items=False) if delivery is None: raise ValueError("delivery not found") if delivery.status != DELIVERY_STATUS_READY: raise ValueError("delivery is not ready") path = str(delivery.zip_path or "").strip() if not path or not os.path.isfile(path): raise ValueError("zip archive is not available") if not _path_within(delivery.delivery_root, path): raise ValueError("zip path is outside delivery root") return path async def resolve_item_path( self, db: AsyncSession, *, delivery_id: str, item_id: int, user: AuthUserORM, ) -> str: delivery = await self.get_delivery(db, delivery_id=delivery_id, user=user, include_items=False) if delivery is None: raise ValueError("delivery not found") if delivery.status != DELIVERY_STATUS_READY: raise ValueError("delivery is not ready") result = await db.execute( select(ResultDeliveryItemORM).where( ResultDeliveryItemORM.id == int(item_id), ResultDeliveryItemORM.delivery_id == delivery_id, ) ) item = result.scalar_one_or_none() if item is None: raise ValueError("delivery item not found") if item.status != ITEM_STATUS_COPIED: raise ValueError("delivery item is not available") relative_path = str(item.relative_path or "").strip() if not relative_path: raise ValueError("delivery item path is missing") path = os.path.normpath(os.path.abspath(os.path.join(delivery.delivery_dir, relative_path))) if not os.path.isfile(path): raise ValueError("delivery item file is missing") if not _path_within(delivery.delivery_dir, path): raise ValueError("delivery item path is outside delivery directory") return path result_delivery_service = ResultDeliveryService()