""" 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 # Bind all user activity events to reset the idle timer 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() 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 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 ────────────────────────────────────────────────────────── sidebar = tk.Frame(self, bg=COLOURS["surface"], width=200) sidebar.pack(side="left", fill="y") sidebar.pack_propagate(False) # 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 if self.current_user["role"] == "admin": nav_items = [ ("🏠 Dashboard", "dashboard", self._show_dashboard), ("👤 Users", "users", self._show_users), ("🌐 Websites", "websites", self._show_websites), ("🗓 Shifts", "shift_mgmt", self._show_shift_mgmt), ("📋 Activity Log", "log", self._show_log), ("📊 My Shifts", "shifts", self._show_shifts), ("📑 Reports", "reports", self._show_reports), ("📧 Email Reports","email", self._show_email_settings), ("⚙ Settings", "settings", self._show_settings), ] else: nav_items = [ ("📊 My Shifts", "shifts", self._show_shifts), ] for label, key, cmd in nav_items: 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=12, cursor="hand2", ) btn.pack(fill="x") self._nav_buttons[key] = btn # 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 _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 ────────────────────────────────────────────────────── def _reset_idle_timer(self, event=None): """Cancel any pending timeout/warning timers and restart them.""" if not self.current_user: return # not logged in — nothing to time out 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, "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()