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>
110 lines
4.4 KiB
Python
110 lines
4.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, 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,
|
|
project_codes: list[str],
|
|
landlord_property_ids: list[str],
|
|
) -> list[str]:
|
|
"""Resolve the selection to the distinct ``landlord_property_id`` set the
|
|
Download Package is built from (ADR-0060) — files are matched on
|
|
``landlord_property_id`` (``uploaded_files`` has no ``property_id``), so
|
|
that is the selection key throughout. Two independent sources, unioned:
|
|
|
|
- **project codes** — every property in the named HubSpot projects, read
|
|
from ``hubspot_deal_data`` (the project↔property grain lives there).
|
|
- **hand-picked landlord_property_ids** — used as given.
|
|
|
|
The returned set size is the cap basis (property count, ADR-0060).
|
|
"""
|
|
resolved: set[str] = {lpid for lpid in landlord_property_ids if lpid}
|
|
if project_codes:
|
|
rows = self._session.connection().execute(
|
|
text(
|
|
"SELECT DISTINCT landlord_property_id FROM hubspot_deal_data"
|
|
" WHERE project_code = ANY(:codes)"
|
|
" AND landlord_property_id IS NOT NULL"
|
|
),
|
|
{"codes": project_codes},
|
|
)
|
|
resolved.update(row[0] for row in rows.all())
|
|
return sorted(resolved)
|
|
|
|
def create_download_subtask(
|
|
self, task_id: UUID, subtask_id: UUID, recipe: dict[str, Any]
|
|
) -> bool:
|
|
"""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).
|
|
|
|
The insert is **conditional on the task having no sub_task yet**, so the
|
|
DB — not a prior read — arbitrates a double-submit race: two concurrent
|
|
requests can both pass the ``already_distributed`` check, but only one
|
|
insert lands. Returns whether this call created the sub_task."""
|
|
now = datetime.now(timezone.utc)
|
|
result = self._session.connection().execute(
|
|
text(
|
|
"INSERT INTO sub_task (id, task_id, status, inputs, updated_at)"
|
|
" SELECT :id, :task_id, 'waiting', :inputs, :updated_at"
|
|
" WHERE NOT EXISTS ("
|
|
" SELECT 1 FROM sub_task WHERE task_id = :task_id"
|
|
" )"
|
|
),
|
|
{
|
|
"id": subtask_id,
|
|
"task_id": task_id,
|
|
"inputs": json.dumps(recipe),
|
|
"updated_at": now,
|
|
},
|
|
)
|
|
self._session.commit()
|
|
return result.rowcount == 1
|