mirror of
https://github.com/Hestia-Homes/Model.git
synced 2026-08-03 05:18:22 +00:00
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>
131 lines
5 KiB
Python
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)
|