summaryrefslogtreecommitdiff
path: root/main.py
diff options
context:
space:
mode:
authorRich Kreider <rjkreider@gmail.com>2026-05-20 12:08:15 -0400
committerRich Kreider <rjkreider@gmail.com>2026-05-20 12:08:15 -0400
commite0ba9b27116f1d3758be527390b4dcfed09a7a94 (patch)
tree404f64e937234b88e5dfc55c2d5388f9f000189d /main.py
saqauditor initial commit
Diffstat (limited to 'main.py')
-rw-r--r--main.py1101
1 files changed, 1101 insertions, 0 deletions
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("<Configure>",
+ 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("<Enter>", lambda e: sb_canvas.bind_all("<MouseWheel>", _mw_sb))
+ sb_canvas.bind("<Leave>", lambda e: sb_canvas.unbind_all("<MouseWheel>"))
+
+ 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("<Configure>",
+ 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("<MouseWheel>",
+ lambda ev: c.yview_scroll(-1 * (ev.delta // 120), "units"))
+ def _unbind_mw(e, c=canvas):
+ c.unbind_all("<MouseWheel>")
+
+ canvas.bind("<Enter>", _bind_mw)
+ canvas.bind("<Leave>", _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'<span style="background:{bg};color:{col};padding:2px 8px;'
+ f'border-radius:3px;font-size:11px;font-weight:bold;">{status}</span>')
+
+
+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("&","&amp;").replace("<","&lt;").replace(">","&gt;")
+
+ lines = [f"""<!DOCTYPE html>
+<html lang="en">
+<head><meta charset="UTF-8">
+<title>PCI DSS {esc(saq['name'])} — {esc(client.get('client_name',''))}</title>
+<style>
+*{{box-sizing:border-box;margin:0;padding:0}}
+body{{font-family:'Segoe UI',Arial,sans-serif;font-size:13px;color:#212121;background:#fafafa}}
+.page{{max-width:1100px;margin:auto;padding:24px}}
+.top-hdr{{background:#1a237e;color:#fff;padding:20px 24px;border-radius:6px;margin-bottom:18px}}
+.top-hdr h1{{font-size:20px;margin-bottom:4px}}
+.top-hdr .sub{{opacity:.8;font-size:12px}}
+.client-grid{{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;
+ background:#fff;border:1px solid #e0e0e0;padding:14px;border-radius:4px;margin-bottom:18px}}
+.client-field .lbl{{font-size:10px;text-transform:uppercase;color:#777;font-weight:600}}
+.client-field .val{{font-size:13px}}
+.summary{{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px}}
+.sum-card{{background:#fff;border:1px solid #e0e0e0;border-radius:4px;
+ padding:12px;text-align:center}}
+.sum-num{{font-size:26px;font-weight:700}}
+.sum-lbl{{font-size:11px;color:#777;margin-top:2px}}
+.pct-bar-wrap{{background:#fff;border:1px solid #e0e0e0;border-radius:4px;padding:14px;
+ margin-bottom:18px}}
+.pct-bar-bg{{background:#eee;border-radius:4px;height:14px}}
+.pct-bar-fill{{background:#1a237e;height:14px;border-radius:4px}}
+.req-section{{margin-bottom:20px;background:#fff;border:1px solid #e0e0e0;border-radius:4px;overflow:hidden}}
+.req-hdr{{background:#283593;color:#fff;padding:10px 14px;font-size:13px;font-weight:700}}
+.ctrl-hdr{{background:#e8eaf6;color:#283593;padding:7px 14px;font-size:12px;
+ font-weight:600;border-left:4px solid #3949ab}}
+.item{{display:grid;grid-template-columns:70px 1fr 150px 1fr;
+ gap:8px;padding:8px 14px;border-bottom:1px solid #f0f0f0;align-items:start}}
+.item:last-child{{border-bottom:none}}
+.item-id{{font-weight:700;font-size:11px;color:#3949ab;padding-top:1px}}
+.item-q{{font-size:12px;line-height:1.5;white-space:pre-wrap}}
+.item-notes{{font-size:11px;color:#555;font-style:italic}}
+.footer{{text-align:center;color:#999;font-size:11px;margin-top:24px}}
+@media print{{.no-print{{display:none}}body{{background:#fff}}}}
+</style>
+</head>
+<body><div class="page">
+<div class="top-hdr">
+ <h1>PCI DSS Compliance Assessment — {esc(saq['name'])}</h1>
+ <div class="sub">{esc(client.get('client_name',''))} &nbsp;|&nbsp;
+ Assessor: {esc(client.get('assessor_name',''))} &nbsp;|&nbsp;
+ {esc(client.get('assessment_date',''))} &nbsp;|&nbsp;
+ {esc(saq['version'])}</div>
+</div>
+
+<div class="client-grid">
+"""]
+
+ for key, label in CLIENT_FIELDS:
+ lines.append(f'<div class="client-field">'
+ f'<div class="lbl">{esc(label)}</div>'
+ f'<div class="val">{esc(client.get(key,"—"))}</div></div>\n')
+
+ lines.append('</div>\n')
+
+ # Progress
+ lines.append(f"""<div class="pct-bar-wrap">
+<div style="font-weight:700;margin-bottom:6px">
+ Overall Progress: {pct}% &nbsp; ({done}/{total} requirements assessed)
+</div>
+<div class="pct-bar-bg"><div class="pct-bar-fill" style="width:{pct}%"></div></div>
+</div>
+<div class="summary">
+""")
+ for status in STATUS_OPTIONS:
+ col = STATUS_COLORS[status]
+ lines.append(f'<div class="sum-card">'
+ f'<div class="sum-num" style="color:{col}">{counts[status]}</div>'
+ f'<div class="sum-lbl">{status}</div></div>\n')
+ lines.append('</div>\n')
+
+ # Requirements
+ for req in saq.get("requirements", []):
+ lines.append(f'<div class="req-section">'
+ f'<div class="req-hdr">Requirement {esc(req["id"])}: {esc(req["title"])}</div>\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'<div class="item">'
+ f'<div class="item-id">{esc(item["id"])}</div>'
+ f'<div class="item-q">{q}</div>'
+ f'<div class="item-status">{badge}</div>'
+ f'<div class="item-notes">{esc(notes)}</div>'
+ f'</div>\n')
+
+ for ctrl in req.get("controls", []):
+ lines.append(f'<div class="ctrl-hdr">{esc(ctrl["id"])} — {esc(ctrl["title"])}</div>\n')
+ for item in ctrl.get("items", []):
+ render_item(item)
+
+ for item in req.get("items", []):
+ render_item(item)
+
+ lines.append('</div>\n')
+
+ ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
+ lines.append(f'<div class="footer">Generated by PCI DSS Compliance Assessment Tool'
+ f' | {ts} | {esc(saq["name"])} {esc(saq["version"])}</div>\n')
+ lines.append('</div></body></html>')
+
+ 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: <b>{pct}%</b> ({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("&","&amp;")
+ .replace("<","&lt;")
+ .replace(">","&gt;")
+ .replace("\n","<br/>"))
+
+ story.append(Paragraph(
+ f'<font color="#283593"><b>[{item["id"]}]</b></font> {q_safe}', sty_q))
+
+ hex_col = f"#{color.hexval():06x}"
+ status_line = f'<font color="{hex_col}"><b>Status: {status}</b></font>'
+ if notes:
+ n_safe = notes.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
+ status_line += f' <i>| Notes: {n_safe}</i>'
+ 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"<i>{req['objective']}</i>", 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()