Files
claudetools/.claude/skills/alis/scripts/alis_client.py
Howard Enos 31f2bdb84f 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
2026-06-29 16:55:55 -07:00

308 lines
13 KiB
Python

#!/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())