Files
WebChecker/app.py
T

540 lines
22 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
# Bind all user activity events to reset the idle timer
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()
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
# Encrypt any plain-text credentials in config.ini (one-time migration)
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 ──────────────────────────────────────────────────────────
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),
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
("⚙ Settings", "settings", self._show_settings),
]
else:
nav_items = [
("📊 My Shifts", "shifts", self._show_shifts),
("🤖 AI Summary", "ai_summary", self._show_ai_summary),
]
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 _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 _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,
"ai_summary": self._show_ai_summary,
"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()