diff --git a/onedrive_planner_http.py b/onedrive_planner_http.py index 9ad87d6..ce3769a 100644 --- a/onedrive_planner_http.py +++ b/onedrive_planner_http.py @@ -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( diff --git a/scripts/probe_assignments.py b/scripts/probe_assignments.py new file mode 100644 index 0000000..eb6e00d --- /dev/null +++ b/scripts/probe_assignments.py @@ -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()) diff --git a/tests/test_assigned_task_skip.py b/tests/test_assigned_task_skip.py new file mode 100644 index 0000000..2b78d69 --- /dev/null +++ b/tests/test_assigned_task_skip.py @@ -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