From e0ba9b27116f1d3758be527390b4dcfed09a7a94 Mon Sep 17 00:00:00 2001 From: Rich Kreider Date: Wed, 20 May 2026 12:08:15 -0400 Subject: saqauditor initial commit --- main.py | 1101 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1101 insertions(+) create mode 100644 main.py (limited to 'main.py') diff --git a/main.py b/main.py new file mode 100644 index 0000000..ad1d238 --- /dev/null +++ b/main.py @@ -0,0 +1,1101 @@ +#!/usr/bin/env python3 +""" +PCI DSS Compliance Assessment Tool +CTC Internal Tool — Portable Python/tkinter application +""" + +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +import json +import os +import sys +import datetime +from pathlib import Path + +try: + import sv_ttk + HAS_SV_TTK = True +except ImportError: + HAS_SV_TTK = False + + +# ─── Path helpers ──────────────────────────────────────────────────────────── + +def resource_path(relative_path): + """Absolute path to resource — works for dev and PyInstaller one-file.""" + if hasattr(sys, "_MEIPASS"): + return os.path.join(sys._MEIPASS, relative_path) + return os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path) + + +def sessions_dir(): + """Sessions folder next to the exe (or script) — created on first use.""" + if hasattr(sys, "_MEIPASS"): + base = os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.abspath(__file__)) + d = os.path.join(base, "sessions") + os.makedirs(d, exist_ok=True) + return d + + +# ─── Constants ──────────────────────────────────────────────────────────────── + +STATUS_OPTIONS = ["Not Tested", "In Place", "Not In Place", "N/A"] +STATUS_COLORS = { + "Not Tested": "#9e9e9e", + "In Place": "#2e7d32", + "Not In Place": "#c62828", + "N/A": "#1565c0", +} +STATUS_BG = { + "Not Tested": "#f5f5f5", + "In Place": "#e8f5e9", + "Not In Place": "#ffebee", + "N/A": "#e3f2fd", +} + +CLIENT_FIELDS = [ + ("client_name", "Client Name"), + ("assessor_name", "Assessor"), + ("assessment_date", "Assessment Date"), + ("period_start", "Period Start"), + ("period_end", "Period End"), + ("terminal_vendor", "Terminal Vendor"), + ("terminal_model", "Terminal Model"), + ("processor", "Payment Processor"), + ("merchant_id", "Merchant ID"), +] + +APP_TITLE = "SAQAuditor: PCI DSS Compliance Assessment Tool by Rich Kreider" +VERSION = "1.0" + + +# ─── Main Application ───────────────────────────────────────────────────────── + +class PCITool: + def __init__(self, root: tk.Tk): + self.root = root + self.root.title(APP_TITLE) + self.root.geometry("1440x900") + self.root.minsize(1100, 700) + + if HAS_SV_TTK: + sv_ttk.set_theme("light") + + # State + self.saq_types: dict = {} # id -> SAQ dict + self.current_saq = None + self.current_session = None # full session dict (from disk) + self.session_file = None # path to current session file + self.responses: dict = {} # req_id -> {status, notes, last_updated} + self.response_vars: dict = {} # req_id -> {status: StringVar, notes: StringVar} + self.unsaved = False + + # Client vars + self.client_vars: dict = {} + for key, _ in CLIENT_FIELDS: + self.client_vars[key] = tk.StringVar() + + self.load_saq_types() + self._build_ui() + + self.root.protocol("WM_DELETE_WINDOW", self._on_close) + + # ── Data loading ────────────────────────────────────────────────────────── + + def load_saq_types(self): + data_dir = resource_path("data") + if not os.path.isdir(data_dir): + messagebox.showerror("Error", f"Data directory not found:\n{data_dir}") + return + for fname in sorted(os.listdir(data_dir)): + if fname.endswith(".json"): + try: + with open(os.path.join(data_dir, fname), encoding="utf-8") as f: + d = json.load(f) + self.saq_types[d["id"]] = d + except Exception as e: + print(f"[WARN] Could not load {fname}: {e}") + + # ── UI construction ─────────────────────────────────────────────────────── + + def _build_ui(self): + self._build_toolbar() + + paned = tk.PanedWindow(self.root, orient=tk.HORIZONTAL, + sashwidth=5, sashrelief=tk.FLAT, + bg="#bdbdbd") + paned.pack(fill=tk.BOTH, expand=True) + + # Left sidebar + self.sidebar = ttk.Frame(paned, width=290) + self.sidebar.pack_propagate(False) + paned.add(self.sidebar, minsize=260) + + # Right content + self.content = ttk.Frame(paned) + paned.add(self.content, minsize=750) + + self._build_sidebar() + self._build_welcome() + + # Status bar + self._status_var = tk.StringVar(value="Ready. Create or open a session to begin.") + ttk.Label(self.root, textvariable=self._status_var, + relief=tk.SUNKEN, anchor=tk.W, padding=(6, 2) + ).pack(fill=tk.X, side=tk.BOTTOM) + + def _build_toolbar(self): + bar = ttk.Frame(self.root, relief=tk.GROOVE, padding=(4, 3)) + bar.pack(fill=tk.X, side=tk.TOP) + + for text, cmd in [("New Session", self.cmd_new), + ("Open Session", self.cmd_open), + ("Save", self.cmd_save), + ("Save As", self.cmd_save_as)]: + ttk.Button(bar, text=text, command=cmd, width=12).pack(side=tk.LEFT, padx=2) + + ttk.Separator(bar, orient=tk.VERTICAL).pack(side=tk.LEFT, fill=tk.Y, padx=6) + + # Export dropdown + exp_btn = ttk.Menubutton(bar, text="Export ▾", width=10) + exp_menu = tk.Menu(exp_btn, tearoff=False) + exp_menu.add_command(label="Export → HTML", command=lambda: self.cmd_export("html")) + exp_menu.add_command(label="Export → Word (.docx)", command=lambda: self.cmd_export("docx")) + exp_menu.add_command(label="Export → PDF", command=lambda: self.cmd_export("pdf")) + exp_btn["menu"] = exp_menu + exp_btn.pack(side=tk.LEFT, padx=2) + + ttk.Label(bar, text=APP_TITLE, font=("Segoe UI", 10, "bold") + ).pack(side=tk.RIGHT, padx=10) + + def _build_sidebar(self): + # Wrap sidebar in scrollable canvas so it never clips + sb_canvas = tk.Canvas(self.sidebar, borderwidth=0, highlightthickness=0, + width=280) + sb_scroll = ttk.Scrollbar(self.sidebar, orient=tk.VERTICAL, + command=sb_canvas.yview) + self._sb_inner = ttk.Frame(sb_canvas) + + self._sb_inner.bind("", + lambda e: sb_canvas.configure(scrollregion=sb_canvas.bbox("all"))) + + sb_canvas.create_window((0, 0), window=self._sb_inner, anchor="nw", width=278) + sb_canvas.configure(yscrollcommand=sb_scroll.set) + + sb_canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + sb_scroll.pack(side=tk.RIGHT, fill=tk.Y) + + def _mw_sb(e): sb_canvas.yview_scroll(-1 * (e.delta // 120), "units") + sb_canvas.bind("", lambda e: sb_canvas.bind_all("", _mw_sb)) + sb_canvas.bind("", lambda e: sb_canvas.unbind_all("")) + + self._build_saq_selector() + self._build_client_fields() + self._build_dashboard_panel() + + def _build_saq_selector(self): + frame = ttk.LabelFrame(self._sb_inner, text="Assessment Type", padding=8) + frame.pack(fill=tk.X, padx=6, pady=(8, 4)) + + self._saq_var = tk.StringVar() + if not self.saq_types: + ttk.Label(frame, text="No SAQ data files found.", foreground="red").pack() + return + + for saq_id, saq_data in self.saq_types.items(): + ttk.Radiobutton(frame, text=saq_data["name"], + variable=self._saq_var, value=saq_id).pack(anchor=tk.W) + + def _build_client_fields(self): + frame = ttk.LabelFrame(self._sb_inner, text="Client Information", padding=8) + frame.pack(fill=tk.X, padx=6, pady=4) + + for key, label in CLIENT_FIELDS: + ttk.Label(frame, text=label + ":", font=("Segoe UI", 8), + foreground="#555").pack(anchor=tk.W) + e = ttk.Entry(frame, textvariable=self.client_vars[key]) + e.pack(fill=tk.X, pady=(0, 5)) + self.client_vars[key].trace_add("write", lambda *_: self._mark_unsaved()) + + # Default date + self.client_vars["assessment_date"].set( + datetime.date.today().strftime("%Y-%m-%d")) + + def _build_dashboard_panel(self): + frame = ttk.LabelFrame(self._sb_inner, text="Dashboard", padding=8) + frame.pack(fill=tk.X, padx=6, pady=4) + + self._dash_overall = ttk.Label(frame, text="No session loaded", + font=("Segoe UI", 9, "bold")) + self._dash_overall.pack(anchor=tk.W) + + # Progress bar + self._dash_pbar = ttk.Progressbar(frame, length=230, mode="determinate") + self._dash_pbar.pack(fill=tk.X, pady=(4, 6)) + + ttk.Separator(frame).pack(fill=tk.X, pady=(0, 6)) + + self._dash_req_frame = ttk.Frame(frame) + self._dash_req_frame.pack(fill=tk.X) + + ttk.Separator(frame).pack(fill=tk.X, pady=6) + + # Status legend / counts + self._dash_counts_frame = ttk.Frame(frame) + self._dash_counts_frame.pack(fill=tk.X) + + for status, color in STATUS_COLORS.items(): + row = ttk.Frame(self._dash_counts_frame) + row.pack(fill=tk.X, pady=1) + tk.Label(row, text="■", fg=color, font=("Segoe UI", 9) + ).pack(side=tk.LEFT) + lbl = ttk.Label(row, text=f" {status}: —", + font=("Segoe UI", 8)) + lbl.pack(side=tk.LEFT) + setattr(self, f"_dash_{status.replace(' ', '_').lower()}_lbl", lbl) + + def _build_welcome(self): + for w in self.content.winfo_children(): + w.destroy() + + f = ttk.Frame(self.content) + f.pack(expand=True) + + ttk.Label(f, text=APP_TITLE, + font=("Segoe UI", 17, "bold")).pack(pady=(0, 8)) + ttk.Label(f, text=f"v{VERSION} | CTC Internal Tool", + foreground="#777").pack() + ttk.Separator(f).pack(fill=tk.X, pady=16) + ttk.Label(f, text="Select an SAQ type in the left panel,\n" + "fill in client information, then click New Session.", + justify=tk.CENTER, font=("Segoe UI", 10)).pack() + + self._notebook = None + + # ── Assessment UI ───────────────────────────────────────────────────────── + + def _build_assessment_ui(self): + for w in self.content.winfo_children(): + w.destroy() + + self._notebook = ttk.Notebook(self.content) + self._notebook.pack(fill=tk.BOTH, expand=True, padx=4, pady=4) + self.response_vars = {} + + for req in self.current_saq.get("requirements", []): + self._build_req_tab(req) + + def _build_req_tab(self, req: dict): + outer = ttk.Frame(self._notebook) + self._notebook.add(outer, text=f"Req {req['id']}") + + # Header + hdr = ttk.Frame(outer, padding=(10, 8, 10, 4)) + hdr.pack(fill=tk.X) + ttk.Label(hdr, + text=f"Requirement {req['id']}: {req['title']}", + font=("Segoe UI", 12, "bold"), + wraplength=950).pack(anchor=tk.W) + if req.get("objective"): + ttk.Label(hdr, text=req["objective"], + font=("Segoe UI", 8, "italic"), + foreground="#555", wraplength=950).pack(anchor=tk.W, pady=(2, 0)) + ttk.Separator(hdr).pack(fill=tk.X, pady=(6, 0)) + + # Scrollable body + body_outer = ttk.Frame(outer) + body_outer.pack(fill=tk.BOTH, expand=True) + + canvas = tk.Canvas(body_outer, borderwidth=0, highlightthickness=0) + vsb = ttk.Scrollbar(body_outer, orient=tk.VERTICAL, command=canvas.yview) + body = ttk.Frame(canvas) + + body.bind("", + lambda e, c=canvas: c.configure(scrollregion=c.bbox("all"))) + + canvas.create_window((0, 0), window=body, anchor="nw") + canvas.configure(yscrollcommand=vsb.set) + canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + vsb.pack(side=tk.RIGHT, fill=tk.Y) + + def _bind_mw(e, c=canvas): + c.bind_all("", + lambda ev: c.yview_scroll(-1 * (ev.delta // 120), "units")) + def _unbind_mw(e, c=canvas): + c.unbind_all("") + + canvas.bind("", _bind_mw) + canvas.bind("", _unbind_mw) + + # Content + for control in req.get("controls", []): + self._build_control_section(body, control) + for item in req.get("items", []): + self._build_item_row(body, item) + + def _build_control_section(self, parent: ttk.Frame, control: dict): + lf = ttk.LabelFrame(parent, + text=f"{control['id']} — {control['title']}", + padding=(8, 4)) + lf.pack(fill=tk.X, padx=8, pady=5) + for item in control.get("items", []): + self._build_item_row(lf, item) + + def _build_item_row(self, parent: ttk.Frame, item: dict): + iid = item["id"] + outer = ttk.Frame(parent) + outer.pack(fill=tk.X, pady=3) + + # ── Question row ── + q_row = ttk.Frame(outer) + q_row.pack(fill=tk.X) + + ttk.Label(q_row, text=iid, + font=("Segoe UI", 8, "bold"), + foreground="#283593", + width=10, anchor=tk.NW).pack(side=tk.LEFT, anchor=tk.N) + + ttk.Label(q_row, text=item.get("question", ""), + wraplength=820, justify=tk.LEFT, + font=("Segoe UI", 8)).pack(side=tk.LEFT, fill=tk.X, + expand=True, anchor=tk.N) + + # ── Response row ── + r_row = ttk.Frame(outer) + r_row.pack(fill=tk.X, padx=(80, 8), pady=(2, 0)) + + # Status + status_var = tk.StringVar(value="Not Tested") + notes_var = tk.StringVar() + + if iid in self.responses: + status_var.set(self.responses[iid].get("status", "Not Tested")) + notes_var.set(self.responses[iid].get("notes", "")) + + status_cb = ttk.Combobox(r_row, textvariable=status_var, + values=STATUS_OPTIONS, state="readonly", width=16) + status_cb.pack(side=tk.LEFT, padx=(0, 6)) + + # Colour dot + dot = tk.Label(r_row, text="●", + font=("Segoe UI", 11), + fg=STATUS_COLORS.get(status_var.get(), "#9e9e9e")) + dot.pack(side=tk.LEFT, padx=(0, 8)) + + ttk.Label(r_row, text="Notes:", font=("Segoe UI", 8), + foreground="#555").pack(side=tk.LEFT, padx=(0, 3)) + notes_ent = ttk.Entry(r_row, textvariable=notes_var) + notes_ent.pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Guidance (if present) — small italic below + if item.get("guidance"): + g = item["guidance"] + preview = g[:200] + ("…" if len(g) > 200 else "") + ttk.Label(outer, text=preview, + font=("Segoe UI", 7, "italic"), + foreground="#888", wraplength=820, + justify=tk.LEFT).pack(anchor=tk.W, padx=(80, 8)) + + ttk.Separator(outer).pack(fill=tk.X, pady=(4, 0)) + + # Callbacks + def _on_status(*_a, _iid=iid, _sv=status_var, _dot=dot): + _dot.config(fg=STATUS_COLORS.get(_sv.get(), "#9e9e9e")) + self._save_response(_iid) + self._update_dashboard() + + def _on_notes(*_a, _iid=iid): + self._save_response(_iid) + + status_var.trace_add("write", _on_status) + notes_var.trace_add("write", _on_notes) + + self.response_vars[iid] = {"status": status_var, "notes": notes_var} + + # ── Response / session management ───────────────────────────────────────── + + def _save_response(self, iid: str): + if iid not in self.response_vars: + return + self.responses[iid] = { + "status": self.response_vars[iid]["status"].get(), + "notes": self.response_vars[iid]["notes"].get(), + "last_updated": datetime.datetime.now().isoformat(), + } + self._mark_unsaved() + + def _mark_unsaved(self): + if not self.unsaved: + self.unsaved = True + t = self.root.title() + if not t.startswith("*"): + self.root.title("* " + t) + + def _mark_saved(self): + self.unsaved = False + self.root.title(self.root.title().lstrip("* ")) + + def _build_session(self) -> dict: + return { + "tool_version": VERSION, + "saq_type": self.current_saq["id"], + "created": (self.current_session or {}).get( + "created", datetime.datetime.now().isoformat()), + "last_modified": datetime.datetime.now().isoformat(), + "client": {k: v.get() for k, v in self.client_vars.items()}, + "responses": self.responses, + } + + def _get_all_items(self, req: dict) -> list: + items = [] + for c in req.get("controls", []): + items.extend(c.get("items", [])) + items.extend(req.get("items", [])) + return items + + # ── Dashboard ───────────────────────────────────────────────────────────── + + def _update_dashboard(self): + if not self.current_saq: + return + + counts = {s: 0 for s in STATUS_OPTIONS} + total = 0 + + # Per-requirement rows + for w in self._dash_req_frame.winfo_children(): + w.destroy() + + for req in self.current_saq.get("requirements", []): + items = self._get_all_items(req) + rtotal = len(items) + rdone = 0 + for item in items: + total += 1 + s = self.responses.get(item["id"], {}).get("status", "Not Tested") + counts[s] = counts.get(s, 0) + 1 + if s != "Not Tested": + rdone += 1 + + row = ttk.Frame(self._dash_req_frame) + row.pack(fill=tk.X, pady=1) + ttk.Label(row, text=f"Req {req['id']}:", + font=("Segoe UI", 8), width=8).pack(side=tk.LEFT) + pct_r = int(rdone / rtotal * 100) if rtotal else 0 + ttk.Label(row, text=f"{rdone}/{rtotal} ({pct_r}%)", + font=("Segoe UI", 8)).pack(side=tk.LEFT) + + done = total - counts.get("Not Tested", 0) + pct = int(done / total * 100) if total else 0 + + self._dash_overall.config(text=f"Overall: {pct}% ({done}/{total})") + self._dash_pbar["value"] = pct + + for status in STATUS_OPTIONS: + attr = f"_dash_{status.replace(' ', '_').lower()}_lbl" + if hasattr(self, attr): + lbl = getattr(self, attr) + lbl.config(text=f" {status}: {counts.get(status, 0)}") + + # ── Commands ────────────────────────────────────────────────────────────── + + def cmd_new(self): + if self.unsaved and not self._confirm_discard(): + return + + saq_id = self._saq_var.get() + if not saq_id: + messagebox.showwarning("No SAQ Selected", + "Select an SAQ type in the left panel first.") + return + if saq_id not in self.saq_types: + messagebox.showerror("Error", f"SAQ '{saq_id}' data not found.") + return + + self.current_saq = self.saq_types[saq_id] + self.current_session = None + self.session_file = None + self.responses = {} + self.unsaved = False + + # Reset client fields (keep existing data — user may have filled them in) + self._build_assessment_ui() + self._update_dashboard() + self.root.title( + f"{APP_TITLE} — {self.current_saq['name']} — " + f"{self.client_vars['client_name'].get() or 'New Session'}") + self._set_status(f"New session started: {self.current_saq['name']}") + + def cmd_open(self): + if self.unsaved and not self._confirm_discard(): + return + + fpath = filedialog.askopenfilename( + title="Open Session", + initialdir=sessions_dir(), + filetypes=[("JSON Session", "*.json"), ("All Files", "*.*")]) + if not fpath: + return + + try: + with open(fpath, encoding="utf-8") as f: + session = json.load(f) + except Exception as e: + messagebox.showerror("Open Error", str(e)) + return + + saq_id = session.get("saq_type") + if saq_id not in self.saq_types: + messagebox.showerror("Error", + f"Session references SAQ type '{saq_id}' which is not installed.") + return + + self.current_saq = self.saq_types[saq_id] + self.current_session = session + self.session_file = fpath + self.responses = session.get("responses", {}) + self.unsaved = False + + # Restore client info + for k, v in self.client_vars.items(): + v.set(session.get("client", {}).get(k, "")) + + self._saq_var.set(saq_id) + self._build_assessment_ui() + self._update_dashboard() + self._mark_saved() + self.root.title( + f"{APP_TITLE} — {self.current_saq['name']} — " + f"{self.client_vars['client_name'].get() or 'Session'}") + self._set_status(f"Opened: {fpath}") + + def cmd_save(self): + if not self.current_saq: + messagebox.showwarning("No Session", "No active session to save.") + return + if not self.session_file: + self.cmd_save_as() + return + self._do_save(self.session_file) + + def cmd_save_as(self): + if not self.current_saq: + messagebox.showwarning("No Session", "No active session to save.") + return + + client = self.client_vars["client_name"].get() or "client" + saq_id = self.current_saq["id"] + today = datetime.date.today().isoformat() + default = f"{client}_{saq_id}_{today}.json" + default = "".join(c for c in default if c.isalnum() or c in "-_.") + + fpath = filedialog.asksaveasfilename( + title="Save Session As", + initialdir=sessions_dir(), + initialfile=default, + defaultextension=".json", + filetypes=[("JSON Session", "*.json"), ("All Files", "*.*")]) + if not fpath: + return + + self.session_file = fpath + self._do_save(fpath) + + def _do_save(self, fpath: str): + session = self._build_session() + try: + with open(fpath, "w", encoding="utf-8") as f: + json.dump(session, f, indent=2, ensure_ascii=False) + self.current_session = session + self._mark_saved() + self._set_status(f"Saved: {fpath}") + except Exception as e: + messagebox.showerror("Save Error", str(e)) + + def cmd_export(self, fmt: str): + if not self.current_saq: + messagebox.showwarning("No Session", "No active session to export.") + return + + exts = {"html": ".html", "docx": ".docx", "pdf": ".pdf"} + ext = exts[fmt] + + client = self.client_vars["client_name"].get() or "client" + today = datetime.date.today().isoformat() + default = f"{client}_PCI_{self.current_saq['id']}_{today}{ext}" + default = "".join(c for c in default if c.isalnum() or c in "-_.") + + fpath = filedialog.asksaveasfilename( + title=f"Export as {fmt.upper()}", + initialfile=default, + defaultextension=ext, + filetypes=[(f"{fmt.upper()} File", f"*{ext}"), ("All Files", "*.*")]) + if not fpath: + return + + session = self._build_session() + try: + if fmt == "html": + export_html(fpath, session, self.current_saq, self._get_all_items) + elif fmt == "docx": + export_docx(fpath, session, self.current_saq, self._get_all_items) + elif fmt == "pdf": + export_pdf(fpath, session, self.current_saq, self._get_all_items) + + self._set_status(f"Exported: {fpath}") + if messagebox.askyesno("Export Complete", + f"Exported to:\n{fpath}\n\nOpen file?"): + os.startfile(fpath) + + except ImportError as e: + messagebox.showerror("Missing Library", + f"Required library not installed:\n{e}\n\n" + "Run: pip install python-docx reportlab\n" + "Then rebuild the executable.") + except Exception as e: + messagebox.showerror("Export Error", str(e)) + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _confirm_discard(self) -> bool: + return messagebox.askyesno("Unsaved Changes", + "You have unsaved changes. Discard and continue?") + + def _on_close(self): + if self.unsaved: + resp = messagebox.askyesnocancel("Unsaved Changes", + "Save before closing?") + if resp is None: + return + if resp: + self.cmd_save() + self.root.destroy() + + def _set_status(self, msg: str): + self._status_var.set(msg) + + +# ─── Export functions ───────────────────────────────────────────────────────── + +def _status_badge_html(status: str) -> str: + bg = STATUS_BG.get(status, "#f5f5f5") + col = STATUS_COLORS.get(status, "#9e9e9e") + return (f'{status}') + + +def export_html(fpath: str, session: dict, saq: dict, get_all_items): + client = session.get("client", {}) + responses = session.get("responses", {}) + + counts = {s: 0 for s in STATUS_OPTIONS} + total = 0 + for req in saq.get("requirements", []): + for item in get_all_items(req): + total += 1 + s = responses.get(item["id"], {}).get("status", "Not Tested") + counts[s] = counts.get(s, 0) + 1 + + done = total - counts.get("Not Tested", 0) + pct = int(done / total * 100) if total else 0 + + def esc(t): return str(t).replace("&","&").replace("<","<").replace(">",">") + + lines = [f""" + + +PCI DSS {esc(saq['name'])} — {esc(client.get('client_name',''))} + + +
+
+

PCI DSS Compliance Assessment — {esc(saq['name'])}

+
{esc(client.get('client_name',''))}  |  + Assessor: {esc(client.get('assessor_name',''))}  |  + {esc(client.get('assessment_date',''))}  |  + {esc(saq['version'])}
+
+ +
+"""] + + for key, label in CLIENT_FIELDS: + lines.append(f'
' + f'
{esc(label)}
' + f'
{esc(client.get(key,"—"))}
\n') + + lines.append('
\n') + + # Progress + lines.append(f"""
+
+ Overall Progress: {pct}%   ({done}/{total} requirements assessed) +
+
+
+
+""") + for status in STATUS_OPTIONS: + col = STATUS_COLORS[status] + lines.append(f'
' + f'
{counts[status]}
' + f'
{status}
\n') + lines.append('
\n') + + # Requirements + for req in saq.get("requirements", []): + lines.append(f'
' + f'
Requirement {esc(req["id"])}: {esc(req["title"])}
\n') + + def render_item(item): + r = responses.get(item["id"], {}) + status = r.get("status", "Not Tested") + notes = r.get("notes", "") + badge = _status_badge_html(status) + q = esc(item.get("question", "")) + lines.append(f'
' + f'
{esc(item["id"])}
' + f'
{q}
' + f'
{badge}
' + f'
{esc(notes)}
' + f'
\n') + + for ctrl in req.get("controls", []): + lines.append(f'
{esc(ctrl["id"])} — {esc(ctrl["title"])}
\n') + for item in ctrl.get("items", []): + render_item(item) + + for item in req.get("items", []): + render_item(item) + + lines.append('
\n') + + ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") + lines.append(f'\n') + lines.append('
') + + with open(fpath, "w", encoding="utf-8") as f: + f.write("".join(lines)) + + +def export_docx(fpath: str, session: dict, saq: dict, get_all_items): + from docx import Document + from docx.shared import Pt, RGBColor, Inches, Cm + from docx.enum.text import WD_ALIGN_PARAGRAPH + + client = session.get("client", {}) + responses = session.get("responses", {}) + + doc = Document() + + # Page margins + for section in doc.sections: + section.top_margin = Cm(2) + section.bottom_margin = Cm(2) + section.left_margin = Cm(2.5) + section.right_margin = Cm(2.5) + + # Title + t = doc.add_heading(f"PCI DSS {saq['name']} Assessment Report", 0) + t.alignment = WD_ALIGN_PARAGRAPH.CENTER + + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + r = p.add_run(f"{client.get('client_name','')} | " + f"Assessor: {client.get('assessor_name','')} | " + f"{client.get('assessment_date','')}") + r.italic = True + + doc.add_paragraph() + + # Client info table + doc.add_heading("Client Information", 1) + tbl = doc.add_table(rows=1, cols=2) + tbl.style = "Table Grid" + hdr_cells = tbl.rows[0].cells + hdr_cells[0].text = "Field" + hdr_cells[1].text = "Value" + for key, label in CLIENT_FIELDS: + row = tbl.add_row() + row.cells[0].text = label + row.cells[1].text = client.get(key, "") + extra = tbl.add_row() + extra.cells[0].text = "SAQ Type / Version" + extra.cells[1].text = f"{saq['name']} / {saq['version']}" + + doc.add_paragraph() + + # Summary + counts = {s: 0 for s in STATUS_OPTIONS} + total = 0 + for req in saq.get("requirements", []): + for item in get_all_items(req): + total += 1 + s = responses.get(item["id"], {}).get("status", "Not Tested") + counts[s] = counts.get(s, 0) + 1 + + done = total - counts.get("Not Tested", 0) + pct = int(done / total * 100) if total else 0 + + doc.add_heading("Assessment Summary", 1) + doc.add_paragraph(f"Overall Progress: {pct}% ({done}/{total} requirements assessed)") + + stbl = doc.add_table(rows=1, cols=2) + stbl.style = "Table Grid" + stbl.rows[0].cells[0].text = "Status" + stbl.rows[0].cells[1].text = "Count" + for status in STATUS_OPTIONS: + r = stbl.add_row() + r.cells[0].text = status + r.cells[1].text = str(counts[status]) + + doc.add_paragraph() + + # Status colours + sc = { + "In Place": RGBColor(0x2e, 0x7d, 0x32), + "Not In Place": RGBColor(0xc6, 0x28, 0x28), + "N/A": RGBColor(0x15, 0x65, 0xc0), + "Not Tested": RGBColor(0x9e, 0x9e, 0x9e), + } + + def write_item(item): + resp = responses.get(item["id"], {}) + status = resp.get("status", "Not Tested") + notes = resp.get("notes", "") + + p = doc.add_paragraph() + p.paragraph_format.space_after = Pt(0) + run = p.add_run(f"[{item['id']}] ") + run.bold = True + run.font.color.rgb = RGBColor(0x28, 0x35, 0x93) + p.add_run(item.get("question", "")) + + p2 = doc.add_paragraph() + p2.paragraph_format.left_indent = Inches(0.3) + p2.paragraph_format.space_after = Pt(4) + sr = p2.add_run(f"Status: {status}") + sr.bold = True + sr.font.color.rgb = sc.get(status, RGBColor(0x9e, 0x9e, 0x9e)) + if notes: + p2.add_run(f" Notes: {notes}").italic = True + + for req in saq.get("requirements", []): + doc.add_heading(f"Requirement {req['id']}: {req['title']}", 1) + if req.get("objective"): + p = doc.add_paragraph(req["objective"]) + p.runs[0].italic = True + + for ctrl in req.get("controls", []): + doc.add_heading(f"{ctrl['id']} — {ctrl['title']}", 2) + for item in ctrl.get("items", []): + write_item(item) + + for item in req.get("items", []): + write_item(item) + + doc.add_paragraph() + fp = doc.add_paragraph() + fp.alignment = WD_ALIGN_PARAGRAPH.CENTER + ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") + fp.add_run(f"Generated by PCI DSS Compliance Assessment Tool | " + f"{ts} | {saq['name']} {saq['version']}").italic = True + + doc.save(fpath) + + +def export_pdf(fpath: str, session: dict, saq: dict, get_all_items): + from reportlab.lib.pagesizes import letter + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib import colors + from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, + Table, TableStyle, HRFlowable) + from reportlab.lib.units import inch + + client = session.get("client", {}) + responses = session.get("responses", {}) + + doc = SimpleDocTemplate(fpath, pagesize=letter, + topMargin=0.75*inch, bottomMargin=0.75*inch, + leftMargin=1*inch, rightMargin=1*inch) + + styles = getSampleStyleSheet() + navy = colors.HexColor("#1a237e") + indigo = colors.HexColor("#283593") + med_ind = colors.HexColor("#3949ab") + + T = lambda name, **kw: ParagraphStyle(name, parent=styles["Normal"], **kw) + + sty_title = T("title", fontSize=17, textColor=navy, spaceAfter=4, + alignment=1, fontName="Helvetica-Bold") + sty_sub = T("sub", fontSize=10, textColor=colors.grey, + alignment=1, spaceAfter=14) + sty_h1 = T("h1", fontSize=13, textColor=indigo, spaceBefore=12, + spaceAfter=4, fontName="Helvetica-Bold") + sty_h2 = T("h2", fontSize=11, textColor=med_ind, spaceBefore=8, + spaceAfter=2, fontName="Helvetica-Bold") + sty_qid = T("qid", fontSize=9, textColor=med_ind, + fontName="Helvetica-Bold") + sty_q = T("q", fontSize=9, spaceAfter=0) + sty_status = T("status", fontSize=8, leftIndent=20, spaceAfter=4) + sty_foot = T("foot", fontSize=8, textColor=colors.grey, + alignment=1, spaceBefore=12) + + sc = { + "In Place": colors.HexColor("#2e7d32"), + "Not In Place": colors.HexColor("#c62828"), + "N/A": colors.HexColor("#1565c0"), + "Not Tested": colors.HexColor("#9e9e9e"), + } + + story = [] + + story.append(Paragraph(f"PCI DSS {saq['name']} Assessment Report", sty_title)) + story.append(Paragraph( + f"{client.get('client_name','')} | " + f"Assessor: {client.get('assessor_name','')} | " + f"{client.get('assessment_date','')} | {saq['version']}", + sty_sub)) + + # Client info + story.append(Paragraph("Client Information", sty_h1)) + ci_data = [["Field", "Value"]] + for key, label in CLIENT_FIELDS: + ci_data.append([label, client.get(key, "")]) + ci_data.append(["SAQ Type / Version", f"{saq['name']} / {saq['version']}"]) + + ci_tbl = Table(ci_data, colWidths=[2*inch, 4.5*inch]) + ci_tbl.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), indigo), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f5f5")]), + ])) + story.append(ci_tbl) + story.append(Spacer(1, 0.2*inch)) + + # Summary + counts = {s: 0 for s in STATUS_OPTIONS} + total = 0 + for req in saq.get("requirements", []): + for item in get_all_items(req): + total += 1 + s = responses.get(item["id"], {}).get("status", "Not Tested") + counts[s] = counts.get(s, 0) + 1 + + done = total - counts.get("Not Tested", 0) + pct = int(done / total * 100) if total else 0 + + story.append(Paragraph("Assessment Summary", sty_h1)) + story.append(Paragraph( + f"Overall Progress: {pct}% ({done}/{total} requirements assessed)", + styles["Normal"])) + story.append(Spacer(1, 0.08*inch)) + + sm_data = [["Status", "Count"]] + [[s, str(counts[s])] for s in STATUS_OPTIONS] + sm_tbl = Table(sm_data, colWidths=[3*inch, 1*inch]) + sm_tbl.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), indigo), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), + ])) + story.append(sm_tbl) + story.append(Spacer(1, 0.2*inch)) + + # Requirements + def render_item_pdf(item): + resp = responses.get(item["id"], {}) + status = resp.get("status", "Not Tested") + notes = resp.get("notes", "") + color = sc.get(status, colors.grey) + + q_safe = (item.get("question","") + .replace("&","&") + .replace("<","<") + .replace(">",">") + .replace("\n","
")) + + story.append(Paragraph( + f'[{item["id"]}] {q_safe}', sty_q)) + + hex_col = f"#{color.hexval():06x}" + status_line = f'Status: {status}' + if notes: + n_safe = notes.replace("&","&").replace("<","<").replace(">",">") + status_line += f' | Notes: {n_safe}' + story.append(Paragraph(status_line, sty_status)) + + for req in saq.get("requirements", []): + story.append(Paragraph( + f"Requirement {req['id']}: {req['title']}", sty_h1)) + if req.get("objective"): + story.append(Paragraph(f"{req['objective']}", styles["Normal"])) + + for ctrl in req.get("controls", []): + story.append(Paragraph( + f"{ctrl['id']} — {ctrl['title']}", sty_h2)) + for item in ctrl.get("items", []): + render_item_pdf(item) + + for item in req.get("items", []): + render_item_pdf(item) + + ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M") + story.append(Paragraph( + f"Generated by PCI DSS Compliance Assessment Tool | " + f"{ts} | {saq['name']} {saq['version']}", sty_foot)) + + doc.build(story) + + +# ─── Entry point ────────────────────────────────────────────────────────────── + +def main(): + root = tk.Tk() + try: + root.iconbitmap(resource_path("icon.ico")) + except Exception: + pass + PCITool(root) + root.mainloop() + + +if __name__ == "__main__": + main() -- cgit v1.2.3