mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-07-19 17:03:02 +00:00
The 'all properties' selection resolved against property.portfolio_id, but a
portfolio spans multiple HubSpot projects — so 'all' pulled the whole portfolio,
not the project the user was looking at. The project↔property grain lives on
hubspot_deal_data, not property.
task.inputs is now {project_codes?: str[], property_ids?: int[], portfolio_id?}:
the route resolves the distinct landlord_property_id set as the union of every
property in the named project_codes (from hubspot_deal_data) and the hand-picked
property_ids; portfolio_id is optional and only names the package. Drops the
portfolio-scoped select_all. Cap now applies to the resolved set size.
ADR-0060, CONTEXT.md and the request schema updated to match. New router tests
cover project-code resolution, the union+dedup, null landlord_property_id drop,
and the empty-project rejection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
5.9 KiB
Python
162 lines
5.9 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_property_ids: Any = config.get("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_property_ids, list) or not all(
|
|
isinstance(pid, int) for pid in cast(list[Any], raw_property_ids)
|
|
):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="task.inputs property_ids must be a list of integers.",
|
|
)
|
|
project_codes: list[str] = cast(list[str], raw_project_codes)
|
|
property_ids: list[int] = cast(list[int], raw_property_ids)
|
|
if not project_codes and not property_ids:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="task.inputs must provide project_codes or property_ids.",
|
|
)
|
|
|
|
landlord_property_ids = tasks.resolve_selection(project_codes, property_ids)
|
|
if not landlord_property_ids:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
"The selection resolves to no downloadable properties — none of "
|
|
"the selected projects or properties has a landlord_property_id."
|
|
),
|
|
)
|
|
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"}
|