From 31f2bdb84ff2c795c8a979c44d15625102a27ca4 Mon Sep 17 00:00:00 2001 From: Howard Enos Date: Mon, 29 Jun 2026 16:55:55 -0700 Subject: [PATCH] sync: auto-sync from HOWARD-HOME at 2026-06-29 16:55:22 Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-06-29 16:55:22 --- .claude/memory/MEMORY.md | 1 + .claude/memory/reference_alis_medtelligent.md | 37 ++ .claude/skills/alis/SKILL.md | 130 +++++++ .../references/ALIS_Staff_Import.template.xls | Bin 0 -> 9216 bytes .../skills/alis/references/api-reference.md | 75 ++++ .claude/skills/alis/references/role-map.json | 82 ++++ .claude/skills/alis/scripts/alis.py | 289 +++++++++++++++ .claude/skills/alis/scripts/alis_client.py | 307 +++++++++++++++ .claude/skills/alis/scripts/import_builder.py | 350 ++++++++++++++++++ 9 files changed, 1271 insertions(+) create mode 100644 .claude/memory/reference_alis_medtelligent.md create mode 100644 .claude/skills/alis/SKILL.md create mode 100644 .claude/skills/alis/references/ALIS_Staff_Import.template.xls create mode 100644 .claude/skills/alis/references/api-reference.md create mode 100644 .claude/skills/alis/references/role-map.json create mode 100644 .claude/skills/alis/scripts/alis.py create mode 100644 .claude/skills/alis/scripts/alis_client.py create mode 100644 .claude/skills/alis/scripts/import_builder.py diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 8cfa769f..0c301839 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -2,6 +2,7 @@ ## Reference - [ACG resource map](reference_resource_map.md) — **READ THIS FIRST** when a task references a server/service/tenant/API. What we have access to, how to connect from this machine, per-machine exceptions, gotchas. Points at the detail files below. +- [ALIS (Medtelligent)](reference_alis_medtelligent.md) — Cascades assisted-living EHR. API host api.alisonline.com, community 622; username must be tenant-qualified (howard.enos@cascadestucson). Staff are READ-ONLY via API — create/change staff via web-UI Staff Import .xls. Use the `alis` skill. - [GuruRMM User Manager](reference_gururmm_user_manager.md) — GuruRMM has a built-in per-agent User Manager tab (reset_password/enable/disable/groups for local+domain+AAD endpoint users; domain users only on a DC via `is_dc`). Use it, NOT raw Set-ADAccountPassword via /rmm. Endpoints: /api/agents/{id}/users + /users/action. - [exchange-op = all-access Exchange tier](feedback_exchange_op_all_access.md) — STOP claiming "no tier can write mail." Exchange Operator app = Exchange Admin role + full_access_as_app + Exchange.ManageAsApp = full all-access (move mail, rules, config, EWS). Default to `exchange-op` for any Exchange write. - [Tedards tenant facts](reference_tedards_tenant_facts.md) — Bill Tedards law office; tenant `4fcbb1f4…`; bt@/y226@ mailboxes; matter-number filing; UAL ingestion OFF; 9 synced devices; botched-import DUPLICATE folder. diff --git a/.claude/memory/reference_alis_medtelligent.md b/.claude/memory/reference_alis_medtelligent.md new file mode 100644 index 00000000..606f6b80 --- /dev/null +++ b/.claude/memory/reference_alis_medtelligent.md @@ -0,0 +1,37 @@ +--- +name: reference_alis_medtelligent +description: ALIS (Medtelligent assisted-living EHR) API + staff-import facts for Cascades Tucson — auth quirk, read-only staff, web-UI import path. Use the `alis` skill. +metadata: + type: reference +--- + +ALIS = Medtelligent's assisted-living EHR (Cascades of Tucson client). All API traffic +goes to the shared host **`api.alisonline.com`** (the tenant URL `cascadestucson.alisonline.com` +is just the login subdomain), scoped by the user's company + a `communityId`. **Cascades = +communityId 622** (the only community this credential sees). Use the **`alis` skill** — don't +hand-roll the API. + +**Auth (verified live 2026-06-29):** `POST /user/tokens` with `{username, password}` → JWT +(`accessToken` ~1h) + `refreshToken`; send `Authorization: Bearer `. The +**username MUST be tenant-qualified**: `howard.enos@cascadestucson` works; bare `howard.enos` +returns HTTP 400. Login creds in vault: `clients/cascades-tucson/alis-api-howard-user` +(Howard's password was exposed in chat 2026-06-29 — flagged to rotate). Other ALIS vault +entries: `alis-api-microsoft-basic` (BasicAuth used by Microsoft), `alis-sso-app-registration`. +Global API security is OR(Bearer|BasicAuth|VendorKey) — a user JWT alone authorizes reads. + +**Staff are READ-ONLY via the API** — only GET endpoints exist (`/v1/integration/staff?communityId=622` +etc.); no create/update/delete. **To create/change staff (and their logins) you upload a +13-column .xls in the ALIS web UI: Staff → Import.** That import sets Login Enabled + Password, +so it's also how staff logins are provisioned. The `alis` skill builds that workbook from a +CSV/JSON and infers each new hire's Security Roles from how existing staff of the same Job Role +are set up (job-role → security-role map learned from live data; 23 real security roles, Job +Role is free text). The API *does* allow writes for residents/prospects/billing (not staff). + +**Import format (confirmed from a real ALIS export, ALIS_Staff_Update_Import.xls):** two layouts. +CREATE (new staff) has a Password column + NO ALIS ID — rows without an ALIS ID are created. +UPDATE (existing staff) leads with **ALIS ID** (the staffId, the match key) + no Password. So +present-ALIS-ID = update, absent = create. **Dates are MM/DD/YYYY.** Security Roles are +comma-separated multi-values; the `alis` skill infers the full typical combo per job role from +current staff. Still test ONE row first before a bulk run. + +Related: [[reference_resource_map]], [[feedback-vault-every-credential]]. diff --git a/.claude/skills/alis/SKILL.md b/.claude/skills/alis/SKILL.md new file mode 100644 index 00000000..cae9be3d --- /dev/null +++ b/.claude/skills/alis/SKILL.md @@ -0,0 +1,130 @@ +--- +name: alis +description: >- + Build the ALIS (Medtelligent assisted-living EHR) staff bulk-import spreadsheet so new + staff/logins can be created in the ALIS web UI, and read the live ALIS staff roster via + the API as the setup reference. Maps a CSV/JSON of new hires onto Medtelligent's exact + 13-column import template, validates Status/Login/Gender against the dropdowns, and infers + each new hire's Security Roles from how existing staff of the same Job Role are configured + (a job-role -> security-role map learned from live data). The ALIS API is READ-ONLY for + staff (no write endpoint exists) - changes happen by uploading the generated .xls. Tenant: + Cascades of Tucson (communityId 622). Triggers: alis, alisonline, medtelligent, import + staff/users into alis, alis staff import, add staff to alis, build the alis import file, + alis staff roster, alis security roles. +--- + +# ALIS Skill (Medtelligent) — staff import builder + roster reference + +ALIS is Medtelligent's assisted-living EHR. This skill exists to **create/change staff +(and their logins) in bulk**, which in ALIS is done by **uploading an .xls in the web UI** +(`Staff -> Import`) — there is **no staff-write API**. The skill: + +1. **Reads** the live staff roster via the ALIS API (read-only) to learn how staff are set + up — the security-role and job-role vocabulary, and a **job-role → security-role map**. +2. **Builds** a correctly-formatted import workbook from your CSV/JSON of new hires, + inferring each person's Security Roles from that reference so new staff match existing + ones. You upload it in ALIS and fine-tune there. + +## Running the CLI + +```bash +PY="bash $CLAUDETOOLS_ROOT/.claude/scripts/py.sh" +ALIS="$PY C:/claudetools/.claude/skills/alis/scripts/alis.py" + +# --- read (reference) --- +$ALIS auth-test # mint token, confirm scope +$ALIS communities # communityId(s) in scope (Cascades = 622) +$ALIS staff --status Hired --limit 20 # roster +$ALIS roles # live security + job role vocabulary +$ALIS role-map [--refresh] # jobRole -> securityRole(s), learned from Hired staff + +# --- build (the deliverable) --- +$ALIS template --out new_staff.xls # blank, exactly-formatted template to hand-fill +$ALIS build-import --input hires.csv --out import.xls # NEW staff (create format) +$ALIS build-import --input hires.csv --out import.xls --gen-passwords # also mint logins +$ALIS build-import --input edits.csv --out upd.xls --format update # edit existing (ALIS ID) +$ALIS inspect import.xls # dump a workbook to verify before upload +``` + +Transport auto-selects httpx if installed, else stdlib urllib. Workbook I/O needs +`xlwt` (write .xls) + `xlrd` (read .xls); `openpyxl` for .xlsx. + +## Credentials + +Never hardcoded. The user login (used to mint a JWT) loads from the SOPS vault: + +``` +bash "$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" \ + get-field clients/cascades-tucson/alis-api-howard-user.sops.yaml credentials.username +``` + +**Auth model (critical, verified live 2026-06-29):** `POST /user/tokens` with +`{username, password}` returns a JWT (`accessToken`, ~1h) + `refreshToken`; send +`Authorization: Bearer `. The **username must be tenant-qualified** — +`howard.enos@cascadestucson`, not bare `howard.enos` (bare returns HTTP 400). Env overrides +for testing: `ALIS_USERNAME`, `ALIS_PASSWORD`, `ALIS_BASE_URL`, `ALIS_COMMUNITY_ID`. + +## The import template (what ALIS expects) + +13 columns, exact order — Sheet1 header, then one row per staff. Sheet2 carries the +dropdown lists. The builder reproduces both exactly. + +| Column | Notes | +|---|---| +| First Name, Last Name | required | +| Staff Record Number | your HR id / match key | +| Security Roles | inferred from Job Role if blank (see role-map) | +| Staff Status | Applicant / **Hired** / Discharged / Rejected (default Hired) | +| Hire Date | date — format unconfirmed, builder passes your string through | +| Login Enabled | Yes / No (auto: Yes if Email+Password present, else No) | +| Email | required if Login Enabled = Yes | +| Password | credential — see below | +| Date of Birth, Gender | Gender: Female / Male | +| Job Role | free text; drives Security Role inference | +| Cell Phone | | + +Input headers are matched flexibly (`Job Title`→Job Role, `Cell`/`Mobile`→Cell Phone, +`DOB`→Date of Birth, `Login`→Login Enabled, etc.). + +## Passwords (handle with care) + +- By default the builder does **not** invent passwords — supply them, or set + `Login Enabled = No`. +- `--gen-passwords` mints a strong password for each Login-enabled row missing one and writes + them to a **plaintext sidecar `*_passwords.csv`** next to the .xls. That file is a credential + set: distribute to staff, then **delete it or vault it**. Never commit it. The terminal + output never prints passwords. + +## Two import formats — create vs update (confirmed from the real export) + +ALIS has two layouts; the builder writes either via `--format`: + +- **create** (default, new staff): columns include **Password**, no ALIS ID. Rows **without** + an ALIS ID are **created** as new staff. +- **update** (existing staff): leads with **ALIS ID** (the staffId), no Password. Rows **with** + an ALIS ID **update** the matching staff member. (This is the format ALIS exports as + `ALIS_Staff_Update_Import.xls`.) + +So the create-vs-update match key is **ALIS ID** — present = update, absent = create. +**Dates are `MM/DD/YYYY`** (confirmed). Security Roles are **comma-separated** and the builder +infers the *full typical combination* for a job role (e.g. `Med Tech` → +`Caregiver (Cascades), Medication Tech`), learned from current staff in `role-map.json`. + +Still smart to **test with ONE row first** before a bulk run. Scope: this credential only sees +Cascades of Tucson (communityId 622). + +## Scope reality + +The ALIS API has **only GET endpoints for staff** — no create/update/delete. Do not try to +"PUT a staff record"; it does not exist. The .xls upload is the supported write path. (The +API *does* have writes for residents/prospects/billing, out of scope for this skill.) + +## Files + +- `scripts/alis_client.py` — API client (JWT auth via vault, staff/community reads, role-map). +- `scripts/import_builder.py` — template spec, input parsing, validation, role inference, + password gen, .xls read/write. +- `scripts/alis.py` — CLI. +- `references/api-reference.md` — endpoints, auth, template, role vocabulary. +- `references/role-map.json` — cached job-role → security-role reference (refresh with + `role-map --refresh`). diff --git a/.claude/skills/alis/references/ALIS_Staff_Import.template.xls b/.claude/skills/alis/references/ALIS_Staff_Import.template.xls new file mode 100644 index 0000000000000000000000000000000000000000..d714a556601b072763cacde10d9f971ff1deced8 GIT binary patch literal 9216 zcmeHMU2GIp6h5=Rv)yfXTS`GpV2gDL2cG;(jQW5vQCWOaQ={>Pgz!*JOiUzXNi_NZjNf8|o0v(>>&Y zh>3{|1?5=s`|!7AR0AU0#3vsIwip31P}?FMQjwSB*~cO)HgdWlJ>cs@i^%Khxm-Ql z@HFHzgYwHtvR;e^Ea_xw_@Pi?Z{2bB#|GVDL>!y*fnMS^L8u{vma>hG_r{5Vj zv`o$WZMj*+bIk0NV=k}6i{!nHZ{?1gtK|3;IU5bc&Q3&C)~fVxUA8u>%LUx5t{%BL z3yX_uy zJ3Qzv@Sw~1JssZ7b$fFmbbS)Q-sEoj^3pYx)1cbqe(i#0$$W<~)ihi)2tKm&+)MQYzU}>PaIL zn~(|Ur*YYkOB(zH4FVD`R+T+vLD}Fa=%o5hpucUGqy*kBp)c&(6D8?M+tM>J0XejJ zmvE`RT0zX+>Pp=NR{o!pkm7e)}+pRjVnRvtP}jt#0IfN;B& z%_jGajOB=M>*2%M%up&lohdRdk8pJ)aZvLEa%N? zJJ)=FjebNhsqt!i3s(4Xf{O7X-)jM{^-?Sg#Hjh83nTy3GW~snJ{n z9NyO9TEG#LZi|9rpwV0m9Ovy4EPsbmL#7+8}PTIGAUf{%;8^0g`hl~s@# z5~oS0Vnm1XRR26Vr0Fi~(soA`~Oo2?-bhL&qr_jxyx}T*8%;P!Y`C zKic=2Yp-yu=VTYW(t*Pj>#Lg*mtv5)#Fb4CBv3ApYs)Cd0ieb(@u^6otwz}YZ7_NIE(c%o!V6se(KQ%<{3{`sviMy zel|F%wt+O5zg_9X+XoI`(u(6Kag}7VjUP&Jq_l1uHJQq?jbq1gX^x}D)o.alisonline.com` (e.g. `cascadestucson.alisonline.com`) +but **all API traffic goes to the shared host `api.alisonline.com`**, scoped by the +logged-in user's company + a `communityId`. + +Spec sizes at build (2026-06-29): v1 = 139 paths (main surface), v2 = 14 (newer streaming +exports + incidents), v3 = 5 (streaming exports). Tags: User (auth), Admin, Export:*, +Integration:* (Billing/Care/Communities/Residents/Staff/Prospects/Incidents/Hooks/App +Specific), Pharmacy. + +## Authentication + +Global security is an OR list: `Bearer | BasicAuth | VendorKey` — any one authorizes. + +- **Bearer (what this skill uses):** + - `POST /user/tokens` body `{"username":"@","password":"..."}` + → `{ accessToken (JWT, expiresIn 3600), refreshToken }` + - `POST /user/tokens/refresh` body `{accessToken, refreshToken}` → new pair + - Send `Authorization: Bearer ` on every call. + - **The username MUST be tenant-qualified.** `howard.enos` → HTTP 400 + (`Username must match ^@$`); `howard.enos@cascadestucson` → 200. + tenantKey = the tenant subdomain (`cascadestucson`). +- **VendorKey:** `X-Vendor-Key: ` header — issued by Medtelligent when an App-Store app + is installed (per-client creds). Not required when a user JWT is used. +- **BasicAuth:** `Authorization: Basic ...` — the existing + `clients/cascades-tucson/alis-api-microsoft-basic` vault entry is this style (used by + Microsoft to call ALIS). + +## Staff endpoints — READ ONLY (this is the key constraint) + +There is **no** POST/PUT/PATCH/DELETE for staff anywhere in v1/v2/v3. All six are GET: + +| Method | Path | Returns | +|---|---|---| +| GET | `/v1/integration/staff?communityId={id}` | roster (communityId REQUIRED — omitting → 403 "Not authorized for facility 0") | +| GET | `/v1/integration/staff/{staffId}` | one staff member | +| GET | `/v1/integration/staff/{staffId}/basicInfo` | address, license, jobRole, **securityRoles** | +| GET | `/v1/integration/staff/{staffId}/photo` | photo | +| GET | `/v1/export/staff` | bulk export | +| GET | `/v1/export/staff/complianceDetails` | training/compliance | + +Staff list record fields: `staffId, companyTextKey, communityId, firstName, lastName, +nickName, staffRecordNumber, mobilePhoneNumber, primaryEmail, dateOfBirth, status, +hireDate, dischargeDate, hasPhoto, jobRole, securityRoles[]`. + +Optional query params on the list: `status`, `includeAssociatedStaff`. + +**To CHANGE staff** → there is no API. Use the ALIS web UI **Staff → Import** with the +13-column .xls (see `import_builder.py` / SKILL.md). That import sets `Login Enabled` and +`Password`, i.e. it is also how staff *logins* are provisioned. + +## Other write surfaces (out of scope for this skill, but available on the JWT) + +The API *does* allow writes for non-staff objects, e.g.: +- Residents: `POST /v1/integration/residents` (create), `POST .../{residentId}/basicInfo`, + contacts, photo, observations, vitals, room assignments, diagnoses, monitoring flags. +- Prospects (CRM): `POST/PUT /v1/integration/prospects...` +- Billing: incidental charges, payments, statements. +- Webhooks: `/v1/integration/hooks` (subscribe to object create/modify events). + +## Scope observed (Cascades, communityId 622, build 2026-06-29) + +- 1 community: 622 = "Cascades of Tucson". +- 612 staff total (504 Discharged, 107 Hired, 1 Observer). +- 23 distinct Security Roles in use; 74 distinct Job Role strings (free text — includes + typos/dupes like `caregiver` vs `Certified Caregiver`, and junk like `Test`, + `Dead Weight` — treat Job Role as free text, Security Roles as the controlled list). +- See `role-map.json` for the snapshot + job-role → security-role mapping. diff --git a/.claude/skills/alis/references/role-map.json b/.claude/skills/alis/references/role-map.json new file mode 100644 index 00000000..bcb9908d --- /dev/null +++ b/.claude/skills/alis/references/role-map.json @@ -0,0 +1,82 @@ +{ + "source": "ALIS_Staff_Update_Import.xls (current Hired staff export)", + "community": { + "id": 622, + "name": "Cascades of Tucson" + }, + "rowCount": 101, + "dateFormat": "MM/DD/YYYY", + "securityRolesAreCommaSeparated": true, + "securityRoleVocabulary": [ + "Activities", + "Business Support", + "CFO", + "Caregiver (Cascades)", + "Community Administrator", + "Front Desk & Security", + "General Director or Manager", + "General Line Level", + "Health Admin Assistant", + "Master Administrator", + "Medication Tech", + "Nurse (Cascades)", + "Pharmacy Tech", + "Sales", + "Sales - Move In Coordinator", + "Sales Manager" + ], + "jobRoleVocabulary": [ + "Activity Director", + "Activity Staff", + "Admin Assistant", + "Business Office Manager", + "CFO", + "Certified Caregiver", + "Controller", + "Cook", + "Dining Room Manager", + "Dining Staff", + "Director of Sales/Marketing", + "Housekeeping Director", + "Kitchen Supervisor", + "Laundry", + "Line Cook", + "Maintenance Director", + "Maintenance Worker", + "Med Tech", + "Memory Care Reception", + "Memory Care Unit Reception", + "Office Staff", + "Pharmacy Tech", + "Resident Caregiver (non-certified)", + "Sales Associate", + "Test" + ], + "jobRoleToSecurityRolesCombo": { + "Activity Director": "General Director or Manager, Activities", + "Admin Assistant": "General Director or Manager, Caregiver (Cascades), Health Admin Assistant, Medication Tech", + "Certified Caregiver": "Caregiver (Cascades)", + "Housekeeping Director": "General Director or Manager", + "Kitchen Supervisor": "General Line Level", + "Laundry": "General Line Level", + "Line Cook": "General Line Level", + "Director of Sales/Marketing": "Sales Manager, Sales , Community Administrator", + "Memory Care Unit Reception": "Front Desk & Security, General Line Level", + "Dining Room Manager": "General Director or Manager", + "CFO": "Community Administrator", + "Test": "Caregiver (Cascades), Medication Tech", + "Dining Staff": "General Line Level", + "Cook": "General Line Level", + "Sales Associate": "Sales Manager, Sales , Community Administrator", + "Med Tech": "Caregiver (Cascades), Medication Tech", + "Maintenance Worker": "General Line Level", + "Office Staff": "Front Desk & Security, General Director or Manager, Sales - Move In Coordinator", + "Maintenance Director": "General Director or Manager", + "Controller": "CFO", + "Resident Caregiver (non-certified)": "Caregiver (Cascades)", + "Memory Care Reception": "Front Desk & Security", + "Business Office Manager": "CFO", + "Pharmacy Tech": "Pharmacy Tech", + "Activity Staff": "Activities" + } +} \ No newline at end of file diff --git a/.claude/skills/alis/scripts/alis.py b/.claude/skills/alis/scripts/alis.py new file mode 100644 index 00000000..d1d1f51b --- /dev/null +++ b/.claude/skills/alis/scripts/alis.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +"""CLI for the `alis` skill. + +ALIS (Medtelligent) staff are READ via the API and CHANGED via a web-UI bulk +import. This CLI does both halves: + - read: auth-test, communities, staff, roles, role-map (reference for setup) + - build: template, build-import, inspect (produce/check the import workbook) + +The API is read-only for staff - there is no write subcommand because no staff +write endpoint exists. The end deliverable is an .xls you upload in ALIS. + +Examples: + python alis.py auth-test + python alis.py communities + python alis.py staff [--status Hired] [--limit 20] [--json] + python alis.py roles # live security/job role vocab + python alis.py role-map [--refresh] # jobRole -> securityRole reference + python alis.py template --out new_staff.xls # blank, ready-to-fill template + python alis.py build-import --input hires.csv --out import.xls [--gen-passwords] + python alis.py inspect import.xls +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +from alis_client import ALISClient, ALISError +import import_builder as ib + + +# --- errorlog (soft-fail, per CLAUDE.md) -------------------------------------- +def _log_skill_error(skill: str, msg: str, context: str = "") -> None: + try: + root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "..") + ) + h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh") + if not os.path.exists(h): + return + a = ["bash", h, skill, msg] + if context: + a += ["--context", context] + subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=10) + except Exception: + pass + + +# 404/"not found" on a probe is expected; a real auth/transport failure is not. +_EXPECTED = ("http 404", "not found", "http 429", "too many requests") + + +def _should_log(msg: str) -> bool: + if os.environ.get("ALIS_SUPPRESS_ERRORLOG"): + return False + m = (msg or "").lower() + return not any(x in m for x in _EXPECTED) + + +def _emit(obj, as_json: bool) -> None: + print(json.dumps(obj, indent=2, default=str)) + + +def _trunc(s, n): + s = "" if s is None else str(s) + return s if len(s) <= n else s[: n - 1] + "…" + + +# --- read subcommands --------------------------------------------------------- +def cmd_auth_test(args) -> int: + c = ALISClient() + c.authenticate() + comms = c.list_communities() + print("[OK] authenticated to", c.api_base_url) + print("[INFO] communities:", + [(x.get("communityId"), x.get("communityName")) for x in comms]) + return 0 + + +def cmd_communities(args) -> int: + c = ALISClient() + comms = c.list_communities() + if args.json: + _emit(comms, True) + else: + for x in comms: + print(f" {x.get('communityId'):>6} {x.get('communityName')}") + return 0 + + +def cmd_staff(args) -> int: + c = ALISClient() + staff = c.list_staff(community_id=args.community, status=args.status) + if args.json: + _emit(staff[: args.limit] if args.limit else staff, True) + return 0 + print(f"[INFO] {len(staff)} staff" + + (f" (status={args.status})" if args.status else "")) + shown = staff[: args.limit] if args.limit else staff + for s in shown: + name = f"{s.get('firstName','')} {s.get('lastName','')}".strip() + sr = s.get("securityRoles") or [] + sr = ", ".join(sr) if isinstance(sr, list) else str(sr) + print(f" {s.get('staffId'):>7} {_trunc(name,26):26} " + f"{_trunc(s.get('status',''),11):11} " + f"{_trunc(s.get('jobRole') or '-',26):26} [{_trunc(sr,40)}]") + if args.limit and len(staff) > args.limit: + print(f" ... {len(staff)-args.limit} more (raise --limit or use --json)") + return 0 + + +def cmd_roles(args) -> int: + c = ALISClient() + rm = c.build_role_map(community_id=args.community) + if args.json: + _emit(rm, True) + return 0 + print(f"[INFO] from {rm['sourceStaffCount']} staff " + f"({rm['hiredCount']} Hired), community {rm['community']['id']}") + print("\n=== Security Roles in use ===") + for r in rm["securityRoleVocabulary"]: + print(f" {r}") + print("\n=== Job Roles in use ===") + for r in rm["jobRoleVocabulary"]: + print(f" {r}") + return 0 + + +def cmd_role_map(args) -> int: + if args.refresh: + c = ALISClient() + rm = c.build_role_map(community_id=args.community) + path = Path(__file__).resolve().parent.parent / "references" / "role-map.json" + path.write_text(json.dumps(rm, indent=2), encoding="utf-8") + print(f"[OK] refreshed role-map.json from live ({rm['sourceStaffCount']} staff)") + else: + rm = ib.load_role_map() + if not rm: + print("[WARNING] no cached role-map.json; run with --refresh", file=sys.stderr) + return 1 + combo = rm.get("jobRoleToSecurityRolesCombo") or { + k: ", ".join(v) for k, v in rm.get("jobRoleToSecurityRoles", {}).items()} + if args.json: + _emit(combo, True) + return 0 + print("=== Job Role -> typical Security Roles (learned from current staff) ===") + for jr, sr in sorted(combo.items()): + print(f" {_trunc(jr,34):34} -> {sr}") + return 0 + + +# --- build subcommands -------------------------------------------------------- +def cmd_template(args) -> int: + out = ib.write_workbook([], args.out) + print(f"[OK] blank ALIS staff-import template -> {out}") + print("[INFO] columns:", " | ".join(ib.TEMPLATE_HEADERS)) + return 0 + + +def cmd_build_import(args) -> int: + rows_in = ib.read_input_rows(args.input) + if not rows_in: + print("[ERROR] no rows read from input", file=sys.stderr) + return 1 + role_map = ib.load_role_map() + if args.refresh_roles: + try: + role_map = ALISClient().build_role_map(community_id=args.community) + except ALISError as exc: + print(f"[WARNING] live role refresh failed ({exc}); using cached map", + file=sys.stderr) + rows, report = ib.enrich_and_validate( + rows_in, role_map, default_status=args.default_status, + suggest_roles=not args.no_suggest) + + sidecar = None + if args.gen_passwords: + if args.format == "update": + print("[WARNING] --gen-passwords ignored: the update format has no " + "Password column. Use --format create for new logins.", + file=sys.stderr) + else: + generated = ib.fill_passwords(rows) + sidecar = ib.write_password_sidecar(generated, args.out) + + out = ib.write_workbook(rows, args.out, fmt=args.format) + + # Report (never prints passwords) + warn_rows = [r for r in report if r["warnings"]] + print(f"[OK] wrote {len(rows)} staff -> {out} (format={args.format})") + print("[INFO] columns:", " | ".join(ib.HEADERS_BY_FORMAT[args.format])) + for r in report: + for n in r["notes"]: + print(f" [INFO] row {r['row']} ({r['name']}): {n}") + for r in warn_rows: + for w in r["warnings"]: + print(f" [WARNING] row {r['row']} ({r['name']}): {w}") + if sidecar: + print(f"[CRITICAL] generated passwords written PLAINTEXT to {sidecar}") + print(" Distribute to staff, then DELETE or vault it. Never commit it.") + print(f"\n[NEXT] Upload the .xls in ALIS: Staff -> Import. Dates use " + f"{ib.DATE_FORMAT_HINT}. Rows without an ALIS ID are CREATED; the update " + "format (with ALIS ID) edits existing staff. Test with ONE row first.") + return 0 + + +def cmd_inspect(args) -> int: + data = ib.read_workbook(args.path) + if args.json: + _emit(data, True) + return 0 + print(f"[INFO] format: {data['format']}") + for name, rows in data["sheets"].items(): + print(f"=== {name} ({len(rows)} rows shown) ===") + for i, row in enumerate(rows): + trimmed = list(row) + while trimmed and trimmed[-1] in ("", None): + trimmed.pop() + print(f" row{i}: {trimmed}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="alis", description="ALIS staff read + import-builder") + p.add_argument("--json", action="store_true", help="emit raw JSON") + p.add_argument("--community", type=int, default=None, + help="communityId (default: vault/env, Cascades=622)") + sub = p.add_subparsers(dest="cmd", required=True) + + sub.add_parser("auth-test", help="mint a token and list communities") + sub.add_parser("communities", help="list communities in scope") + + sp = sub.add_parser("staff", help="staff roster") + sp.add_argument("--status", help="filter: Applicant|Hired|Discharged|Rejected") + sp.add_argument("--limit", type=int, default=25) + + sub.add_parser("roles", help="security + job role vocabulary (live)") + + sp = sub.add_parser("role-map", help="job-role -> security-role reference") + sp.add_argument("--refresh", action="store_true", help="rebuild from live + cache") + + sp = sub.add_parser("template", help="write a blank import template .xls") + sp.add_argument("--out", required=True) + + sp = sub.add_parser("build-import", help="build an import .xls from a CSV/JSON") + sp.add_argument("--input", required=True, help="CSV/JSON of new staff") + sp.add_argument("--out", required=True, help="output .xls path") + sp.add_argument("--format", choices=["create", "update"], default="create", + help="create = new staff (has Password); update = edit " + "existing (leads with ALIS ID)") + sp.add_argument("--default-status", default="Hired") + sp.add_argument("--no-suggest", action="store_true", + help="do not infer Security Roles from Job Role") + sp.add_argument("--refresh-roles", action="store_true", + help="pull live role-map instead of cached") + sp.add_argument("--gen-passwords", action="store_true", + help="generate passwords for Login-enabled rows lacking one " + "(writes a plaintext sidecar CSV)") + + sp = sub.add_parser("inspect", help="dump an existing import workbook") + sp.add_argument("path") + + return p + + +_DISPATCH = { + "auth-test": cmd_auth_test, "communities": cmd_communities, "staff": cmd_staff, + "roles": cmd_roles, "role-map": cmd_role_map, "template": cmd_template, + "build-import": cmd_build_import, "inspect": cmd_inspect, +} + + +def main(argv=None) -> int: + args = build_parser().parse_args(argv) + try: + return _DISPATCH[args.cmd](args) + except (ALISError, ib.ImportBuilderError) as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + if _should_log(str(exc)): + _log_skill_error("alis", str(exc), context=f"cmd={args.cmd}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/alis/scripts/alis_client.py b/.claude/skills/alis/scripts/alis_client.py new file mode 100644 index 00000000..bae3918c --- /dev/null +++ b/.claude/skills/alis/scripts/alis_client.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +"""ALIS (Medtelligent assisted-living EHR) REST API client for the `alis` skill. + +ALIS is a partner/App-Store integration API at https://api.alisonline.com. A +tenant (e.g. Cascades of Tucson) lives at .alisonline.com but ALL API +traffic goes to the shared api.alisonline.com host, scoped by the logged-in +user's company + a communityId. + +Auth (verified live 2026-06-29): + POST /user/tokens {"username":"@","password":"..."} + -> {accessToken (JWT, ~1h), expiresIn, refreshToken} + Send Authorization: Bearer on every call. + Refresh: POST /user/tokens/refresh {accessToken, refreshToken}. + CRITICAL: the username MUST be tenant-qualified (e.g. howard.enos@cascadestucson) + or /user/tokens returns 400. The bare login name alone fails. + Global API security is OR(Bearer | BasicAuth | VendorKey) - a user JWT alone + authorizes the integration reads we use. + +Scope reality: this API is READ-ONLY for staff (only GET endpoints exist - no +create/update/delete). Staff are CHANGED via the ALIS web-UI bulk import (see +import_builder.py). This client's job is to READ current staff so new staff can +be set up the same way (the job-role -> security-role reference map). + +Credentials load from the SOPS vault; env overrides exist for testing. +Transport: prefers httpx if installed, else stdlib urllib (no hard dependency). +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any, Optional + +try: + import httpx # type: ignore + + _HAS_HTTPX = True +except ImportError: # pragma: no cover - depends on environment + _HAS_HTTPX = False + +ERROR_BODY_MAX_CHARS = 500 + +ALIS_BASE_URL = os.environ.get("ALIS_BASE_URL", "https://api.alisonline.com") +TIMEOUT_SECONDS = 60.0 +CONNECT_TIMEOUT_SECONDS = 10.0 + +# Cascades of Tucson is the only tenant this credential sees today. +DEFAULT_COMMUNITY_ID = int(os.environ.get("ALIS_COMMUNITY_ID", "622")) + +VAULT_ENTRY = "clients/cascades-tucson/alis-api-howard-user.sops.yaml" +VAULT_USERNAME_FIELD = "credentials.username" +VAULT_PASSWORD_FIELD = "credentials.password" + +SKILL_DIR = Path(__file__).resolve().parent.parent + + +class ALISError(RuntimeError): + """Raised for transport or API errors.""" + + +# --- credential loading ------------------------------------------------------- +def _resolve_claudetools_root() -> Path: + derived_root = SKILL_DIR.parent.parent.parent # skills/alis -> repo root + env_root = os.environ.get("CLAUDETOOLS_ROOT") + if env_root: + return Path(env_root) + identity_path = derived_root / ".claude" / "identity.json" + if identity_path.exists(): + try: + data = json.loads(identity_path.read_text(encoding="utf-8")) + root = data.get("claudetools_root") + if root: + return Path(root) + except (json.JSONDecodeError, OSError): + pass + return derived_root + + +def _vault_get(field: str) -> str: + root = _resolve_claudetools_root() + vault_script = root / ".claude" / "scripts" / "vault.sh" + if not vault_script.exists(): + raise ALISError(f"vault wrapper not found at {vault_script}") + try: + completed = subprocess.run( + ["bash", str(vault_script), "get-field", VAULT_ENTRY, field], + capture_output=True, text=True, timeout=60, + ) + except FileNotFoundError as exc: + raise ALISError("'bash' not found on PATH; install Git Bash.") from exc + except subprocess.TimeoutExpired as exc: + raise ALISError("vault call timed out.") from exc + if completed.returncode != 0: + raise ALISError( + f"vault read failed for {field} (exit {completed.returncode}): " + f"{completed.stderr.strip()}" + ) + val = completed.stdout.strip() + if not val: + raise ALISError(f"vault returned empty value for {field}.") + return val + + +def load_credentials() -> tuple[str, str]: + """Return (username, password). Env overrides ALIS_USERNAME/ALIS_PASSWORD, + else the SOPS vault. Username must already be tenant-qualified.""" + user = os.environ.get("ALIS_USERNAME") + pw = os.environ.get("ALIS_PASSWORD") + if not user: + user = _vault_get(VAULT_USERNAME_FIELD) + if not pw: + pw = _vault_get(VAULT_PASSWORD_FIELD) + return user, pw + + +# --- client ------------------------------------------------------------------- +class ALISClient: + def __init__( + self, + username: Optional[str] = None, + password: Optional[str] = None, + api_base_url: str = ALIS_BASE_URL, + community_id: int = DEFAULT_COMMUNITY_ID, + timeout: float = TIMEOUT_SECONDS, + connect_timeout: float = CONNECT_TIMEOUT_SECONDS, + ): + self.api_base_url = api_base_url.rstrip("/") + self.community_id = community_id + self._username = username + self._password = password + self._access_token: Optional[str] = None + self._refresh_token: Optional[str] = None + self.timeout = timeout + self.connect_timeout = connect_timeout + + # -- auth ------------------------------------------------------------------ + def _ensure_credentials(self) -> None: + if not self._username or not self._password: + self._username, self._password = load_credentials() + + @property + def access_token(self) -> str: + if not self._access_token: + self.authenticate() + return self._access_token # type: ignore[return-value] + + def authenticate(self) -> None: + """Mint a fresh JWT via POST /user/tokens.""" + self._ensure_credentials() + body = {"username": self._username, "password": self._password} + url = f"{self.api_base_url}/user/tokens" + data = json.dumps(body).encode("utf-8") + result = self._send("POST", url, data, with_auth=False) + if not isinstance(result, dict) or "accessToken" not in result: + raise ALISError(f"Unexpected token response: {str(result)[:200]}") + self._access_token = result["accessToken"] + self._refresh_token = result.get("refreshToken") + + # -- core transport -------------------------------------------------------- + def _request(self, method: str, path: str, + params: Optional[dict] = None, + body: Optional[dict] = None) -> Any: + url = f"{self.api_base_url}/{path.lstrip('/')}" + if params: + url = f"{url}?{urllib.parse.urlencode(params)}" + data = json.dumps(body).encode("utf-8") if body is not None else None + return self._send(method, url, data, with_auth=True) + + def _send(self, method: str, url: str, data: Optional[bytes], + with_auth: bool) -> Any: + headers = {"Content-Type": "application/json", "Accept": "application/json"} + if with_auth: + headers["Authorization"] = f"Bearer {self.access_token}" + if _HAS_HTTPX: + try: + timeout = httpx.Timeout(self.timeout, connect=self.connect_timeout) + with httpx.Client(timeout=timeout) as client: + resp = client.request(method, url, content=data, headers=headers) + resp.raise_for_status() + return resp.json() if resp.content else None + except httpx.TimeoutException as exc: + raise ALISError(f"ALIS request timed out: {exc}") from exc + except httpx.HTTPStatusError as exc: + detail = (exc.response.text or "")[:ERROR_BODY_MAX_CHARS] + raise ALISError( + f"ALIS HTTP {exc.response.status_code} [{method} {url}]: {detail}" + ) from exc + except httpx.HTTPError as exc: + raise ALISError(f"ALIS request failed: {exc}") from exc + + req = urllib.request.Request(url, data=data, method=method, headers=headers) + try: + with urllib.request.urlopen(req, timeout=self.timeout) as resp: + raw = resp.read() + return json.loads(raw.decode("utf-8")) if raw else None + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:ERROR_BODY_MAX_CHARS] + raise ALISError(f"ALIS HTTP {exc.code} [{method} {url}]: {detail}") from exc + except urllib.error.URLError as exc: + raise ALISError(f"ALIS request failed: {exc}") from exc + + # ====================================================================== + # READ METHODS (this API is read-only for staff) + # ====================================================================== + def list_communities(self) -> list[dict]: + """Communities the logged-in user can see. VERIFIED LIVE.""" + return self._request("GET", "/v1/integration/communities") or [] + + def list_staff(self, community_id: Optional[int] = None, + status: Optional[str] = None, + include_associated: bool = False) -> list[dict]: + """Staff roster for a community. VERIFIED LIVE. + communityId is REQUIRED - omitting it 403s 'Not authorized for facility 0'. + Each record: staffId, firstName, lastName, staffRecordNumber, primaryEmail, + mobilePhoneNumber, dateOfBirth, status, hireDate, dischargeDate, jobRole, + securityRoles (list).""" + params: dict = {"communityId": community_id or self.community_id} + if status: + params["status"] = status + if include_associated: + params["includeAssociatedStaff"] = "true" + res = self._request("GET", "/v1/integration/staff", params=params) + return res if isinstance(res, list) else (res or {}).get("data", []) or [] + + def get_staff(self, staff_id: int) -> dict: + """One staff member's full record. VERIFIED LIVE.""" + return self._request("GET", f"/v1/integration/staff/{staff_id}") or {} + + def get_staff_basic_info(self, staff_id: int) -> dict: + """Staff basicInfo: address, license, jobRole, securityRoles, etc.""" + return self._request( + "GET", f"/v1/integration/staff/{staff_id}/basicInfo") or {} + + # -- derived reference model ----------------------------------------------- + def build_role_map(self, community_id: Optional[int] = None) -> dict: + """Derive the setup-reference from LIVE staff: the security-role and + job-role vocabularies actually in use, and a job-role -> security-role(s) + map learned from HIRED staff. This is how a new hire of a given job role + should be configured to match existing staff.""" + staff = self.list_staff(community_id=community_id) + hired = [s for s in staff if s.get("status") == "Hired"] + sr_all: Counter = Counter() + jr_all: Counter = Counter() + jr_to_sr: dict[str, Counter] = defaultdict(Counter) + for s in staff: + sr = s.get("securityRoles") or [] + if isinstance(sr, str): + sr = [sr] + for x in sr: + sr_all[x] += 1 + jr = (s.get("jobRole") or "").strip() + if jr: + jr_all[jr] += 1 + for s in hired: + jr = (s.get("jobRole") or "").strip() + if not jr: + continue + sr = s.get("securityRoles") or [] + if isinstance(sr, str): + sr = [sr] + for x in sr: + jr_to_sr[jr][x] += 1 + return { + "community": {"id": community_id or self.community_id}, + "sourceStaffCount": len(staff), + "hiredCount": len(hired), + "securityRoleVocabulary": sorted(sr_all), + "jobRoleVocabulary": sorted(jr_all), + "jobRoleToSecurityRoles": { + jr: [r for r, _ in c.most_common()] for jr, c in jr_to_sr.items() + }, + } + + # ====================================================================== + # POWER TOOL + # ====================================================================== + def raw(self, method: str, path: str, + params: Optional[dict] = None, body: Optional[dict] = None) -> Any: + """Call any endpoint directly (read-only API - no staff writes exist).""" + return self._request(method.upper(), path, params=params, body=body) + + +def main() -> int: + """Self-check: mint a token (live) and confirm community scope.""" + try: + client = ALISClient() + client.authenticate() + print("[OK] authenticated; transport =", + "httpx" if _HAS_HTTPX else "urllib") + comms = client.list_communities() + print("[INFO] base =", client.api_base_url) + print("[INFO] communities:", + [(c.get("communityId"), c.get("communityName")) for c in comms]) + return 0 + except ALISError as exc: + print(f"[ERROR] {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/alis/scripts/import_builder.py b/.claude/skills/alis/scripts/import_builder.py new file mode 100644 index 00000000..72ea929b --- /dev/null +++ b/.claude/skills/alis/scripts/import_builder.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Build / inspect the ALIS Staff Import workbook for the `alis` skill. + +ALIS has NO staff-write API. Staff (and their logins) are created/changed in +bulk by uploading an .xls on the ALIS web UI (Staff -> Import). This module +produces a workbook that exactly matches Medtelligent's template so it uploads +cleanly, and validates each row against the template's controlled values and +against the live job-role -> security-role reference (role-map.json). + +Template (Sheet1 header, exact order - DO NOT reorder/rename): + First Name | Last Name | Staff Record Number | Security Roles | Staff Status | + Hire Date | Login Enabled | Email | Password | Date of Birth | Gender | + Job Role | Cell Phone +Sheet2 holds the dropdown lists: + Staff Status: Applicant / Discharged / Hired / Rejected + Login Enabled: Yes / No + Gender: Female / Male + +Write uses xlwt (legacy .xls, matching the template format). Read uses xlrd. +""" +from __future__ import annotations + +import csv +import json +import secrets +import string +from pathlib import Path +from typing import Any, Optional + +# ALIS has TWO import layouts (column order matters for the importer): +# - CREATE (blank "new staff" template): has Password, NO ALIS ID. Rows WITHOUT +# an ALIS ID are created as new staff. +# - UPDATE (export of current staff): leads with ALIS ID, NO Password. Rows WITH +# an ALIS ID update the matching existing staff member. +# We build CREATE files for new hires by default. +TEMPLATE_HEADERS = [ + "First Name", "Last Name", "Staff Record Number", "Security Roles", + "Staff Status", "Hire Date", "Login Enabled", "Email", "Password", + "Date of Birth", "Gender", "Job Role", "Cell Phone", +] +UPDATE_TEMPLATE_HEADERS = [ + "ALIS ID", "First Name", "Last Name", "Staff Record Number", "Security Roles", + "Staff Status", "Hire Date", "Login Enabled", "Email", + "Date of Birth", "Gender", "Job Role", "Cell Phone", +] +HEADERS_BY_FORMAT = {"create": TEMPLATE_HEADERS, "update": UPDATE_TEMPLATE_HEADERS} + +# ALIS dates render as MM/DD/YYYY (confirmed from the real staff export). +DATE_FORMAT_HINT = "MM/DD/YYYY" + +# Sheet2 dropdown lists (mirror the official template). +STATUS_VALUES = ["Applicant", "Discharged", "Hired", "Rejected"] +LOGIN_VALUES = ["Yes", "No"] +GENDER_VALUES = ["Female", "Male"] + +# Accepted input aliases -> canonical header. Lower-cased, stripped, non-alnum +# removed for matching, so "first_name", "FirstName", "First Name" all map. +_ALIASES = { + "alisid": "ALIS ID", "alis": "ALIS ID", "staffaliasid": "ALIS ID", + "firstname": "First Name", "first": "First Name", "fname": "First Name", + "lastname": "Last Name", "last": "Last Name", "lname": "Last Name", + "staffrecordnumber": "Staff Record Number", "recordnumber": "Staff Record Number", + "recordno": "Staff Record Number", "employeeid": "Staff Record Number", + "empid": "Staff Record Number", "staffid": "Staff Record Number", + "securityroles": "Security Roles", "securityrole": "Security Roles", + "role": "Security Roles", "accessrole": "Security Roles", + "staffstatus": "Staff Status", "status": "Staff Status", + "hiredate": "Hire Date", "datehired": "Hire Date", "startdate": "Hire Date", + "loginenabled": "Login Enabled", "login": "Login Enabled", + "loginaccess": "Login Enabled", "portalaccess": "Login Enabled", + "email": "Email", "emailaddress": "Email", "workemail": "Email", + "password": "Password", "pass": "Password", "pwd": "Password", + "dateofbirth": "Date of Birth", "dob": "Date of Birth", "birthdate": "Date of Birth", + "gender": "Gender", "sex": "Gender", + "jobrole": "Job Role", "jobtitle": "Job Role", "title": "Job Role", + "position": "Job Role", + "cellphone": "Cell Phone", "cell": "Cell Phone", "mobile": "Cell Phone", + "phone": "Cell Phone", "mobilephone": "Cell Phone", "cellphonenumber": "Cell Phone", +} + + +class ImportBuilderError(RuntimeError): + pass + + +def _norm(s: str) -> str: + return "".join(ch for ch in str(s).lower() if ch.isalnum()) + + +def _canon_header(raw: str) -> Optional[str]: + raw_s = str(raw).strip() + for h in TEMPLATE_HEADERS: + if _norm(h) == _norm(raw_s): + return h + return _ALIASES.get(_norm(raw_s)) + + +def load_role_map(path: Optional[Path] = None) -> dict: + """Load the cached job-role -> security-role reference (role-map.json).""" + if path is None: + path = Path(__file__).resolve().parent.parent / "references" / "role-map.json" + if not path.exists(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +# --- input parsing ------------------------------------------------------------ +def read_input_rows(path: str) -> list[dict]: + """Read a CSV or JSON file into a list of {canonical-header: value} dicts. + Unknown columns are ignored (with the raw key preserved under '_extra').""" + p = Path(path) + if not p.exists(): + raise ImportBuilderError(f"input file not found: {path}") + text = p.read_text(encoding="utf-8-sig") + raw_rows: list[dict] + if p.suffix.lower() == ".json": + data = json.loads(text) + raw_rows = data if isinstance(data, list) else data.get("staff") or data.get("rows") or [] + else: # CSV/TSV + delim = "\t" if p.suffix.lower() in (".tsv", ".tab") else "," + raw_rows = list(csv.DictReader(text.splitlines(), delimiter=delim)) + rows: list[dict] = [] + for raw in raw_rows: + row: dict = {} + extra: dict = {} + for k, v in raw.items(): + if k is None: + continue + canon = _canon_header(k) + if canon: + row[canon] = "" if v is None else str(v).strip() + else: + extra[k] = v + if extra: + row["_extra"] = extra + rows.append(row) + return rows + + +# --- validation + enrichment -------------------------------------------------- +def _suggest_security_role(job_role: str, role_map: dict) -> Optional[str]: + """Return the typical FULL Security Roles string (comma-separated, as ALIS + stores it) for a job role, learned from current staff. Prefers the modal + exact combo (jobRoleToSecurityRolesCombo); falls back to the older + single-role map for compatibility.""" + if not job_role: + return None + combo = role_map.get("jobRoleToSecurityRolesCombo", {}) + for k, v in combo.items(): + if k.lower() == job_role.lower() and v: + return v + # legacy fallback: list of roles -> join + mapping = role_map.get("jobRoleToSecurityRoles", {}) + for k, v in mapping.items(): + if k.lower() == job_role.lower() and v: + return ", ".join(v) if isinstance(v, list) else str(v) + return None + + +def enrich_and_validate(rows: list[dict], role_map: dict, + default_status: str = "Hired", + suggest_roles: bool = True) -> tuple[list[dict], list[dict]]: + """Apply defaults, infer Security Roles from Job Role, validate enums. + Returns (processed_rows, report) where report has per-row notes/warnings.""" + sec_vocab = set(role_map.get("securityRoleVocabulary", [])) + report: list[dict] = [] + out: list[dict] = [] + for i, r in enumerate(rows): + notes: list[str] = [] + warns: list[str] = [] + # Keep every known header (union of both layouts) so ALIS ID survives for + # update rows; write_workbook selects the columns per chosen format. + all_headers = ["ALIS ID"] + TEMPLATE_HEADERS + row = {h: str(r.get(h, "") or "").strip() for h in all_headers} + + if not row["First Name"] or not row["Last Name"]: + warns.append("missing First/Last Name (required)") + + # Status + if not row["Staff Status"]: + row["Staff Status"] = default_status + notes.append(f"Staff Status defaulted to {default_status}") + elif row["Staff Status"] not in STATUS_VALUES: + warns.append(f"Staff Status '{row['Staff Status']}' not in {STATUS_VALUES}") + + # Gender + if row["Gender"]: + g = row["Gender"].strip().capitalize() + if g in GENDER_VALUES: + row["Gender"] = g + else: + warns.append(f"Gender '{row['Gender']}' not in {GENDER_VALUES}") + + # Security Roles - infer from Job Role if blank + if not row["Security Roles"] and suggest_roles: + sug = _suggest_security_role(row["Job Role"], role_map) + if sug: + row["Security Roles"] = sug + notes.append(f"Security Roles inferred '{sug}' from Job Role " + f"'{row['Job Role']}' (review)") + elif row["Job Role"]: + warns.append(f"no reference Security Role for Job Role " + f"'{row['Job Role']}' - set manually") + elif row["Security Roles"] and sec_vocab: + unknown = [p.strip() for p in row["Security Roles"].split(",") + if p.strip() and p.strip() not in sec_vocab] + if unknown: + warns.append(f"Security Role(s) {unknown} not seen in current staff " + "- confirm they're real ALIS roles") + + # Login Enabled - default from presence of email+password + if not row["Login Enabled"]: + if row["Email"] and row["Password"]: + row["Login Enabled"] = "Yes" + notes.append("Login Enabled defaulted to Yes (email+password present)") + else: + row["Login Enabled"] = "No" + notes.append("Login Enabled defaulted to No (no email/password)") + elif row["Login Enabled"] not in LOGIN_VALUES: + warns.append(f"Login Enabled '{row['Login Enabled']}' not in {LOGIN_VALUES}") + + if row["Login Enabled"] == "Yes" and not row["Email"]: + warns.append("Login Enabled=Yes but no Email (login needs an email)") + + out.append(row) + report.append({ + "row": i + 1, + "name": f"{row['First Name']} {row['Last Name']}".strip(), + "notes": notes, "warnings": warns, + }) + return out, report + + +def generate_password(length: int = 14) -> str: + """Strong random password (letters+digits+symbols), avoids ambiguous chars.""" + alphabet = (string.ascii_uppercase.replace("O", "").replace("I", "") + + string.ascii_lowercase.replace("l", "") + + string.digits.replace("0", "").replace("1", "") + + "!@#$%*?") + while True: + pw = "".join(secrets.choice(alphabet) for _ in range(length)) + if (any(c.islower() for c in pw) and any(c.isupper() for c in pw) + and any(c.isdigit() for c in pw) and any(c in "!@#$%*?" for c in pw)): + return pw + + +def fill_passwords(rows: list[dict]) -> list[dict]: + """For Login Enabled=Yes rows missing a Password, generate one in place. + Returns the list of {name, email, password} that were generated.""" + generated = [] + for row in rows: + if row.get("Login Enabled") == "Yes" and not row.get("Password"): + pw = generate_password() + row["Password"] = pw + generated.append({ + "name": f"{row['First Name']} {row['Last Name']}".strip(), + "email": row.get("Email", ""), "password": pw, + }) + return generated + + +# --- workbook writing --------------------------------------------------------- +def write_workbook(rows: list[dict], out_path: str, fmt: str = "create") -> str: + """Write the rows to an .xls matching the ALIS template (Sheet1 data + + Sheet2 dropdown lists). fmt='create' (new staff, has Password) or 'update' + (existing staff, leads with ALIS ID). Returns the output path.""" + headers = HEADERS_BY_FORMAT.get(fmt) + if headers is None: + raise ImportBuilderError(f"unknown format '{fmt}' (use create|update)") + try: + import xlwt # type: ignore + except ImportError as exc: + raise ImportBuilderError( + "xlwt is required to write .xls. Install with: pip install xlwt" + ) from exc + + wb = xlwt.Workbook(encoding="utf-8") + header_style = xlwt.easyxf("font: bold on; align: wrap on") + s1 = wb.add_sheet("Sheet1") + for c, h in enumerate(headers): + s1.write(0, c, h, header_style) + for ri, row in enumerate(rows, start=1): + for ci, h in enumerate(headers): + s1.write(ri, ci, row.get(h, "")) + + # Sheet2 = validation lists, laid out exactly like the official template: + # col0 Status, col1 Login Enabled, col2 Gender. + s2 = wb.add_sheet("Sheet2") + lists = [STATUS_VALUES, LOGIN_VALUES, GENDER_VALUES] + for col, values in enumerate(lists): + for r, v in enumerate(values): + s2.write(r, col, v) + + out = Path(out_path) + out.parent.mkdir(parents=True, exist_ok=True) + wb.save(str(out)) + return str(out) + + +def write_password_sidecar(generated: list[dict], xls_path: str) -> Optional[str]: + """Write generated logins to a sidecar CSV next to the workbook. Contains + PLAINTEXT passwords - caller must warn + tell the user to distribute then + delete/vault it. Returns the sidecar path, or None if nothing generated.""" + if not generated: + return None + side = Path(xls_path).with_name(Path(xls_path).stem + "_passwords.csv") + with side.open("w", newline="", encoding="utf-8") as f: + w = csv.writer(f) + w.writerow(["Name", "Email", "Password"]) + for g in generated: + w.writerow([g["name"], g["email"], g["password"]]) + return str(side) + + +# --- workbook reading (inspect) ----------------------------------------------- +def read_workbook(path: str, max_rows: int = 50) -> dict: + """Read an existing .xls (or .xlsx) staff-import workbook for inspection.""" + p = Path(path) + if not p.exists(): + raise ImportBuilderError(f"workbook not found: {path}") + if p.suffix.lower() == ".xlsx": + try: + import openpyxl # type: ignore + except ImportError as exc: + raise ImportBuilderError("openpyxl required for .xlsx") from exc + wb = openpyxl.load_workbook(str(p), read_only=True, data_only=True) + sheets = {} + for sh in wb.worksheets: + rows = [] + for ri, row in enumerate(sh.iter_rows(values_only=True)): + if ri >= max_rows: + break + rows.append([("" if c is None else c) for c in row]) + sheets[sh.title] = rows + return {"format": "xlsx", "sheets": sheets} + try: + import xlrd # type: ignore + except ImportError as exc: + raise ImportBuilderError("xlrd required for .xls") from exc + wb = xlrd.open_workbook(str(p)) + sheets = {} + for sh in wb.sheets(): + rows = [] + for r in range(min(sh.nrows, max_rows)): + rows.append([sh.cell_value(r, c) for c in range(sh.ncols)]) + sheets[sh.name] = rows + return {"format": "xls", "sheets": sheets}