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:
1 parent
dd864e0b4c
commit
bfd72825b4
3 files changed
+317
-9
No files matched your search
+108
-9
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user