Model/applications/sharepoint_renamer/handler.py
Daniel Roth 3e6464fb15 Bind a property's rename callback without a default-argument trick 🟪
partial binds plan eagerly and without introducing a parameter the caller
never passes, so there is no late-binding question to reason about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 10:05:29 +00:00

131 lines
5 KiB
Python

"""SQS-triggered Lambda: rename Sero SharePoint files to their canonical names.
Runs on the app-owned-task lane. Each invocation creates its own ``task`` plus
an initial ``sub_task`` and runs inside the ``TaskOrchestrator`` lifecycle, so a
button press in the admin portal leaves a row with a status, a CloudWatch link,
and — on completion — a summary of what the run did. The portal reads
``tasks`` / ``sub_task`` directly, so the trigger stays fire-and-forget SQS and
nothing is handed back to the caller.
Partial failures complete rather than fail: a run where SharePoint rejected some
files ends **complete** with those failures listed in ``outputs``, matching the
best-effort Download Package convention (ADR-0060). One locked file should not
turn a productive run red. Missing folders likewise do not fail a run.
"""
import os
from functools import partial
from typing import Any
from uuid import UUID
from applications.sharepoint_renamer.sharepoint_renamer_request import (
SharepointRenamerRequest,
)
from domain.tasks.tasks import Source
from orchestration.sharepoint_renamer_orchestrator import (
PropertyPlan,
RenameSummary,
SharepointRenamerOrchestrator,
)
from orchestration.task_orchestrator import TaskOrchestrator
from utilities.aws_lambda.task_handler import task_handler
from utils.sharepoint.domna_sharepoint_client import DomnaSharepointClient
from utils.sharepoint.domna_sites import resolve_site
CSV_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "sero_address_list.csv"
)
def _summary_json(summary: RenameSummary) -> dict[str, Any]:
"""The portal's view of a run, mapped field by field rather than derived
from ``RenameSummary``. The explicit mapping is the point: it keeps the
portal's contract stable when the internal type is renamed or reshaped, and
``outputs`` is serialised to text so it must be plain JSON either way.
Counts are per-**file**; ``missing_folders`` is per-**property**."""
return {
"renamed": summary.renamed,
"would_rename": summary.would_rename,
"skipped_already_canonical": summary.skipped_already_canonical,
"skipped_images": summary.skipped_images,
"missing_folders": summary.missing_folders,
"failed": [
{
"uprn": failure.uprn,
"original_name": failure.original_name,
"new_name": failure.new_name,
"error": failure.error,
}
for failure in summary.failed
],
}
def _plan_json(plan: PropertyPlan) -> dict[str, Any]:
"""A property sub_task's inputs: enough to see what was intended, and to
re-run just this property by re-sending them."""
return {
"uprn": plan.uprn,
"address": plan.address,
"postcode": plan.postcode,
"renames": [
{
"item_id": intent.item_id,
"original_name": intent.original_name,
"new_name": intent.new_name,
}
for intent in plan.intents
],
}
@task_handler(
task_source="sharepoint_renamer",
source=Source.SHAREPOINT_SITE,
pass_task_orchestrator=True,
)
def handler(
body: dict[str, Any], context: Any, orchestrator: TaskOrchestrator, task_id: UUID
) -> dict[str, Any]:
request = SharepointRenamerRequest.model_validate(body)
sp_client = DomnaSharepointClient(resolve_site(request.sharepoint_site))
renamer = SharepointRenamerOrchestrator(
sp_client, CSV_PATH, dry_run=request.dry_run
)
summary = RenameSummary()
# Plan and rename one property at a time (the plan is a lazy generator), so
# a run killed by the timeout still leaves every property it reached both
# renamed and recorded. Only properties with something to do are worth a
# sub_task — a steady-state run over already-canonical folders creates none.
for plan in renamer.plan():
if not plan.intents:
summary.merge_in(renamer.rename(plan))
continue
subtask = orchestrator.create_child_subtask(task_id, inputs=_plan_json(plan))
orchestrator.run_subtask(
subtask.id, work=partial(_record, renamer, plan, summary)
)
return _summary_json(summary)
def _record(
renamer: SharepointRenamerOrchestrator, plan: PropertyPlan, summary: RenameSummary
) -> dict[str, Any]:
"""Rename one property, adding its result to the run total and returning it
for that property's own sub_task outputs.
``rename`` records a rejected file rather than raising, so the sub_task
completes with the failure in its outputs. Failing it instead would turn
the whole Task red — any failed child does — and one locked file must not
do that (ADR-0060)."""
result = renamer.rename(plan)
summary.merge_in(result)
return _summary_json(result)
if __name__ == "__main__":
# Defaults to a dry run: this module is now decorated, so invoking it by
# hand writes a real task row *and*, without the flag, renames live files.
handler({"sharepoint_site": "SOCIAL_HOUSING_WAVE_3", "dry_run": True}, None)