#!/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()