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:
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())
|
||||
Reference in New Issue
Block a user