#!/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}