""" 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. # 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 ("", "", "", ""): 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("", _on_sidebar_configure) sidebar_canvas.bind("", _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("", _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 . 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 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()