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(
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
Reference in new issue
Block a user