04/21 Fisrt commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# utils package
|
||||
+125
@@ -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
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user