652 lines
27 KiB
Python
652 lines
27 KiB
Python
"""
|
|
app.py — Main application shell.
|
|
Bootstraps the root window, handles login flow,
|
|
and renders the appropriate panel based on user role.
|
|
|
|
Session timeout: IDLE_TIMEOUT_MS controls how long (ms) the app waits
|
|
before auto-locking back to the login screen. Default = 30 minutes.
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
import logging
|
|
import sys
|
|
|
|
from config import initialize_database, DB_CONFIG
|
|
from utils.ui_helpers import ThemeManager, COLOURS, FONT_BOLD, FONT_SMALL
|
|
|
|
logger = logging.getLogger("app")
|
|
|
|
# ─── Session Configuration ────────────────────────────────────────────────────
|
|
IDLE_TIMEOUT_MS = 30 * 60 * 1000 # 30 minutes in milliseconds
|
|
IDLE_WARNING_MS = 60 * 1000 # warn 1 minute before timeout
|
|
|
|
# ─── Version Configuration ────────────────────────────────────────────────────
|
|
APP_VERSION = "1.0.0"
|
|
VERSION_CHECK_URL = "https://api.github.com/repos/your-org/website-checker/releases/latest"
|
|
VERSION_CHECK_ENABLED = False # set True and update URL when publishing releases
|
|
|
|
|
|
class App(tk.Tk):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.withdraw() # Hide root until login succeeds
|
|
self.title("Website Checker")
|
|
self.configure(bg=COLOURS["bg"])
|
|
self.minsize(960, 620)
|
|
self.theme = ThemeManager(self, initial="light")
|
|
|
|
self.current_user = None
|
|
self._idle_timer = None # after() handle for timeout
|
|
self._warning_timer = None # after() handle for 1-min warning
|
|
self._idle_last_reset = 0.0 # monotonic timestamp of last actual reset
|
|
|
|
# Bind all user activity events to reset the idle timer.
|
|
# <Motion> fires continuously — the handler throttles itself to at most
|
|
# once every 5 seconds so we don't schedule/cancel hundreds of after()
|
|
# handles per second during normal mouse movement.
|
|
for event in ("<Motion>", "<KeyPress>", "<ButtonPress>", "<MouseWheel>"):
|
|
self.bind_all(event, self._reset_idle_timer, add="+")
|
|
|
|
self._boot()
|
|
|
|
# ─── Boot Sequence ────────────────────────────────────────────────────────
|
|
|
|
def _boot(self):
|
|
"""Show DB setup dialog if needed, then initialise DB and show login."""
|
|
from config import config_exists
|
|
if not config_exists():
|
|
self._show_setup(on_complete=self._init_db)
|
|
else:
|
|
self._init_db()
|
|
|
|
def _show_setup(self, on_complete):
|
|
from views.settings_view import SettingsView
|
|
SettingsView(self, on_save_callback=on_complete, first_run=True)
|
|
|
|
def _init_db(self):
|
|
"""Initialise database schema, then proceed to login."""
|
|
try:
|
|
initialize_database()
|
|
# Signal the DB log handler that the pool is ready so queued
|
|
# startup log records are flushed to app_log immediately.
|
|
try:
|
|
from config import db_log_handler
|
|
db_log_handler.install()
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
messagebox.showerror(
|
|
"Database Error",
|
|
f"Cannot connect to the database.\n\n"
|
|
f"Host: {DB_CONFIG['host']}\nDB: {DB_CONFIG['database']}\n\n"
|
|
f"Error: {e}\n\n"
|
|
f"Please check your connection settings."
|
|
)
|
|
# Re-open setup so the user can correct the credentials
|
|
self._show_setup(on_complete=self._init_db)
|
|
return
|
|
# One-time migration: import config.ini [database] → OS keychain,
|
|
# and config.ini [email]/[groq]/[crypto] → app_settings DB table.
|
|
try:
|
|
from config import migrate_plaintext_config
|
|
migrate_plaintext_config()
|
|
except Exception as e:
|
|
logger.warning(f"Config migration skipped: {e}")
|
|
self._show_login()
|
|
|
|
def _show_login(self):
|
|
from views.login_view import LoginView
|
|
LoginView(self, on_success_callback=self._on_login_success)
|
|
|
|
# ─── Post-login ───────────────────────────────────────────────────────────
|
|
|
|
def _on_login_success(self, user: dict):
|
|
self.current_user = user
|
|
self.deiconify()
|
|
self._centre()
|
|
self._build_shell()
|
|
self._reset_idle_timer() # start session timeout clock
|
|
# Start the email scheduler for admin accounts
|
|
if user.get("role") == "admin":
|
|
from utils.scheduler import start as _sched_start
|
|
_sched_start()
|
|
# Non-blocking version check (admin only)
|
|
if user.get("role") == "admin" and VERSION_CHECK_ENABLED:
|
|
import threading
|
|
threading.Thread(target=self._check_for_updates,
|
|
daemon=True).start()
|
|
|
|
def _centre(self):
|
|
self.update_idletasks()
|
|
w, h = 1100, 700
|
|
x = (self.winfo_screenwidth() - w) // 2
|
|
y = (self.winfo_screenheight() - h) // 2
|
|
self.geometry(f"{w}x{h}+{x}+{y}")
|
|
|
|
# ─── Application Shell ────────────────────────────────────────────────────
|
|
|
|
def _build_shell(self):
|
|
# Clear any previous content
|
|
for child in self.winfo_children():
|
|
child.destroy()
|
|
|
|
# ── Sidebar ──────────────────────────────────────────────────────────
|
|
# The sidebar outer frame holds the fixed-width column.
|
|
# Inside it we place a Canvas + Scrollbar so that the nav items are
|
|
# scrollable on shorter screens (especially for admin with many items).
|
|
sidebar_outer = tk.Frame(self, bg=COLOURS["surface"], width=200)
|
|
sidebar_outer.pack(side="left", fill="y")
|
|
sidebar_outer.pack_propagate(False)
|
|
|
|
sidebar_canvas = tk.Canvas(
|
|
sidebar_outer, bg=COLOURS["surface"],
|
|
highlightthickness=0, width=200,
|
|
)
|
|
sidebar_scrollbar = ttk.Scrollbar(
|
|
sidebar_outer, orient="vertical", command=sidebar_canvas.yview)
|
|
sidebar_canvas.configure(yscrollcommand=sidebar_scrollbar.set)
|
|
|
|
# Scrollbar only visible on overflow — pack canvas first so it fills
|
|
sidebar_canvas.pack(side="left", fill="both", expand=True)
|
|
sidebar_scrollbar.pack(side="right", fill="y")
|
|
|
|
# The actual sidebar frame lives inside the canvas window
|
|
sidebar = tk.Frame(sidebar_canvas, bg=COLOURS["surface"], width=200)
|
|
sidebar_window = sidebar_canvas.create_window(
|
|
(0, 0), window=sidebar, anchor="nw", width=200)
|
|
|
|
def _on_sidebar_configure(event):
|
|
sidebar_canvas.configure(
|
|
scrollregion=sidebar_canvas.bbox("all"))
|
|
|
|
def _on_canvas_resize(event):
|
|
sidebar_canvas.itemconfig(sidebar_window, width=event.width)
|
|
|
|
sidebar.bind("<Configure>", _on_sidebar_configure)
|
|
sidebar_canvas.bind("<Configure>", _on_canvas_resize)
|
|
|
|
# Mouse-wheel scrolling inside the sidebar
|
|
def _on_mousewheel(event):
|
|
sidebar_canvas.yview_scroll(
|
|
int(-1 * (event.delta / 120)), "units")
|
|
|
|
sidebar.bind_all("<MouseWheel>", _on_mousewheel)
|
|
|
|
# App branding
|
|
brand = tk.Frame(sidebar, bg=COLOURS["surface"], pady=20)
|
|
brand.pack(fill="x")
|
|
tk.Label(brand, text="🌐", font=("Segoe UI", 24),
|
|
bg=COLOURS["surface"], fg=COLOURS["accent"]).pack()
|
|
tk.Label(brand, text="Website\nChecker",
|
|
font=FONT_BOLD,
|
|
bg=COLOURS["surface"],
|
|
fg=COLOURS["text"]).pack()
|
|
|
|
ttk.Separator(sidebar, orient="horizontal").pack(fill="x", pady=8)
|
|
|
|
# Content area
|
|
self.content = ttk.Frame(self)
|
|
self.content.pack(side="left", fill="both", expand=True, padx=20, pady=20)
|
|
|
|
# ── Navigation Buttons ────────────────────────────────────────────────
|
|
self._nav_buttons = {}
|
|
self._active_section = None
|
|
|
|
# nav_groups is a list of (group_label, items) tuples.
|
|
# group_label=None means no section header — used for the user role.
|
|
# A separator is rendered between every group automatically.
|
|
if self.current_user["role"] == "admin":
|
|
nav_groups = [
|
|
("MANAGEMENT", [
|
|
("🏠 Dashboard", "dashboard", self._show_dashboard),
|
|
("👤 Users", "users", self._show_users),
|
|
("🌐 Websites", "websites", self._show_websites),
|
|
("🗓 Shifts", "shift_mgmt", self._show_shift_mgmt),
|
|
("📑 Reports", "reports", self._show_reports),
|
|
("📋 Activity Log", "log", self._show_log),
|
|
("📧 Email Reports", "email", self._show_email_settings),
|
|
("⚙ Settings", "settings", self._show_settings),
|
|
]),
|
|
("MY WORKSPACE", [
|
|
("📊 My Shifts", "shifts", self._show_shifts),
|
|
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
|
|
("📌 Bid Tracker", "bid_tracker", self._show_bid_tracker),
|
|
]),
|
|
]
|
|
else:
|
|
nav_groups = [
|
|
(None, [
|
|
("📊 My Shifts", "shifts", self._show_shifts),
|
|
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
|
|
("📌 Bid Tracker", "bid_tracker", self._show_bid_tracker),
|
|
]),
|
|
]
|
|
|
|
def _make_nav_btn(label, key, cmd):
|
|
btn = tk.Button(
|
|
sidebar,
|
|
text=label,
|
|
command=cmd,
|
|
bg=COLOURS["surface"],
|
|
fg=COLOURS["text"],
|
|
activebackground=COLOURS["accent"],
|
|
activeforeground=COLOURS["white"],
|
|
relief="flat",
|
|
anchor="w",
|
|
font=FONT_BOLD,
|
|
padx=20,
|
|
pady=10,
|
|
cursor="hand2",
|
|
)
|
|
btn.pack(fill="x")
|
|
self._nav_buttons[key] = btn
|
|
|
|
for i, (group_label, items) in enumerate(nav_groups):
|
|
# Separator between groups (not before the first one)
|
|
if i > 0:
|
|
ttk.Separator(sidebar, orient="horizontal").pack(
|
|
fill="x", pady=4)
|
|
|
|
# Section header label (skip for user role where label is None)
|
|
if group_label:
|
|
tk.Label(
|
|
sidebar,
|
|
text=group_label,
|
|
bg=COLOURS["surface"],
|
|
fg=COLOURS["text_dim"],
|
|
font=(FONT_SMALL[0], 8, "bold"),
|
|
anchor="w",
|
|
padx=20,
|
|
pady=4,
|
|
).pack(fill="x")
|
|
|
|
for label, key, cmd in items:
|
|
_make_nav_btn(label, key, cmd)
|
|
|
|
# Spacer + user info at bottom
|
|
ttk.Separator(sidebar, orient="horizontal").pack(fill="x", side="bottom", pady=8)
|
|
user_frame = tk.Frame(sidebar, bg=COLOURS["surface"], pady=10)
|
|
user_frame.pack(side="bottom", fill="x")
|
|
|
|
tk.Label(user_frame,
|
|
text=(self.current_user["full_name"] or self.current_user["username"]),
|
|
font=FONT_BOLD,
|
|
bg=COLOURS["surface"],
|
|
fg=COLOURS["text"],
|
|
wraplength=160).pack(padx=12)
|
|
tk.Label(user_frame,
|
|
text=self.current_user["role"].capitalize(),
|
|
font=FONT_SMALL,
|
|
bg=COLOURS["surface"],
|
|
fg=COLOURS["text_dim"]).pack(padx=12, pady=(0, 4))
|
|
|
|
logout_btn = tk.Button(
|
|
user_frame,
|
|
text="⇠ Sign Out",
|
|
command=self._logout,
|
|
bg=COLOURS["danger"],
|
|
fg=COLOURS["white"],
|
|
activebackground="#c94444",
|
|
activeforeground=COLOURS["white"],
|
|
relief="flat",
|
|
font=FONT_SMALL,
|
|
cursor="hand2",
|
|
pady=6,
|
|
)
|
|
logout_btn.pack(fill="x", padx=12, pady=4)
|
|
|
|
# Change password button (all roles)
|
|
tk.Button(
|
|
user_frame,
|
|
text="🔑 Change Password",
|
|
command=self._show_change_password,
|
|
bg=COLOURS["surface2"],
|
|
fg=COLOURS["text_dim"],
|
|
activebackground=COLOURS["accent"],
|
|
activeforeground=COLOURS["white"],
|
|
relief="flat",
|
|
font=FONT_SMALL,
|
|
cursor="hand2",
|
|
pady=4,
|
|
).pack(fill="x", padx=12, pady=(0, 4))
|
|
|
|
# Theme toggle button
|
|
self._theme_btn = tk.Button(
|
|
user_frame,
|
|
text="☀ Light Mode" if self.theme.is_dark else "🌙 Dark Mode",
|
|
command=self._toggle_theme,
|
|
bg=COLOURS["surface2"],
|
|
fg=COLOURS["text_dim"],
|
|
activebackground=COLOURS["accent"],
|
|
activeforeground=COLOURS["white"],
|
|
relief="flat",
|
|
font=FONT_SMALL,
|
|
cursor="hand2",
|
|
pady=4,
|
|
)
|
|
self._theme_btn.pack(fill="x", padx=12, pady=(0, 8))
|
|
|
|
# ── Default view ──────────────────────────────────────────────────────
|
|
if self.current_user["role"] == "admin":
|
|
self._show_dashboard()
|
|
else:
|
|
self._show_shifts()
|
|
|
|
def _clear_content(self):
|
|
for child in self.content.winfo_children():
|
|
child.destroy()
|
|
|
|
def _set_active_nav(self, key: str):
|
|
for k, btn in self._nav_buttons.items():
|
|
if k == key:
|
|
btn.config(bg=COLOURS["accent"], fg=COLOURS["white"])
|
|
else:
|
|
btn.config(bg=COLOURS["surface"], fg=COLOURS["text"])
|
|
self._active_section = key
|
|
|
|
# ─── Section Renderers ────────────────────────────────────────────────────
|
|
|
|
def _show_dashboard(self):
|
|
self._clear_content()
|
|
self._set_active_nav("dashboard")
|
|
from views.admin_dashboard_view import AdminDashboardView
|
|
AdminDashboardView(self.content, self.current_user).pack(fill="both", expand=True)
|
|
|
|
def _show_users(self):
|
|
self._clear_content()
|
|
self._set_active_nav("users")
|
|
from views.admin_users_view import AdminUsersView
|
|
AdminUsersView(self.content, self.current_user).pack(fill="both", expand=True)
|
|
|
|
def _show_websites(self):
|
|
self._clear_content()
|
|
self._set_active_nav("websites")
|
|
from views.admin_websites_view import AdminWebsitesView
|
|
AdminWebsitesView(self.content, self.current_user).pack(fill="both", expand=True)
|
|
|
|
def _show_shift_mgmt(self):
|
|
self._clear_content()
|
|
self._set_active_nav("shift_mgmt")
|
|
from views.admin_shifts_view import AdminShiftsView
|
|
AdminShiftsView(self.content, self.current_user).pack(fill="both", expand=True)
|
|
|
|
def _show_log(self):
|
|
self._clear_content()
|
|
self._set_active_nav("log")
|
|
from views.admin_log_view import AdminLogView
|
|
AdminLogView(self.content, self.current_user).pack(fill="both", expand=True)
|
|
|
|
def _show_shifts(self):
|
|
self._clear_content()
|
|
self._set_active_nav("shifts")
|
|
from views.user_dashboard_view import UserDashboardView
|
|
UserDashboardView(self.content, self.current_user).pack(fill="both", expand=True)
|
|
|
|
def _show_reports(self):
|
|
self._clear_content()
|
|
self._set_active_nav("reports")
|
|
from views.reports_view import ReportsView
|
|
ReportsView(self.content, self.current_user).pack(fill="both", expand=True)
|
|
|
|
def _show_settings(self):
|
|
self._clear_content()
|
|
self._set_active_nav("settings")
|
|
from views.settings_view import SettingsView
|
|
SettingsView(self, on_save_callback=self._on_settings_saved, first_run=False)
|
|
|
|
def _show_email_settings(self):
|
|
self._clear_content()
|
|
self._set_active_nav("email")
|
|
from views.email_settings_view import EmailSettingsView
|
|
EmailSettingsView(self, self.current_user)
|
|
|
|
def _show_ai_summary(self):
|
|
self._clear_content()
|
|
self._set_active_nav("ai_summary")
|
|
from views.ai_summary_view import AiSummaryView
|
|
AiSummaryView(self.content, self.current_user).pack(fill="both", expand=True)
|
|
|
|
def _show_bid_tracker(self):
|
|
self._clear_content()
|
|
self._set_active_nav("bid_tracker")
|
|
from views.bid_tracker_view import BidTrackerView
|
|
BidTrackerView(self.content, self.current_user).pack(fill="both", expand=True)
|
|
|
|
def _on_settings_saved(self):
|
|
"""Called after settings are saved — reload config and reconnect."""
|
|
from config import reload_db_config
|
|
reload_db_config()
|
|
self._init_db()
|
|
|
|
# ─── Session Timeout ──────────────────────────────────────────────────────
|
|
|
|
# Minimum interval (seconds) between idle-timer resets triggered by
|
|
# continuous events like <Motion>. KeyPress / ButtonPress always reset
|
|
# immediately (they are infrequent by nature).
|
|
_IDLE_THROTTLE_S = 5.0
|
|
|
|
def _reset_idle_timer(self, event=None):
|
|
"""Cancel any pending timeout/warning timers and restart them.
|
|
|
|
Throttled for <Motion> events: the timer is only rescheduled if at
|
|
least _IDLE_THROTTLE_S seconds have elapsed since the last reset.
|
|
This prevents hundreds of after_cancel/after() calls per second
|
|
during normal mouse movement without affecting correctness.
|
|
"""
|
|
if not self.current_user:
|
|
return # not logged in — nothing to time out
|
|
|
|
import time
|
|
# Throttle continuous motion events; discrete actions always go through
|
|
if event and getattr(event, "type", None) is not None:
|
|
try:
|
|
# EventType.Motion == 6 in tkinter
|
|
if int(event.type) == 6:
|
|
now = time.monotonic()
|
|
if now - self._idle_last_reset < self._IDLE_THROTTLE_S:
|
|
return
|
|
self._idle_last_reset = now
|
|
except Exception:
|
|
pass
|
|
|
|
if self._idle_timer:
|
|
self.after_cancel(self._idle_timer)
|
|
if self._warning_timer:
|
|
self.after_cancel(self._warning_timer)
|
|
|
|
# Schedule 1-minute warning before timeout
|
|
warning_delay = IDLE_TIMEOUT_MS - IDLE_WARNING_MS
|
|
if warning_delay > 0:
|
|
self._warning_timer = self.after(warning_delay, self._on_idle_warning)
|
|
|
|
self._idle_timer = self.after(IDLE_TIMEOUT_MS, self._on_session_timeout)
|
|
|
|
def _on_idle_warning(self):
|
|
"""Show a non-blocking warning that the session is about to expire."""
|
|
if not self.current_user:
|
|
return
|
|
# Use a non-modal label overlay so it doesn't block the timer
|
|
self._timeout_warning = tk.Toplevel(self)
|
|
self._timeout_warning.title("Session Expiring")
|
|
self._timeout_warning.configure(bg=COLOURS["warning"])
|
|
self._timeout_warning.resizable(False, False)
|
|
self._timeout_warning.attributes("-topmost", True)
|
|
|
|
w, h = 340, 120
|
|
x = (self.winfo_screenwidth() - w) // 2
|
|
y = (self.winfo_screenheight() - h) // 2
|
|
self._timeout_warning.geometry(f"{w}x{h}+{x}+{y}")
|
|
|
|
tk.Label(
|
|
self._timeout_warning,
|
|
text="Session Expiring",
|
|
font=FONT_BOLD, bg=COLOURS["warning"], fg=COLOURS["white"]
|
|
).pack(pady=(16, 4))
|
|
tk.Label(
|
|
self._timeout_warning,
|
|
text="Your session will expire in 1 minute\ndue to inactivity.",
|
|
font=FONT_SMALL, bg=COLOURS["warning"], fg=COLOURS["white"],
|
|
justify="center"
|
|
).pack()
|
|
tk.Button(
|
|
self._timeout_warning,
|
|
text=" Stay Logged In ",
|
|
command=self._dismiss_warning,
|
|
bg=COLOURS["white"], fg=COLOURS["warning"],
|
|
relief="flat", font=FONT_BOLD, cursor="hand2",
|
|
padx=10, pady=6
|
|
).pack(pady=12)
|
|
|
|
def _dismiss_warning(self):
|
|
"""User clicked 'Stay Logged In' — close the warning and reset timer."""
|
|
if hasattr(self, "_timeout_warning") and self._timeout_warning.winfo_exists():
|
|
self._timeout_warning.destroy()
|
|
self._reset_idle_timer()
|
|
|
|
def _on_session_timeout(self):
|
|
"""Auto-logout after idle timeout."""
|
|
if not self.current_user:
|
|
return
|
|
if hasattr(self, "_timeout_warning") and self._timeout_warning.winfo_exists():
|
|
self._timeout_warning.destroy()
|
|
|
|
username = self.current_user["username"]
|
|
from models import log_action
|
|
log_action(self.current_user["id"], "SESSION_TIMEOUT", "users",
|
|
self.current_user["id"],
|
|
f"Session timed out for user '{username}' after inactivity.")
|
|
logger.info(f"Session timeout: auto-logged out user '{username}'.")
|
|
|
|
messagebox.showinfo(
|
|
"Session Expired",
|
|
"Your session has expired due to inactivity.\nPlease log in again."
|
|
)
|
|
self._logout()
|
|
|
|
# ─── Version Check ────────────────────────────────────────────────────────
|
|
|
|
def _check_for_updates(self):
|
|
"""
|
|
Background thread: fetch the latest release tag from GitHub and
|
|
show a dismissible banner if a newer version is available.
|
|
Set VERSION_CHECK_ENABLED=True and update VERSION_CHECK_URL to activate.
|
|
"""
|
|
try:
|
|
import urllib.request
|
|
import json
|
|
req = urllib.request.Request(
|
|
VERSION_CHECK_URL,
|
|
headers={"User-Agent": "WebsiteChecker/" + APP_VERSION},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=6) as resp:
|
|
data = json.loads(resp.read())
|
|
latest_tag = data.get("tag_name", "").lstrip("v")
|
|
release_url = data.get("html_url", "")
|
|
|
|
if latest_tag and latest_tag != APP_VERSION:
|
|
logger.info(f"New version available: {latest_tag} (current: {APP_VERSION})")
|
|
self.after(0, lambda: self._show_update_banner(latest_tag, release_url))
|
|
except Exception as e:
|
|
logger.debug(f"Version check failed (non-critical): {e}")
|
|
|
|
def _show_update_banner(self, latest_version: str, release_url: str):
|
|
"""Show a non-intrusive dismissible update notification at the top."""
|
|
if not self.current_user:
|
|
return
|
|
try:
|
|
banner = tk.Frame(self.content, bg=COLOURS["accent"], pady=6)
|
|
banner.pack(fill="x", side="top", before=self.content.winfo_children()[0])
|
|
|
|
tk.Label(banner,
|
|
text=f" Update available: v{latest_version} "
|
|
f"(you have v{APP_VERSION})",
|
|
bg=COLOURS["accent"], fg=COLOURS["white"],
|
|
font=FONT_SMALL).pack(side="left", padx=(8, 0))
|
|
|
|
if release_url:
|
|
import webbrowser
|
|
tk.Button(
|
|
banner, text="View Release",
|
|
command=lambda: webbrowser.open(release_url),
|
|
bg=COLOURS["white"], fg=COLOURS["accent"],
|
|
relief="flat", font=FONT_SMALL, cursor="hand2",
|
|
padx=8, pady=2,
|
|
).pack(side="left", padx=8)
|
|
|
|
tk.Button(
|
|
banner, text="✕ Dismiss",
|
|
command=banner.destroy,
|
|
bg=COLOURS["accent"], fg=COLOURS["white"],
|
|
relief="flat", font=FONT_SMALL, cursor="hand2",
|
|
padx=8,
|
|
).pack(side="right", padx=8)
|
|
except Exception:
|
|
pass # UI may not be ready — silently skip
|
|
|
|
# ─── Change Password ──────────────────────────────────────────────────────
|
|
|
|
def _show_change_password(self):
|
|
from views.change_password_view import ChangePasswordView
|
|
ChangePasswordView(self, self.current_user)
|
|
|
|
# ─── Theme Toggle ─────────────────────────────────────────────────────────
|
|
|
|
def _toggle_theme(self):
|
|
"""Flip dark↔light, rebuild the shell, restore active section."""
|
|
active = self._active_section
|
|
self.theme.toggle(rebuild_callback=self._build_shell)
|
|
# Re-navigate to the section that was active before the rebuild
|
|
if active and active in self._nav_buttons:
|
|
nav_map = {
|
|
"dashboard": self._show_dashboard,
|
|
"users": self._show_users,
|
|
"websites": self._show_websites,
|
|
"shift_mgmt": self._show_shift_mgmt,
|
|
"log": self._show_log,
|
|
"shifts": self._show_shifts,
|
|
"reports": self._show_reports,
|
|
"email": self._show_email_settings,
|
|
"ai_summary": self._show_ai_summary,
|
|
"bid_tracker": self._show_bid_tracker,
|
|
"settings": self._show_settings,
|
|
}
|
|
if active in nav_map:
|
|
nav_map[active]()
|
|
|
|
# ─── Logout ───────────────────────────────────────────────────────────────
|
|
|
|
def _logout(self):
|
|
# Cancel any running idle timers
|
|
if self._idle_timer:
|
|
self.after_cancel(self._idle_timer)
|
|
self._idle_timer = None
|
|
if self._warning_timer:
|
|
self.after_cancel(self._warning_timer)
|
|
self._warning_timer = None
|
|
|
|
# Stop background scheduler
|
|
try:
|
|
from utils.scheduler import stop as _sched_stop
|
|
_sched_stop()
|
|
except Exception:
|
|
pass
|
|
|
|
from models import log_action
|
|
if self.current_user:
|
|
log_action(self.current_user["id"], "LOGOUT", "users",
|
|
self.current_user["id"],
|
|
f"User '{self.current_user['username']}' logged out.")
|
|
logger.info(f"User '{self.current_user['username']}' signed out.")
|
|
self.current_user = None
|
|
for child in self.winfo_children():
|
|
child.destroy()
|
|
self.withdraw()
|
|
self._show_login()
|
|
|
|
|
|
# ─── Entry Point ──────────────────────────────────────────────────────────────
|
|
|
|
if __name__ == "__main__":
|
|
app = App()
|
|
app.mainloop() |