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(
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
Read-only probe: for one plan, list every Not-Started task with its assignee
count and attachment count, so the "skip assigned tasks" rule can be validated
against what the Planner grid shows before any behaviour is changed.
Downloads nothing and never PATCHes a task — safe against production Planner.
Run: .venv\\Scripts\\python scripts\\probe_assignments.py --facility "Balboa"
"""
from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "src"
for p in (str(ROOT), str(SRC)):
if p not in sys.path:
sys.path.insert(0, p)
from config import read_app_config # noqa: E402
from onedrive_planner_http import ( # noqa: E402
_build_client,
_discover_all_plans,
_get_paged,
_login_with_retry,
_match_plan,
_task_file_urls,
)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--facility", required=True, help="facility / plan name fragment")
ap.add_argument("--headed", action="store_true")
args = ap.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(message)s",
stream=sys.stdout,
)
cfg = read_app_config()
token, _ = _login_with_retry(cfg, headless=not args.headed)
client = _build_client(cfg, token)
plans = _discover_all_plans(client, cfg)
plan = _match_plan(plans, args.facility)
if not plan:
print(f"no plan matched {args.facility!r}")
return 1
print(f"\nplan: {plan.get('title')!r}\n")
tasks = _get_paged(
client,
f"planner/plans/{plan['id']}/tasks",
cfg,
**{"$select": "id,title,percentComplete,assignments,orderHint"},
)
print(f"{'%':>4} {'assignees':>9} {'files':>5} title")
print("-" * 78)
would_download = would_skip = 0
for t in tasks:
pct = t.get("percentComplete") or 0
if pct != 0:
continue
assignees = len(t.get("assignments") or {})
n_files = len(_task_file_urls(client, t["id"], cfg))
print(f"{pct:>4} {assignees:>9} {n_files:>5} {(t.get('title') or '')[:50]}")
if n_files:
if assignees:
would_skip += 1
else:
would_download += 1
print("-" * 78)
print(f"With the 'skip assigned' rule: {would_download} would download, "
f"{would_skip} would skip (attachment-bearing tasks only)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+121
View File
@@ -0,0 +1,121 @@
"""Only unassigned Not-Started tasks may be downloaded.
A task carrying an 'Assigned to' avatar in the Planner grid has been picked up
by a person; the bot must leave it — and its status — alone.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "src"
for _p in (str(ROOT), str(SRC)):
if _p not in sys.path:
sys.path.insert(0, _p)
from onedrive_planner_http import _task_assignee_ids, _write_summary # noqa: E402
class TestTaskAssigneeIds:
def test_unassigned_task_has_no_assignees(self):
assert _task_assignee_ids({"title": "8.24.26"}) == []
assert _task_assignee_ids({"title": "8.21.26", "assignments": {}}) == []
assert _task_assignee_ids({"assignments": None}) == []
def test_assigned_task_returns_every_user_id(self):
task = {
"title": "Facesheets Needed (GG)",
"assignments": {
"aaaaaaaa-1111-2222-3333-444444444444": {"orderHint": "8"},
"bbbbbbbb-5555-6666-7777-888888888888": {"orderHint": "9"},
},
}
assert sorted(_task_assignee_ids(task)) == [
"aaaaaaaa-1111-2222-3333-444444444444",
"bbbbbbbb-5555-6666-7777-888888888888",
]
def test_blank_user_id_is_ignored(self):
assert _task_assignee_ids({"assignments": {"": {}}}) == []
class TestSummaryWorkbook:
def _write(self, tmp_path: Path, payload: dict) -> Path:
out = tmp_path / "Download_Summary_HTTP_test.xlsx"
_write_summary({"Balboa Nursing and Rehab": payload}, [], out, "2026-08-24 18:00")
return out
def _balboa_payload(self) -> dict:
"""The real Balboa plan: 2 unassigned with files, 2 assigned."""
return {
"original": [Path("a.pdf"), Path("b.pdf")],
"tasks_total": 4,
"tasks_with_attachments": 2,
"tasks_with_downloads": 2,
"tasks_skipped_no_attachment": 0,
"tasks_skipped_assigned": 2,
"status_changed": 2,
"task_rows": [
{"Facility Name": "Balboa Nursing and Rehab", "Task Name": "8.24.26",
"Assigned To": "", "Attachments": 1, "Downloaded": "Yes",
"Reason": "Downloaded 1/1 attachment(s)"},
{"Facility Name": "Balboa Nursing and Rehab", "Task Name": "8.21.26",
"Assigned To": "", "Attachments": 1, "Downloaded": "Yes",
"Reason": "Downloaded 1/1 attachment(s)"},
{"Facility Name": "Balboa Nursing and Rehab",
"Task Name": "Facesheets Needed (GG)", "Assigned To": "A S",
"Attachments": "", "Downloaded": "No",
"Reason": "Skipped — assigned to A S"},
{"Facility Name": "Balboa Nursing and Rehab",
"Task Name": "Facesheets Needed (GG)", "Assigned To": "D M",
"Attachments": "", "Downloaded": "No",
"Reason": "Skipped — assigned to D M"},
],
}
def test_download_sheet_reports_assigned_skips(self, tmp_path):
out = self._write(tmp_path, self._balboa_payload())
df = pd.read_excel(out, sheet_name="Download")
assert df.loc[0, "Tasks Skipped (Assigned)"] == 2
assert df.loc[0, "Files Downloaded"] == 2
assert df.loc[0, "Status"] == "Success"
def test_tasks_sheet_has_a_yes_no_row_per_task(self, tmp_path):
out = self._write(tmp_path, self._balboa_payload())
df = pd.read_excel(out, sheet_name="Tasks")
assert list(df.columns) == [
"Facility Name", "Task Name", "Assigned To",
"Attachments", "Downloaded", "Reason",
]
assert list(df["Downloaded"]) == ["Yes", "Yes", "No", "No"]
facesheets = df[df["Task Name"] == "Facesheets Needed (GG)"]
assert len(facesheets) == 2
assert all(facesheets["Downloaded"] == "No")
assert all("assigned to" in r for r in facesheets["Reason"])
def test_facility_whose_tasks_are_all_assigned(self, tmp_path):
out = self._write(tmp_path, {
"original": [],
"tasks_total": 3,
"tasks_with_attachments": 0,
"tasks_with_downloads": 0,
"tasks_skipped_no_attachment": 0,
"tasks_skipped_assigned": 3,
"status_changed": 0,
"task_rows": [],
})
df = pd.read_excel(out, sheet_name="Download")
assert df.loc[0, "Status"] == "All assigned — skipped"
def test_tasks_sheet_exists_even_with_no_task_rows(self, tmp_path):
out = self._write(tmp_path, {
"original": [], "tasks_total": 0, "tasks_with_attachments": 0,
"tasks_with_downloads": 0, "tasks_skipped_no_attachment": 0,
"tasks_skipped_assigned": 0, "status_changed": 0, "task_rows": [],
})
df = pd.read_excel(out, sheet_name="Tasks")
assert df.empty
assert "Downloaded" in df.columns