04/21 Fisrt commit

This commit is contained in:
2026-04-21 17:16:37 -04:00
parent f94d86f04b
commit 7548dfc9bf
23 changed files with 6860 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
# utils package
+125
View File
@@ -0,0 +1,125 @@
"""
utils/crypto.py — Fernet symmetric encryption for website credentials.
Key derivation:
- A 32-byte random salt is generated on first use and stored in config.ini
under [crypto] / salt.
- The Fernet key is derived from the salt + a fixed application secret
using PBKDF2-HMAC-SHA256 (100,000 iterations).
- This means credentials are tied to the specific config.ini file on the
operator's machine; moving config.ini to another machine retains access.
Migration:
- _decrypt() tries Fernet first; if that fails it returns the raw value
unchanged so that plaintext legacy credentials are still readable.
- Callers should re-encrypt on next write (update_website handles this).
"""
import base64
import logging
import os
import configparser
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
logger = logging.getLogger("crypto")
_APP_SECRET = b"WebsiteChecker-v1-CredentialKey"
_CONFIG_FILE = "config.ini"
_ITERATIONS = 100_000
_fernet: "Fernet | None" = None
# ─── Key bootstrap ────────────────────────────────────────────────────────────
def _get_or_create_salt() -> bytes:
"""Read salt from config.ini [crypto] section; create and persist if absent."""
cfg = configparser.ConfigParser()
cfg.read(_CONFIG_FILE, encoding="utf-8")
if "crypto" in cfg and cfg["crypto"].get("salt"):
return base64.b64decode(cfg["crypto"]["salt"])
# Generate a fresh 32-byte salt
salt = os.urandom(32)
if "crypto" not in cfg:
cfg["crypto"] = {}
cfg["crypto"]["salt"] = base64.b64encode(salt).decode("ascii")
with open(_CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info("Crypto: generated and persisted new credential encryption salt.")
return salt
def _build_fernet() -> Fernet:
salt = _get_or_create_salt()
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=_ITERATIONS,
)
key = base64.urlsafe_b64encode(kdf.derive(_APP_SECRET))
return Fernet(key)
def _get_fernet() -> Fernet:
global _fernet
if _fernet is None:
_fernet = _build_fernet()
return _fernet
def reset_fernet():
"""Force key reload — call after config.ini is replaced (e.g. settings save)."""
global _fernet
_fernet = None
# ─── Public API ───────────────────────────────────────────────────────────────
def encrypt(plaintext: str) -> str:
"""
Encrypt a plaintext string. Returns a UTF-8-safe ciphertext string
prefixed with 'enc:' so we can detect encrypted values reliably.
Returns the original string unchanged if plaintext is empty.
"""
if not plaintext:
return plaintext
try:
token = _get_fernet().encrypt(plaintext.encode("utf-8"))
return "enc:" + token.decode("ascii")
except Exception as e:
logger.error(f"Credential encryption failed: {e}")
return plaintext # safe fallback — don't lose data
def decrypt(ciphertext: str) -> str:
"""
Decrypt a ciphertext string produced by encrypt().
- If ciphertext starts with 'enc:', decrypts with Fernet.
- Otherwise returns the value as-is (plaintext legacy credential).
Returns empty string on failure.
"""
if not ciphertext:
return ciphertext
if not ciphertext.startswith("enc:"):
# Legacy plaintext — return unchanged; will be re-encrypted on next save
return ciphertext
try:
token = ciphertext[4:].encode("ascii")
return _get_fernet().decrypt(token).decode("utf-8")
except InvalidToken:
logger.error("Credential decryption failed — wrong key or corrupted data.")
return ""
except Exception as e:
logger.error(f"Credential decryption error: {e}")
return ""
def is_encrypted(value: str) -> bool:
"""Return True if the value was produced by encrypt()."""
return isinstance(value, str) and value.startswith("enc:")
+110
View File
@@ -0,0 +1,110 @@
"""
utils/export.py — CSV and Excel export helpers for report data.
"""
import csv
import logging
import os
from datetime import datetime
logger = logging.getLogger("export")
def _timestamp() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def export_csv(rows: list, columns: list, base_filename: str, save_dir: str) -> str:
"""
Write rows (list of dicts) to a CSV file.
Returns the full path of the written file.
"""
filename = f"{base_filename}_{_timestamp()}.csv"
filepath = os.path.join(save_dir, filename)
try:
with open(filepath, "w", newline="", encoding="utf-8-sig") as fh:
writer = csv.DictWriter(fh, fieldnames=columns, extrasaction="ignore")
writer.writeheader()
for row in rows:
# Convert non-string types (date, datetime) to string
clean = {k: (str(v) if v is not None else "") for k, v in row.items()}
writer.writerow(clean)
logger.info(f"[EXPORT] CSV written: {filepath} ({len(rows)} rows)")
return filepath
except Exception as e:
logger.error(f"CSV export failed: {e}")
raise
def export_excel(rows: list, columns: list, base_filename: str,
save_dir: str, sheet_title: str = "Report") -> str:
"""
Write rows (list of dicts) to an .xlsx file with basic formatting.
Returns the full path of the written file.
"""
try:
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
except ImportError:
raise RuntimeError(
"openpyxl is required for Excel export.\n"
"Install it with: pip install openpyxl"
)
filename = f"{base_filename}_{_timestamp()}.xlsx"
filepath = os.path.join(save_dir, filename)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = sheet_title[:31] # Excel sheet name limit
# ── Header style ──────────────────────────────────────────────────────────
header_fill = PatternFill("solid", fgColor="7C6AF7") # accent purple
header_font = Font(bold=True, color="FFFFFF", name="Calibri", size=11)
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
thin_border = Border(
bottom=Side(style="thin", color="45475A"),
right=Side(style="thin", color="45475A"),
)
# ── Write header row ──────────────────────────────────────────────────────
for col_idx, col_name in enumerate(columns, start=1):
cell = ws.cell(row=1, column=col_idx, value=col_name.replace("_", " ").title())
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
cell.border = thin_border
ws.row_dimensions[1].height = 22
# ── Write data rows ───────────────────────────────────────────────────────
alt_fill = PatternFill("solid", fgColor="2A2A3E")
for row_idx, row in enumerate(rows, start=2):
fill = alt_fill if row_idx % 2 == 0 else PatternFill("solid", fgColor="1E1E2E")
for col_idx, col_name in enumerate(columns, start=1):
val = row.get(col_name)
if val is None:
val = ""
cell = ws.cell(row=row_idx, column=col_idx, value=str(val))
cell.font = Font(name="Calibri", size=10, color="CDD6F4")
cell.fill = fill
cell.alignment = Alignment(vertical="center", wrap_text=False)
cell.border = thin_border
# ── Auto-width columns (capped at 60) ─────────────────────────────────────
for col_idx, col_name in enumerate(columns, start=1):
col_letter = openpyxl.utils.get_column_letter(col_idx)
header_len = len(col_name.replace("_", " ").title())
max_data_len = max(
(len(str(row.get(col_name) or "")) for row in rows),
default=0
)
ws.column_dimensions[col_letter].width = min(max(header_len, max_data_len) + 4, 60)
# ── Freeze top row ────────────────────────────────────────────────────────
ws.freeze_panes = "A2"
wb.save(filepath)
logger.info(f"[EXPORT] Excel written: {filepath} ({len(rows)} rows)")
return filepath
+219
View File
@@ -0,0 +1,219 @@
"""
utils/scheduler.py — Daily completion report email scheduler.
Runs a background daemon thread that wakes every minute, checks whether
the configured send_time (HH:MM) has been reached today, and sends the
summary report via SMTP if it hasn't been sent yet.
Configuration (config.ini [email] section):
enabled = true/false
smtp_host = smtp.example.com
smtp_port = 587
smtp_user = sender@example.com
smtp_password= secret
use_tls = true
recipients = admin@example.com, manager@example.com
send_time = 18:00 (24-hour HH:MM, local time)
Call start() once after login succeeds (admin only).
Call stop() on logout/shutdown.
"""
import configparser
import datetime
import logging
import os
import smtplib
import threading
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
logger = logging.getLogger("scheduler")
CONFIG_FILE = "config.ini"
_scheduler_thread: "threading.Thread | None" = None
_stop_event = threading.Event()
# ─── Config helpers ───────────────────────────────────────────────────────────
def load_email_config() -> dict:
cfg = configparser.ConfigParser()
if not os.path.exists(CONFIG_FILE):
return {}
cfg.read(CONFIG_FILE, encoding="utf-8")
if "email" not in cfg:
return {}
s = cfg["email"]
return {
"enabled": s.getboolean("enabled", fallback=False),
"smtp_host": s.get("smtp_host", ""),
"smtp_port": s.getint("smtp_port", fallback=587),
"smtp_user": s.get("smtp_user", ""),
"smtp_password": s.get("smtp_password", ""),
"use_tls": s.getboolean("use_tls", fallback=True),
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
"send_time": s.get("send_time", "18:00"),
}
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
smtp_user: str, smtp_password: str, use_tls: bool,
recipients: str, send_time: str):
cfg = configparser.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
cfg["email"] = {
"enabled": str(enabled).lower(),
"smtp_host": smtp_host,
"smtp_port": str(smtp_port),
"smtp_user": smtp_user,
"smtp_password": smtp_password,
"use_tls": str(use_tls).lower(),
"recipients": recipients,
"send_time": send_time,
}
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info("Email configuration saved.")
def test_smtp_connection(smtp_host, smtp_port, smtp_user, smtp_password, use_tls) -> tuple:
"""
Attempt a connection without sending mail.
Returns (success: bool, message: str).
"""
try:
if use_tls:
server = smtplib.SMTP(smtp_host, smtp_port, timeout=8)
server.starttls()
else:
server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=8)
server.login(smtp_user, smtp_password)
server.quit()
return True, "Connection successful."
except Exception as e:
return False, str(e)
# ─── Report builder ───────────────────────────────────────────────────────────
def _build_html_report() -> str:
"""Build a simple HTML daily summary table."""
try:
from models import get_admin_dashboard_stats
stats = get_admin_dashboard_stats()
except Exception as e:
return f"<p>Error generating report: {e}</p>"
today = datetime.date.today().strftime("%A, %d %B %Y")
rows = stats.get("user_stats", [])
table_rows = ""
for r in rows:
pct = float(r.get("pct_complete") or 0)
color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f"
table_rows += (
f"<tr>"
f"<td style='padding:8px 12px'>{r['username']}</td>"
f"<td style='padding:8px 12px'>{r['full_name'] or ''}</td>"
f"<td style='padding:8px 12px;text-align:center'>{int(r['checked_count'] or 0)}</td>"
f"<td style='padding:8px 12px;text-align:center'>{int(r['total_sites'] or 0)}</td>"
f"<td style='padding:8px 12px;text-align:center;"
f"color:{color};font-weight:bold'>{pct:.0f}%</td>"
f"</tr>"
)
return f"""
<html><body style="font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e">
<h2 style="color:#5b4de8">Website Checker — Daily Report</h2>
<p style="color:#6b6b80">{today}</p>
<table border="0" cellspacing="0" cellpadding="0"
style="border-collapse:collapse;width:100%;max-width:600px">
<thead>
<tr style="background:#e0dff8">
<th style="padding:10px 12px;text-align:left">Username</th>
<th style="padding:10px 12px;text-align:left">Full Name</th>
<th style="padding:10px 12px;text-align:center">Checked</th>
<th style="padding:10px 12px;text-align:center">Total</th>
<th style="padding:10px 12px;text-align:center">Completion</th>
</tr>
</thead>
<tbody>{table_rows}</tbody>
</table>
<p style="color:#6b6b80;font-size:12px;margin-top:24px">
Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.
</p>
</body></html>
"""
def _send_report(cfg: dict):
"""Build and send the daily report email."""
html = _build_html_report()
today = datetime.date.today().strftime("%d %b %Y")
subject = f"Website Checker — Daily Report {today}"
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = cfg["smtp_user"]
msg["To"] = ", ".join(cfg["recipients"])
msg.attach(MIMEText(html, "html", "utf-8"))
try:
if cfg["use_tls"]:
server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=10)
server.starttls()
else:
server = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=10)
server.login(cfg["smtp_user"], cfg["smtp_password"])
server.sendmail(cfg["smtp_user"], cfg["recipients"], msg.as_string())
server.quit()
logger.info(f"Daily report emailed to: {cfg['recipients']}")
except Exception as e:
logger.error(f"Failed to send daily report email: {e}")
# ─── Scheduler loop ───────────────────────────────────────────────────────────
def _scheduler_loop():
last_sent_date = None
while not _stop_event.is_set():
_stop_event.wait(60) # sleep 60 seconds between checks
if _stop_event.is_set():
break
cfg = load_email_config()
if not cfg.get("enabled") or not cfg.get("smtp_host") or not cfg.get("recipients"):
continue
try:
send_h, send_m = map(int, cfg["send_time"].split(":"))
except Exception:
continue
now = datetime.datetime.now()
today = now.date()
if (now.hour > send_h or (now.hour == send_h and now.minute >= send_m)):
if last_sent_date != today:
last_sent_date = today
logger.info(f"Scheduler: sending daily report at {now.strftime('%H:%M')}.")
_send_report(cfg)
def start():
"""Start the background scheduler thread. Call once after successful login."""
global _scheduler_thread, _stop_event
_stop_event.clear()
_scheduler_thread = threading.Thread(target=_scheduler_loop,
name="EmailScheduler", daemon=True)
_scheduler_thread.start()
logger.info("Email scheduler started.")
def stop():
"""Signal the scheduler to stop cleanly."""
global _stop_event
_stop_event.set()
logger.info("Email scheduler stopped.")
+490
View File
@@ -0,0 +1,490 @@
"""
utils/ui_helpers.py — Shared UI constants, theme helpers, and reusable widgets.
Key additions vs previous version:
• ThemeManager — singleton that owns the active palette and reapplies
ttk styles + rebuilds the app shell on toggle
• THEMES — dark and light colour palettes
• DateEntry — Entry + calendar popup (no third-party libs required)
• scrolled_text — now reads colours from ThemeManager so it re-themes
"""
import calendar
import tkinter as tk
from tkinter import ttk, messagebox
from datetime import date
# ─── Font Constants (theme-independent) ───────────────────────────────────────
FONT_FAMILY = "Segoe UI"
FONT = (FONT_FAMILY, 10)
FONT_BOLD = (FONT_FAMILY, 10, "bold")
FONT_TITLE = (FONT_FAMILY, 16, "bold")
FONT_HEADING = (FONT_FAMILY, 12, "bold")
FONT_SMALL = (FONT_FAMILY, 9)
# ─── Colour Palettes ──────────────────────────────────────────────────────────
THEMES = {
"dark": {
"bg": "#1e1e2e",
"surface": "#2a2a3e",
"surface2": "#313150",
"accent": "#7c6af7",
"accent_hover": "#9d8fff",
"danger": "#e05c5c",
"success": "#4caf50",
"warning": "#f0a500",
"text": "#cdd6f4",
"text_dim": "#6c7086",
"border": "#45475a",
"checked": "#4caf50",
"unchecked": "#e05c5c",
"white": "#ffffff",
"cal_header": "#313150",
"cal_weekend": "#e05c5c",
"cal_today": "#7c6af7",
"cal_selected": "#4caf50",
},
"light": {
"bg": "#f5f5fa",
"surface": "#ffffff",
"surface2": "#e8e8f0",
"accent": "#5b4de8",
"accent_hover": "#7c6af7",
"danger": "#d32f2f",
"success": "#388e3c",
"warning": "#f57c00",
"text": "#1a1a2e",
"text_dim": "#6b6b80",
"border": "#c5c5d8",
"checked": "#388e3c",
"unchecked": "#d32f2f",
"white": "#ffffff",
"cal_header": "#e0dff8",
"cal_weekend": "#d32f2f",
"cal_today": "#5b4de8",
"cal_selected": "#388e3c",
},
}
# ─── ThemeManager (singleton) ─────────────────────────────────────────────────
class ThemeManager:
_instance = None
def __init__(self, root, initial="dark"):
ThemeManager._instance = self
self._root = root
self._current = initial
self.apply()
@classmethod
def get(cls):
if cls._instance is None:
return THEMES["dark"]
return THEMES[cls._instance._current]
@property
def is_dark(self):
return self._current == "dark"
def toggle(self, rebuild_callback=None):
self._current = "light" if self._current == "dark" else "dark"
self.apply()
if rebuild_callback:
rebuild_callback()
def apply(self):
C = THEMES[self._current]
style = ttk.Style(self._root)
style.theme_use("clam")
style.configure(".",
background=C["bg"], foreground=C["text"],
fieldbackground=C["surface"], bordercolor=C["border"],
relief="flat", font=FONT)
style.configure("TFrame", background=C["bg"])
style.configure("Surface.TFrame", background=C["surface"])
style.configure("TLabel",
background=C["bg"], foreground=C["text"], font=FONT)
style.configure("Title.TLabel",
font=FONT_TITLE, foreground=C["accent"])
style.configure("Heading.TLabel",
font=FONT_HEADING, foreground=C["text"])
style.configure("Dim.TLabel",
foreground=C["text_dim"], font=FONT_SMALL)
style.configure("TEntry",
fieldbackground=C["surface2"], foreground=C["text"],
insertcolor=C["text"], bordercolor=C["border"],
relief="flat", padding=6)
style.configure("TCombobox",
fieldbackground=C["surface2"], foreground=C["text"],
selectbackground=C["accent"], selectforeground=C["white"])
style.map("TCombobox",
fieldbackground=[("readonly", C["surface2"])],
foreground=[("readonly", C["text"])])
style.configure("TButton",
background=C["accent"], foreground=C["white"],
padding=(12, 6), relief="flat", font=FONT_BOLD)
style.map("TButton",
background=[("active", C["accent_hover"])],
relief=[("active", "flat")])
style.configure("Danger.TButton",
background=C["danger"], foreground=C["white"])
style.map("Danger.TButton",
background=[("active", "#c94444" if self.is_dark else "#b71c1c")])
style.configure("Success.TButton",
background=C["success"], foreground=C["white"])
style.map("Success.TButton",
background=[("active", "#3d9140" if self.is_dark else "#2e7d32")])
style.configure("Ghost.TButton",
background=C["surface"], foreground=C["text"], relief="flat")
style.map("Ghost.TButton",
background=[("active", C["surface2"])])
style.configure("Treeview",
background=C["surface"], foreground=C["text"],
fieldbackground=C["surface"], rowheight=30, font=FONT)
style.configure("Treeview.Heading",
background=C["surface2"], foreground=C["accent"],
font=FONT_BOLD, relief="flat")
style.map("Treeview",
background=[("selected", C["accent"])],
foreground=[("selected", C["white"])])
style.configure("TNotebook",
background=C["bg"], bordercolor=C["border"])
style.configure("TNotebook.Tab",
background=C["surface"], foreground=C["text_dim"],
padding=(16, 8), font=FONT_BOLD)
style.map("TNotebook.Tab",
background=[("selected", C["accent"])],
foreground=[("selected", C["white"])])
style.configure("TScrollbar",
background=C["surface2"], troughcolor=C["bg"],
bordercolor=C["bg"], arrowcolor=C["text_dim"])
style.configure("TCheckbutton",
background=C["bg"], foreground=C["text"], font=FONT)
style.configure("TProgressbar",
troughcolor=C["surface2"], background=C["accent"])
style.configure("TSeparator", background=C["border"])
self._root.configure(bg=C["bg"])
# ─── Colour proxy — keeps `COLOURS["key"]` syntax working everywhere ──────────
class _ColourProxy(dict):
def __getitem__(self, key):
return ThemeManager.get()[key]
def get(self, key, default=None):
return ThemeManager.get().get(key, default)
COLOURS = _ColourProxy()
# ─── Legacy shim ──────────────────────────────────────────────────────────────
def apply_theme(root, mode="dark"):
"""Backwards-compatible shim. Prefer ThemeManager directly."""
ThemeManager(root, initial=mode)
# ─── DateEntry — Entry + calendar popup ───────────────────────────────────────
class DateEntry(tk.Frame):
"""
Themed date-picker: read-only Entry showing YYYY-MM-DD plus a calendar
button that opens a month-grid popup.
de = DateEntry(parent, initial_date="2025-04-20")
de.grid(row=0, column=1, sticky="ew")
value = de.get() # "2025-04-21"
de.set("2025-05-01")
"""
def __init__(self, parent, initial_date="", width=12, **kwargs):
C = ThemeManager.get()
super().__init__(parent, bg=C["bg"], **kwargs)
try:
self._date = date.fromisoformat(initial_date) if initial_date else date.today()
except ValueError:
self._date = date.today()
self._var = tk.StringVar(value=self._date.isoformat())
self._entry = tk.Entry(
self, textvariable=self._var, width=width,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT,
state="readonly",
readonlybackground=C["surface2"],
)
self._entry.pack(side="left", ipady=5, padx=(0, 2))
self._btn = tk.Button(
self, text="📅",
command=self._open_popup,
bg=C["surface2"], fg=C["text"],
activebackground=C["accent"],
activeforeground=C["white"],
relief="flat", font=FONT,
cursor="hand2", padx=4,
)
self._btn.pack(side="left")
def get(self):
return self._var.get()
def set(self, value):
try:
self._date = date.fromisoformat(value)
self._var.set(self._date.isoformat())
except (ValueError, TypeError):
pass
def _open_popup(self):
_CalendarPopup(self, self._date, callback=self._on_date_selected)
def _on_date_selected(self, selected):
self._date = selected
self._var.set(selected.isoformat())
class _CalendarPopup(tk.Toplevel):
"""Month-grid calendar popup used by DateEntry."""
def __init__(self, anchor_widget, initial, callback):
super().__init__(anchor_widget)
self.callback = callback
self._year = initial.year
self._month = initial.month
self._selected = initial
self.overrideredirect(True)
self.resizable(False, False)
C = ThemeManager.get()
self.configure(bg=C["border"])
self._build()
self._position(anchor_widget)
self.grab_set()
self.focus_set()
self.bind("<Escape>", lambda _: self.destroy())
def _position(self, anchor):
anchor.update_idletasks()
x = anchor.winfo_rootx()
y = anchor.winfo_rooty() + anchor.winfo_height() + 2
self.geometry(f"+{x}+{y}")
def _build(self):
C = ThemeManager.get()
outer = tk.Frame(self, bg=C["surface"], padx=2, pady=2)
outer.pack(fill="both", expand=True)
# Navigation
nav = tk.Frame(outer, bg=C["cal_header"])
nav.pack(fill="x")
def nav_btn(text, cmd):
return tk.Button(
nav, text=text, command=cmd,
bg=C["cal_header"], fg=C["text"],
activebackground=C["accent"], activeforeground=C["white"],
relief="flat", font=FONT_BOLD, cursor="hand2",
padx=8, pady=4,
)
nav_btn("◀◀", self._prev_year).pack(side="left")
nav_btn("", self._prev_month).pack(side="left")
self._header_lbl = tk.Label(
nav, bg=C["cal_header"], fg=C["text"], font=FONT_BOLD)
self._header_lbl.pack(side="left", expand=True, fill="x")
nav_btn("", self._next_month).pack(side="right")
nav_btn("▶▶", self._next_year).pack(side="right")
# Day-name headers
hdr = tk.Frame(outer, bg=C["surface"])
hdr.pack(fill="x")
for i, dn in enumerate(["Mo","Tu","We","Th","Fr","Sa","Su"]):
fg = C["cal_weekend"] if i >= 5 else C["text_dim"]
tk.Label(hdr, text=dn, bg=C["surface"], fg=fg,
font=FONT_SMALL, width=4, anchor="center").grid(
row=0, column=i, padx=1, pady=2)
self._grid_frame = tk.Frame(outer, bg=C["surface"])
self._grid_frame.pack(fill="both", expand=True)
tk.Button(
outer, text="Today",
command=self._select_today,
bg=C["cal_today"], fg=C["white"],
activebackground=C["accent_hover"],
activeforeground=C["white"],
relief="flat", font=FONT_SMALL, cursor="hand2", pady=3,
).pack(fill="x", pady=(4, 2))
self._render_month()
def _render_month(self):
C = ThemeManager.get()
for w in self._grid_frame.winfo_children():
w.destroy()
self._header_lbl.config(
text=f"{calendar.month_name[self._month]} {self._year}")
today = date.today()
cal = calendar.monthcalendar(self._year, self._month)
for row_i, week in enumerate(cal):
for col_i, day in enumerate(week):
if day == 0:
tk.Label(self._grid_frame, text="",
bg=C["surface"], width=4).grid(
row=row_i, column=col_i, padx=1, pady=1)
continue
d = date(self._year, self._month, day)
is_today = (d == today)
is_selected = (d == self._selected)
is_weekend = (col_i >= 5)
if is_selected:
bg, fg = C["cal_selected"], C["white"]
elif is_today:
bg, fg = C["cal_today"], C["white"]
else:
bg = C["surface2"] if is_weekend else C["surface"]
fg = C["cal_weekend"] if is_weekend else C["text"]
tk.Button(
self._grid_frame,
text=str(day), bg=bg, fg=fg,
activebackground=C["accent"],
activeforeground=C["white"],
relief="flat", font=FONT_SMALL,
width=3, cursor="hand2",
command=lambda dd=d: self._pick(dd),
).grid(row=row_i, column=col_i, padx=1, pady=1)
def _pick(self, d):
self._selected = d
self.callback(d)
self.destroy()
def _select_today(self):
self._pick(date.today())
def _prev_month(self):
if self._month == 1:
self._month, self._year = 12, self._year - 1
else:
self._month -= 1
self._render_month()
def _next_month(self):
if self._month == 12:
self._month, self._year = 1, self._year + 1
else:
self._month += 1
self._render_month()
def _prev_year(self):
self._year -= 1
self._render_month()
def _next_year(self):
self._year += 1
self._render_month()
# ─── Reusable Widget Factories ────────────────────────────────────────────────
def labelled_entry(parent, label, row, show=None, width=35):
lbl = ttk.Label(parent, text=label)
lbl.grid(row=row, column=0, sticky="w", padx=(0, 12), pady=6)
var = tk.StringVar()
ent = ttk.Entry(parent, textvariable=var, width=width, show=show or "")
ent.grid(row=row, column=1, sticky="ew", pady=6)
return var, ent
def scrolled_text(parent, height=6, width=50):
C = ThemeManager.get()
frame = tk.Frame(parent, bg=C["surface2"])
sb = tk.Scrollbar(frame)
sb.pack(side="right", fill="y")
txt = tk.Text(
frame, height=height, width=width, wrap="word",
yscrollcommand=sb.set,
bg=C["surface2"], fg=C["text"],
insertbackground=C["text"],
relief="flat", font=FONT, padx=8, pady=6,
)
txt.pack(side="left", fill="both", expand=True)
sb.config(command=txt.yview)
return frame, txt
def confirm_delete(item_name):
return messagebox.askyesno(
"Confirm Delete",
f"Are you sure you want to delete '{item_name}'?\nThis action cannot be undone."
)
def show_error(message, title="Error"):
messagebox.showerror(title, message)
def show_info(message, title="Success"):
messagebox.showinfo(title, message)
def make_scrollable_frame(parent):
C = ThemeManager.get()
canvas = tk.Canvas(parent, bg=C["bg"], highlightthickness=0)
vsb = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
inner = ttk.Frame(canvas)
inner.bind("<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
canvas.create_window((0, 0), window=inner, anchor="nw")
canvas.configure(yscrollcommand=vsb.set)
canvas.pack(side="left", fill="both", expand=True)
vsb.pack(side="right", fill="y")
def _safe_scroll(event):
try:
canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
except Exception:
pass
def _enter(e):
canvas.bind_all("<MouseWheel>", _safe_scroll)
def _leave(e):
try:
canvas.unbind_all("<MouseWheel>")
except Exception:
pass
canvas.bind("<Enter>", _enter)
canvas.bind("<Leave>", _leave)
return canvas, inner