mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-12 13:29:04 +00:00
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>
166 lines
6.1 KiB
Python
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"}
|