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