"""The bulk-download route's view of the app-owned task's sub_task (ADR-0060). Parameterised SQL rather than the SQLModel mirrors: importing the ``infrastructure.postgres`` mirror of ``sub_task`` into the FastAPI process double-registers the table and crashes at import (the same constraint the Modelling Run distributor works around — see ``modelling/run_tasks.py``). """ import json from datetime import datetime, timezone from typing import Any, Optional, cast from uuid import UUID from sqlalchemy import text from sqlmodel import Session class DocumentDownloadTasks: def __init__(self, session: Session) -> None: self._session = session def read_selection_config(self, task_id: UUID) -> dict[str, Any]: """The task's selection config, read from the FE-owned ``task.inputs`` JSON (ADR-0060) — ``{portfolio_id, property_ids?, select_all?}``. Empty dict when the task has no inputs.""" row = ( self._session.connection() .execute( text("SELECT inputs FROM tasks WHERE id = :task_id"), {"task_id": task_id}, ) .first() ) if row is None or row[0] is None: return {} parsed: Any = json.loads(row[0]) return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else {} def already_distributed(self, task_id: UUID) -> bool: """Whether the task already has a sub_task — the 409 guard against a double-submit re-triggering the same download.""" row = ( self._session.connection() .execute( text("SELECT 1 FROM sub_task WHERE task_id = :task_id LIMIT 1"), {"task_id": task_id}, ) .first() ) return row is not None def resolve_selection( self, portfolio_id: int, property_ids: Optional[list[int]], select_all: bool, ) -> tuple[int, list[str]]: """Resolve the selection to (selected property count, the distinct ``landlord_property_id`` values that carry a downloadable identifier). The count is the cap basis (property count, ADR-0060); files are matched by ``landlord_property_id`` (the missing ``property_id`` on ``uploaded_files`` is a known future gap), so a selected property with a null ``landlord_property_id`` contributes no files. """ if select_all: rows = self._session.connection().execute( text( "SELECT id, landlord_property_id FROM property" " WHERE portfolio_id = :portfolio_id" ), {"portfolio_id": portfolio_id}, ) else: rows = self._session.connection().execute( text( "SELECT id, landlord_property_id FROM property" " WHERE portfolio_id = :portfolio_id AND id = ANY(:ids)" ), {"portfolio_id": portfolio_id, "ids": property_ids or []}, ) resolved = rows.all() landlord_property_ids = sorted( {row[1] for row in resolved if row[1] is not None} ) return len(resolved), landlord_property_ids def create_download_subtask( self, task_id: UUID, subtask_id: UUID, recipe: dict[str, Any] ) -> None: """Pre-create the single ``waiting`` sub_task under the app-owned task, pinning the recipe (landlord_property_ids, recipient_email, package_name) onto its inputs — the worker's reproducible instructions (ADR-0055/0060).""" now = datetime.now(timezone.utc) self._session.connection().execute( text( "INSERT INTO sub_task (id, task_id, status, inputs, updated_at)" " VALUES (:id, :task_id, 'waiting', :inputs, :updated_at)" ), { "id": subtask_id, "task_id": task_id, "inputs": json.dumps(recipe), "updated_at": now, }, ) self._session.commit()