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
This commit is contained in:
@@ -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.
|
||||
|
||||
37
.claude/memory/reference_alis_medtelligent.md
Normal file
37
.claude/memory/reference_alis_medtelligent.md
Normal file
@@ -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 <accessToken>`. 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]].
|
||||
130
.claude/skills/alis/SKILL.md
Normal file
130
.claude/skills/alis/SKILL.md
Normal file
@@ -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 <accessToken>`. 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`).
|
||||
BIN
.claude/skills/alis/references/ALIS_Staff_Import.template.xls
Normal file
BIN
.claude/skills/alis/references/ALIS_Staff_Import.template.xls
Normal file
Binary file not shown.
75
.claude/skills/alis/references/api-reference.md
Normal file
75
.claude/skills/alis/references/api-reference.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# ALIS API Reference (for the `alis` skill)
|
||||
|
||||
Source of truth: the live Swagger specs the ALIS API publishes.
|
||||
- Swagger UI: https://api.alisonline.com/index.html
|
||||
- Raw specs: `https://api.alisonline.com/specs/v1/openapi.json` (also `v2`, `v3`)
|
||||
- Vendor docs (gated, requires support login): https://support.alisonline.com/
|
||||
|
||||
ALIS = Medtelligent's assisted-living EHR. The API is a **partner/App-Store integration
|
||||
API**. A tenant lives at `<tenant>.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":"<user>@<tenantKey>","password":"..."}`
|
||||
→ `{ accessToken (JWT, expiresIn 3600), refreshToken }`
|
||||
- `POST /user/tokens/refresh` body `{accessToken, refreshToken}` → new pair
|
||||
- Send `Authorization: Bearer <accessToken>` on every call.
|
||||
- **The username MUST be tenant-qualified.** `howard.enos` → HTTP 400
|
||||
(`Username must match ^<username>@<tenantKey>$`); `howard.enos@cascadestucson` → 200.
|
||||
tenantKey = the tenant subdomain (`cascadestucson`).
|
||||
- **VendorKey:** `X-Vendor-Key: <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.
|
||||
82
.claude/skills/alis/references/role-map.json
Normal file
82
.claude/skills/alis/references/role-map.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
289
.claude/skills/alis/scripts/alis.py
Normal file
289
.claude/skills/alis/scripts/alis.py
Normal file
@@ -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())
|
||||
307
.claude/skills/alis/scripts/alis_client.py
Normal file
307
.claude/skills/alis/scripts/alis_client.py
Normal file
@@ -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 <tenant>.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":"<user>@<tenantKey>","password":"..."}
|
||||
-> {accessToken (JWT, ~1h), expiresIn, refreshToken}
|
||||
Send Authorization: Bearer <accessToken> 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())
|
||||
350
.claude/skills/alis/scripts/import_builder.py
Normal file
350
.claude/skills/alis/scripts/import_builder.py
Normal file
@@ -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}
|
||||
Reference in New Issue
Block a user