""" MISF VOB — OneDrive / Planner HTTP download (API-first, no Selenium). Architecture: 1. Playwright opens Microsoft login, captures the Bearer token from the first 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 - Resolve SharePoint file references via Graph shares endpoint - Stream-download each PDF 3. Identical output structure to the Selenium bot: Output//Original// Output//Workable/ (DD-MM-YYYY_FacilityName[_N].pdf) Output//Workable_Medicare/ Output//Summary/Download_Summary_HTTP_.xlsx Output//checkpoint_http.json Usage: python onedrive_planner_http.py python onedrive_planner_http.py --limit 3 python onedrive_planner_http.py --facility "Arbor Hills" python onedrive_planner_http.py --resume """ from __future__ import annotations import argparse import base64 import logging import shutil import sys import time import traceback from datetime import datetime from pathlib import Path from urllib.parse import unquote, urlparse import httpx import pandas as pd from playwright.sync_api import sync_playwright ROOT = Path(__file__).resolve().parent SRC = ROOT / "src" if str(SRC) not in sys.path: sys.path.insert(0, str(SRC)) from checkpoint import clear_checkpoint, load_checkpoint, save_checkpoint # noqa: E402 from config import ( # noqa: E402 PROJECT_ROOT, TODAY_ISO_DATE, AppConfig, credentials_setup_message, missing_onedrive_credentials, read_app_config, read_facility_list, ) from onedrive_planner import _sanitize_folder_name # noqa: E402 from totp_utils import validate_totp_secret # noqa: E402 _log = logging.getLogger(__name__) GRAPH_BASE = "https://graph.microsoft.com/v1.0" def _find_chromium_exe() -> str | None: """ Locate the Playwright-downloaded Chromium executable without relying on Playwright's internal path detection, which fails on some Windows setups. Searches ms-playwright in LOCALAPPDATA and PLAYWRIGHT_BROWSERS_PATH. """ import os search_roots = [] if os.environ.get("PLAYWRIGHT_BROWSERS_PATH"): search_roots.append(Path(os.environ["PLAYWRIGHT_BROWSERS_PATH"])) local_app = os.environ.get("LOCALAPPDATA", "") if local_app: search_roots.append(Path(local_app) / "ms-playwright") search_roots.append(Path.home() / "AppData" / "Local" / "ms-playwright") for base in search_roots: if not base.is_dir(): continue # Prefer highest version number for d in sorted(base.glob("chromium-*"), reverse=True): for rel in ("chrome-win64/chrome.exe", "chrome-win/chrome.exe"): exe = d / rel if exe.is_file(): _log.debug("Found Chromium at: %s", exe) return str(exe) return None # --------------------------------------------------------------------------- # First-run browser install # --------------------------------------------------------------------------- def _ensure_playwright_browsers() -> None: """Auto-install Playwright Chromium browser on first run on a new machine. PyInstaller bundles node.exe + package/cli.js inside _internal/playwright/driver/ but does NOT include playwright.cmd (a batch file). So we invoke node.exe directly. """ if _find_chromium_exe(): return # Already installed import subprocess print( "\n[First-time setup] Playwright Chromium browser not found.\n" "Downloading now (~150 MB). This only happens once on this machine.\n" ) # Locate the driver directory. # Frozen (PyInstaller onedir): _MEIPASS/playwright/driver/ # Non-frozen (dev): next to the playwright package itself. driver_dir: Path | None = None if getattr(sys, "frozen", False): driver_dir = Path(sys._MEIPASS) / "playwright" / "driver" else: try: import playwright as _pw driver_dir = Path(_pw.__file__).parent / "driver" except Exception: pass cmd: list[str] | None = None if driver_dir and driver_dir.is_dir(): node_exe = driver_dir / "node.exe" cli_js = driver_dir / "package" / "cli.js" if node_exe.is_file() and cli_js.is_file(): # PyInstaller bundle: run node.exe directly (no playwright.cmd present) cmd = [str(node_exe), str(cli_js), "install", "chromium"] else: # Dev / non-frozen fallback: try playwright.cmd / playwright shell script for name in ("playwright.cmd", "playwright"): p = driver_dir / name if p.is_file(): cmd = [str(p), "install", "chromium"] break if cmd is None: raise RuntimeError( "Could not locate the Playwright driver inside the bundle.\n" "Expected: _internal/playwright/driver/node.exe\n" "Please reinstall the application." ) try: result = subprocess.run(cmd, timeout=600) if result.returncode != 0: raise RuntimeError(f"playwright install chromium exited {result.returncode}") print("[Setup complete] Browser ready. Continuing...\n") except Exception as exc: raise RuntimeError( f"Could not auto-install Playwright browser: {exc}\n" "Run manually: playwright install chromium" ) from exc # --------------------------------------------------------------------------- # Logging setup # --------------------------------------------------------------------------- def _setup_logging() -> Path: log_dir = PROJECT_ROOT / "Output" / TODAY_ISO_DATE / "logs" log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / "planner_http.log" root = logging.getLogger() root.setLevel(logging.DEBUG) # Guard: don't add duplicate handlers if called more than once in the same process if root.handlers: return log_path fmt = logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s") fh = logging.FileHandler(log_path, encoding="utf-8") fh.setLevel(logging.DEBUG) fh.setFormatter(fmt) root.addHandler(fh) ch = logging.StreamHandler(sys.stdout) ch.setLevel(logging.INFO) ch.setFormatter(fmt) root.addHandler(ch) return log_path # --------------------------------------------------------------------------- # Auth — Playwright login, Bearer token capture # --------------------------------------------------------------------------- class _TokenStore: """Collects the latest Bearer token from intercepted Playwright requests. Only accepts tokens from Planner/group-scoped Graph endpoints to avoid capturing narrow User.Read tokens that fire during the auth flow (e.g. /me, /me/photo) before the Planner-scoped tokens arrive. """ # Endpoints whose tokens are guaranteed to carry Planner/Group read scope _PLANNER_PATHS = ( "/planner/", "/groups/", "/me/memberOf", "/me/transitiveMemberOf", ) def __init__(self) -> None: self.token: str | None = None self._at: float = 0.0 def on_request(self, request) -> None: url = request.url if "graph.microsoft.com" not in url: return if not any(p in url for p in self._PLANNER_PATHS): return # ignore narrow auth-flow tokens (e.g. /me profile) auth = request.headers.get("authorization", "") if auth.startswith("Bearer "): candidate = auth[7:] if candidate != self.token: self.token = candidate self._at = time.monotonic() _log.debug("Planner-scoped token captured from: %.80s", url) @property def age_s(self) -> float: return time.monotonic() - self._at if self.token else float("inf") def _playwright_login_and_capture_token( cfg: AppConfig, *, headless: bool = True, timeout_s: float = 120, ) -> tuple[str, dict[str, str]]: """ Open Playwright, complete Microsoft login, wait until the Planner app fires its first Graph API call, capture the Bearer token, then close. Returns (bearer_token, cookies_dict). """ import pyotp # available via existing totp_utils dependency store = _TokenStore() proxy_cfg: dict | None = None if cfg.proxy_ip and cfg.proxy_port: proxy_url = f"http://{cfg.proxy_ip}:{cfg.proxy_port}" proxy_cfg = {"server": proxy_url} if cfg.proxy_username and cfg.proxy_password: proxy_cfg["username"] = cfg.proxy_username proxy_cfg["password"] = str(cfg.proxy_password) chromium_exe = _find_chromium_exe() if chromium_exe: _log.info("Using Chromium at: %s", chromium_exe) else: _log.warning("Chromium not found via dynamic search — letting Playwright auto-detect") with sync_playwright() as pw: launch_kwargs: dict = { "headless": headless, "proxy": proxy_cfg, } if chromium_exe: # Bypass Playwright's broken path detection on this machine launch_kwargs["executable_path"] = chromium_exe browser = pw.chromium.launch(**launch_kwargs) ctx = browser.new_context( user_agent=( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ) ) page = ctx.new_page() page.on("request", store.on_request) planner_url = cfg.planner_url or "https://planner.cloud.microsoft/" _log.info("Navigating to Planner for login (%s)", planner_url[:80]) page.goto(planner_url, timeout=60_000) # Username page.wait_for_selector("input[type='email'], input[name='loginfmt']", timeout=30_000) page.fill("input[type='email'], input[name='loginfmt']", cfg.onedrive_username) page.click("input[type='submit'], button[type='submit']") # Password page.wait_for_selector("input[type='password'], input[name='passwd']", timeout=20_000) page.fill("input[type='password'], input[name='passwd']", cfg.onedrive_password) page.click("input[type='submit'], button[type='submit']") # TOTP (optional) try: page.wait_for_selector( "input[name='otc'], input[autocomplete='one-time-code']", timeout=10_000, ) otp = pyotp.TOTP(cfg.microsoft_totp_secret).now() page.fill("input[name='otc'], input[autocomplete='one-time-code']", otp) page.click("input[type='submit'], button[type='submit']") _log.info("TOTP submitted") except Exception: _log.debug("No TOTP prompt detected") # "Stay signed in?" — click No try: page.wait_for_selector("input[id='idBtn_Back']", timeout=8_000) page.click("input[id='idBtn_Back']") except Exception: pass # Wait until Planner fires a Graph API call so we capture the token deadline = time.monotonic() + timeout_s while time.monotonic() < deadline and not store.token: try: page.wait_for_selector( "[data-testid*='planner'], [class*='planner'], [role='main']", timeout=4_000, ) except Exception: pass if not store.token: time.sleep(1) if not store.token: raise RuntimeError( "Could not capture Bearer token — Microsoft login may have failed " "or Planner did not fire any Graph API requests within " f"{timeout_s}s." ) cookies = {c["name"]: c["value"] for c in ctx.cookies()} browser.close() _log.info( "Login complete — token captured (age %.1fs), %d cookies harvested", store.age_s, len(cookies), ) return store.token, cookies # --------------------------------------------------------------------------- # httpx client # --------------------------------------------------------------------------- def _build_client(cfg: AppConfig, token: str) -> httpx.Client: proxy_url: str | None = None if cfg.proxy_ip and cfg.proxy_port: proxy_url = f"http://{cfg.proxy_ip}:{cfg.proxy_port}" if cfg.proxy_username and cfg.proxy_password: proxy_url = ( f"http://{cfg.proxy_username}:{cfg.proxy_password}" f"@{cfg.proxy_ip}:{cfg.proxy_port}" ) return httpx.Client( headers={ "Authorization": f"Bearer {token}", "Accept": "application/json", }, timeout=60.0, follow_redirects=True, proxy=proxy_url, # httpx >= 0.28: singular `proxy=`, not `proxies=` ) def _refresh_token(client: httpx.Client, cfg: AppConfig) -> None: """Re-login via Playwright and update the client's Authorization header in place.""" _log.warning("Token expired (401) — re-harvesting via Playwright...") new_token, _ = _playwright_login_and_capture_token(cfg, headless=True) client.headers["Authorization"] = f"Bearer {new_token}" _log.info("Token refreshed.") # --------------------------------------------------------------------------- # Graph API helpers # --------------------------------------------------------------------------- def _get(client: httpx.Client, path: str, cfg: AppConfig, **params) -> dict: """GET a Graph API path, refreshing token once on 401.""" url = f"{GRAPH_BASE}/{path.lstrip('/')}" for attempt in range(2): r = client.get(url, params=params or None) if r.status_code == 401 and attempt == 0: _refresh_token(client, cfg) continue r.raise_for_status() return r.json() raise RuntimeError(f"Unreachable: {url}") # pragma: no cover def _get_paged(client: httpx.Client, path: str, cfg: AppConfig, **params) -> list[dict]: """Follow @odata.nextLink pagination until exhausted.""" results: list[dict] = [] url = f"{GRAPH_BASE}/{path.lstrip('/')}" first = True while url: for attempt in range(2): if first: r = client.get(url, params=params or None) else: r = client.get(url) if r.status_code == 401 and attempt == 0: _refresh_token(client, cfg) continue r.raise_for_status() break data = r.json() results.extend(data.get("value", [])) url = data.get("@odata.nextLink") first = False return results def _update_task_status( client: httpx.Client, task_id: str, etag: str, percent_complete: int, cfg: AppConfig, ) -> bool: """PATCH a Planner task's percentComplete (0=Not Started, 50=In Progress, 100=Complete). Planner requires the If-Match header with the task's current ETag for optimistic concurrency — without it the API returns 428 Precondition Required. Returns True on success, False on any error (warning is logged but run continues). """ url = f"{GRAPH_BASE}/planner/tasks/{task_id}" req_headers = { "If-Match": etag, "Content-Type": "application/json", } body = {"percentComplete": percent_complete} try: for attempt in range(2): r = client.patch(url, json=body, headers=req_headers) if r.status_code == 401 and attempt == 0: _refresh_token(client, cfg) continue r.raise_for_status() return True except httpx.HTTPStatusError as exc: _log.warning( " Status update failed for task %s: HTTP %s — %s", task_id, exc.response.status_code, exc.response.text[:200], ) except Exception as exc: _log.warning(" Status update failed for task %s: %s", task_id, exc) return False # --------------------------------------------------------------------------- # Plan discovery via group memberships # --------------------------------------------------------------------------- def _discover_all_plans(client: httpx.Client, cfg: AppConfig) -> list[dict]: """ Return every Planner plan visible to the user through their Microsoft 365 group memberships. Each dict has at minimum: id, title, _group_name. """ _log.info("Fetching user's group memberships...") groups = _get_paged( client, "me/memberOf/microsoft.graph.group", cfg, **{"$select": "id,displayName", "$top": "999"}, ) _log.info("%d groups found", len(groups)) all_plans: list[dict] = [] for group in groups: gid = group.get("id", "") if not gid: continue try: plans = _get_paged( client, f"groups/{gid}/planner/plans", cfg, **{"$select": "id,title"}, ) for p in plans: p["_group_id"] = gid p["_group_name"] = group.get("displayName", "") all_plans.extend(plans) except httpx.HTTPStatusError as exc: if exc.response.status_code in (403, 404): pass # group has no Planner or access denied else: _log.warning("plans for group %s: HTTP %s", gid, exc.response.status_code) time.sleep(0.1) # gentle rate-limit courtesy _log.info("Total plans discovered: %d", len(all_plans)) return all_plans def _match_plan(plans: list[dict], facility_name: str) -> dict | None: """Match a facility name to a Planner plan title (exact → prefix → substring).""" fac = facility_name.strip().lower() # Pass 1: exact match for p in plans: if (p.get("title") or "").strip().lower() == fac: return p # Pass 2: one string is a prefix of the other (handles trailing words) for p in plans: title = (p.get("title") or "").strip().lower() if title.startswith(fac) or fac.startswith(title): _log.warning("Prefix plan match: '%s' -> '%s'", facility_name, p.get("title")) return p # Pass 3: substring match — only for names long enough to be unambiguous # (short names like "Hills" would falsely match "Arbor Hills") if len(fac) >= 8: for p in plans: title = (p.get("title") or "").strip().lower() if fac in title: _log.warning("Substring plan match: '%s' -> '%s'", facility_name, p.get("title")) return p return None # --------------------------------------------------------------------------- # Tasks + file references # --------------------------------------------------------------------------- def _not_started_tasks(client: httpx.Client, plan_id: str, cfg: AppConfig) -> list[dict]: tasks = _get_paged( client, f"planner/plans/{plan_id}/tasks", cfg, **{"$select": "id,title,percentComplete"}, ) 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_file_urls(client: httpx.Client, task_id: str, cfg: AppConfig) -> list[str]: """Return SharePoint file URLs from a task's references (details endpoint).""" try: details = _get(client, f"planner/tasks/{task_id}/details", cfg) except httpx.HTTPStatusError as exc: _log.warning("task details %s: HTTP %s", task_id, exc.response.status_code) return [] refs: dict = details.get("references", {}) or {} urls: list[str] = [] for encoded_key in refs: try: url = unquote(encoded_key) if url.startswith("http"): urls.append(url) except Exception: pass return urls # --------------------------------------------------------------------------- # SharePoint URL resolution + download # --------------------------------------------------------------------------- def _encode_share_url(url: str) -> str: """Convert a SharePoint URL to Graph's u!{base64url} share-id format.""" b64 = base64.urlsafe_b64encode(url.encode("utf-8")).rstrip(b"=").decode() return f"u!{b64}" def _resolve_sp_url( client: httpx.Client, sp_url: str, cfg: AppConfig ) -> tuple[str | None, str | None]: """ Resolve a SharePoint URL to (pre-auth download URL, filename). Uses the Graph shares endpoint — same call visible in DevTools. """ share_id = _encode_share_url(sp_url) try: # Do NOT use $select here: @microsoft.graph.downloadUrl is an OData # annotation and is only present in the full (unfiltered) response. # Requesting it via $select causes Graph to return 200 with no annotation. item = _get(client, f"shares/{share_id}/driveItem", cfg) dl_url = item.get("@microsoft.graph.downloadUrl") name = item.get("name") or Path(urlparse(sp_url).path).name or "download.pdf" return dl_url, name except httpx.HTTPStatusError as exc: _log.warning("shares resolve %s…: HTTP %s", sp_url[:60], exc.response.status_code) return None, None def _download_file( download_url: str, dest_folder: Path, filename: str, ) -> Path | None: """Stream-download a pre-authenticated Azure Blob SAS URL to dest_folder/filename. If the file already exists (from a previous interrupted run) it is kept as-is and its path is returned — no re-download, no duplicate _2/_3 copies. Uses a fresh proxy-free client: the SAS URL authenticates via query-string and must not be routed through the corporate proxy. """ dest_folder.mkdir(parents=True, exist_ok=True) dest = dest_folder / filename # Skip re-download if file already exists and is non-empty if dest.exists() and dest.stat().st_size > 0: _log.info(" Already exists, skipping download: %s (%d bytes)", dest.name, dest.stat().st_size) return dest try: # Proxy-free: Azure Blob SAS URLs authenticate via query-string, # routing through the corporate proxy wastes bandwidth and can timeout. with httpx.Client(timeout=120.0, follow_redirects=True) as blob_client: with blob_client.stream("GET", download_url) as r: r.raise_for_status() with open(dest, "wb") as f: for chunk in r.iter_bytes(65536): f.write(chunk) _log.info(" Downloaded: %s (%d bytes)", dest.name, dest.stat().st_size) return dest except Exception as exc: _log.error(" Download failed for %s: %s", filename, exc) dest.unlink(missing_ok=True) return None # --------------------------------------------------------------------------- # Workable file copy (same naming as Selenium bot) # --------------------------------------------------------------------------- def _unique_workable_path(root: Path, base: str, ext: str) -> Path: ext = ext if ext.startswith(".") else f".{ext}" first = root / f"{base}{ext}" if not first.exists(): return first for n in range(2, 1000): candidate = root / f"{base}_{n}{ext}" if not candidate.exists(): return candidate raise RuntimeError(f"Cannot allocate workable filename for {base}{ext}") def _build_workable_files( *, facility_name: str, downloaded: list[Path], output_root: Path, today_iso: str, ) -> tuple[list[Path], Path]: ddmmyyyy = datetime.strptime(today_iso, "%Y-%m-%d").strftime("%d-%m-%Y") fac_safe = _sanitize_folder_name(facility_name) workable_root = output_root / today_iso / "Workable" medicare_root = output_root / today_iso / "Workable_Medicare" if not downloaded: return [], workable_root workable_root.mkdir(parents=True, exist_ok=True) medicare_root.mkdir(parents=True, exist_ok=True) workable_paths: list[Path] = [] for i, src in enumerate(downloaded, start=1): ext = src.suffix or ".pdf" base = ( f"{ddmmyyyy}_{fac_safe}" if len(downloaded) == 1 else f"{ddmmyyyy}_{fac_safe}_{i}" ) # Check if this workable file already exists (previous interrupted run) dst_first = workable_root / f"{base}{ext}" if dst_first.exists() and dst_first.stat().st_size > 0: _log.info(" Workable already exists, keeping: %s", dst_first.name) dst = dst_first else: dst = _unique_workable_path(workable_root, base, ext) shutil.copy2(src, dst) workable_paths.append(dst) med_dst_first = medicare_root / f"{dst.stem}{ext}" if med_dst_first.exists() and med_dst_first.stat().st_size > 0: _log.info(" Medicare already exists, keeping: %s", med_dst_first.name) else: med_dst = _unique_workable_path(medicare_root, dst.stem, ext) shutil.copy2(dst, med_dst) _log.info( "Workable: %d file(s) -> %s | Medicare -> %s", len(workable_paths), workable_root, medicare_root, ) return workable_paths, workable_root # --------------------------------------------------------------------------- # Summary Excel (same schema as Selenium bot) # --------------------------------------------------------------------------- def _write_summary( results: dict[str, dict], failures: list[tuple[str, str, str]], summary_path: Path, run_timestamp: str, ) -> None: failure_names = {f for f, _, _ in failures} rows: list[dict] = [] for facility, payload in results.items(): if facility in failure_names: continue file_count = len(payload.get("original") or []) tasks_total = int(payload.get("tasks_total") or 0) 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) status_changed = int(payload.get("status_changed") or 0) if tasks_total <= 0: status = "No tasks" elif tasks_att <= 0: status = "No attachments" elif tasks_dl <= 0 or file_count <= 0: status = "Download failed" elif tasks_dl >= tasks_att: status = "Success" else: status = "Partial" if file_count <= 0: status_changed_label = "N/A" elif status_changed >= tasks_dl: status_changed_label = f"Yes — {status_changed} task(s) → In Progress" elif status_changed > 0: status_changed_label = f"Partial — {status_changed}/{tasks_dl} task(s) → In Progress" else: status_changed_label = "No — Planner PATCH failed" rows.append({ "Run Timestamp": run_timestamp, "Facility Name": facility, "Files Downloaded": file_count, "Tasks Not Started": tasks_total, "Tasks With Attachments": tasks_att, "Tasks Skipped (No File)": tasks_skip, "Status": status, "Status Changed": status_changed_label, }) for facility, err, status in failures: rows.append({ "Run Timestamp": run_timestamp, "Facility Name": facility, "Files Downloaded": 0, "Tasks Not Started": 0, "Tasks With Attachments": 0, "Tasks Skipped (No File)": 0, "Status": status, "Status Changed": "N/A", }) 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) # --------------------------------------------------------------------------- # Per-facility processing # --------------------------------------------------------------------------- def _process_facility( client: httpx.Client, cfg: AppConfig, plans: list[dict], facility_name: str, dest_folder: Path, ) -> dict: plan = _match_plan(plans, facility_name) if plan is None: raise ValueError(f"No Planner plan found matching '{facility_name}'") plan_id = plan["id"] _log.info( "%s -> plan '%s' (group: %s)", facility_name, plan.get("title"), plan.get("_group_name", ""), ) # ── Only Not Started tasks (percentComplete == 0) are ever processed. # ── Within those, only tasks that have at least one file attachment are # downloaded. Tasks that are In Progress, Completed, or have no # attachments are silently skipped. tasks = _not_started_tasks(client, plan_id, cfg) tasks_total = len(tasks) tasks_with_att = 0 tasks_with_dl = 0 tasks_no_att = 0 # Not Started but no file attachment → skip status_changed = 0 downloaded: list[Path] = [] _log.info( " Processing ONLY Not Started tasks with attachments (%d Not Started found)", tasks_total, ) for task in tasks: tid = task["id"] title = task.get("title", "") 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 continue # Not Started WITH attachment(s) — download ALL of them tasks_with_att += 1 total_urls = len(file_urls) task_dl = 0 _log.info(" PROCESS '%s': %d attachment(s)", title[:60], total_urls) for att_idx, sp_url in enumerate(file_urls, start=1): dl_url, filename = _resolve_sp_url(client, sp_url, cfg) if not dl_url or not filename: _log.warning( " [%d/%d] Could not resolve attachment URL: %.80s", att_idx, total_urls, sp_url, ) continue _log.info(" [%d/%d] Downloading: %s", att_idx, total_urls, filename) path = _download_file(dl_url, dest_folder, filename) if path: downloaded.append(path) task_dl += 1 if task_dl == total_urls: # Every attachment downloaded — mark In Progress tasks_with_dl += 1 etag = task.get("@odata.etag", "") if etag: ok = _update_task_status(client, tid, etag, 50, cfg) if ok: _log.info( " Status -> In Progress: '%s' (%d/%d files downloaded)", title[:60], task_dl, total_urls, ) status_changed += 1 else: _log.warning(" No ETag for task '%s' — status not updated", title[:60]) elif task_dl > 0: # Partial — some failed; don't mark In Progress, leave for retry tasks_with_dl += 1 _log.warning( " '%s': partial download %d/%d — status NOT changed (will retry next run)", title[:60], task_dl, total_urls, ) else: _log.warning( " '%s': 0/%d attachments downloaded — status NOT changed", title[:60], total_urls, ) _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), ) return { "original": downloaded, "tasks_total": tasks_total, "tasks_with_attachments": tasks_with_att, "tasks_with_downloads": tasks_with_dl, "tasks_skipped_no_attachment": tasks_no_att, "status_changed": status_changed, "status_via_task_details": 0, } # --------------------------------------------------------------------------- # Main workflow # --------------------------------------------------------------------------- def _filter_facilities( facilities: list[str], *, facility_limit: int, facility_name: str | None, ) -> list[str]: if facility_name: name = facility_name.strip() matches = [f for f in facilities if f.lower() == name.lower()] if not matches: raise ValueError(f"Facility '{name}' not found in Facilities sheet.") return matches if facility_limit > 0: return facilities[:facility_limit] return facilities def run_planner_http_workflow( *, facility_limit: int = 0, facility_name: str | None = None, resume: bool = False, ) -> None: run_timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") log_path = _setup_logging() _log.info("=" * 66) _log.info(" MISF Planner HTTP Download — Start [%s]", run_timestamp) _log.info(" Run date : %s", TODAY_ISO_DATE) _log.info(" Log file : %s", log_path) _log.info("=" * 66) cfg = read_app_config() output_root = PROJECT_ROOT / "Output" original_root = output_root / TODAY_ISO_DATE / "Original" checkpoint_path = output_root / TODAY_ISO_DATE / "checkpoint_http.json" facilities = read_facility_list() facilities = _filter_facilities( facilities, facility_limit=facility_limit, facility_name=facility_name, ) # Resume state start_index = 0 completed: set[str] = set() if resume: ckpt = load_checkpoint(checkpoint_path) start_index = int(ckpt.get("last_completed_index", 0) or 0) completed = {str(x) for x in (ckpt.get("completed_facilities", []) or [])} _log.info("Resuming from index %d, %d already done", start_index, len(completed)) else: clear_checkpoint(checkpoint_path) # Validate credentials before launching the browser — fail fast with a clear message missing = missing_onedrive_credentials( cfg.onedrive_username, cfg.onedrive_password, cfg.microsoft_totp_secret, ) if missing: raise ValueError(credentials_setup_message(missing)) validate_totp_secret(cfg.microsoft_totp_secret) # --- Ensure browser is installed (auto-downloads on first run) --- _ensure_playwright_browsers() # --- Login (Playwright, headless) --- _log.info("Launching Playwright for Microsoft login...") token, _cookies = _playwright_login_and_capture_token(cfg, headless=True) client = _build_client(cfg, token) # --- Discover all plans once --- _log.info("Discovering Planner plans via Graph API...") plans = _discover_all_plans(client, cfg) _log.info("Plan discovery done: %d plans available", len(plans)) results: dict[str, dict] = {} failures: list[tuple[str, str, str]] = [] total_files = 0 for idx, fac in enumerate(facilities, start=1): if idx < start_index or fac in completed: _log.info("Skipping %s (already processed)", fac) continue _log.info("-" * 50) _log.info("Facility %d / %d: %s", idx, len(facilities), fac) dest = original_root / _sanitize_folder_name(fac) try: result = _process_facility(client, cfg, plans, fac, dest) downloaded = result["original"] file_count = len(downloaded) if downloaded: workable_paths, workable_root = _build_workable_files( facility_name=fac, downloaded=downloaded, output_root=output_root, today_iso=TODAY_ISO_DATE, ) else: workable_paths = [] workable_root = output_root / TODAY_ISO_DATE / "Workable" if dest.exists() and not any(dest.iterdir()): dest.rmdir() results[fac] = {**result, "workable": workable_paths} total_files += file_count completed.add(fac) save_checkpoint( checkpoint_path, last_completed_index=idx, facility_name=fac, completed_facilities=sorted(completed), ) _log.info("%s: %d file(s) -> %s", fac, file_count, workable_root) except Exception as exc: err_msg = str(exc) _log.error("%s FAILED: %s", fac, err_msg) status = ( "Not appear" if "no planner plan found" in err_msg.lower() else "FAILED" ) failures.append((fac, err_msg, status)) results[fac] = { "original": [], "workable": [], "comment": err_msg[:500], "tasks_total": 0, "tasks_with_attachments": 0, "tasks_with_downloads": 0, "tasks_skipped_no_attachment": 0, "status_changed": 0, "status_via_task_details": 0, } save_checkpoint( checkpoint_path, last_completed_index=idx, facility_name=fac, completed_facilities=sorted(completed), ) summary_path = ( output_root / TODAY_ISO_DATE / "Summary" / f"Download_Summary_HTTP_{TODAY_ISO_DATE}.xlsx" ) _write_summary(results, failures, summary_path, run_timestamp) clear_checkpoint(checkpoint_path) _log.info("=" * 66) _log.info( " COMPLETE — %d file(s) downloaded, %d failure(s)", total_files, len(failures), ) _log.info("=" * 66) # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def _build_arg_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( description="Download face-sheet PDFs from Planner via Microsoft Graph API (no Selenium)." ) p.add_argument( "--limit", type=int, default=0, help="Process only the first N facilities (0 = all).", ) p.add_argument( "--facility", type=str, default="", help="Process a single facility by exact name.", ) p.add_argument( "--resume", action="store_true", help="Resume from checkpoint.", ) return p if __name__ == "__main__": _frozen = getattr(sys, "frozen", False) # True when running as .exe args = _build_arg_parser().parse_args() exit_code = 0 try: run_planner_http_workflow( facility_limit=max(0, args.limit), facility_name=args.facility.strip() or None, resume=args.resume, ) except KeyboardInterrupt: print("\nCancelled.") exit_code = 130 except Exception: traceback.print_exc() exit_code = 1 if _frozen and exit_code != 0: # On error only: keep the window open so the user can read what went wrong. input("\n✗ An error occurred (see above). Press Enter to close...") sys.exit(exit_code)