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:
2026-06-29 16:55:55 -07:00
parent e692bff2bc
commit 31f2bdb84f
9 changed files with 1271 additions and 0 deletions

View File

@@ -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.

View 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]].

View 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`).

View 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.

View 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"
}
}

View 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())

View 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())

View 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}