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
|
## 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.
|
- [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.
|
- [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.
|
- [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.
|
- [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