Model/backend/app/documents/download_tasks.py
Khalim Conn-Kowlessar 3c00e0ef00 FastAPI trigger route: POST /v1/documents/bulk-download 🟩
Resolves the authenticated requester's email (ADR-0059), resolves + caps the
property selection, pins the recipe (landlord_property_ids, recipient_email,
package_name) onto one pre-created sub_task (raw SQL, dodging the mirror
double-registration), and enqueues one message to the worker. Refuses
double-submits (409) and oversized selections (400).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 09:59:02 +00:00

91 lines
3.4 KiB
Python

"""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
from uuid import UUID
from sqlalchemy import text
from sqlmodel import Session
class DocumentDownloadTasks:
def __init__(self, session: Session) -> None:
self._session = session
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()