308 lines
13 KiB
Python
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())
|