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:
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