Skip Not-Started tasks that are already assigned to someone

A task carrying an "Assigned to" avatar in the Planner grid has been picked
up by a person, so its attachments must not be downloaded and its status must
not be moved to In Progress. Until now the only filter was percentComplete
== 0, so an assigned task with an attachment was downloaded like any other —
on the Balboa Nursing and Rehab plan that was one of the two "Facesheets
Needed (GG)" tasks assigned to Dr. Mark Lukasik and Adina Smets.

- _not_started_tasks() now selects `assignments` as well.
- _task_assignee_ids()/_resolve_user_names(): the assignee check runs before
  the task-details call, so an assigned task costs no extra Graph request;
  display names are resolved once per user and cached for the run, falling
  back to the raw ID when the lookup fails.
- Summary workbook gains a "Tasks Skipped (Assigned)" column and a second
  "Tasks" sheet with one row per task — facility, task name, assigned to,
  attachment count, Downloaded Yes/No and the reason — so a skip can be
  explained without reading the log.
- New "All assigned — skipped" facility status, distinct from "No attachments".

scripts/probe_assignments.py is the read-only probe used to confirm the rule
against the live plan before changing behaviour.

Verified on Balboa Nursing and Rehab: 4 Not Started -> 8.24.26 and 8.21.26
downloaded (unassigned), both Facesheets Needed (GG) skipped. 110 tests pass
(tests/test_uipath_pilot.py and tests/test_config.py fail on this checkout for
pre-existing, unrelated reasons).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
VasanthandClaude Opus 5 committed 2026-08-24 18:46:00 +05:30
1 parent dd864e0b4c
commit bfd72825b4
3 files changed
+317 -9

No files matched your search

+108 -9
View File
@@ -6,7 +6,8 @@ Architecture:
authenticated request to graph.microsoft.com, then closes immediately.
2. All subsequent work uses httpx against the Microsoft Graph API:
- Discover plans via user's group memberships
- List Not-Started tasks per facility plan
- List Not-Started tasks per facility plan, skipping any task that is
already assigned to someone (their work — not ours to download)
- Resolve SharePoint file references via Graph shares endpoint
- Stream-download each PDF
3. Identical output structure to the Selenium bot:
@@ -880,13 +881,42 @@ def _not_started_tasks(client: httpx.Client, plan_id: str, cfg: AppConfig) -> li
client,
f"planner/plans/{plan_id}/tasks",
cfg,
**{"$select": "id,title,percentComplete"},
**{"$select": "id,title,percentComplete,assignments"},
)
ns = [t for t in tasks if (t.get("percentComplete") or 0) == 0]
_log.info("Plan %s: %d tasks total, %d not started", plan_id, len(tasks), len(ns))
return ns
def _task_assignee_ids(task: dict) -> list[str]:
"""User IDs a task is assigned to (the Planner grid's 'Assigned to' column)."""
return [uid for uid in (task.get("assignments") or {}) if uid]
def _resolve_user_names(
client: httpx.Client,
user_ids: list[str],
cfg: AppConfig,
cache: dict[str, str],
) -> list[str]:
"""Display names for assignee IDs, cached across the run.
A lookup failure is never fatal — the ID is shown instead so the summary
still records who the task was assigned to.
"""
names: list[str] = []
for uid in user_ids:
if uid not in cache:
try:
user = _get(client, f"users/{uid}", cfg, **{"$select": "displayName"})
cache[uid] = user.get("displayName") or uid
except Exception as exc:
_log.debug("user lookup %s failed: %s", uid, exc)
cache[uid] = uid
names.append(cache[uid])
return names
def _task_file_urls(client: httpx.Client, task_id: str, cfg: AppConfig) -> list[str]:
"""Return SharePoint file URLs from a task's references (details endpoint)."""
try:
@@ -1054,6 +1084,7 @@ def _write_summary(
) -> None:
failure_names = {f for f, _, _ in failures}
rows: list[dict] = []
task_rows: list[dict] = []
for facility, payload in results.items():
if facility in failure_names:
@@ -1063,10 +1094,14 @@ def _write_summary(
tasks_att = int(payload.get("tasks_with_attachments") or 0)
tasks_dl = int(payload.get("tasks_with_downloads") or 0)
tasks_skip = int(payload.get("tasks_skipped_no_attachment") or 0)
tasks_assigned = int(payload.get("tasks_skipped_assigned") or 0)
status_changed = int(payload.get("status_changed") or 0)
task_rows.extend(payload.get("task_rows") or [])
if tasks_total <= 0:
status = "No tasks"
elif tasks_att <= 0 and tasks_assigned > 0:
status = "All assigned — skipped"
elif tasks_att <= 0:
status = "No attachments"
elif tasks_dl <= 0 or file_count <= 0:
@@ -1091,6 +1126,7 @@ def _write_summary(
"Files Downloaded": file_count,
"Tasks Not Started": tasks_total,
"Tasks With Attachments": tasks_att,
"Tasks Skipped (Assigned)": tasks_assigned,
"Tasks Skipped (No File)": tasks_skip,
"Status": status,
"Status Changed": status_changed_label,
@@ -1103,14 +1139,26 @@ def _write_summary(
"Files Downloaded": 0,
"Tasks Not Started": 0,
"Tasks With Attachments": 0,
"Tasks Skipped (Assigned)": 0,
"Tasks Skipped (No File)": 0,
"Status": status,
"Status Changed": "N/A",
})
task_columns = [
"Facility Name", "Task Name", "Assigned To",
"Attachments", "Downloaded", "Reason",
]
tasks_df = pd.DataFrame(task_rows, columns=task_columns)
summary_path.parent.mkdir(parents=True, exist_ok=True)
pd.DataFrame(rows).to_excel(summary_path, index=False, sheet_name="Download")
_log.info("Summary -> %s", summary_path)
with pd.ExcelWriter(summary_path, engine="openpyxl") as writer:
pd.DataFrame(rows).to_excel(writer, index=False, sheet_name="Download")
tasks_df.to_excel(writer, index=False, sheet_name="Tasks")
_log.info(
"Summary -> %s (%d facility row(s), %d task row(s))",
summary_path, len(rows), len(tasks_df),
)
# ---------------------------------------------------------------------------
@@ -1143,26 +1191,58 @@ def _process_facility(
tasks_with_att = 0
tasks_with_dl = 0
tasks_no_att = 0 # Not Started but no file attachment → skip
tasks_assigned = 0 # Not Started but already assigned to someone → skip
status_changed = 0
downloaded: list[Path] = []
task_rows: list[dict] = []
name_cache: dict[str, str] = {}
_log.info(
" Processing ONLY Not Started tasks with attachments (%d Not Started found)",
" Processing ONLY unassigned Not Started tasks with attachments "
"(%d Not Started found)",
tasks_total,
)
for task in tasks:
tid = task["id"]
title = task.get("title", "")
assignee_ids = _task_assignee_ids(task)
# A task someone has already picked up is theirs to work — never
# download it and never touch its status. Checked before the
# attachment lookup so an assigned task costs no extra Graph call.
if assignee_ids:
names = _resolve_user_names(client, assignee_ids, cfg, name_cache)
assigned_to = ", ".join(names)
_log.info(" SKIP '%s': assigned to %s", title[:60], assigned_to)
tasks_assigned += 1
task_rows.append({
"Facility Name": facility_name,
"Task Name": title,
"Assigned To": assigned_to,
"Attachments": "",
"Downloaded": "No",
"Reason": f"Skipped — assigned to {assigned_to}",
})
continue
file_urls = _task_file_urls(client, tid, cfg)
if not file_urls:
# Not Started but no attachment — skip entirely
_log.info(" SKIP '%s': Not Started but no attachment", title[:60])
tasks_no_att += 1
task_rows.append({
"Facility Name": facility_name,
"Task Name": title,
"Assigned To": "",
"Attachments": 0,
"Downloaded": "No",
"Reason": "Skipped — no attachment",
})
continue
# Not Started WITH attachment(s) — download ALL of them
# Unassigned, Not Started, WITH attachment(s) — download ALL of them
tasks_with_att += 1
total_urls = len(file_urls)
task_dl = 0
@@ -1182,6 +1262,21 @@ def _process_facility(
downloaded.append(path)
task_dl += 1
if task_dl == total_urls:
reason = f"Downloaded {task_dl}/{total_urls} attachment(s)"
elif task_dl > 0:
reason = f"Partial — {task_dl}/{total_urls} attachment(s) downloaded"
else:
reason = f"Download failed — 0/{total_urls} attachment(s)"
task_rows.append({
"Facility Name": facility_name,
"Task Name": title,
"Assigned To": "",
"Attachments": total_urls,
"Downloaded": "Yes" if task_dl else "No",
"Reason": reason,
})
if task_dl == total_urls:
# Every attachment downloaded — mark In Progress
tasks_with_dl += 1
@@ -1210,9 +1305,10 @@ def _process_facility(
)
_log.info(
" Summary: %d Not Started | %d with attachments (processed) | "
"%d no attachment (skipped) | %d file(s) downloaded",
tasks_total, tasks_with_att, tasks_no_att, len(downloaded),
" Summary: %d Not Started | %d assigned (skipped) | "
"%d with attachments (processed) | %d no attachment (skipped) | "
"%d file(s) downloaded",
tasks_total, tasks_assigned, tasks_with_att, tasks_no_att, len(downloaded),
)
return {
@@ -1221,6 +1317,8 @@ def _process_facility(
"tasks_with_attachments": tasks_with_att,
"tasks_with_downloads": tasks_with_dl,
"tasks_skipped_no_attachment": tasks_no_att,
"tasks_skipped_assigned": tasks_assigned,
"task_rows": task_rows,
"status_changed": status_changed,
"status_via_task_details": 0,
}
@@ -1362,6 +1460,7 @@ def run_planner_http_workflow(
"original": [], "workable": [], "comment": err_msg[:500],
"tasks_total": 0, "tasks_with_attachments": 0,
"tasks_with_downloads": 0, "tasks_skipped_no_attachment": 0,
"tasks_skipped_assigned": 0, "task_rows": [],
"status_changed": 0, "status_via_task_details": 0,
}
save_checkpoint(