Model/backend/app/documents/router.py
Khalim Conn-Kowlessar 3c9b75cbce Hand-pick selection by landlord_property_id, not property.id
uploaded_files has no property_id — matching is on landlord_property_id — so the
property_ids path was pointless indirection (look property.id up in property just
to translate back to landlord_property_id). The FE holds landlord_property_id per
row anyway.

task.inputs hand-pick key is now landlord_property_ids: str[] (was property_ids:
int[]). resolve_selection unions two landlord_property_id sources — project_codes
expanded via hubspot_deal_data, and the hand-picked ids taken as given — and no
longer queries the property table at all. Trigger-only change; the domain plan,
orchestrator and matching already work in landlord_property_id.

ADR-0060, CONTEXT.md and the request schema updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 12:04:35 +00:00

166 lines
6.1 KiB
Python

"""Bulk Document Download trigger: POST /v1/documents/bulk-download (ADR-0060).
Resolves the authenticated requester's email, resolves + caps the property
selection, pins the recipe onto a single pre-created sub_task under the
app-owned task, and enqueues one message to the bulk_document_download worker.
Never assembles the package synchronously — the worker emails the link.
"""
import json
from collections.abc import Callable, Iterator
from typing import Any, cast
from uuid import uuid4
import boto3
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from backend.app.config import get_settings
from backend.app.db.connection import db_engine
from backend.app.dependencies import validate_jwt_token, validate_token
from backend.app.documents.download_tasks import DocumentDownloadTasks
from backend.app.documents.schemas import BulkDownloadRequest
# The selection cap (ADR-0060) — rejected at the trigger so an oversized request
# never reaches the worker. A conservative, tunable starting value.
MAX_PROPERTIES = 500
MessageSender = Callable[[str], None]
def get_session() -> Iterator[Session]:
with Session(db_engine) as session:
yield session
def get_message_sender() -> MessageSender:
settings = get_settings()
client: Any = cast(Any, boto3.client("sqs", settings.AWS_DEFAULT_REGION)) # pyright: ignore[reportUnknownMemberType]
queue_url = settings.BULK_DOCUMENT_DOWNLOAD_SQS_URL
def send(body: str) -> None:
client.send_message(QueueUrl=queue_url, MessageBody=body)
return send
def get_requesting_user_email(user: Any = Depends(validate_jwt_token)) -> str:
"""The authenticated requester's email — the Download Package recipient.
ADR-0059: the route resolves the user (JWT ``dbId`` -> ``UserModel``) rather
than only gating the token, and threads the email into the job."""
email: Any = getattr(user, "email", None)
if email is None and isinstance(user, dict):
email = cast(dict[str, Any], user).get("email")
if not email and get_settings().ENVIRONMENT == "local":
# Local `get_user` returns a dummy dict with no email; a placeholder lets
# the route be exercised end-to-end on a dev machine (email is a no-op
# locally anyway).
email = "local-dev@example.com"
if not email:
raise HTTPException(
status_code=400,
detail="Could not determine the requesting user's email address.",
)
return str(email)
router = APIRouter(
prefix="/documents",
tags=["documents"],
dependencies=[Depends(validate_token)],
)
@router.post("/bulk-download", status_code=202)
async def bulk_download(
body: BulkDownloadRequest,
session: Session = Depends(get_session),
send_message: MessageSender = Depends(get_message_sender),
recipient_email: str = Depends(get_requesting_user_email),
) -> dict[str, str]:
tasks = DocumentDownloadTasks(session)
if tasks.already_distributed(body.task_id):
raise HTTPException(
status_code=409,
detail=(
f"Task {body.task_id} already has a sub_task — a download has "
"already been started for it."
),
)
config = tasks.read_selection_config(body.task_id)
portfolio_id: Any = config.get("portfolio_id")
raw_project_codes: Any = config.get("project_codes") or []
raw_landlord_property_ids: Any = config.get("landlord_property_ids") or []
if not isinstance(raw_project_codes, list) or not all(
isinstance(code, str) for code in cast(list[Any], raw_project_codes)
):
raise HTTPException(
status_code=400,
detail="task.inputs project_codes must be a list of strings.",
)
if not isinstance(raw_landlord_property_ids, list) or not all(
isinstance(lpid, str) for lpid in cast(list[Any], raw_landlord_property_ids)
):
raise HTTPException(
status_code=400,
detail="task.inputs landlord_property_ids must be a list of strings.",
)
project_codes: list[str] = cast(list[str], raw_project_codes)
selected_landlord_property_ids: list[str] = cast(
list[str], raw_landlord_property_ids
)
if not project_codes and not selected_landlord_property_ids:
raise HTTPException(
status_code=400,
detail="task.inputs must provide project_codes or landlord_property_ids.",
)
landlord_property_ids = tasks.resolve_selection(
project_codes, selected_landlord_property_ids
)
if not landlord_property_ids:
raise HTTPException(
status_code=400,
detail=(
"The selection resolves to no properties — the project codes "
"matched nothing and no landlord_property_ids were given."
),
)
if len(landlord_property_ids) > MAX_PROPERTIES:
raise HTTPException(
status_code=400,
detail=(
f"The selection has {len(landlord_property_ids)} properties, over "
f"the {MAX_PROPERTIES} limit — narrow your selection."
),
)
subtask_id = uuid4()
package_name = (
f"portfolio-{portfolio_id}-{body.task_id}"
if isinstance(portfolio_id, int)
else f"documents-{body.task_id}"
)
recipe = {
"landlord_property_ids": landlord_property_ids,
"recipient_email": recipient_email,
"package_name": package_name,
}
created = tasks.create_download_subtask(body.task_id, subtask_id, recipe)
if not created:
# Lost a double-submit race — another request already created the
# sub_task. The DB arbitrated; don't enqueue a second job.
raise HTTPException(
status_code=409,
detail=(
f"Task {body.task_id} already has a sub_task — a download has "
"already been started for it."
),
)
send_message(
json.dumps({"task_id": str(body.task_id), "subtask_id": str(subtask_id)})
)
return {"message": "Bulk document download started"}