""" Generate ConnectWise security-role documentation from the source XLSX. Produces: - api-member-security-roles.yaml : machine-readable source of truth - api-member-security-roles.md : human-readable reference Re-run this script after editing the source XLSX. Both outputs are deterministic — they will produce identical content from identical input, so diffs in version control reflect only real permission-model changes. Usage: python generate_role_docs.py \ --source source/Security_Roles_Matrix_11132017.xlsx \ --out-yaml ../api-member-security-roles.yaml \ --out-md ../api-member-security-roles.md """ from __future__ import annotations import argparse import re from dataclasses import dataclass, field from datetime import date from pathlib import Path from typing import Dict, List, Optional import yaml from openpyxl import load_workbook # --------------------------------------------------------------------------- # Parsing # --------------------------------------------------------------------------- # A level description line looks like "ALL: text..." or "NONE: text..." # We capture the prefix (ALL | NONE | MINE | MY) and the trailing description. LEVEL_LINE = re.compile(r"^(ALL|NONE|MINE|MY)\s*:\s*(.*)$", re.DOTALL) # Recognized ConnectWise permission levels, most-to-least privileged. LEVEL_ORDER = ["ALL", "MY", "MINE", "NONE"] VERBS = ["add", "edit", "delete", "inquire"] VERB_COLS = {"add": 3, "edit": 4, "delete": 5, "inquire": 6} @dataclass class CellPermission: """Parsed contents of a single (action, verb) cell.""" levels: Dict[str, str] = field(default_factory=dict) # level -> description note: Optional[str] = None # for "Not applicable. See Inquire level." etc. raw: str = "" # original cell text, preserved for audit @dataclass class ActionRow: module: str action: str permissions: Dict[str, CellPermission] # verb -> CellPermission def parse_cell(raw: Optional[str]) -> CellPermission: """Parse a single cell's multi-line content into levels + note.""" if raw is None: return CellPermission(raw="") text = str(raw).strip() cp = CellPermission(raw=text) if not text: return cp # Split into candidate entries. Each entry is typically one line that # starts with a level prefix, but description text can itself contain # newlines. We therefore split on newlines and accumulate continuation # lines into the preceding entry. current_level: Optional[str] = None current_buf: List[str] = [] note_buf: List[str] = [] def flush_level() -> None: nonlocal current_level, current_buf if current_level is not None: cp.levels[current_level] = " ".join(current_buf).strip() current_level = None current_buf = [] for line in text.splitlines(): line = line.strip() if not line: continue m = LEVEL_LINE.match(line) if m: flush_level() current_level = m.group(1).upper() current_buf = [m.group(2).strip()] elif current_level is not None: current_buf.append(line) else: # No level prefix yet — belongs to the note. note_buf.append(line) flush_level() if note_buf: cp.note = " ".join(note_buf).strip() return cp def read_matrix(xlsx_path: Path) -> List[ActionRow]: wb = load_workbook(xlsx_path, data_only=True) ws = wb.active # Single sheet in this workbook. # Header row is row 2 per the source file; data begins row 3. actions: List[ActionRow] = [] for r in range(3, ws.max_row + 1): module = ws.cell(row=r, column=1).value action = ws.cell(row=r, column=2).value if not (module or action): continue # skip fully empty rows if not module or not action: # Partial row — keep but flag. This shouldn't happen in the # current source; if it does, the generator should fail loudly # rather than silently produce wrong output. raise ValueError( f"Row {r} has a missing Module or Action: " f"module={module!r}, action={action!r}" ) perms: Dict[str, CellPermission] = {} for verb, col in VERB_COLS.items(): perms[verb] = parse_cell(ws.cell(row=r, column=col).value) actions.append( ActionRow(module=module.strip(), action=action.strip(), permissions=perms) ) return actions # --------------------------------------------------------------------------- # Output: YAML # --------------------------------------------------------------------------- def build_yaml_document(actions: List[ActionRow], source_file: str) -> dict: """Build a plain-dict representation that YAML dumps cleanly.""" # Group by module, preserving action order within each module. modules: Dict[str, List[ActionRow]] = {} for a in actions: modules.setdefault(a.module, []).append(a) doc = { "metadata": { "source_file": source_file, "generated_on": date.today().isoformat(), "generator": "docs/integrations/connectwise/source/generate_role_docs.py", "description": ( "ConnectWise security-role matrix. Each (module, action) entry " "describes what each access level (ALL, MY, MINE, NONE) means " "for the Add, Edit, Delete, and Inquire verbs. This is a " "reference catalog, not a per-role assignment — role " "assignments live in ConnectWise and are mirrored in the " "ResolutionFlow integration config." ), "level_order_most_to_least_privileged": LEVEL_ORDER, }, "modules": {}, } for module_name, rows in modules.items(): module_block = {"actions": {}} for a in rows: action_block: Dict[str, object] = {} for verb in VERBS: cell = a.permissions[verb] entry: Dict[str, object] = {} if cell.levels: # Emit levels in canonical order, only those present. entry["levels"] = { lvl: cell.levels[lvl] for lvl in LEVEL_ORDER if lvl in cell.levels } if cell.note: entry["note"] = cell.note if not entry: # Truly empty cell — represent explicitly so downstream # consumers can distinguish "empty" from "missing". entry["note"] = "(no description provided)" action_block[verb] = entry module_block["actions"][a.action] = action_block doc["modules"][module_name] = module_block return doc class _LiteralStr(str): """Marker type so PyYAML renders long strings as block literals.""" def _literal_presenter(dumper, data): return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") yaml.add_representer(_LiteralStr, _literal_presenter) def _use_block_style_for_long_strings(obj): """Recursively wrap long strings so the YAML is readable, not one-line.""" if isinstance(obj, dict): return {k: _use_block_style_for_long_strings(v) for k, v in obj.items()} if isinstance(obj, list): return [_use_block_style_for_long_strings(v) for v in obj] if isinstance(obj, str) and (len(obj) > 80 or "\n" in obj): return _LiteralStr(obj) return obj def dump_yaml(doc: dict, out_path: Path) -> None: prepared = _use_block_style_for_long_strings(doc) out_path.parent.mkdir(parents=True, exist_ok=True) with out_path.open("w", encoding="utf-8") as f: f.write("# ConnectWise API Member Security Roles — reference matrix.\n") f.write("# Generated from the source XLSX; do not edit by hand.\n") f.write("# Re-run generate_role_docs.py after updating the XLSX.\n\n") yaml.dump( prepared, f, sort_keys=False, allow_unicode=True, width=100, default_flow_style=False, ) # --------------------------------------------------------------------------- # Output: Markdown # --------------------------------------------------------------------------- def _md_escape(text: str) -> str: """Escape pipes and collapse whitespace for Markdown table cells.""" return text.replace("|", "\\|").replace("\n", " ").strip() def build_markdown(actions: List[ActionRow], source_file: str) -> str: modules: Dict[str, List[ActionRow]] = {} for a in actions: modules.setdefault(a.module, []).append(a) lines: List[str] = [] lines.append("# ConnectWise API Member — Security Roles Reference") lines.append("") lines.append( f"_Generated {date.today().isoformat()} from " f"`{source_file}`. Do not edit by hand — update the XLSX and " f"re-run `generate_role_docs.py`._" ) lines.append("") lines.append("## How to read this document") lines.append("") lines.append( "Each ConnectWise module lists the actions it governs. For every " "action, four permission verbs — **Add**, **Edit**, **Delete**, " "**Inquire** — can be granted at one of these levels, most to " "least privileged:" ) lines.append("") lines.append("| Level | Meaning |") lines.append("|-------|---------|") lines.append("| `ALL` | Access to all records in the system. |") lines.append("| `MY` | Access to records owned by the user's team. |") lines.append("| `MINE` | Access only to records owned by the user. |") lines.append("| `NONE` | No access. |") lines.append("") lines.append( "Not every level applies to every action — the source matrix " "only documents the levels that are meaningful for each cell. " "Cells marked _Not applicable_ reference another verb (usually " "Inquire) where the meaningful level is defined." ) lines.append("") lines.append( "The machine-readable form of this document is " "[`api-member-security-roles.yaml`](./api-member-security-roles.yaml). " "Use the YAML when writing integration code; use this Markdown " "when reviewing, discussing, or onboarding." ) lines.append("") lines.append("## Table of contents") lines.append("") for module_name in modules: anchor = module_name.lower().replace(" ", "-").replace("/", "") lines.append(f"- [{module_name}](#{anchor}) — {len(modules[module_name])} actions") lines.append("") for module_name, rows in modules.items(): lines.append(f"## {module_name}") lines.append("") for a in rows: lines.append(f"### {a.action}") lines.append("") lines.append("| Verb | Level | Description |") lines.append("|------|-------|-------------|") wrote_any = False for verb in VERBS: cell = a.permissions[verb] if cell.levels: for lvl in LEVEL_ORDER: if lvl in cell.levels: lines.append( f"| {verb.capitalize()} | `{lvl}` | " f"{_md_escape(cell.levels[lvl])} |" ) wrote_any = True elif cell.note: lines.append( f"| {verb.capitalize()} | — | " f"_{_md_escape(cell.note)}_ |" ) wrote_any = True if not wrote_any: lines.append("| — | — | _(no description provided)_ |") lines.append("") return "\n".join(lines) + "\n" def write_markdown(md_text: str, out_path: Path) -> None: out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(md_text, encoding="utf-8") # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source", type=Path, required=True, help="Path to the source .xlsx") parser.add_argument("--out-yaml", type=Path, required=True, help="Path to write the YAML output") parser.add_argument("--out-md", type=Path, required=True, help="Path to write the Markdown output") args = parser.parse_args() actions = read_matrix(args.source) doc = build_yaml_document(actions, source_file=args.source.name) dump_yaml(doc, args.out_yaml) md = build_markdown(actions, source_file=args.source.name) write_markdown(md, args.out_md) # Quick data-quality summary to stdout — helpful when re-running after edits. from collections import Counter modules_seen = Counter(a.module for a in actions) print(f"Parsed {len(actions)} actions across {len(modules_seen)} modules:") for m, n in modules_seen.most_common(): print(f" {m}: {n}") print(f"\nWrote {args.out_yaml}") print(f"Wrote {args.out_md}") if __name__ == "__main__": main()