diff --git a/README.md b/README.md index 64a4764..a9b2fbc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,136 @@ -# WebChecker +# Website Checker — Desktop App +A Tkinter-based desktop application for shift-based website monitoring, +backed by a remote MySQL database. + +--- + +## Project Structure + +``` +website_checker/ +├── app.py ← Entry point & main application shell +├── config.py ← DB config, connection pool, schema init +├── models.py ← All database access (CRUD + logging) +├── requirements.txt +├── utils/ +│ └── ui_helpers.py ← Theme, colour constants, reusable widgets +└── views/ + ├── login_view.py ← Login screen + ├── admin_users_view.py ← Admin: User Management + ├── admin_websites_view.py ← Admin: Website Link Management + ├── admin_log_view.py ← Admin: Activity Log + └── user_dashboard_view.py ← User: Shift Checklist Dashboard +``` + +--- + +## Prerequisites + +- Python 3.9 or newer (must include Tkinter — standard on Windows/macOS) +- A remote MySQL 5.7+ / MariaDB 10.3+ server +- The database and a user with CREATE / INSERT / UPDATE / DELETE privileges + +--- + +## Setup Instructions + +### 1. Install Python dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Configure the database connection + +Open `config.py` and update the `DB_CONFIG` dictionary: + +```python +DB_CONFIG = { + "host": "your-mysql-host", # ← change this + "port": 3306, + "database": "website_checker", # ← create this DB first + "user": "your-db-user", # ← change this + "password": "your-db-password", # ← change this +} +``` + +> **Important:** Create the database on your MySQL server first: +> ```sql +> CREATE DATABASE website_checker CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +> ``` + +### 3. Run the application + +```bash +python app.py +``` + +On first launch, the app automatically creates all required tables and seeds a +default admin account: + +| Field | Value | +|----------|------------| +| Username | `admin` | +| Password | `admin123` | + +**Change the admin password immediately after first login.** + +--- + +## Feature Overview + +### Admin Role +| Feature | Description | +|---|---| +| User Management | Create, edit, deactivate, and delete users; assign admin or regular role | +| Website Management | Add sites with name, URL, multiple login credentials, and notes | +| Activity Log | View a chronological audit trail of all create/edit/delete/login events | +| Shift Dashboard | Admins can also use the shift checklist like regular users | + +### Regular User Role +| Feature | Description | +|---|---| +| Login | Secure username/password login | +| Shift Checklist | See all active websites; click URL to open in browser; check off each site | +| Credentials Popup | Clicking a site shows its stored login credentials with a password reveal toggle | +| Notes | Write or update a per-site note for each shift day | +| Progress Bar | Visual indicator of how many sites have been checked this shift | + +--- + +## Activity Logging + +Every significant action is recorded in the `activity_log` table: + +| Action | Trigger | +|---|---| +| `LOGIN` / `LOGOUT` | User authentication events | +| `CREATE_USER` | Admin creates a new user | +| `UPDATE_USER` | Admin edits a user | +| `DELETE_USER` | Admin deletes a user | +| `CREATE_WEBSITE` | Admin adds a website | +| `UPDATE_WEBSITE` | Admin edits a website | +| `DELETE_WEBSITE` | Admin soft-deletes a website | +| `CHECK_WEBSITE` | User marks a website as checked | +| `UPDATE_NOTE` | User updates their shift note | + +Logs are also written to `app.log` in the application directory. + +--- + +## Database Schema (auto-created on first run) + +- `users` — application accounts with role-based access +- `websites` — monitored sites +- `website_credentials` — multiple username/password pairs per site +- `shift_checks` — one record per user per site per day +- `activity_log` — full audit trail + +--- + +## Notes + +- Websites are **soft-deleted** (flagged inactive) to preserve historical check records. +- Passwords are stored as **SHA-256 hashes**. For production, consider upgrading to `bcrypt`. +- The connection pool size is set to 5; increase `pool_size` in `config.py` for larger teams. diff --git a/app.py b/app.py new file mode 100644 index 0000000..39eaba9 --- /dev/null +++ b/app.py @@ -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 ("", "", "", ""): + 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() diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..22495aa --- /dev/null +++ b/config.ini @@ -0,0 +1,10 @@ +[database] +host = 67.217.62.199 +port = 3306 +database = webchecker +user = webchecker +password = 7x+MxGmks_3U + +[crypto] +salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ= + diff --git a/config.py b/config.py new file mode 100644 index 0000000..a238111 --- /dev/null +++ b/config.py @@ -0,0 +1,315 @@ +""" +config.py — Application configuration and DB connection manager. +Update DB_CONFIG with your remote MySQL server credentials. +""" + +import mysql.connector +from mysql.connector import pooling +import logging + +# ─── Logging Setup ──────────────────────────────────────────────────────────── +import sys + +_file_handler = logging.FileHandler("app.log", encoding="utf-8") +_stream_handler = logging.StreamHandler(stream=sys.stdout) + +# Force UTF-8 on the stream so Windows cp1252 consoles don't choke on +# Unicode characters (arrows, em-dashes, etc.) in log messages. +if hasattr(_stream_handler.stream, "reconfigure"): + try: + _stream_handler.stream.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + +_formatter = logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s - %(message)s" +) +_file_handler.setFormatter(_formatter) +_stream_handler.setFormatter(_formatter) + +logging.basicConfig( + level=logging.INFO, + handlers=[_file_handler, _stream_handler], +) +logger = logging.getLogger("config") + +# ─── Config File Load / Save ────────────────────────────────────────────────── +import configparser as _cp +import os as _os + +CONFIG_FILE = "config.ini" +APP_TITLE = "Website Checker" +APP_VERSION = "1.0.0" + + +def load_config() -> dict: + """ + Load DB settings from config.ini. + Returns a dict with keys: host, port, database, user, password. + Returns empty dict if the file does not exist or is incomplete. + """ + cfg = _cp.ConfigParser() + if not _os.path.exists(CONFIG_FILE): + return {} + cfg.read(CONFIG_FILE, encoding="utf-8") + if "database" not in cfg: + return {} + section = cfg["database"] + return { + "host": section.get("host", ""), + "port": section.getint("port", 3306), + "database": section.get("database", ""), + "user": section.get("user", ""), + "password": section.get("password", ""), + } + + +def save_config(host: str, port: int, database: str, user: str, password: str): + """Persist DB connection settings to config.ini.""" + cfg = _cp.ConfigParser() + cfg["database"] = { + "host": host, + "port": str(port), + "database": database, + "user": user, + "password": password, + } + with open(CONFIG_FILE, "w", encoding="utf-8") as fh: + cfg.write(fh) + logger.info(f"Configuration saved to {CONFIG_FILE}.") + + +def config_exists() -> bool: + """Return True if config.ini contains all required connection fields.""" + ini = load_config() + return bool(ini.get("host") and ini.get("database") and ini.get("user")) + + +def reload_db_config(): + """ + Re-read config.ini and update DB_CONFIG in place. + Also resets the connection pool so the next get_connection() uses new creds. + """ + global _pool, DB_CONFIG + ini = load_config() + DB_CONFIG.update({ + "host": ini.get("host", DB_CONFIG["host"]), + "port": ini.get("port", DB_CONFIG["port"]), + "database": ini.get("database", DB_CONFIG["database"]), + "user": ini.get("user", DB_CONFIG["user"]), + "password": ini.get("password", DB_CONFIG["password"]), + }) + _pool = None # force pool recreation on next connection + logger.info("DB_CONFIG reloaded from config.ini.") + + +# ─── Database Configuration ─────────────────────────────────────────────────── +# Populated from config.ini at runtime; falls back to placeholder strings so +# the module is importable even before first-run setup has completed. +_ini = load_config() +DB_CONFIG = { + "host": _ini.get("host", "your-mysql-host"), + "port": _ini.get("port", 3306), + "database": _ini.get("database", "website_checker"), + "user": _ini.get("user", "your-db-user"), + "password": _ini.get("password", "your-db-password"), + "connection_timeout": 10, +} + +# ─── Connection Pool ────────────────────────────────────────────────────────── +_pool = None + +def get_connection_pool(): + global _pool + if _pool is None: + try: + _pool = pooling.MySQLConnectionPool( + pool_name="app_pool", + pool_size=5, + **DB_CONFIG + ) + logger.info("Database connection pool initialised.") + except mysql.connector.Error as e: + logger.error(f"Failed to create connection pool: {e}") + raise + return _pool + + +def get_connection(): + """Return a connection from the pool.""" + return get_connection_pool().get_connection() + + +def initialize_database(): + """Create all required tables if they do not exist.""" + ddl_statements = [ + """ + CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + role ENUM('admin','user') NOT NULL DEFAULT 'user', + full_name VARCHAR(200), + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, + """ + CREATE TABLE IF NOT EXISTS websites ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL, + url TEXT NOT NULL, + check_type ENUM('daily','weekly') NOT NULL DEFAULT 'daily', + note TEXT, + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, + """ + CREATE TABLE IF NOT EXISTS website_credentials ( + id INT AUTO_INCREMENT PRIMARY KEY, + website_id INT NOT NULL, + username VARCHAR(200) NOT NULL, + password VARCHAR(255) NOT NULL, + label VARCHAR(100), + FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, + """ + CREATE TABLE IF NOT EXISTS shift_checks ( + id INT AUTO_INCREMENT PRIMARY KEY, + website_id INT NOT NULL, + user_id INT NOT NULL, + checked_at DATETIME DEFAULT CURRENT_TIMESTAMP, + user_note TEXT, + FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, + """ + CREATE TABLE IF NOT EXISTS activity_log ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT, + action VARCHAR(100) NOT NULL, + entity VARCHAR(100), + entity_id INT, + detail TEXT, + logged_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, + """ + CREATE TABLE IF NOT EXISTS shifts ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL, + days_of_week VARCHAR(20) NOT NULL DEFAULT '1234567', + start_time TIME NOT NULL DEFAULT '00:00:00', + end_time TIME NOT NULL DEFAULT '23:59:59', + is_active TINYINT(1) NOT NULL DEFAULT 1, + note TEXT, + created_by INT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, + """ + CREATE TABLE IF NOT EXISTS shift_users ( + shift_id INT NOT NULL, + user_id INT NOT NULL, + PRIMARY KEY (shift_id, user_id), + FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, + """ + CREATE TABLE IF NOT EXISTS shift_websites ( + shift_id INT NOT NULL, + website_id INT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + PRIMARY KEY (shift_id, website_id), + FOREIGN KEY (shift_id) REFERENCES shifts(id) ON DELETE CASCADE, + FOREIGN KEY (website_id) REFERENCES websites(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, + """ + CREATE TABLE IF NOT EXISTS login_attempts ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL, + attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP, + ip_address VARCHAR(45), + INDEX idx_username_time (username, attempted_at) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """, + ] + + conn = None + try: + conn = get_connection() + cursor = conn.cursor() + for stmt in ddl_statements: + cursor.execute(stmt) + conn.commit() + logger.info("Database schema initialised successfully.") + + # ── Safe migrations for existing deployments ─────────────────────── + # Add check_type column to websites if it doesn't exist yet + cursor.execute( + """ + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'websites' + AND COLUMN_NAME = 'check_type' + """ + ) + (has_col,) = cursor.fetchone() + if not has_col: + cursor.execute( + "ALTER TABLE websites ADD COLUMN check_type ENUM('daily','weekly') " + "NOT NULL DEFAULT 'daily' AFTER url" + ) + conn.commit() + logger.info("Migration: added check_type column to websites table.") + + # Add failed_attempts column to users if it doesn't exist + cursor.execute( + """ + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'users' + AND COLUMN_NAME = 'failed_attempts' + """ + ) + (has_fa,) = cursor.fetchone() + if not has_fa: + cursor.execute( + "ALTER TABLE users " + "ADD COLUMN failed_attempts TINYINT UNSIGNED NOT NULL DEFAULT 0 AFTER is_active, " + "ADD COLUMN locked_until DATETIME NULL AFTER failed_attempts" + ) + conn.commit() + logger.info("Migration: added failed_attempts and locked_until columns to users table.") + + # Seed default admin if users table is empty + cursor.execute("SELECT COUNT(*) FROM users") + (count,) = cursor.fetchone() + if count == 0: + import bcrypt as _bcrypt + default_pw = _bcrypt.hashpw(b"admin123", _bcrypt.gensalt(rounds=12)).decode("utf-8") + cursor.execute( + "INSERT INTO users (username, password, role, full_name) VALUES (%s,%s,'admin','System Admin')", + ("admin", default_pw) + ) + conn.commit() + logger.info("Default admin account seeded (username: admin / password: admin123).") + cursor.close() + except mysql.connector.Error as e: + logger.error(f"Database initialisation error: {e}") + raise + finally: + if conn: + conn.close() diff --git a/models.py b/models.py new file mode 100644 index 0000000..a31b97e --- /dev/null +++ b/models.py @@ -0,0 +1,1282 @@ +""" +models.py — Data-access layer for all entities. +Each public function logs its action via the activity_log table. +""" + +import hashlib +import logging +import bcrypt +from config import get_connection +from utils.crypto import encrypt as _enc, decrypt as _dec + +logger = logging.getLogger("models") + + +# ─── Password Helpers ───────────────────────────────────────────────────────── + +def _hash_password(password: str) -> str: + """Hash a plaintext password with bcrypt (rounds=12). Returns a str.""" + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8") + + +def _verify_password(password: str, stored: str) -> bool: + """ + Verify a plaintext password against a stored hash. + Supports both bcrypt hashes (current) and legacy SHA-256 hex strings + (64-char hex, no '$' prefix) so existing accounts keep working after upgrade. + Returns True on match. + """ + # Legacy SHA-256 detection: 64 hex chars, no bcrypt prefix + if len(stored) == 64 and stored.isalnum(): + return hashlib.sha256(password.encode("utf-8")).hexdigest() == stored + # bcrypt + try: + return bcrypt.checkpw(password.encode("utf-8"), stored.encode("utf-8")) + except Exception: + return False + + +def _needs_rehash(stored: str) -> bool: + """Return True if the stored hash is a legacy SHA-256 string.""" + return len(stored) == 64 and stored.isalnum() + + +def log_action(user_id, action, entity=None, entity_id=None, detail=None): + """Insert a record into activity_log.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute( + """ + INSERT INTO activity_log (user_id, action, entity, entity_id, detail) + VALUES (%s, %s, %s, %s, %s) + """, + (user_id, action, entity, entity_id, detail) + ) + conn.commit() + cur.close() + logger.info(f"[LOG] user_id={user_id} action={action} entity={entity} id={entity_id} - {detail}") + except Exception as e: + logger.error(f"Failed to write activity log: {e}") + finally: + if conn: + conn.close() + + +# ─── Authentication ──────────────────────────────────────────────────────────── + +def authenticate(username: str, password: str): + """ + Return user dict on success, None on failure. + If the stored hash is a legacy SHA-256 string, transparently rehashes it + with bcrypt on successful login so the account is silently upgraded. + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT * FROM users WHERE username=%s AND is_active=1", + (username,) + ) + user = cur.fetchone() + + if not user or not _verify_password(password, user["password"]): + logger.warning(f"Failed login attempt for username='{username}'.") + cur.close() + record_failed_attempt(username) + return None + + # Transparent bcrypt upgrade for legacy SHA-256 accounts + if _needs_rehash(user["password"]): + new_hash = _hash_password(password) + cur.execute( + "UPDATE users SET password=%s WHERE id=%s", + (new_hash, user["id"]) + ) + conn.commit() + logger.info(f"Password rehashed to bcrypt for user '{username}'.") + + cur.close() + clear_failed_attempts(user["id"]) + log_action(user["id"], "LOGIN", "users", user["id"], + f"User '{username}' logged in.") + return user + finally: + if conn: + conn.close() + + +# ─── Login Rate Limiting ─────────────────────────────────────────────────────── + +MAX_FAILED_ATTEMPTS = 5 # lock after this many consecutive failures +LOCKOUT_MINUTES = 15 # lock duration in minutes + + +def check_login_allowed(username: str) -> tuple[bool, int]: + """ + Check whether the given username is permitted to attempt a login. + + Returns: + (allowed: bool, seconds_remaining: int) + - allowed=True, seconds_remaining=0 => may proceed + - allowed=False, seconds_remaining>0 => account locked; wait N seconds + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT failed_attempts, locked_until FROM users WHERE username=%s", + (username,) + ) + row = cur.fetchone() + cur.close() + + if not row: + return True, 0 # unknown username — let authenticate() handle it + + locked_until = row.get("locked_until") + if locked_until: + import datetime + now = datetime.datetime.now() + if now < locked_until: + remaining = int((locked_until - now).total_seconds()) + return False, remaining + + return True, 0 + finally: + if conn: + conn.close() + + +def record_failed_attempt(username: str): + """ + Increment failed_attempts for the user. + If MAX_FAILED_ATTEMPTS is reached, set locked_until to now + LOCKOUT_MINUTES. + Also inserts a row in login_attempts for the audit trail. + """ + conn = None + try: + import datetime + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT id, failed_attempts FROM users WHERE username=%s", + (username,) + ) + row = cur.fetchone() + + # Always log the attempt regardless of whether the user exists + cur.execute( + "INSERT INTO login_attempts (username) VALUES (%s)", + (username,) + ) + + if row: + new_count = (row["failed_attempts"] or 0) + 1 + locked_until = None + if new_count >= MAX_FAILED_ATTEMPTS: + locked_until = ( + datetime.datetime.now() + + datetime.timedelta(minutes=LOCKOUT_MINUTES) + ) + logger.warning( + f"Account '{username}' locked until {locked_until} " + f"after {new_count} failed attempts." + ) + log_action( + row["id"], "ACCOUNT_LOCKED", "users", row["id"], + f"Account locked after {new_count} failed login attempts." + ) + cur.execute( + "UPDATE users SET failed_attempts=%s, locked_until=%s WHERE id=%s", + (new_count, locked_until, row["id"]) + ) + + conn.commit() + cur.close() + except Exception as e: + logger.error(f"record_failed_attempt error: {e}") + finally: + if conn: + conn.close() + + +def clear_failed_attempts(user_id: int): + """Reset failed_attempts and locked_until on successful login.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute( + "UPDATE users SET failed_attempts=0, locked_until=NULL WHERE id=%s", + (user_id,) + ) + conn.commit() + cur.close() + except Exception as e: + logger.error(f"clear_failed_attempts error: {e}") + finally: + if conn: + conn.close() + + +# ─── Change Password ─────────────────────────────────────────────────────────── + +# Password strength rules +PW_MIN_LENGTH = 8 +PW_REQUIRE_UPPER = True +PW_REQUIRE_DIGIT = True +PW_REQUIRE_SPECIAL = True +_SPECIAL_CHARS = set("!@#$%^&*()_+-=[]{}|;':\",./<>?") + + +def validate_password_strength(password: str) -> list[str]: + """ + Return a list of unmet requirement strings. + Empty list means the password passes all rules. + """ + errors = [] + if len(password) < PW_MIN_LENGTH: + errors.append(f"At least {PW_MIN_LENGTH} characters") + if PW_REQUIRE_UPPER and not any(c.isupper() for c in password): + errors.append("At least one uppercase letter") + if PW_REQUIRE_DIGIT and not any(c.isdigit() for c in password): + errors.append("At least one number") + if PW_REQUIRE_SPECIAL and not any(c in _SPECIAL_CHARS for c in password): + errors.append("At least one special character (!@#$%^&* etc.)") + return errors + + +def change_password(user_id: int, old_password: str, new_password: str) -> tuple[bool, str]: + """ + Change a user's password after verifying the current one. + + Returns: + (success: bool, message: str) + """ + # Strength check first — no DB round-trip needed + issues = validate_password_strength(new_password) + if issues: + return False, "New password does not meet requirements:\n• " + "\n• ".join(issues) + + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute("SELECT password FROM users WHERE id=%s AND is_active=1", (user_id,)) + row = cur.fetchone() + + if not row: + cur.close() + return False, "User account not found." + + if not _verify_password(old_password, row["password"]): + cur.close() + log_action(user_id, "CHANGE_PASSWORD_FAIL", "users", user_id, + "Incorrect current password provided.") + return False, "Current password is incorrect." + + if old_password == new_password: + cur.close() + return False, "New password must differ from the current password." + + cur.execute( + "UPDATE users SET password=%s WHERE id=%s", + (_hash_password(new_password), user_id) + ) + conn.commit() + cur.close() + log_action(user_id, "CHANGE_PASSWORD", "users", user_id, + "Password changed successfully.") + logger.info(f"Password changed for user_id={user_id}.") + return True, "Password changed successfully." + except Exception as e: + logger.error(f"change_password error: {e}") + return False, f"An error occurred: {e}" + finally: + if conn: + conn.close() + + +# ─── User CRUD ──────────────────────────────────────────────────────────────── + +def get_all_users(): + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute("SELECT id, username, role, full_name, is_active, created_at FROM users ORDER BY username") + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +def get_user_by_id(user_id: int): + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute("SELECT id, username, role, full_name, is_active FROM users WHERE id=%s", (user_id,)) + row = cur.fetchone() + cur.close() + return row + finally: + if conn: + conn.close() + + +def create_user(admin_id, username, password, role, full_name): + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute( + "INSERT INTO users (username, password, role, full_name) VALUES (%s,%s,%s,%s)", + (username, _hash_password(password), role, full_name) + ) + conn.commit() + new_id = cur.lastrowid + cur.close() + log_action(admin_id, "CREATE_USER", "users", new_id, + f"Created user '{username}' role='{role}'.") + return new_id + finally: + if conn: + conn.close() + + +def update_user(admin_id, user_id, username, role, full_name, is_active, password=None): + conn = None + try: + conn = get_connection() + cur = conn.cursor() + if password: + cur.execute( + "UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, password=%s WHERE id=%s", + (username, role, full_name, is_active, _hash_password(password), user_id) + ) + else: + cur.execute( + "UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s WHERE id=%s", + (username, role, full_name, is_active, user_id) + ) + conn.commit() + cur.close() + log_action(admin_id, "UPDATE_USER", "users", user_id, + f"Updated user id={user_id}.") + finally: + if conn: + conn.close() + + +def delete_user(admin_id, user_id): + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("DELETE FROM users WHERE id=%s", (user_id,)) + conn.commit() + cur.close() + log_action(admin_id, "DELETE_USER", "users", user_id, f"Deleted user id={user_id}.") + finally: + if conn: + conn.close() + + +# ─── Website CRUD ───────────────────────────────────────────────────────────── + +def get_all_websites(): + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT w.*, u.username AS creator + FROM websites w + LEFT JOIN users u ON u.id = w.created_by + WHERE w.is_active = 1 + ORDER BY w.name + """ + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +def get_website_by_id(website_id: int): + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute("SELECT * FROM websites WHERE id=%s", (website_id,)) + row = cur.fetchone() + cur.close() + return row + finally: + if conn: + conn.close() + + +def create_website(admin_id, name, url, check_type, note, credentials: list): + """ + credentials: list of dicts with keys: username, password, label + check_type: 'daily' or 'weekly' + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute( + "INSERT INTO websites (name, url, check_type, note, created_by) VALUES (%s,%s,%s,%s,%s)", + (name, url, check_type, note, admin_id) + ) + conn.commit() + new_id = cur.lastrowid + + for cred in credentials: + cur.execute( + "INSERT INTO website_credentials (website_id, username, password, label) VALUES (%s,%s,%s,%s)", + (new_id, cred["username"], _enc(cred["password"]), cred.get("label", "")) + ) + log_action(admin_id, "ADD_CREDENTIAL", "website_credentials", new_id, + f"Added credential label='{cred.get('label','')}' " + f"user='{cred['username']}' for website '{name}'.") + conn.commit() + cur.close() + log_action(admin_id, "CREATE_WEBSITE", "websites", new_id, + f"Created website '{name}' check_type='{check_type}'.") + return new_id + finally: + if conn: + conn.close() + + +def update_website(admin_id, website_id, name, url, check_type, note, credentials: list): + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + + # Fetch existing credentials for diff/audit + cur.execute( + "SELECT username, label FROM website_credentials WHERE website_id=%s", + (website_id,) + ) + old_creds = {(r["username"], r["label"] or "") for r in cur.fetchall()} + + cur.execute( + "UPDATE websites SET name=%s, url=%s, check_type=%s, note=%s WHERE id=%s", + (name, url, check_type, note, website_id) + ) + # Replace credentials + cur.execute("DELETE FROM website_credentials WHERE website_id=%s", (website_id,)) + new_creds = set() + for cred in credentials: + cur.execute( + "INSERT INTO website_credentials (website_id, username, password, label) VALUES (%s,%s,%s,%s)", + (website_id, cred["username"], _enc(cred["password"]), cred.get("label", "")) + ) + new_creds.add((cred["username"], cred.get("label", "") or "")) + + conn.commit() + cur.close() + + # Audit: log added and removed credentials + added = new_creds - old_creds + removed = old_creds - new_creds + for user, label in added: + log_action(admin_id, "ADD_CREDENTIAL", "website_credentials", website_id, + f"Added credential label='{label}' user='{user}' " + f"for website id={website_id}.") + for user, label in removed: + log_action(admin_id, "REMOVE_CREDENTIAL", "website_credentials", website_id, + f"Removed credential label='{label}' user='{user}' " + f"from website id={website_id}.") + + log_action(admin_id, "UPDATE_WEBSITE", "websites", website_id, + f"Updated website id={website_id} check_type='{check_type}'.") + finally: + if conn: + conn.close() + + +def delete_website(admin_id, website_id): + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE websites SET is_active=0 WHERE id=%s", (website_id,)) + conn.commit() + cur.close() + log_action(admin_id, "DELETE_WEBSITE", "websites", website_id, f"Soft-deleted website id={website_id}.") + finally: + if conn: + conn.close() + + +def get_website_credentials(website_id: int): + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT * FROM website_credentials WHERE website_id=%s", + (website_id,) + ) + rows = cur.fetchall() + cur.close() + # Decrypt passwords transparently (legacy plaintext passes through unchanged) + for row in rows: + row["password"] = _dec(row["password"]) + return rows + finally: + if conn: + conn.close() + + +# ─── Shift Check CRUD ───────────────────────────────────────────────────────── + +def get_today_checks(user_id: int): + """ + Return all websites the user must check today, with today's check status. + - Daily sites: shown every day. + - Weekly sites: shown only once per ISO week (hidden once checked this week). + Falls back to all active websites when no shifts are assigned. + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + + # Determine if user has any active shifts today + cur.execute( + """ + SELECT COUNT(*) AS cnt + FROM shifts s + JOIN shift_users su ON su.shift_id = s.id + WHERE su.user_id = %s + AND s.is_active = 1 + AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0 + """, + (user_id,) + ) + has_shifts = cur.fetchone()["cnt"] > 0 + + if has_shifts: + cur.execute( + """ + SELECT + w.id, + w.name, + w.url, + w.check_type, + w.note AS site_note, + MIN(sw.sort_order) AS sort_order, + GROUP_CONCAT(DISTINCT s.name ORDER BY s.name SEPARATOR ', ') + AS shift_names, + sc.id AS check_id, + sc.checked_at, + sc.user_note, + sc.user_id AS checked_by_id + FROM shift_websites sw + JOIN shifts s ON s.id = sw.shift_id + JOIN websites w ON w.id = sw.website_id + JOIN shift_users su ON su.shift_id = s.id AND su.user_id = %s + LEFT JOIN shift_checks sc + ON sc.website_id = w.id + AND sc.user_id = %s + AND DATE(sc.checked_at) = CURDATE() + WHERE s.is_active = 1 + AND w.is_active = 1 + AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0 + AND ( + w.check_type = 'daily' + OR ( + w.check_type = 'weekly' + AND NOT EXISTS ( + SELECT 1 FROM shift_checks sc2 + WHERE sc2.website_id = w.id + AND sc2.user_id = %s + AND YEARWEEK(sc2.checked_at, 1) = YEARWEEK(CURDATE(), 1) + ) + ) + ) + GROUP BY w.id, sc.id, sc.checked_at, sc.user_note, sc.user_id + ORDER BY sort_order, w.name + """, + (user_id, user_id, user_id) + ) + else: + # Legacy fallback: show all active websites (original behaviour) + cur.execute( + """ + SELECT + w.id, + w.name, + w.url, + w.check_type, + w.note AS site_note, + 0 AS sort_order, + NULL AS shift_names, + sc.id AS check_id, + sc.checked_at, + sc.user_note, + sc.user_id AS checked_by_id + FROM websites w + LEFT JOIN shift_checks sc + ON sc.website_id = w.id + AND sc.user_id = %s + AND DATE(sc.checked_at) = CURDATE() + WHERE w.is_active = 1 + AND ( + w.check_type = 'daily' + OR ( + w.check_type = 'weekly' + AND NOT EXISTS ( + SELECT 1 FROM shift_checks sc2 + WHERE sc2.website_id = w.id + AND sc2.user_id = %s + AND YEARWEEK(sc2.checked_at, 1) = YEARWEEK(CURDATE(), 1) + ) + ) + ) + ORDER BY w.name + """, + (user_id, user_id) + ) + + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +def mark_website_checked(user_id, website_id, user_note=""): + conn = None + try: + conn = get_connection() + cur = conn.cursor() + # Allow only one check per site per day per user; upsert via delete+insert + cur.execute( + "DELETE FROM shift_checks WHERE website_id=%s AND user_id=%s AND DATE(checked_at)=CURDATE()", + (website_id, user_id) + ) + cur.execute( + "INSERT INTO shift_checks (website_id, user_id, user_note) VALUES (%s,%s,%s)", + (website_id, user_id, user_note) + ) + conn.commit() + new_id = cur.lastrowid + cur.close() + log_action(user_id, "CHECK_WEBSITE", "websites", website_id, + f"User {user_id} checked website {website_id}. Note: {user_note}") + return new_id + finally: + if conn: + conn.close() + + +def update_check_note(user_id, website_id, user_note): + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute( + """ + UPDATE shift_checks SET user_note=%s + WHERE website_id=%s AND user_id=%s AND DATE(checked_at)=CURDATE() + """, + (user_note, website_id, user_id) + ) + conn.commit() + cur.close() + log_action(user_id, "UPDATE_NOTE", "shift_checks", website_id, + f"Updated note for website {website_id}.") + finally: + if conn: + conn.close() + + +def get_activity_log(limit=200): + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT al.*, u.username + FROM activity_log al + LEFT JOIN users u ON u.id = al.user_id + ORDER BY al.logged_at DESC + LIMIT %s + """, + (limit,) + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +# ─── Reports ────────────────────────────────────────────────────────────────── + +def get_shift_report(date_from=None, date_to=None, user_id=None, website_id=None): + """ + Flexible shift-check report query. + All parameters are optional; omitting them returns the full dataset. + Returns list of dicts with columns: + check_date, checked_at, username, full_name, + website_name, url, user_note, status + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + + conditions = [] + params = [] + + if date_from: + conditions.append("DATE(sc.checked_at) >= %s") + params.append(str(date_from)) + if date_to: + conditions.append("DATE(sc.checked_at) <= %s") + params.append(str(date_to)) + if user_id: + conditions.append("sc.user_id = %s") + params.append(user_id) + if website_id: + conditions.append("sc.website_id = %s") + params.append(website_id) + + where_clause = ("WHERE " + " AND ".join(conditions)) if conditions else "" + + cur.execute( + f""" + SELECT + DATE(sc.checked_at) AS check_date, + sc.checked_at, + u.username, + COALESCE(u.full_name, u.username) AS full_name, + w.name AS website_name, + w.url, + COALESCE(sc.user_note, '') AS user_note, + 'Checked' AS status + FROM shift_checks sc + JOIN users u ON u.id = sc.user_id + JOIN websites w ON w.id = sc.website_id + {where_clause} + ORDER BY sc.checked_at DESC, u.username, w.name + """, + params + ) + rows = cur.fetchall() + cur.close() + logger.info(f"Shift report queried - {len(rows)} rows returned.") + return rows + finally: + if conn: + conn.close() + + +def get_unchecked_report(target_date=None, user_id=None): + """ + Return websites that were NOT checked on target_date (default: today) + for the given user (or all users if omitted). + Columns: check_date, username, full_name, website_name, url, status + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + + date_val = str(target_date) if target_date else None + + user_filter = "AND u.id = %s" if user_id else "" + params_inner = [date_val or "CURDATE()"] + if user_id: + params_inner.append(user_id) + + cur.execute( + f""" + SELECT + %s AS check_date, + u.username, + COALESCE(u.full_name, u.username) AS full_name, + w.name AS website_name, + w.url, + 'Not Checked' AS status + FROM websites w + CROSS JOIN users u + WHERE w.is_active = 1 + AND u.is_active = 1 + AND u.role = 'user' + {user_filter} + AND NOT EXISTS ( + SELECT 1 FROM shift_checks sc + WHERE sc.website_id = w.id + AND sc.user_id = u.id + AND DATE(sc.checked_at) = %s + ) + ORDER BY u.username, w.name + """, + ([date_val or "CURDATE()"] + ([user_id] if user_id else []) + [date_val or "CURDATE()"]) + ) + rows = cur.fetchall() + cur.close() + logger.info(f"Unchecked report queried - {len(rows)} rows returned.") + return rows + finally: + if conn: + conn.close() + + +def get_summary_report(date_from=None, date_to=None): + """ + Per-user per-day summary: total sites checked vs total active sites. + Columns: check_date, username, full_name, checked_count, total_sites, pct_complete + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + + conditions = [] + params = [] + if date_from: + conditions.append("DATE(sc.checked_at) >= %s") + params.append(str(date_from)) + if date_to: + conditions.append("DATE(sc.checked_at) <= %s") + params.append(str(date_to)) + + where_clause = ("WHERE " + " AND ".join(conditions)) if conditions else "" + + cur.execute( + f""" + SELECT + DATE(sc.checked_at) AS check_date, + u.username, + COALESCE(u.full_name, u.username) AS full_name, + COUNT(DISTINCT sc.website_id) AS checked_count, + (SELECT COUNT(*) FROM websites WHERE is_active=1) AS total_sites, + ROUND( + COUNT(DISTINCT sc.website_id) * 100.0 / + NULLIF((SELECT COUNT(*) FROM websites WHERE is_active=1), 0), + 1 + ) AS pct_complete + FROM shift_checks sc + JOIN users u ON u.id = sc.user_id + {where_clause} + GROUP BY DATE(sc.checked_at), sc.user_id + ORDER BY check_date DESC, u.username + """, + params + ) + rows = cur.fetchall() + cur.close() + logger.info(f"Summary report queried - {len(rows)} rows returned.") + return rows + finally: + if conn: + conn.close() + + +def get_report_filter_options(): + """Return (users_list, websites_list) for populating filter dropdowns.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT id, username, full_name FROM users WHERE is_active=1 ORDER BY username" + ) + users = cur.fetchall() + cur.execute( + "SELECT id, name FROM websites WHERE is_active=1 ORDER BY name" + ) + websites = cur.fetchall() + cur.close() + return users, websites + finally: + if conn: + conn.close() + + +# ─── Shift Management ───────────────────────────────────────────────────────── + +def get_all_shifts(): + """Return all shifts with assigned user count and website count.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT + s.*, + u.username AS creator, + COUNT(DISTINCT su.user_id) AS user_count, + COUNT(DISTINCT sw.website_id) AS website_count + FROM shifts s + LEFT JOIN users u ON u.id = s.created_by + LEFT JOIN shift_users su ON su.shift_id = s.id + LEFT JOIN shift_websites sw ON sw.shift_id = s.id + GROUP BY s.id + ORDER BY s.name + """ + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +def get_shift_by_id(shift_id: int): + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute("SELECT * FROM shifts WHERE id=%s", (shift_id,)) + row = cur.fetchone() + cur.close() + return row + finally: + if conn: + conn.close() + + +def get_shift_assigned_users(shift_id: int): + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT u.id, u.username, u.full_name + FROM shift_users su + JOIN users u ON u.id = su.user_id + WHERE su.shift_id = %s + ORDER BY u.username + """, + (shift_id,) + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +def get_shift_assigned_websites(shift_id: int): + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT w.id, w.name, w.url, sw.sort_order + FROM shift_websites sw + JOIN websites w ON w.id = sw.website_id + WHERE sw.shift_id = %s + ORDER BY sw.sort_order, w.name + """, + (shift_id,) + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +def create_shift(admin_id, name, days_of_week, start_time, end_time, + note, user_ids: list, website_ids: list): + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute( + """ + INSERT INTO shifts (name, days_of_week, start_time, end_time, note, created_by) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (name, days_of_week, start_time, end_time, note, admin_id) + ) + conn.commit() + shift_id = cur.lastrowid + + for uid in user_ids: + cur.execute( + "INSERT IGNORE INTO shift_users (shift_id, user_id) VALUES (%s, %s)", + (shift_id, uid) + ) + for idx, wid in enumerate(website_ids): + cur.execute( + "INSERT IGNORE INTO shift_websites (shift_id, website_id, sort_order) VALUES (%s, %s, %s)", + (shift_id, wid, idx) + ) + conn.commit() + cur.close() + log_action(admin_id, "CREATE_SHIFT", "shifts", shift_id, + f"Created shift '{name}' days={days_of_week} " + f"users={user_ids} websites={website_ids}.") + return shift_id + finally: + if conn: + conn.close() + + +def update_shift(admin_id, shift_id, name, days_of_week, start_time, end_time, + note, is_active, user_ids: list, website_ids: list): + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute( + """ + UPDATE shifts + SET name=%s, days_of_week=%s, start_time=%s, end_time=%s, + note=%s, is_active=%s + WHERE id=%s + """, + (name, days_of_week, start_time, end_time, note, is_active, shift_id) + ) + # Replace user assignments + cur.execute("DELETE FROM shift_users WHERE shift_id=%s", (shift_id,)) + for uid in user_ids: + cur.execute( + "INSERT IGNORE INTO shift_users (shift_id, user_id) VALUES (%s, %s)", + (shift_id, uid) + ) + # Replace website assignments + cur.execute("DELETE FROM shift_websites WHERE shift_id=%s", (shift_id,)) + for idx, wid in enumerate(website_ids): + cur.execute( + "INSERT IGNORE INTO shift_websites (shift_id, website_id, sort_order) VALUES (%s, %s, %s)", + (shift_id, wid, idx) + ) + conn.commit() + cur.close() + log_action(admin_id, "UPDATE_SHIFT", "shifts", shift_id, + f"Updated shift id={shift_id} name='{name}' active={is_active}.") + finally: + if conn: + conn.close() + + +def delete_shift(admin_id, shift_id): + """Soft-delete: mark inactive. Preserves shift_checks history.""" + conn = None + try: + conn = get_connection() + cur = conn.cursor() + cur.execute("UPDATE shifts SET is_active=0 WHERE id=%s", (shift_id,)) + conn.commit() + cur.close() + log_action(admin_id, "DELETE_SHIFT", "shifts", shift_id, + f"Soft-deleted shift id={shift_id}.") + finally: + if conn: + conn.close() + + +def get_user_active_shifts(user_id: int): + """ + Return all active shifts assigned to a user that are scheduled for today + (day-of-week match only; time window is informational for now). + MySQL DAYOFWEEK: 1=Sunday … 7=Saturday. + We store days as a string of digits e.g. '1234567' or '23456'. + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT s.* + FROM shifts s + JOIN shift_users su ON su.shift_id = s.id + WHERE su.user_id = %s + AND s.is_active = 1 + AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0 + ORDER BY s.start_time + """, + (user_id,) + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + + +def get_today_checks_for_shift(user_id: int, shift_id: int): + """ + Return websites assigned to a specific shift with today's check status + for the given user. + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT + w.id, + w.name, + w.url, + w.note AS site_note, + sw.sort_order, + sc.id AS check_id, + sc.checked_at, + sc.user_note, + sc.user_id AS checked_by_id + FROM shift_websites sw + JOIN websites w ON w.id = sw.website_id + LEFT JOIN shift_checks sc + ON sc.website_id = w.id + AND sc.user_id = %s + AND DATE(sc.checked_at) = CURDATE() + WHERE sw.shift_id = %s + AND w.is_active = 1 + ORDER BY sw.sort_order, w.name + """, + (user_id, shift_id) + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() + +# ─── Admin Dashboard ────────────────────────────────────────────────────────── + +def get_admin_dashboard_stats(): + """ + Return today's completion stats for ALL active regular users. + Columns: user_id, username, full_name, checked_count, total_sites, pct_complete + Also returns total_sites and total_users as separate scalars. + """ + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + + # Per-user completion for today + cur.execute( + """ + SELECT + u.id AS user_id, + u.username, + COALESCE(u.full_name, u.username) AS full_name, + COUNT(DISTINCT sc.website_id) AS checked_count, + ( + SELECT COUNT(DISTINCT sw2.website_id) + FROM shift_websites sw2 + JOIN shifts s2 ON s2.id = sw2.shift_id + JOIN shift_users su2 ON su2.shift_id = s2.id AND su2.user_id = u.id + WHERE s2.is_active = 1 + AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s2.days_of_week) > 0 + ) AS total_sites, + ROUND( + COUNT(DISTINCT sc.website_id) * 100.0 / + NULLIF(( + SELECT COUNT(DISTINCT sw2.website_id) + FROM shift_websites sw2 + JOIN shifts s2 ON s2.id = sw2.shift_id + JOIN shift_users su2 ON su2.shift_id = s2.id AND su2.user_id = u.id + WHERE s2.is_active = 1 + AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s2.days_of_week) > 0 + ), 0), + 1 + ) AS pct_complete + FROM users u + LEFT JOIN shift_checks sc + ON sc.user_id = u.id + AND DATE(sc.checked_at) = CURDATE() + WHERE u.is_active = 1 + AND u.role = 'user' + GROUP BY u.id + ORDER BY pct_complete DESC, u.username + """ + ) + user_stats = cur.fetchall() + + # Overall totals + cur.execute("SELECT COUNT(*) AS n FROM websites WHERE is_active=1") + total_sites = cur.fetchone()["n"] + + cur.execute("SELECT COUNT(*) AS n FROM users WHERE is_active=1 AND role='user'") + total_users = cur.fetchone()["n"] + + cur.execute( + "SELECT COUNT(DISTINCT user_id) AS n FROM shift_checks WHERE DATE(checked_at)=CURDATE()" + ) + active_today = cur.fetchone()["n"] + + cur.close() + return { + "user_stats": user_stats, + "total_sites": total_sites, + "total_users": total_users, + "active_today": active_today, + } + finally: + if conn: + conn.close() + + +def get_unchecked_sites_for_user(user_id: int): + """Return unchecked sites for a user today (for notification system).""" + conn = None + try: + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + """ + SELECT w.id, w.name, s.end_time + FROM shift_websites sw + JOIN shifts s ON s.id = sw.shift_id + JOIN websites w ON w.id = sw.website_id + JOIN shift_users su ON su.shift_id = s.id AND su.user_id = %s + WHERE s.is_active = 1 + AND w.is_active = 1 + AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0 + AND NOT EXISTS ( + SELECT 1 FROM shift_checks sc + WHERE sc.website_id = w.id + AND sc.user_id = %s + AND DATE(sc.checked_at) = CURDATE() + ) + GROUP BY w.id, s.end_time + ORDER BY s.end_time, w.name + """, + (user_id, user_id) + ) + rows = cur.fetchall() + cur.close() + return rows + finally: + if conn: + conn.close() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fbcdd66 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +mysql-connector-python>=8.0.0 +bcrypt>=4.0.0 +openpyxl>=3.1.0 +cryptography>=41.0.0 +matplotlib>=3.7.0 +plyer>=2.1.0 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..db3e327 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +# utils package diff --git a/utils/crypto.py b/utils/crypto.py new file mode 100644 index 0000000..771e640 --- /dev/null +++ b/utils/crypto.py @@ -0,0 +1,125 @@ +""" +utils/crypto.py — Fernet symmetric encryption for website credentials. + +Key derivation: + - A 32-byte random salt is generated on first use and stored in config.ini + under [crypto] / salt. + - The Fernet key is derived from the salt + a fixed application secret + using PBKDF2-HMAC-SHA256 (100,000 iterations). + - This means credentials are tied to the specific config.ini file on the + operator's machine; moving config.ini to another machine retains access. + +Migration: + - _decrypt() tries Fernet first; if that fails it returns the raw value + unchanged so that plaintext legacy credentials are still readable. + - Callers should re-encrypt on next write (update_website handles this). +""" + +import base64 +import logging +import os +import configparser + +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes + +logger = logging.getLogger("crypto") + +_APP_SECRET = b"WebsiteChecker-v1-CredentialKey" +_CONFIG_FILE = "config.ini" +_ITERATIONS = 100_000 +_fernet: "Fernet | None" = None + + +# ─── Key bootstrap ──────────────────────────────────────────────────────────── + +def _get_or_create_salt() -> bytes: + """Read salt from config.ini [crypto] section; create and persist if absent.""" + cfg = configparser.ConfigParser() + cfg.read(_CONFIG_FILE, encoding="utf-8") + + if "crypto" in cfg and cfg["crypto"].get("salt"): + return base64.b64decode(cfg["crypto"]["salt"]) + + # Generate a fresh 32-byte salt + salt = os.urandom(32) + if "crypto" not in cfg: + cfg["crypto"] = {} + cfg["crypto"]["salt"] = base64.b64encode(salt).decode("ascii") + + with open(_CONFIG_FILE, "w", encoding="utf-8") as fh: + cfg.write(fh) + logger.info("Crypto: generated and persisted new credential encryption salt.") + return salt + + +def _build_fernet() -> Fernet: + salt = _get_or_create_salt() + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=_ITERATIONS, + ) + key = base64.urlsafe_b64encode(kdf.derive(_APP_SECRET)) + return Fernet(key) + + +def _get_fernet() -> Fernet: + global _fernet + if _fernet is None: + _fernet = _build_fernet() + return _fernet + + +def reset_fernet(): + """Force key reload — call after config.ini is replaced (e.g. settings save).""" + global _fernet + _fernet = None + + +# ─── Public API ─────────────────────────────────────────────────────────────── + +def encrypt(plaintext: str) -> str: + """ + Encrypt a plaintext string. Returns a UTF-8-safe ciphertext string + prefixed with 'enc:' so we can detect encrypted values reliably. + Returns the original string unchanged if plaintext is empty. + """ + if not plaintext: + return plaintext + try: + token = _get_fernet().encrypt(plaintext.encode("utf-8")) + return "enc:" + token.decode("ascii") + except Exception as e: + logger.error(f"Credential encryption failed: {e}") + return plaintext # safe fallback — don't lose data + + +def decrypt(ciphertext: str) -> str: + """ + Decrypt a ciphertext string produced by encrypt(). + - If ciphertext starts with 'enc:', decrypts with Fernet. + - Otherwise returns the value as-is (plaintext legacy credential). + Returns empty string on failure. + """ + if not ciphertext: + return ciphertext + if not ciphertext.startswith("enc:"): + # Legacy plaintext — return unchanged; will be re-encrypted on next save + return ciphertext + try: + token = ciphertext[4:].encode("ascii") + return _get_fernet().decrypt(token).decode("utf-8") + except InvalidToken: + logger.error("Credential decryption failed — wrong key or corrupted data.") + return "" + except Exception as e: + logger.error(f"Credential decryption error: {e}") + return "" + + +def is_encrypted(value: str) -> bool: + """Return True if the value was produced by encrypt().""" + return isinstance(value, str) and value.startswith("enc:") diff --git a/utils/export.py b/utils/export.py new file mode 100644 index 0000000..8c01dd0 --- /dev/null +++ b/utils/export.py @@ -0,0 +1,110 @@ +""" +utils/export.py — CSV and Excel export helpers for report data. +""" + +import csv +import logging +import os +from datetime import datetime + +logger = logging.getLogger("export") + + +def _timestamp() -> str: + return datetime.now().strftime("%Y%m%d_%H%M%S") + + +def export_csv(rows: list, columns: list, base_filename: str, save_dir: str) -> str: + """ + Write rows (list of dicts) to a CSV file. + Returns the full path of the written file. + """ + filename = f"{base_filename}_{_timestamp()}.csv" + filepath = os.path.join(save_dir, filename) + + try: + with open(filepath, "w", newline="", encoding="utf-8-sig") as fh: + writer = csv.DictWriter(fh, fieldnames=columns, extrasaction="ignore") + writer.writeheader() + for row in rows: + # Convert non-string types (date, datetime) to string + clean = {k: (str(v) if v is not None else "") for k, v in row.items()} + writer.writerow(clean) + logger.info(f"[EXPORT] CSV written: {filepath} ({len(rows)} rows)") + return filepath + except Exception as e: + logger.error(f"CSV export failed: {e}") + raise + + +def export_excel(rows: list, columns: list, base_filename: str, + save_dir: str, sheet_title: str = "Report") -> str: + """ + Write rows (list of dicts) to an .xlsx file with basic formatting. + Returns the full path of the written file. + """ + try: + import openpyxl + from openpyxl.styles import Font, PatternFill, Alignment, Border, Side + except ImportError: + raise RuntimeError( + "openpyxl is required for Excel export.\n" + "Install it with: pip install openpyxl" + ) + + filename = f"{base_filename}_{_timestamp()}.xlsx" + filepath = os.path.join(save_dir, filename) + + wb = openpyxl.Workbook() + ws = wb.active + ws.title = sheet_title[:31] # Excel sheet name limit + + # ── Header style ────────────────────────────────────────────────────────── + header_fill = PatternFill("solid", fgColor="7C6AF7") # accent purple + header_font = Font(bold=True, color="FFFFFF", name="Calibri", size=11) + header_align = Alignment(horizontal="center", vertical="center", wrap_text=True) + thin_border = Border( + bottom=Side(style="thin", color="45475A"), + right=Side(style="thin", color="45475A"), + ) + + # ── Write header row ────────────────────────────────────────────────────── + for col_idx, col_name in enumerate(columns, start=1): + cell = ws.cell(row=1, column=col_idx, value=col_name.replace("_", " ").title()) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_align + cell.border = thin_border + + ws.row_dimensions[1].height = 22 + + # ── Write data rows ─────────────────────────────────────────────────────── + alt_fill = PatternFill("solid", fgColor="2A2A3E") + for row_idx, row in enumerate(rows, start=2): + fill = alt_fill if row_idx % 2 == 0 else PatternFill("solid", fgColor="1E1E2E") + for col_idx, col_name in enumerate(columns, start=1): + val = row.get(col_name) + if val is None: + val = "" + cell = ws.cell(row=row_idx, column=col_idx, value=str(val)) + cell.font = Font(name="Calibri", size=10, color="CDD6F4") + cell.fill = fill + cell.alignment = Alignment(vertical="center", wrap_text=False) + cell.border = thin_border + + # ── Auto-width columns (capped at 60) ───────────────────────────────────── + for col_idx, col_name in enumerate(columns, start=1): + col_letter = openpyxl.utils.get_column_letter(col_idx) + header_len = len(col_name.replace("_", " ").title()) + max_data_len = max( + (len(str(row.get(col_name) or "")) for row in rows), + default=0 + ) + ws.column_dimensions[col_letter].width = min(max(header_len, max_data_len) + 4, 60) + + # ── Freeze top row ──────────────────────────────────────────────────────── + ws.freeze_panes = "A2" + + wb.save(filepath) + logger.info(f"[EXPORT] Excel written: {filepath} ({len(rows)} rows)") + return filepath diff --git a/utils/scheduler.py b/utils/scheduler.py new file mode 100644 index 0000000..cab9ca8 --- /dev/null +++ b/utils/scheduler.py @@ -0,0 +1,219 @@ +""" +utils/scheduler.py — Daily completion report email scheduler. + +Runs a background daemon thread that wakes every minute, checks whether +the configured send_time (HH:MM) has been reached today, and sends the +summary report via SMTP if it hasn't been sent yet. + +Configuration (config.ini [email] section): + enabled = true/false + smtp_host = smtp.example.com + smtp_port = 587 + smtp_user = sender@example.com + smtp_password= secret + use_tls = true + recipients = admin@example.com, manager@example.com + send_time = 18:00 (24-hour HH:MM, local time) + +Call start() once after login succeeds (admin only). +Call stop() on logout/shutdown. +""" + +import configparser +import datetime +import logging +import os +import smtplib +import threading +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +logger = logging.getLogger("scheduler") + +CONFIG_FILE = "config.ini" +_scheduler_thread: "threading.Thread | None" = None +_stop_event = threading.Event() + + +# ─── Config helpers ─────────────────────────────────────────────────────────── + +def load_email_config() -> dict: + cfg = configparser.ConfigParser() + if not os.path.exists(CONFIG_FILE): + return {} + cfg.read(CONFIG_FILE, encoding="utf-8") + if "email" not in cfg: + return {} + s = cfg["email"] + return { + "enabled": s.getboolean("enabled", fallback=False), + "smtp_host": s.get("smtp_host", ""), + "smtp_port": s.getint("smtp_port", fallback=587), + "smtp_user": s.get("smtp_user", ""), + "smtp_password": s.get("smtp_password", ""), + "use_tls": s.getboolean("use_tls", fallback=True), + "recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()], + "send_time": s.get("send_time", "18:00"), + } + + +def save_email_config(enabled: bool, smtp_host: str, smtp_port: int, + smtp_user: str, smtp_password: str, use_tls: bool, + recipients: str, send_time: str): + cfg = configparser.ConfigParser() + cfg.read(CONFIG_FILE, encoding="utf-8") + cfg["email"] = { + "enabled": str(enabled).lower(), + "smtp_host": smtp_host, + "smtp_port": str(smtp_port), + "smtp_user": smtp_user, + "smtp_password": smtp_password, + "use_tls": str(use_tls).lower(), + "recipients": recipients, + "send_time": send_time, + } + with open(CONFIG_FILE, "w", encoding="utf-8") as fh: + cfg.write(fh) + logger.info("Email configuration saved.") + + +def test_smtp_connection(smtp_host, smtp_port, smtp_user, smtp_password, use_tls) -> tuple: + """ + Attempt a connection without sending mail. + Returns (success: bool, message: str). + """ + try: + if use_tls: + server = smtplib.SMTP(smtp_host, smtp_port, timeout=8) + server.starttls() + else: + server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=8) + server.login(smtp_user, smtp_password) + server.quit() + return True, "Connection successful." + except Exception as e: + return False, str(e) + + +# ─── Report builder ─────────────────────────────────────────────────────────── + +def _build_html_report() -> str: + """Build a simple HTML daily summary table.""" + try: + from models import get_admin_dashboard_stats + stats = get_admin_dashboard_stats() + except Exception as e: + return f"

Error generating report: {e}

" + + today = datetime.date.today().strftime("%A, %d %B %Y") + rows = stats.get("user_stats", []) + + table_rows = "" + for r in rows: + pct = float(r.get("pct_complete") or 0) + color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f" + table_rows += ( + f"" + f"{r['username']}" + f"{r['full_name'] or ''}" + f"{int(r['checked_count'] or 0)}" + f"{int(r['total_sites'] or 0)}" + f"{pct:.0f}%" + f"" + ) + + return f""" + +

Website Checker — Daily Report

+

{today}

+ + + + + + + + + + + {table_rows} +
UsernameFull NameCheckedTotalCompletion
+

+ Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}. +

+ +""" + + +def _send_report(cfg: dict): + """Build and send the daily report email.""" + html = _build_html_report() + today = datetime.date.today().strftime("%d %b %Y") + subject = f"Website Checker — Daily Report {today}" + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = cfg["smtp_user"] + msg["To"] = ", ".join(cfg["recipients"]) + msg.attach(MIMEText(html, "html", "utf-8")) + + try: + if cfg["use_tls"]: + server = smtplib.SMTP(cfg["smtp_host"], cfg["smtp_port"], timeout=10) + server.starttls() + else: + server = smtplib.SMTP_SSL(cfg["smtp_host"], cfg["smtp_port"], timeout=10) + server.login(cfg["smtp_user"], cfg["smtp_password"]) + server.sendmail(cfg["smtp_user"], cfg["recipients"], msg.as_string()) + server.quit() + logger.info(f"Daily report emailed to: {cfg['recipients']}") + except Exception as e: + logger.error(f"Failed to send daily report email: {e}") + + +# ─── Scheduler loop ─────────────────────────────────────────────────────────── + +def _scheduler_loop(): + last_sent_date = None + + while not _stop_event.is_set(): + _stop_event.wait(60) # sleep 60 seconds between checks + if _stop_event.is_set(): + break + + cfg = load_email_config() + if not cfg.get("enabled") or not cfg.get("smtp_host") or not cfg.get("recipients"): + continue + + try: + send_h, send_m = map(int, cfg["send_time"].split(":")) + except Exception: + continue + + now = datetime.datetime.now() + today = now.date() + + if (now.hour > send_h or (now.hour == send_h and now.minute >= send_m)): + if last_sent_date != today: + last_sent_date = today + logger.info(f"Scheduler: sending daily report at {now.strftime('%H:%M')}.") + _send_report(cfg) + + +def start(): + """Start the background scheduler thread. Call once after successful login.""" + global _scheduler_thread, _stop_event + _stop_event.clear() + _scheduler_thread = threading.Thread(target=_scheduler_loop, + name="EmailScheduler", daemon=True) + _scheduler_thread.start() + logger.info("Email scheduler started.") + + +def stop(): + """Signal the scheduler to stop cleanly.""" + global _stop_event + _stop_event.set() + logger.info("Email scheduler stopped.") diff --git a/utils/ui_helpers.py b/utils/ui_helpers.py new file mode 100644 index 0000000..1195ae5 --- /dev/null +++ b/utils/ui_helpers.py @@ -0,0 +1,490 @@ +""" +utils/ui_helpers.py — Shared UI constants, theme helpers, and reusable widgets. + +Key additions vs previous version: + • ThemeManager — singleton that owns the active palette and reapplies + ttk styles + rebuilds the app shell on toggle + • THEMES — dark and light colour palettes + • DateEntry — Entry + calendar popup (no third-party libs required) + • scrolled_text — now reads colours from ThemeManager so it re-themes +""" + +import calendar +import tkinter as tk +from tkinter import ttk, messagebox +from datetime import date + +# ─── Font Constants (theme-independent) ─────────────────────────────────────── +FONT_FAMILY = "Segoe UI" +FONT = (FONT_FAMILY, 10) +FONT_BOLD = (FONT_FAMILY, 10, "bold") +FONT_TITLE = (FONT_FAMILY, 16, "bold") +FONT_HEADING = (FONT_FAMILY, 12, "bold") +FONT_SMALL = (FONT_FAMILY, 9) + +# ─── Colour Palettes ────────────────────────────────────────────────────────── +THEMES = { + "dark": { + "bg": "#1e1e2e", + "surface": "#2a2a3e", + "surface2": "#313150", + "accent": "#7c6af7", + "accent_hover": "#9d8fff", + "danger": "#e05c5c", + "success": "#4caf50", + "warning": "#f0a500", + "text": "#cdd6f4", + "text_dim": "#6c7086", + "border": "#45475a", + "checked": "#4caf50", + "unchecked": "#e05c5c", + "white": "#ffffff", + "cal_header": "#313150", + "cal_weekend": "#e05c5c", + "cal_today": "#7c6af7", + "cal_selected": "#4caf50", + }, + "light": { + "bg": "#f5f5fa", + "surface": "#ffffff", + "surface2": "#e8e8f0", + "accent": "#5b4de8", + "accent_hover": "#7c6af7", + "danger": "#d32f2f", + "success": "#388e3c", + "warning": "#f57c00", + "text": "#1a1a2e", + "text_dim": "#6b6b80", + "border": "#c5c5d8", + "checked": "#388e3c", + "unchecked": "#d32f2f", + "white": "#ffffff", + "cal_header": "#e0dff8", + "cal_weekend": "#d32f2f", + "cal_today": "#5b4de8", + "cal_selected": "#388e3c", + }, +} + + +# ─── ThemeManager (singleton) ───────────────────────────────────────────────── + +class ThemeManager: + _instance = None + + def __init__(self, root, initial="dark"): + ThemeManager._instance = self + self._root = root + self._current = initial + self.apply() + + @classmethod + def get(cls): + if cls._instance is None: + return THEMES["dark"] + return THEMES[cls._instance._current] + + @property + def is_dark(self): + return self._current == "dark" + + def toggle(self, rebuild_callback=None): + self._current = "light" if self._current == "dark" else "dark" + self.apply() + if rebuild_callback: + rebuild_callback() + + def apply(self): + C = THEMES[self._current] + style = ttk.Style(self._root) + style.theme_use("clam") + + style.configure(".", + background=C["bg"], foreground=C["text"], + fieldbackground=C["surface"], bordercolor=C["border"], + relief="flat", font=FONT) + + style.configure("TFrame", background=C["bg"]) + style.configure("Surface.TFrame", background=C["surface"]) + + style.configure("TLabel", + background=C["bg"], foreground=C["text"], font=FONT) + style.configure("Title.TLabel", + font=FONT_TITLE, foreground=C["accent"]) + style.configure("Heading.TLabel", + font=FONT_HEADING, foreground=C["text"]) + style.configure("Dim.TLabel", + foreground=C["text_dim"], font=FONT_SMALL) + + style.configure("TEntry", + fieldbackground=C["surface2"], foreground=C["text"], + insertcolor=C["text"], bordercolor=C["border"], + relief="flat", padding=6) + + style.configure("TCombobox", + fieldbackground=C["surface2"], foreground=C["text"], + selectbackground=C["accent"], selectforeground=C["white"]) + style.map("TCombobox", + fieldbackground=[("readonly", C["surface2"])], + foreground=[("readonly", C["text"])]) + + style.configure("TButton", + background=C["accent"], foreground=C["white"], + padding=(12, 6), relief="flat", font=FONT_BOLD) + style.map("TButton", + background=[("active", C["accent_hover"])], + relief=[("active", "flat")]) + + style.configure("Danger.TButton", + background=C["danger"], foreground=C["white"]) + style.map("Danger.TButton", + background=[("active", "#c94444" if self.is_dark else "#b71c1c")]) + + style.configure("Success.TButton", + background=C["success"], foreground=C["white"]) + style.map("Success.TButton", + background=[("active", "#3d9140" if self.is_dark else "#2e7d32")]) + + style.configure("Ghost.TButton", + background=C["surface"], foreground=C["text"], relief="flat") + style.map("Ghost.TButton", + background=[("active", C["surface2"])]) + + style.configure("Treeview", + background=C["surface"], foreground=C["text"], + fieldbackground=C["surface"], rowheight=30, font=FONT) + style.configure("Treeview.Heading", + background=C["surface2"], foreground=C["accent"], + font=FONT_BOLD, relief="flat") + style.map("Treeview", + background=[("selected", C["accent"])], + foreground=[("selected", C["white"])]) + + style.configure("TNotebook", + background=C["bg"], bordercolor=C["border"]) + style.configure("TNotebook.Tab", + background=C["surface"], foreground=C["text_dim"], + padding=(16, 8), font=FONT_BOLD) + style.map("TNotebook.Tab", + background=[("selected", C["accent"])], + foreground=[("selected", C["white"])]) + + style.configure("TScrollbar", + background=C["surface2"], troughcolor=C["bg"], + bordercolor=C["bg"], arrowcolor=C["text_dim"]) + + style.configure("TCheckbutton", + background=C["bg"], foreground=C["text"], font=FONT) + + style.configure("TProgressbar", + troughcolor=C["surface2"], background=C["accent"]) + + style.configure("TSeparator", background=C["border"]) + + self._root.configure(bg=C["bg"]) + + +# ─── Colour proxy — keeps `COLOURS["key"]` syntax working everywhere ────────── + +class _ColourProxy(dict): + def __getitem__(self, key): + return ThemeManager.get()[key] + def get(self, key, default=None): + return ThemeManager.get().get(key, default) + + +COLOURS = _ColourProxy() + + +# ─── Legacy shim ────────────────────────────────────────────────────────────── + +def apply_theme(root, mode="dark"): + """Backwards-compatible shim. Prefer ThemeManager directly.""" + ThemeManager(root, initial=mode) + + +# ─── DateEntry — Entry + calendar popup ─────────────────────────────────────── + +class DateEntry(tk.Frame): + """ + Themed date-picker: read-only Entry showing YYYY-MM-DD plus a calendar + button that opens a month-grid popup. + + de = DateEntry(parent, initial_date="2025-04-20") + de.grid(row=0, column=1, sticky="ew") + value = de.get() # "2025-04-21" + de.set("2025-05-01") + """ + + def __init__(self, parent, initial_date="", width=12, **kwargs): + C = ThemeManager.get() + super().__init__(parent, bg=C["bg"], **kwargs) + + try: + self._date = date.fromisoformat(initial_date) if initial_date else date.today() + except ValueError: + self._date = date.today() + + self._var = tk.StringVar(value=self._date.isoformat()) + + self._entry = tk.Entry( + self, textvariable=self._var, width=width, + bg=C["surface2"], fg=C["text"], + insertbackground=C["text"], + relief="flat", font=FONT, + state="readonly", + readonlybackground=C["surface2"], + ) + self._entry.pack(side="left", ipady=5, padx=(0, 2)) + + self._btn = tk.Button( + self, text="📅", + command=self._open_popup, + bg=C["surface2"], fg=C["text"], + activebackground=C["accent"], + activeforeground=C["white"], + relief="flat", font=FONT, + cursor="hand2", padx=4, + ) + self._btn.pack(side="left") + + def get(self): + return self._var.get() + + def set(self, value): + try: + self._date = date.fromisoformat(value) + self._var.set(self._date.isoformat()) + except (ValueError, TypeError): + pass + + def _open_popup(self): + _CalendarPopup(self, self._date, callback=self._on_date_selected) + + def _on_date_selected(self, selected): + self._date = selected + self._var.set(selected.isoformat()) + + +class _CalendarPopup(tk.Toplevel): + """Month-grid calendar popup used by DateEntry.""" + + def __init__(self, anchor_widget, initial, callback): + super().__init__(anchor_widget) + self.callback = callback + self._year = initial.year + self._month = initial.month + self._selected = initial + + self.overrideredirect(True) + self.resizable(False, False) + C = ThemeManager.get() + self.configure(bg=C["border"]) + + self._build() + self._position(anchor_widget) + self.grab_set() + self.focus_set() + self.bind("", lambda _: self.destroy()) + + def _position(self, anchor): + anchor.update_idletasks() + x = anchor.winfo_rootx() + y = anchor.winfo_rooty() + anchor.winfo_height() + 2 + self.geometry(f"+{x}+{y}") + + def _build(self): + C = ThemeManager.get() + + outer = tk.Frame(self, bg=C["surface"], padx=2, pady=2) + outer.pack(fill="both", expand=True) + + # Navigation + nav = tk.Frame(outer, bg=C["cal_header"]) + nav.pack(fill="x") + + def nav_btn(text, cmd): + return tk.Button( + nav, text=text, command=cmd, + bg=C["cal_header"], fg=C["text"], + activebackground=C["accent"], activeforeground=C["white"], + relief="flat", font=FONT_BOLD, cursor="hand2", + padx=8, pady=4, + ) + + nav_btn("◀◀", self._prev_year).pack(side="left") + nav_btn("◀", self._prev_month).pack(side="left") + self._header_lbl = tk.Label( + nav, bg=C["cal_header"], fg=C["text"], font=FONT_BOLD) + self._header_lbl.pack(side="left", expand=True, fill="x") + nav_btn("▶", self._next_month).pack(side="right") + nav_btn("▶▶", self._next_year).pack(side="right") + + # Day-name headers + hdr = tk.Frame(outer, bg=C["surface"]) + hdr.pack(fill="x") + for i, dn in enumerate(["Mo","Tu","We","Th","Fr","Sa","Su"]): + fg = C["cal_weekend"] if i >= 5 else C["text_dim"] + tk.Label(hdr, text=dn, bg=C["surface"], fg=fg, + font=FONT_SMALL, width=4, anchor="center").grid( + row=0, column=i, padx=1, pady=2) + + self._grid_frame = tk.Frame(outer, bg=C["surface"]) + self._grid_frame.pack(fill="both", expand=True) + + tk.Button( + outer, text="Today", + command=self._select_today, + bg=C["cal_today"], fg=C["white"], + activebackground=C["accent_hover"], + activeforeground=C["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", pady=3, + ).pack(fill="x", pady=(4, 2)) + + self._render_month() + + def _render_month(self): + C = ThemeManager.get() + for w in self._grid_frame.winfo_children(): + w.destroy() + + self._header_lbl.config( + text=f"{calendar.month_name[self._month]} {self._year}") + today = date.today() + cal = calendar.monthcalendar(self._year, self._month) + + for row_i, week in enumerate(cal): + for col_i, day in enumerate(week): + if day == 0: + tk.Label(self._grid_frame, text="", + bg=C["surface"], width=4).grid( + row=row_i, column=col_i, padx=1, pady=1) + continue + + d = date(self._year, self._month, day) + is_today = (d == today) + is_selected = (d == self._selected) + is_weekend = (col_i >= 5) + + if is_selected: + bg, fg = C["cal_selected"], C["white"] + elif is_today: + bg, fg = C["cal_today"], C["white"] + else: + bg = C["surface2"] if is_weekend else C["surface"] + fg = C["cal_weekend"] if is_weekend else C["text"] + + tk.Button( + self._grid_frame, + text=str(day), bg=bg, fg=fg, + activebackground=C["accent"], + activeforeground=C["white"], + relief="flat", font=FONT_SMALL, + width=3, cursor="hand2", + command=lambda dd=d: self._pick(dd), + ).grid(row=row_i, column=col_i, padx=1, pady=1) + + def _pick(self, d): + self._selected = d + self.callback(d) + self.destroy() + + def _select_today(self): + self._pick(date.today()) + + def _prev_month(self): + if self._month == 1: + self._month, self._year = 12, self._year - 1 + else: + self._month -= 1 + self._render_month() + + def _next_month(self): + if self._month == 12: + self._month, self._year = 1, self._year + 1 + else: + self._month += 1 + self._render_month() + + def _prev_year(self): + self._year -= 1 + self._render_month() + + def _next_year(self): + self._year += 1 + self._render_month() + + +# ─── Reusable Widget Factories ──────────────────────────────────────────────── + +def labelled_entry(parent, label, row, show=None, width=35): + lbl = ttk.Label(parent, text=label) + lbl.grid(row=row, column=0, sticky="w", padx=(0, 12), pady=6) + var = tk.StringVar() + ent = ttk.Entry(parent, textvariable=var, width=width, show=show or "") + ent.grid(row=row, column=1, sticky="ew", pady=6) + return var, ent + + +def scrolled_text(parent, height=6, width=50): + C = ThemeManager.get() + frame = tk.Frame(parent, bg=C["surface2"]) + sb = tk.Scrollbar(frame) + sb.pack(side="right", fill="y") + txt = tk.Text( + frame, height=height, width=width, wrap="word", + yscrollcommand=sb.set, + bg=C["surface2"], fg=C["text"], + insertbackground=C["text"], + relief="flat", font=FONT, padx=8, pady=6, + ) + txt.pack(side="left", fill="both", expand=True) + sb.config(command=txt.yview) + return frame, txt + + +def confirm_delete(item_name): + return messagebox.askyesno( + "Confirm Delete", + f"Are you sure you want to delete '{item_name}'?\nThis action cannot be undone." + ) + + +def show_error(message, title="Error"): + messagebox.showerror(title, message) + + +def show_info(message, title="Success"): + messagebox.showinfo(title, message) + + +def make_scrollable_frame(parent): + C = ThemeManager.get() + canvas = tk.Canvas(parent, bg=C["bg"], highlightthickness=0) + vsb = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview) + inner = ttk.Frame(canvas) + inner.bind("", + lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=inner, anchor="nw") + canvas.configure(yscrollcommand=vsb.set) + canvas.pack(side="left", fill="both", expand=True) + vsb.pack(side="right", fill="y") + + def _safe_scroll(event): + try: + canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + except Exception: + pass + + def _enter(e): + canvas.bind_all("", _safe_scroll) + + def _leave(e): + try: + canvas.unbind_all("") + except Exception: + pass + + canvas.bind("", _enter) + canvas.bind("", _leave) + return canvas, inner diff --git a/views/__init__.py b/views/__init__.py new file mode 100644 index 0000000..18ad452 --- /dev/null +++ b/views/__init__.py @@ -0,0 +1 @@ +# views package diff --git a/views/admin_dashboard_view.py b/views/admin_dashboard_view.py new file mode 100644 index 0000000..4a42821 --- /dev/null +++ b/views/admin_dashboard_view.py @@ -0,0 +1,267 @@ +""" +views/admin_dashboard_view.py — Admin home screen. + +Shows today's completion stats at-a-glance: + - Headline KPI cards (total users, active today, total sites) + - Per-user completion table with inline progress bar + - Auto-refreshes every 60 seconds +""" + +import tkinter as tk +from tkinter import ttk +import logging +from datetime import date + +from utils.ui_helpers import ( + COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, FONT_TITLE, + show_error, +) + +logger = logging.getLogger("admin_dashboard_view") + +REFRESH_INTERVAL_MS = 60_000 # auto-refresh every 60 seconds + + +class AdminDashboardView(ttk.Frame): + def __init__(self, parent, current_user: dict): + super().__init__(parent) + self.current_user = current_user + self._refresh_job = None + self._build_ui() + self._load() + + def destroy(self): + """Cancel the auto-refresh job and mousewheel binding when destroyed.""" + if self._refresh_job: + try: + self.after_cancel(self._refresh_job) + except Exception: + pass + try: + self._canvas.unbind_all("") + except Exception: + pass + super().destroy() + + # ─── Layout ─────────────────────────────────────────────────────────────── + + def _build_ui(self): + # ── Header ──────────────────────────────────────────────────────────── + hdr = ttk.Frame(self) + hdr.pack(fill="x", pady=(0, 16)) + + ttk.Label(hdr, text="Dashboard", + style="Heading.TLabel").pack(side="left") + + self._date_lbl = ttk.Label( + hdr, + text=date.today().strftime("%A, %d %B %Y"), + style="Dim.TLabel" + ) + self._date_lbl.pack(side="right", padx=(0, 4)) + + ttk.Button(hdr, text="↻ Refresh", style="Ghost.TButton", + command=self._load).pack(side="right", padx=(0, 8)) + + # ── KPI cards row ───────────────────────────────────────────────────── + self._kpi_frame = tk.Frame(self, bg=COLOURS["bg"]) + self._kpi_frame.pack(fill="x", pady=(0, 20)) + + # ── User completion table ───────────────────────────────────────────── + ttk.Label(self, text="Today's Progress by User", + style="Heading.TLabel").pack(anchor="w", pady=(0, 8)) + + # Scrollable table frame + table_outer = ttk.Frame(self) + table_outer.pack(fill="both", expand=True) + + self._canvas = tk.Canvas(table_outer, bg=COLOURS["bg"], + highlightthickness=0) + vsb = ttk.Scrollbar(table_outer, orient="vertical", + command=self._canvas.yview) + self._canvas.configure(yscrollcommand=vsb.set) + vsb.pack(side="right", fill="y") + self._canvas.pack(side="left", fill="both", expand=True) + + self._table_frame = ttk.Frame(self._canvas) + self._table_frame.bind( + "", + lambda e: self._canvas.configure( + scrollregion=self._canvas.bbox("all")) + ) + self._canvas_win = self._canvas.create_window( + (0, 0), window=self._table_frame, anchor="nw" + ) + self._canvas.bind( + "", + lambda e: self._canvas.itemconfig(self._canvas_win, width=e.width) + ) + self._canvas.bind("", self._on_canvas_enter) + self._canvas.bind("", self._on_canvas_leave) + + def _on_canvas_enter(self, event): + self._canvas.bind_all( + "", + lambda e: self._safe_scroll(e) + ) + + def _on_canvas_leave(self, event): + try: + self._canvas.unbind_all("") + except Exception: + pass + + def _safe_scroll(self, event): + try: + self._canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + except Exception: + pass + + # ─── Data ───────────────────────────────────────────────────────────────── + + def _load(self): + # Cancel any pending auto-refresh before issuing a new one + if self._refresh_job: + try: + self.after_cancel(self._refresh_job) + except Exception: + pass + + try: + from models import get_admin_dashboard_stats + stats = get_admin_dashboard_stats() + except Exception as e: + show_error(f"Failed to load dashboard:\n{e}") + return + + self._render_kpis(stats) + self._render_table(stats["user_stats"]) + + # Schedule next auto-refresh + self._refresh_job = self.after(REFRESH_INTERVAL_MS, self._load) + logger.info("Admin dashboard refreshed.") + + # ─── KPI Cards ──────────────────────────────────────────────────────────── + + def _render_kpis(self, stats: dict): + for w in self._kpi_frame.winfo_children(): + w.destroy() + + total_users = stats["total_users"] + active_today = stats["active_today"] + total_sites = stats["total_sites"] + + # Compute overall completion % + user_stats = stats["user_stats"] + if user_stats: + avg_pct = sum( + (r["pct_complete"] or 0) for r in user_stats + ) / len(user_stats) + else: + avg_pct = 0 + + cards = [ + ("Total Users", str(total_users), COLOURS["accent"]), + ("Active Today", str(active_today), COLOURS["success"]), + ("Monitored Sites", str(total_sites), COLOURS["warning"]), + ("Avg Completion", f"{avg_pct:.0f}%", COLOURS["accent"]), + ] + + self._kpi_frame.columnconfigure(list(range(len(cards))), weight=1) + + for col, (label, value, colour) in enumerate(cards): + card = tk.Frame(self._kpi_frame, bg=COLOURS["surface"], + padx=20, pady=16) + card.grid(row=0, column=col, padx=(0, 12) if col < len(cards)-1 else 0, + sticky="ew") + + tk.Label(card, text=value, + font=(FONT_BOLD[0], 26, "bold"), + bg=COLOURS["surface"], + fg=colour).pack() + tk.Label(card, text=label, + font=FONT_SMALL, + bg=COLOURS["surface"], + fg=COLOURS["text_dim"]).pack() + + # ─── User Table ─────────────────────────────────────────────────────────── + + def _render_table(self, user_stats: list): + for w in self._table_frame.winfo_children(): + w.destroy() + + # Column headers + headers = ["User", "Full Name", "Checked", "Total", "Completion"] + col_weights = [1, 2, 0, 0, 3] + header_frame = tk.Frame(self._table_frame, bg=COLOURS["surface2"]) + header_frame.pack(fill="x", pady=(0, 2)) + + for col, (hdr, wt) in enumerate(zip(headers, col_weights)): + header_frame.columnconfigure(col, weight=wt, minsize=80) + tk.Label(header_frame, text=hdr, + bg=COLOURS["surface2"], fg=COLOURS["accent"], + font=FONT_BOLD, anchor="w", + padx=12, pady=8).grid( + row=0, column=col, sticky="ew") + + if not user_stats: + tk.Label(self._table_frame, + text="No active users with shifts today.", + bg=COLOURS["bg"], fg=COLOURS["text_dim"], + font=FONT_SMALL).pack(pady=20) + return + + for i, row in enumerate(user_stats): + bg = COLOURS["surface"] if i % 2 == 0 else COLOURS["surface2"] + row_frame = tk.Frame(self._table_frame, bg=bg) + row_frame.pack(fill="x") + + pct = float(row["pct_complete"] or 0) + checked = int(row["checked_count"] or 0) + total = int(row["total_sites"] or 0) + + # Colour: green if 100%, amber if >0%, red if 0% + if pct >= 100: + pct_colour = COLOURS["success"] + elif pct > 0: + pct_colour = COLOURS["warning"] + else: + pct_colour = COLOURS["danger"] + + for col, (wt, content) in enumerate(zip(col_weights, [ + row["username"], + row["full_name"] or "", + str(checked), + str(total), + None, # progress bar column + ])): + row_frame.columnconfigure(col, weight=wt, minsize=80) + + if content is not None: + tk.Label(row_frame, text=content, + bg=bg, fg=COLOURS["text"], + font=FONT, anchor="w", + padx=12, pady=10).grid( + row=0, column=col, sticky="ew") + else: + # Progress bar + percentage label + bar_cell = tk.Frame(row_frame, bg=bg, padx=12, pady=8) + bar_cell.grid(row=0, column=col, sticky="ew") + bar_cell.columnconfigure(0, weight=1) + + # Outer track + track = tk.Frame(bar_cell, bg=COLOURS["border"], + height=14) + track.pack(fill="x", side="left", expand=True) + track.pack_propagate(False) + + # Fill — drawn after geometry update + fill = tk.Frame(track, bg=pct_colour, height=14) + fill.place(relx=0, rely=0, + relwidth=min(pct / 100, 1.0), relheight=1.0) + + tk.Label(bar_cell, + text=f"{pct:.0f}%", + bg=bg, fg=pct_colour, + font=FONT_BOLD, + width=5, anchor="e").pack(side="left", padx=(6, 0)) diff --git a/views/admin_log_view.py b/views/admin_log_view.py new file mode 100644 index 0000000..9fae7c5 --- /dev/null +++ b/views/admin_log_view.py @@ -0,0 +1,50 @@ +""" +views/admin_log_view.py — Admin panel: Activity Log tab. +""" + +import tkinter as tk +from tkinter import ttk +from utils.ui_helpers import COLOURS, FONT_HEADING, show_error + + +class AdminLogView(ttk.Frame): + def __init__(self, parent, current_user: dict): + super().__init__(parent) + self.current_user = current_user + self._build_ui() + self._load() + + def _build_ui(self): + toolbar = ttk.Frame(self) + toolbar.pack(fill="x", pady=(0, 10)) + ttk.Label(toolbar, text="Activity Log", style="Heading.TLabel").pack(side="left") + ttk.Button(toolbar, text="↻ Refresh", style="Ghost.TButton", + command=self._load).pack(side="right") + + cols = ("Time", "User", "Action", "Entity", "Entity ID", "Detail") + self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse") + widths = [140, 100, 130, 100, 70, 320] + for col, w in zip(cols, widths): + self.tree.heading(col, text=col) + self.tree.column(col, width=w, anchor="w") + self.tree.pack(fill="both", expand=True) + + vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=vsb.set) + vsb.place(relx=1, rely=0, relheight=1, anchor="ne") + + def _load(self): + from models import get_activity_log + self.tree.delete(*self.tree.get_children()) + try: + for entry in get_activity_log(): + self.tree.insert("", "end", values=( + str(entry["logged_at"])[:16], + entry.get("username") or "—", + entry["action"], + entry.get("entity") or "", + entry.get("entity_id") or "", + entry.get("detail") or "", + )) + except Exception as e: + show_error(f"Failed to load activity log:\n{e}") diff --git a/views/admin_shifts_view.py b/views/admin_shifts_view.py new file mode 100644 index 0000000..1e053e0 --- /dev/null +++ b/views/admin_shifts_view.py @@ -0,0 +1,587 @@ +""" +views/admin_shifts_view.py — Admin panel: Shift Management. + +Allows admins to: + • Create / edit / soft-delete shifts + • Name each shift and add an optional note + • Set the days of the week the shift runs + • Set a start time and end time + • Assign one or more users to the shift + • Assign one or more websites to the shift (with drag-reorder) +""" + +import tkinter as tk +from tkinter import ttk +import logging + +from utils.ui_helpers import ( + COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, + scrolled_text, show_error, show_info, confirm_delete, +) + +logger = logging.getLogger("admin_shifts_view") + +# Day labels: index 0 = Monday (ISO), stored as MySQL DAYOFWEEK digits +# MySQL: 1=Sun 2=Mon 3=Tue 4=Wed 5=Thu 6=Fri 7=Sat +DAY_MAP = [ + ("Mon", "2"), ("Tue", "3"), ("Wed", "4"), + ("Thu", "5"), ("Fri", "6"), ("Sat", "7"), ("Sun", "1"), +] + + +class AdminShiftsView(ttk.Frame): + def __init__(self, parent, current_user: dict): + super().__init__(parent) + self.current_user = current_user + self._build_ui() + self._load_shifts() + + # ─── Layout ─────────────────────────────────────────────────────────────── + + def _build_ui(self): + toolbar = ttk.Frame(self) + toolbar.pack(fill="x", pady=(0, 10)) + ttk.Label(toolbar, text="Shift Management", + style="Heading.TLabel").pack(side="left") + + ttk.Button(toolbar, text="+ New Shift", + command=self._open_add).pack(side="right", padx=(4, 0)) + ttk.Button(toolbar, text="✎ Edit", + style="Ghost.TButton", + command=self._open_edit).pack(side="right", padx=(4, 0)) + ttk.Button(toolbar, text="✕ Delete", + style="Danger.TButton", + command=self._delete_selected).pack(side="right") + + cols = ("ID", "Shift Name", "Days", "Start", "End", + "Users", "Websites", "Active", "Note") + self.tree = ttk.Treeview(self, columns=cols, + show="headings", selectmode="browse") + widths = [40, 160, 130, 70, 70, 60, 70, 60, 200] + for col, w in zip(cols, widths): + self.tree.heading(col, text=col) + self.tree.column(col, width=w, + anchor="center" if w <= 70 else "w") + self.tree.pack(fill="both", expand=True) + self.tree.bind("", lambda _: self._open_edit()) + + vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=vsb.set) + vsb.place(relx=1, rely=0, relheight=1, anchor="ne") + + # Tag active vs inactive rows + self.tree.tag_configure("inactive", foreground=COLOURS["text_dim"]) + + # ─── Data ───────────────────────────────────────────────────────────────── + + def _load_shifts(self): + from models import get_all_shifts + self.tree.delete(*self.tree.get_children()) + try: + for s in get_all_shifts(): + days_str = _days_label(s["days_of_week"]) + active = "✔" if s["is_active"] else "✘" + tag = "active" if s["is_active"] else "inactive" + self.tree.insert( + "", "end", iid=str(s["id"]), tags=(tag,), + values=( + s["id"], + s["name"], + days_str, + str(s["start_time"]), + str(s["end_time"]), + s["user_count"], + s["website_count"], + active, + (s["note"] or "")[:60], + ) + ) + except Exception as e: + show_error(f"Failed to load shifts:\n{e}") + + def _get_selected_id(self): + sel = self.tree.selection() + return int(sel[0]) if sel else None + + # ─── Actions ────────────────────────────────────────────────────────────── + + def _open_add(self): + ShiftDialog(self, self.current_user, shift_data=None, + on_save=self._load_shifts) + + def _open_edit(self): + sid = self._get_selected_id() + if not sid: + show_error("Please select a shift to edit.") + return + from models import (get_shift_by_id, get_shift_assigned_users, + get_shift_assigned_websites) + data = get_shift_by_id(sid) + data["users"] = get_shift_assigned_users(sid) + data["websites"] = get_shift_assigned_websites(sid) + ShiftDialog(self, self.current_user, shift_data=data, + on_save=self._load_shifts) + + def _delete_selected(self): + sid = self._get_selected_id() + if not sid: + show_error("Please select a shift to delete.") + return + vals = self.tree.item(sid, "values") + name = vals[1] if vals else str(sid) + if confirm_delete(name): + try: + from models import delete_shift + delete_shift(self.current_user["id"], sid) + logger.info(f"Shift id={sid} deleted by admin " + f"{self.current_user['username']}.") + show_info(f"Shift '{name}' deactivated successfully.") + self._load_shifts() + except Exception as e: + show_error(f"Delete failed:\n{e}") + + +# ─── Shift Dialog (Add / Edit) ──────────────────────────────────────────────── + +class ShiftDialog(tk.Toplevel): + def __init__(self, parent, current_user, shift_data, on_save): + super().__init__(parent) + self.current_user = current_user + self.shift_data = shift_data + self.on_save = on_save + self.is_edit = shift_data is not None + self.title("Edit Shift" if self.is_edit else "New Shift") + self.configure(bg=COLOURS["bg"]) + self.grab_set() + self.resizable(True, True) + + self._all_users = [] + self._all_websites = [] + self._load_options() + self._build_ui() + self._centre() + if self.is_edit: + self._populate() + + def _centre(self): + self.update_idletasks() + w, h = 860, 680 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + def _load_options(self): + from models import get_all_users, get_all_websites + try: + self._all_users = [u for u in get_all_users() + if u["is_active"] and u["role"] == "user"] + self._all_websites = [w for w in get_all_websites()] + except Exception as e: + show_error(f"Failed to load options:\n{e}") + + # ─── UI ─────────────────────────────────────────────────────────────────── + + def _build_ui(self): + # ── Header ──────────────────────────────────────────────────────────── + ttk.Label(self, + text="Edit Shift" if self.is_edit else "New Shift", + style="Heading.TLabel").pack(anchor="w", padx=20, pady=(16, 4)) + ttk.Separator(self, orient="horizontal").pack(fill="x", padx=20, pady=(0, 10)) + + # ── Main body (left details | right assignment panels) ──────────────── + body = ttk.Frame(self) + body.pack(fill="both", expand=True, padx=20) + body.columnconfigure(0, weight=0, minsize=280) + body.columnconfigure(1, weight=1) + body.rowconfigure(0, weight=1) + + self._build_details_panel(body) + self._build_assignment_panel(body) + + # ── Bottom buttons ──────────────────────────────────────────────────── + ttk.Separator(self, orient="horizontal").pack(fill="x", padx=20, pady=(10, 6)) + btn_row = ttk.Frame(self) + btn_row.pack(fill="x", padx=20, pady=(0, 16)) + ttk.Button(btn_row, text="Save Shift", + command=self._save).pack(side="right", padx=(8, 0)) + ttk.Button(btn_row, text="Cancel", style="Ghost.TButton", + command=self.destroy).pack(side="right") + + def _build_details_panel(self, parent): + """Left column: name, time, days, active, note.""" + pane = tk.Frame(parent, bg=COLOURS["surface"], padx=16, pady=16) + pane.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) + pane.columnconfigure(1, weight=1) + + ttk.Label(pane, text="Shift Details", + style="Heading.TLabel", + background=COLOURS["surface"]).grid( + row=0, column=0, columnspan=2, sticky="w", pady=(0, 12)) + + def field(label, row): + tk.Label(pane, text=label, bg=COLOURS["surface"], + fg=COLOURS["text_dim"], font=FONT_SMALL).grid( + row=row, column=0, sticky="w", padx=(0, 8), pady=4) + var = tk.StringVar() + ent = ttk.Entry(pane, textvariable=var) + ent.grid(row=row, column=1, sticky="ew", pady=4) + return var + + self.name_var = field("Shift Name *", 1) + self.start_time_var = field("Start Time (HH:MM)", 2) + self.end_time_var = field("End Time (HH:MM)", 3) + + # Days of week checkboxes + tk.Label(pane, text="Days of Week", bg=COLOURS["surface"], + fg=COLOURS["text_dim"], font=FONT_SMALL).grid( + row=4, column=0, sticky="nw", pady=(8, 0)) + + days_frame = tk.Frame(pane, bg=COLOURS["surface"]) + days_frame.grid(row=4, column=1, sticky="w", pady=(8, 0)) + self._day_vars = {} + for day_lbl, digit in DAY_MAP: + var = tk.BooleanVar(value=True) + self._day_vars[digit] = var + cb = tk.Checkbutton( + days_frame, text=day_lbl, variable=var, + bg=COLOURS["surface"], fg=COLOURS["text"], + activebackground=COLOURS["surface"], + activeforeground=COLOURS["accent"], + selectcolor=COLOURS["surface2"], + font=FONT_SMALL, cursor="hand2", + ) + cb.pack(side="left", padx=2) + + # Active toggle + tk.Label(pane, text="Active", bg=COLOURS["surface"], + fg=COLOURS["text_dim"], font=FONT_SMALL).grid( + row=5, column=0, sticky="w", pady=8) + self.active_var = tk.BooleanVar(value=True) + tk.Checkbutton( + pane, variable=self.active_var, + bg=COLOURS["surface"], activebackground=COLOURS["surface"], + selectcolor=COLOURS["surface2"], + ).grid(row=5, column=1, sticky="w", pady=8) + + # Note + tk.Label(pane, text="Note", bg=COLOURS["surface"], + fg=COLOURS["text_dim"], font=FONT_SMALL).grid( + row=6, column=0, sticky="nw", pady=4) + note_frame, self.note_txt = scrolled_text(pane, height=5, width=28) + note_frame.grid(row=6, column=1, sticky="ew", pady=4) + + def _build_assignment_panel(self, parent): + """Right column: dual-list pickers for users and websites.""" + pane = ttk.Frame(parent) + pane.grid(row=0, column=1, sticky="nsew") + pane.rowconfigure(0, weight=1) + pane.rowconfigure(1, weight=1) + pane.columnconfigure(0, weight=1) + + # Users picker + self._user_picker = _DualListPicker( + pane, + title="Assigned Users", + all_items=[(u["id"], f"{u['username']} — {u['full_name'] or ''}",) + for u in self._all_users], + ) + self._user_picker.grid(row=0, column=0, sticky="nsew", pady=(0, 8)) + + # Websites picker + self._site_picker = _DualListPicker( + pane, + title="Assigned Websites", + all_items=[(w["id"], w["name"]) for w in self._all_websites], + allow_reorder=True, + ) + self._site_picker.grid(row=1, column=0, sticky="nsew") + + # ─── Pre-populate ───────────────────────────────────────────────────────── + + def _populate(self): + d = self.shift_data + self.name_var.set(d.get("name") or "") + + # Normalise timedelta → HH:MM string (MySQL returns timedelta for TIME) + self.start_time_var.set(_time_to_str(d.get("start_time"))) + self.end_time_var.set(_time_to_str(d.get("end_time"))) + + dow = d.get("days_of_week") or "1234567" + for digit, var in self._day_vars.items(): + var.set(digit in dow) + + self.active_var.set(bool(d.get("is_active", 1))) + self.note_txt.insert("1.0", d.get("note") or "") + + assigned_user_ids = [u["id"] for u in d.get("users", [])] + assigned_website_ids = [w["id"] for w in d.get("websites", [])] + self._user_picker.set_selected(assigned_user_ids) + self._site_picker.set_selected(assigned_website_ids) + + # ─── Save ───────────────────────────────────────────────────────────────── + + def _save(self): + name = self.name_var.get().strip() + start_time = self.start_time_var.get().strip() or "00:00" + end_time = self.end_time_var.get().strip() or "23:59" + note = self.note_txt.get("1.0", "end-1c").strip() + is_active = int(self.active_var.get()) + + if not name: + show_error("Shift name is required.") + return + if not _valid_time(start_time) or not _valid_time(end_time): + show_error("Times must be in HH:MM format (e.g. 08:00).") + return + + days_of_week = "".join(d for d, v in self._day_vars.items() if v.get()) + if not days_of_week: + show_error("Please select at least one day of the week.") + return + + user_ids = self._user_picker.get_selected_ids() + website_ids = self._site_picker.get_selected_ids() + + try: + if self.is_edit: + from models import update_shift + update_shift( + self.current_user["id"], + self.shift_data["id"], + name, days_of_week, start_time, end_time, + note, is_active, user_ids, website_ids, + ) + logger.info(f"Shift id={self.shift_data['id']} updated " + f"by {self.current_user['username']}.") + show_info("Shift updated successfully.") + else: + from models import create_shift + create_shift( + self.current_user["id"], + name, days_of_week, start_time, end_time, + note, user_ids, website_ids, + ) + logger.info(f"New shift '{name}' created " + f"by {self.current_user['username']}.") + show_info("Shift created successfully.") + self.on_save() + self.destroy() + except Exception as e: + show_error(f"Save failed:\n{e}") + + +# ─── Dual-List Picker Widget ────────────────────────────────────────────────── + +class _DualListPicker(ttk.Frame): + """ + A reusable dual-listbox: Available (left) ↔ Assigned (right). + Supports optional drag-to-reorder on the assigned list. + """ + def __init__(self, parent, title: str, all_items: list, + allow_reorder=False): + super().__init__(parent) + self._all_items = all_items # [(id, label), ...] + self._allow_reorder = allow_reorder + self._drag_start = None + self._build(title) + + def _build(self, title: str): + self.columnconfigure(0, weight=1) + self.columnconfigure(2, weight=1) + self.rowconfigure(1, weight=1) + + ttk.Label(self, text=title, + style="Heading.TLabel").grid( + row=0, column=0, columnspan=3, sticky="w", pady=(4, 6)) + + # ── Available list ──────────────────────────────────────────────────── + avail_frame = tk.Frame(self, bg=COLOURS["surface"]) + avail_frame.grid(row=1, column=0, sticky="nsew") + tk.Label(avail_frame, text="Available", + bg=COLOURS["surface"], fg=COLOURS["text_dim"], + font=FONT_SMALL).pack(anchor="w", padx=4) + + av_lb_frame = tk.Frame(avail_frame, bg=COLOURS["surface"]) + av_lb_frame.pack(fill="both", expand=True) + av_sb = tk.Scrollbar(av_lb_frame, bg=COLOURS["surface2"]) + av_sb.pack(side="right", fill="y") + self._avail_lb = tk.Listbox( + av_lb_frame, selectmode="extended", height=8, + bg=COLOURS["surface2"], fg=COLOURS["text"], + selectbackground=COLOURS["accent"], selectforeground=COLOURS["white"], + relief="flat", font=FONT, activestyle="none", + yscrollcommand=av_sb.set, + ) + self._avail_lb.pack(side="left", fill="both", expand=True) + av_sb.config(command=self._avail_lb.yview) + self._avail_lb.bind("", lambda _: self._add()) + + # ── Arrow buttons ───────────────────────────────────────────────────── + btn_col = tk.Frame(self, bg=COLOURS["bg"]) + btn_col.grid(row=1, column=1, padx=6) + + def arrow_btn(text, cmd): + return tk.Button( + btn_col, text=text, command=cmd, + bg=COLOURS["surface2"], fg=COLOURS["text"], + activebackground=COLOURS["accent"], + activeforeground=COLOURS["white"], + relief="flat", font=FONT_BOLD, + cursor="hand2", width=4, pady=4, + ) + arrow_btn("→", self._add).pack(pady=4) + arrow_btn("←", self._remove).pack(pady=4) + arrow_btn("→→", self._add_all).pack(pady=(12, 4)) + arrow_btn("←←", self._remove_all).pack(pady=4) + + if self._allow_reorder: + arrow_btn("↑", self._move_up).pack(pady=(12, 4)) + arrow_btn("↓", self._move_down).pack(pady=4) + + # ── Assigned list ───────────────────────────────────────────────────── + assign_frame = tk.Frame(self, bg=COLOURS["surface"]) + assign_frame.grid(row=1, column=2, sticky="nsew") + tk.Label(assign_frame, text="Assigned", + bg=COLOURS["surface"], fg=COLOURS["text_dim"], + font=FONT_SMALL).pack(anchor="w", padx=4) + + as_lb_frame = tk.Frame(assign_frame, bg=COLOURS["surface"]) + as_lb_frame.pack(fill="both", expand=True) + as_sb = tk.Scrollbar(as_lb_frame, bg=COLOURS["surface2"]) + as_sb.pack(side="right", fill="y") + self._assign_lb = tk.Listbox( + as_lb_frame, selectmode="extended", height=8, + bg=COLOURS["surface2"], fg=COLOURS["text"], + selectbackground=COLOURS["accent"], selectforeground=COLOURS["white"], + relief="flat", font=FONT, activestyle="none", + yscrollcommand=as_sb.set, + ) + self._assign_lb.pack(side="left", fill="both", expand=True) + as_sb.config(command=self._assign_lb.yview) + self._assign_lb.bind("", lambda _: self._remove()) + + # Internal data: parallel lists of (id, label) + self._avail_data = list(self._all_items) + self._assign_data = [] + self._refresh_listboxes() + + # ─── Operations ─────────────────────────────────────────────────────────── + + def _refresh_listboxes(self): + self._avail_lb.delete(0, "end") + for _, lbl in self._avail_data: + self._avail_lb.insert("end", lbl) + + self._assign_lb.delete(0, "end") + for _, lbl in self._assign_data: + self._assign_lb.insert("end", lbl) + + def _add(self): + sel = list(self._avail_lb.curselection()) + if not sel: + return + items = [self._avail_data[i] for i in sel] + for i in reversed(sel): + del self._avail_data[i] + self._assign_data.extend(items) + self._refresh_listboxes() + + def _remove(self): + sel = list(self._assign_lb.curselection()) + if not sel: + return + items = [self._assign_data[i] for i in sel] + for i in reversed(sel): + del self._assign_data[i] + self._avail_data.extend(items) + self._avail_data.sort(key=lambda x: x[1]) + self._refresh_listboxes() + + def _add_all(self): + self._assign_data.extend(self._avail_data) + self._avail_data.clear() + self._refresh_listboxes() + + def _remove_all(self): + self._avail_data.extend(self._assign_data) + self._assign_data.clear() + self._avail_data.sort(key=lambda x: x[1]) + self._refresh_listboxes() + + def _move_up(self): + sel = self._assign_lb.curselection() + if not sel or sel[0] == 0: + return + i = sel[0] + self._assign_data[i - 1], self._assign_data[i] = \ + self._assign_data[i], self._assign_data[i - 1] + self._refresh_listboxes() + self._assign_lb.selection_set(i - 1) + + def _move_down(self): + sel = self._assign_lb.curselection() + if not sel or sel[0] >= len(self._assign_data) - 1: + return + i = sel[0] + self._assign_data[i + 1], self._assign_data[i] = \ + self._assign_data[i], self._assign_data[i + 1] + self._refresh_listboxes() + self._assign_lb.selection_set(i + 1) + + # ─── Public API ─────────────────────────────────────────────────────────── + + def set_selected(self, ids: list): + """Pre-select items by id (called when editing an existing shift).""" + id_set = set(ids) + id_order = {id_: idx for idx, id_ in enumerate(ids)} + + remaining = [] + selected = [] + for item in self._all_items: + if item[0] in id_set: + selected.append(item) + else: + remaining.append(item) + + selected.sort(key=lambda x: id_order.get(x[0], 9999)) + self._avail_data = remaining + self._assign_data = selected + self._refresh_listboxes() + + def get_selected_ids(self) -> list: + return [id_ for id_, _ in self._assign_data] + + +# ─── Helpers ────────────────────────────────────────────────────────────────── + +def _days_label(dow_str: str) -> str: + """Convert '23456' → 'Mon Tue Wed Thu Fri'.""" + digit_to_lbl = {digit: lbl for lbl, digit in DAY_MAP} + # Preserve order Mon-Sun + order = [d for _, d in DAY_MAP] + return " ".join(digit_to_lbl[d] for d in order if d in (dow_str or "")) + + +def _time_to_str(val) -> str: + """Normalise MySQL TIME (timedelta or str) to HH:MM string.""" + if val is None: + return "00:00" + import datetime + if isinstance(val, datetime.timedelta): + total = int(val.total_seconds()) + h, m = divmod(total // 60, 60) + return f"{h:02d}:{m:02d}" + # Already a string or time object + return str(val)[:5] + + +def _valid_time(s: str) -> bool: + """Return True if s matches HH:MM.""" + parts = s.split(":") + if len(parts) != 2: + return False + try: + h, m = int(parts[0]), int(parts[1]) + return 0 <= h <= 23 and 0 <= m <= 59 + except ValueError: + return False diff --git a/views/admin_users_view.py b/views/admin_users_view.py new file mode 100644 index 0000000..dc073ff --- /dev/null +++ b/views/admin_users_view.py @@ -0,0 +1,219 @@ +""" +views/admin_users_view.py — Admin panel: User Management tab. +""" + +import tkinter as tk +from tkinter import ttk +import logging +from utils.ui_helpers import ( + COLOURS, FONT, FONT_BOLD, FONT_HEADING, + show_error, show_info, confirm_delete +) + +logger = logging.getLogger("admin_users_view") + + +class AdminUsersView(ttk.Frame): + def __init__(self, parent, current_user: dict): + super().__init__(parent) + self.current_user = current_user + self.configure(style="TFrame") + self._selected_user_id = None + self._build_ui() + self._load_users() + + # ─── Layout ─────────────────────────────────────────────────────────────── + + def _build_ui(self): + # Top toolbar + toolbar = ttk.Frame(self) + toolbar.pack(fill="x", pady=(0, 10)) + + ttk.Label(toolbar, text="User Management", style="Heading.TLabel").pack(side="left") + + ttk.Button(toolbar, text="+ Add User", + command=self._open_add_dialog).pack(side="right", padx=(4, 0)) + ttk.Button(toolbar, text="✎ Edit", + style="Ghost.TButton", + command=self._open_edit_dialog).pack(side="right", padx=(4, 0)) + ttk.Button(toolbar, text="✕ Delete", + style="Danger.TButton", + command=self._delete_selected).pack(side="right") + + # Treeview + cols = ("ID", "Username", "Full Name", "Role", "Active", "Created") + self.tree = ttk.Treeview(self, columns=cols, show="headings", selectmode="browse") + widths = [40, 140, 180, 80, 60, 160] + for col, w in zip(cols, widths): + self.tree.heading(col, text=col) + self.tree.column(col, width=w, anchor="center" if w < 120 else "w") + self.tree.pack(fill="both", expand=True) + self.tree.bind("", lambda _: self._open_edit_dialog()) + + vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=vsb.set) + vsb.place(relx=1, rely=0, relheight=1, anchor="ne") + + # ─── Data ───────────────────────────────────────────────────────────────── + + def _load_users(self): + from models import get_all_users + self.tree.delete(*self.tree.get_children()) + try: + for u in get_all_users(): + active = "✔" if u["is_active"] else "✘" + created = str(u["created_at"])[:16] if u["created_at"] else "" + self.tree.insert("", "end", iid=str(u["id"]), + values=(u["id"], u["username"], + u["full_name"] or "", u["role"], + active, created)) + except Exception as e: + show_error(f"Failed to load users:\n{e}") + + def _get_selected_id(self): + sel = self.tree.selection() + return int(sel[0]) if sel else None + + # ─── Dialogs ────────────────────────────────────────────────────────────── + + def _open_add_dialog(self): + UserDialog(self, self.current_user, user_data=None, + on_save=self._load_users) + + def _open_edit_dialog(self): + uid = self._get_selected_id() + if not uid: + show_error("Please select a user to edit.") + return + from models import get_user_by_id + user_data = get_user_by_id(uid) + UserDialog(self, self.current_user, user_data=user_data, + on_save=self._load_users) + + def _delete_selected(self): + uid = self._get_selected_id() + if not uid: + show_error("Please select a user to delete.") + return + if uid == self.current_user["id"]: + show_error("You cannot delete your own account.") + return + vals = self.tree.item(uid, "values") + username = vals[1] if vals else str(uid) + if confirm_delete(username): + try: + from models import delete_user + delete_user(self.current_user["id"], uid) + logger.info(f"Admin {self.current_user['username']} deleted user id={uid}.") + show_info(f"User '{username}' deleted successfully.") + self._load_users() + except Exception as e: + show_error(f"Delete failed:\n{e}") + + +# ─── User Dialog (Add / Edit) ───────────────────────────────────────────────── + +class UserDialog(tk.Toplevel): + def __init__(self, parent, current_user, user_data, on_save): + super().__init__(parent) + self.current_user = current_user + self.user_data = user_data + self.on_save = on_save + self.is_edit = user_data is not None + self.title("Edit User" if self.is_edit else "Add User") + self.resizable(False, False) + self.configure(bg=COLOURS["bg"]) + self.grab_set() + self._build_ui() + self._centre() + + def _centre(self): + self.update_idletasks() + w, h = 420, 420 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + def _build_ui(self): + pad = {"padx": 24, "pady": 8} + ttk.Label(self, + text="Edit User" if self.is_edit else "New User", + style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4)) + + form = ttk.Frame(self) + form.pack(fill="x", padx=24, pady=8) + form.columnconfigure(1, weight=1) + + def row(label, row_n, show=None): + ttk.Label(form, text=label).grid(row=row_n, column=0, + sticky="w", padx=(0, 10), pady=6) + var = tk.StringVar() + ent = ttk.Entry(form, textvariable=var, show=show or "") + ent.grid(row=row_n, column=1, sticky="ew", pady=6) + return var, ent + + self.full_name_var, _ = row("Full Name", 0) + self.username_var, _ = row("Username", 1) + self.password_var, _ = row("Password", 2, show="•") + pw_hint = "(leave blank to keep unchanged)" if self.is_edit else "" + ttk.Label(form, text=pw_hint, style="Dim.TLabel").grid( + row=3, column=1, sticky="w") + + ttk.Label(form, text="Role").grid(row=4, column=0, sticky="w", + padx=(0, 10), pady=6) + self.role_var = tk.StringVar(value="user") + role_cb = ttk.Combobox(form, textvariable=self.role_var, + values=["admin", "user"], state="readonly") + role_cb.grid(row=4, column=1, sticky="ew", pady=6) + + ttk.Label(form, text="Active").grid(row=5, column=0, sticky="w", + padx=(0, 10), pady=6) + self.active_var = tk.BooleanVar(value=True) + ttk.Checkbutton(form, variable=self.active_var).grid(row=5, column=1, + sticky="w", pady=6) + + if self.is_edit: + d = self.user_data + self.full_name_var.set(d.get("full_name") or "") + self.username_var.set(d.get("username") or "") + self.role_var.set(d.get("role") or "user") + self.active_var.set(bool(d.get("is_active", 1))) + + btn_frame = ttk.Frame(self) + btn_frame.pack(fill="x", padx=24, pady=(16, 20)) + ttk.Button(btn_frame, text="Save", command=self._save).pack(side="right", padx=(6, 0)) + ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton", + command=self.destroy).pack(side="right") + + def _save(self): + full_name = self.full_name_var.get().strip() + username = self.username_var.get().strip() + password = self.password_var.get() + role = self.role_var.get() + is_active = int(self.active_var.get()) + + if not username: + show_error("Username is required.") + return + if not self.is_edit and not password: + show_error("Password is required for new users.") + return + + try: + if self.is_edit: + from models import update_user + update_user(self.current_user["id"], + self.user_data["id"], + username, role, full_name, is_active, + password if password else None) + logger.info(f"User id={self.user_data['id']} updated by admin.") + show_info("User updated successfully.") + else: + from models import create_user + create_user(self.current_user["id"], username, password, role, full_name) + logger.info(f"New user '{username}' created by admin.") + show_info("User created successfully.") + self.on_save() + self.destroy() + except Exception as e: + show_error(f"Save failed:\n{e}") diff --git a/views/admin_websites_view.py b/views/admin_websites_view.py new file mode 100644 index 0000000..23d4b55 --- /dev/null +++ b/views/admin_websites_view.py @@ -0,0 +1,337 @@ +""" +views/admin_websites_view.py — Admin panel: Website Link Management tab. +Changes: added Check Type (Daily / Weekly) field and treeview column. +""" + +import tkinter as tk +from tkinter import ttk +import logging +from utils.ui_helpers import ( + COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, + scrolled_text, show_error, show_info, confirm_delete +) + +logger = logging.getLogger("admin_websites_view") + +CHECK_TYPE_OPTIONS = ["daily", "weekly"] +CHECK_TYPE_LABELS = {"daily": "📅 Daily", "weekly": "🗓 Weekly"} + + +class AdminWebsitesView(ttk.Frame): + def __init__(self, parent, current_user: dict): + super().__init__(parent) + self.current_user = current_user + self._build_ui() + self._load_websites() + + # ─── Layout ─────────────────────────────────────────────────────────────── + + def _build_ui(self): + toolbar = ttk.Frame(self) + toolbar.pack(fill="x", pady=(0, 10)) + ttk.Label(toolbar, text="Website Link Management", + style="Heading.TLabel").pack(side="left") + + ttk.Button(toolbar, text="+ Add Website", + command=self._open_add).pack(side="right", padx=(4, 0)) + ttk.Button(toolbar, text="✎ Edit", + style="Ghost.TButton", + command=self._open_edit).pack(side="right", padx=(4, 0)) + ttk.Button(toolbar, text="✕ Delete", + style="Danger.TButton", + command=self._delete_selected).pack(side="right") + + cols = ("ID", "Name", "Type", "URL", "Note", "Created By") + widths = [40, 170, 80, 230, 150, 100] + self.tree = ttk.Treeview(self, columns=cols, show="headings", + selectmode="browse") + for col, w in zip(cols, widths): + self.tree.heading(col, text=col) + self.tree.column(col, width=w, + anchor="center" if w <= 80 else "w") + self.tree.pack(fill="both", expand=True) + self.tree.bind("", lambda _: self._open_edit()) + + # Tag weekly rows with a subtle colour + self.tree.tag_configure("weekly", foreground=COLOURS["warning"]) + + vsb = ttk.Scrollbar(self, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=vsb.set) + vsb.place(relx=1, rely=0, relheight=1, anchor="ne") + + # ─── Data ───────────────────────────────────────────────────────────────── + + def _load_websites(self): + from models import get_all_websites + self.tree.delete(*self.tree.get_children()) + try: + for w in get_all_websites(): + ct = w.get("check_type") or "daily" + tag = "weekly" if ct == "weekly" else "" + self.tree.insert( + "", "end", iid=str(w["id"]), tags=(tag,), + values=( + w["id"], + w["name"], + CHECK_TYPE_LABELS.get(ct, ct), + w["url"], + (w["note"] or "")[:60], + w["creator"] or "", + ) + ) + except Exception as e: + show_error(f"Failed to load websites:\n{e}") + + def _get_selected_id(self): + sel = self.tree.selection() + return int(sel[0]) if sel else None + + # ─── Actions ────────────────────────────────────────────────────────────── + + def _open_add(self): + WebsiteDialog(self, self.current_user, website_data=None, + on_save=self._load_websites) + + def _open_edit(self): + wid = self._get_selected_id() + if not wid: + show_error("Please select a website to edit.") + return + from models import get_website_by_id, get_website_credentials + data = get_website_by_id(wid) + creds = get_website_credentials(wid) + data["credentials"] = creds + WebsiteDialog(self, self.current_user, website_data=data, + on_save=self._load_websites) + + def _delete_selected(self): + wid = self._get_selected_id() + if not wid: + show_error("Please select a website to delete.") + return + vals = self.tree.item(wid, "values") + name = vals[1] if vals else str(wid) + if confirm_delete(name): + try: + from models import delete_website + delete_website(self.current_user["id"], wid) + logger.info(f"Website id={wid} deleted by admin.") + show_info(f"Website '{name}' deleted.") + self._load_websites() + except Exception as e: + show_error(f"Delete failed:\n{e}") + + +# ─── Website Dialog (Add / Edit) ────────────────────────────────────────────── + +class WebsiteDialog(tk.Toplevel): + def __init__(self, parent, current_user, website_data, on_save): + super().__init__(parent) + self.current_user = current_user + self.website_data = website_data + self.on_save = on_save + self.is_edit = website_data is not None + self.cred_rows = [] + + self.title("Edit Website" if self.is_edit else "Add Website") + self.configure(bg=COLOURS["bg"]) + self.grab_set() + self._build_ui() + self._centre() + + def _centre(self): + self.update_idletasks() + w, h = 600, 680 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + def _build_ui(self): + # Scrollable container + canvas = tk.Canvas(self, bg=COLOURS["bg"], highlightthickness=0) + vsb = ttk.Scrollbar(self, orient="vertical", command=canvas.yview) + canvas.configure(yscrollcommand=vsb.set) + vsb.pack(side="right", fill="y") + canvas.pack(fill="both", expand=True) + + self.inner = ttk.Frame(canvas) + self.inner.bind("", + lambda e: canvas.configure(scrollregion=canvas.bbox("all"))) + canvas.create_window((0, 0), window=self.inner, anchor="nw") + + # Scope mousewheel to canvas hover — avoids stale-widget errors on close + def _enter(e): + canvas.bind_all("", + lambda ev: _safe_scroll(ev)) + def _leave(e): + try: + canvas.unbind_all("") + except Exception: + pass + def _safe_scroll(ev): + try: + canvas.yview_scroll(int(-1 * (ev.delta / 120)), "units") + except Exception: + pass + + canvas.bind("", _enter) + canvas.bind("", _leave) + self.protocol("WM_DELETE_WINDOW", lambda: (_leave(None), self.destroy())) + + ttk.Label(self.inner, + text="Edit Website" if self.is_edit else "New Website", + style="Heading.TLabel").pack(anchor="w", padx=24, pady=(20, 4)) + + form = ttk.Frame(self.inner) + form.pack(fill="x", padx=24, pady=4) + form.columnconfigure(1, weight=1) + + def text_row(label, r): + ttk.Label(form, text=label).grid(row=r, column=0, + sticky="nw", padx=(0, 10), pady=6) + var = tk.StringVar() + ent = ttk.Entry(form, textvariable=var) + ent.grid(row=r, column=1, sticky="ew", pady=6) + return var + + self.name_var = text_row("Name *", 0) + self.url_var = text_row("URL *", 1) + + # ── Check Type ──────────────────────────────────────────────────────── + ttk.Label(form, text="Check Type").grid( + row=2, column=0, sticky="w", padx=(0, 10), pady=6) + + type_frame = tk.Frame(form, bg=COLOURS["bg"]) + type_frame.grid(row=2, column=1, sticky="w", pady=6) + + self.check_type_var = tk.StringVar(value="daily") + for val, lbl in [("daily", "📅 Daily"), ("weekly", "🗓 Weekly")]: + rb = tk.Radiobutton( + type_frame, + text=lbl, + variable=self.check_type_var, + value=val, + bg=COLOURS["bg"], + fg=COLOURS["text"], + activebackground=COLOURS["bg"], + activeforeground=COLOURS["accent"], + selectcolor=COLOURS["surface2"], + font=FONT, + cursor="hand2", + ) + rb.pack(side="left", padx=(0, 16)) + + # ── Note ────────────────────────────────────────────────────────────── + ttk.Label(form, text="Note").grid(row=3, column=0, sticky="nw", + padx=(0, 10), pady=6) + note_frame, self.note_txt = scrolled_text(form, height=4) + note_frame.grid(row=3, column=1, sticky="ew", pady=6) + + # ── Credentials section ─────────────────────────────────────────────── + ttk.Separator(self.inner, orient="horizontal").pack( + fill="x", padx=24, pady=12) + cred_header = ttk.Frame(self.inner) + cred_header.pack(fill="x", padx=24) + ttk.Label(cred_header, text="Login Credentials", + style="Heading.TLabel").pack(side="left") + ttk.Button(cred_header, text="+ Add Credential", + command=self._add_cred_row).pack(side="right") + + self.creds_container = ttk.Frame(self.inner) + self.creds_container.pack(fill="x", padx=24, pady=8) + + # ── Buttons ─────────────────────────────────────────────────────────── + ttk.Separator(self.inner, orient="horizontal").pack( + fill="x", padx=24, pady=12) + btn_frame = ttk.Frame(self.inner) + btn_frame.pack(fill="x", padx=24, pady=(0, 20)) + ttk.Button(btn_frame, text="Save", + command=self._save).pack(side="right", padx=(6, 0)) + ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton", + command=self.destroy).pack(side="right") + + # Pre-populate if editing + if self.is_edit: + d = self.website_data + self.name_var.set(d.get("name") or "") + self.url_var.set(d.get("url") or "") + self.check_type_var.set(d.get("check_type") or "daily") + self.note_txt.insert("1.0", d.get("note") or "") + for cred in d.get("credentials", []): + self._add_cred_row(cred) + else: + self._add_cred_row() + + def _add_cred_row(self, cred=None): + frame = ttk.Frame(self.creds_container, style="Surface.TFrame") + frame.pack(fill="x", pady=4, ipady=4) + + label_var = tk.StringVar(value=cred.get("label", "") if cred else "") + user_var = tk.StringVar(value=cred.get("username", "") if cred else "") + pass_var = tk.StringVar(value=cred.get("password", "") if cred else "") + + for col_n, (lbl, var, show) in enumerate([ + ("Label", label_var, None), + ("Username", user_var, None), + ("Password", pass_var, "•"), + ]): + tk.Label(frame, text=lbl, bg=COLOURS["surface"], + fg=COLOURS["text_dim"], font=FONT_SMALL).grid( + row=0, column=col_n*2, sticky="w", padx=(8, 2)) + ent = ttk.Entry(frame, textvariable=var, show=show or "", width=14) + ent.grid(row=0, column=col_n*2+1, sticky="ew", padx=(0, 8), pady=4) + + ttk.Button(frame, text="✕", style="Danger.TButton", width=3, + command=lambda f=frame: self._remove_cred_row(f)).grid( + row=0, column=6, padx=(0, 8)) + + frame.columnconfigure(1, weight=1) + frame.columnconfigure(3, weight=1) + frame.columnconfigure(5, weight=1) + self.cred_rows.append((label_var, user_var, pass_var, frame)) + + def _remove_cred_row(self, frame): + self.cred_rows = [(l, u, p, f) for l, u, p, f in self.cred_rows + if f is not frame] + frame.destroy() + + def _save(self): + name = self.name_var.get().strip() + url = self.url_var.get().strip() + check_type = self.check_type_var.get() + note = self.note_txt.get("1.0", "end-1c").strip() + + if not name or not url: + show_error("Name and URL are required.") + return + + credentials = [] + for label_var, user_var, pass_var, _ in self.cred_rows: + u = user_var.get().strip() + if u: + credentials.append({ + "label": label_var.get().strip(), + "username": u, + "password": pass_var.get().strip(), + }) + + try: + if self.is_edit: + from models import update_website + update_website(self.current_user["id"], + self.website_data["id"], + name, url, check_type, note, credentials) + logger.info(f"Website id={self.website_data['id']} updated " + f"check_type='{check_type}'.") + show_info("Website updated successfully.") + else: + from models import create_website + create_website(self.current_user["id"], + name, url, check_type, note, credentials) + logger.info(f"New website '{name}' created " + f"check_type='{check_type}'.") + show_info("Website created successfully.") + self.on_save() + self.destroy() + except Exception as e: + show_error(f"Save failed:\n{e}") diff --git a/views/change_password_view.py b/views/change_password_view.py new file mode 100644 index 0000000..7219863 --- /dev/null +++ b/views/change_password_view.py @@ -0,0 +1,249 @@ +""" +views/change_password_view.py — Self-service password change dialog. +Available to all authenticated users via the sidebar. +Enforces strength rules and verifies the current password before accepting. +""" + +import tkinter as tk +from tkinter import ttk +import logging + +from utils.ui_helpers import ( + COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, + show_error, show_info, +) + +logger = logging.getLogger("change_password_view") + + +class ChangePasswordView(tk.Toplevel): + """ + Modal dialog for changing the logged-in user's password. + + Parameters + ---------- + master : parent widget + current_user : dict with at least 'id' and 'username' + """ + + def __init__(self, master, current_user: dict): + super().__init__(master) + self.current_user = current_user + self.title("Change Password") + self.configure(bg=COLOURS["bg"]) + self.resizable(False, False) + self.grab_set() + self._build_ui() + self._centre() + + def _centre(self): + self.update_idletasks() + w, h = 440, 460 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + # ─── Layout ─────────────────────────────────────────────────────────────── + + def _build_ui(self): + # Header banner + hdr = tk.Frame(self, bg=COLOURS["accent"], pady=16) + hdr.pack(fill="x") + tk.Label(hdr, text="Change Password", + font=FONT_BOLD, bg=COLOURS["accent"], + fg=COLOURS["white"]).pack() + tk.Label(hdr, + text=f"Logged in as: {self.current_user.get('username', '')}", + font=FONT_SMALL, bg=COLOURS["accent"], + fg=COLOURS["white"]).pack(pady=(2, 0)) + + # Form + form = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=20) + form.pack(fill="both", expand=True) + form.columnconfigure(1, weight=1) + + def field(label, row, show="•"): + tk.Label(form, text=label, bg=COLOURS["bg"], + fg=COLOURS["text_dim"], font=FONT_SMALL, + anchor="w").grid(row=row, column=0, + sticky="w", padx=(0, 12), pady=6) + var = tk.StringVar() + ent = tk.Entry(form, textvariable=var, show=show, + bg=COLOURS["surface2"], fg=COLOURS["text"], + insertbackground=COLOURS["text"], + relief="flat", font=FONT) + ent.grid(row=row, column=1, sticky="ew", ipady=7, pady=6) + return var, ent + + self.old_var, self.old_ent = field("Current Password", 0) + self.new_var, _ = field("New Password", 1) + self.cfm_var, _ = field("Confirm Password", 2) + + self.old_ent.focus_set() + + # Bind new-password field to live strength meter + self.new_var.trace_add("write", lambda *_: self._update_strength()) + + # ── Password strength meter ─────────────────────────────────────────── + tk.Label(form, text="Strength", bg=COLOURS["bg"], + fg=COLOURS["text_dim"], font=FONT_SMALL).grid( + row=3, column=0, sticky="nw", padx=(0, 12), pady=(8, 2)) + + meter_frame = tk.Frame(form, bg=COLOURS["bg"]) + meter_frame.grid(row=3, column=1, sticky="ew", pady=(8, 2)) + + # Five segment bars + self._segments = [] + for i in range(5): + seg = tk.Frame(meter_frame, bg=COLOURS["surface2"], + width=36, height=8) + seg.pack(side="left", padx=2) + seg.pack_propagate(False) + self._segments.append(seg) + + self._strength_lbl = tk.Label(meter_frame, text="", + bg=COLOURS["bg"], + fg=COLOURS["text_dim"], + font=FONT_SMALL) + self._strength_lbl.pack(side="left", padx=(10, 0)) + + # Requirements checklist + tk.Label(form, text="Requirements", bg=COLOURS["bg"], + fg=COLOURS["text_dim"], font=FONT_SMALL).grid( + row=4, column=0, sticky="nw", padx=(0, 12), pady=(12, 4)) + + req_frame = tk.Frame(form, bg=COLOURS["bg"]) + req_frame.grid(row=4, column=1, sticky="ew", pady=(12, 4)) + + from models import (PW_MIN_LENGTH, PW_REQUIRE_UPPER, + PW_REQUIRE_DIGIT, PW_REQUIRE_SPECIAL) + self._req_labels = {} + req_defs = [ + ("length", f"At least {PW_MIN_LENGTH} characters"), + ("upper", "Uppercase letter"), + ("digit", "Number"), + ("special", "Special character"), + ] + for key, text in req_defs: + lbl = tk.Label(req_frame, text=f" {text}", + bg=COLOURS["bg"], fg=COLOURS["text_dim"], + font=FONT_SMALL, anchor="w") + lbl.pack(fill="x") + self._req_labels[key] = lbl + + # Status / error line + self._status_var = tk.StringVar() + tk.Label(form, textvariable=self._status_var, + bg=COLOURS["bg"], fg=COLOURS["danger"], + font=FONT_SMALL, wraplength=340, + justify="left").grid( + row=5, column=0, columnspan=2, sticky="w", pady=(8, 0)) + + # Buttons + btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16) + btn_row.pack(fill="x") + + tk.Button( + btn_row, text="Change Password", + command=self._submit, + bg=COLOURS["accent"], fg=COLOURS["white"], + activebackground=COLOURS["accent_hover"], + activeforeground=COLOURS["white"], + relief="flat", font=FONT_BOLD, + cursor="hand2", padx=14, pady=8, + ).pack(side="right", padx=(8, 0)) + + tk.Button( + btn_row, text="Cancel", + command=self.destroy, + bg=COLOURS["surface"], fg=COLOURS["text_dim"], + activebackground=COLOURS["surface2"], + relief="flat", font=FONT_SMALL, + cursor="hand2", padx=10, pady=8, + ).pack(side="right") + + # Enter key submits + self.bind("", lambda _: self._submit()) + + # ─── Strength Meter ─────────────────────────────────────────────────────── + + def _update_strength(self): + from models import (validate_password_strength, PW_MIN_LENGTH, + PW_REQUIRE_UPPER, PW_REQUIRE_DIGIT, + PW_REQUIRE_SPECIAL, _SPECIAL_CHARS) + pw = self.new_var.get() + + # Evaluate each requirement + met = { + "length": len(pw) >= PW_MIN_LENGTH, + "upper": any(c.isupper() for c in pw), + "digit": any(c.isdigit() for c in pw), + "special": any(c in _SPECIAL_CHARS for c in pw), + } + score = sum(met.values()) # 0–4 + + # Update requirement labels + for key, lbl in self._req_labels.items(): + if met[key]: + lbl.config(fg=COLOURS["success"], text=f"✔ {lbl.cget('text')[2:]}") + else: + lbl.config(fg=COLOURS["text_dim"], text=f" {lbl.cget('text')[2:]}") + + # Re-set correct prefix each time + req_texts = { + "length": f"At least {PW_MIN_LENGTH} characters", + "upper": "Uppercase letter", + "digit": "Number", + "special": "Special character", + } + for key, lbl in self._req_labels.items(): + prefix = "✔ " if met[key] else " " + lbl.config(text=prefix + req_texts[key]) + + # Colour segments + colours = ["#e05c5c", "#f0a500", "#f0a500", "#7c6af7", "#4caf50"] + labels = ["Very Weak", "Weak", "Fair", "Strong", "Very Strong"] + + for i, seg in enumerate(self._segments): + seg.config(bg=colours[score - 1] if i < score and score > 0 + else COLOURS["surface2"]) + + if pw: + self._strength_lbl.config( + text=labels[score - 1] if score > 0 else "", + fg=colours[score - 1] if score > 0 else COLOURS["text_dim"] + ) + else: + self._strength_lbl.config(text="") + + # ─── Submit ─────────────────────────────────────────────────────────────── + + def _submit(self): + old_pw = self.old_var.get() + new_pw = self.new_var.get() + cfm_pw = self.cfm_var.get() + + if not old_pw or not new_pw or not cfm_pw: + self._status_var.set("All fields are required.") + return + + if new_pw != cfm_pw: + self._status_var.set("New password and confirmation do not match.") + return + + from models import change_password + try: + success, message = change_password( + self.current_user["id"], old_pw, new_pw + ) + except Exception as e: + self._status_var.set(f"Error: {e}") + return + + if success: + show_info(message) + logger.info( + f"Password changed by user '{self.current_user['username']}'.") + self.destroy() + else: + self._status_var.set(message) diff --git a/views/email_settings_view.py b/views/email_settings_view.py new file mode 100644 index 0000000..30ee15e --- /dev/null +++ b/views/email_settings_view.py @@ -0,0 +1,239 @@ +""" +views/email_settings_view.py — SMTP / scheduled report configuration dialog. +Admin-only. Persists to config.ini [email] section via utils/scheduler.py. +""" + +import tkinter as tk +from tkinter import ttk +import logging +import threading + +from utils.ui_helpers import ( + COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, + show_error, show_info, +) + +logger = logging.getLogger("email_settings_view") + + +class EmailSettingsView(tk.Toplevel): + def __init__(self, master, current_user: dict): + super().__init__(master) + self.current_user = current_user + self.title("Email Report Settings") + self.configure(bg=COLOURS["bg"]) + self.resizable(False, False) + self.grab_set() + self.protocol("WM_DELETE_WINDOW", self.destroy) + self._build_ui() + self._load_existing() + self._centre() + + def _centre(self): + self.update_idletasks() + w, h = 500, 560 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + def _build_ui(self): + # Header + hdr = tk.Frame(self, bg=COLOURS["accent"], pady=14) + hdr.pack(fill="x") + tk.Label(hdr, text="Daily Report Email Settings", + font=FONT_BOLD, bg=COLOURS["accent"], + fg=COLOURS["white"]).pack() + tk.Label(hdr, + text="Send an HTML completion report to recipients on a daily schedule.", + font=FONT_SMALL, bg=COLOURS["accent"], + fg=COLOURS["white"]).pack(pady=(2, 0)) + + # Form + form = tk.Frame(self, bg=COLOURS["bg"], padx=32, pady=16) + form.pack(fill="both", expand=True) + form.columnconfigure(1, weight=1) + + # Enable toggle + tk.Label(form, text="Enable daily emails", bg=COLOURS["bg"], + fg=COLOURS["text_dim"], font=FONT_SMALL).grid( + row=0, column=0, sticky="w", padx=(0, 12), pady=6) + self.enabled_var = tk.BooleanVar(value=False) + tk.Checkbutton(form, variable=self.enabled_var, + bg=COLOURS["bg"], + activebackground=COLOURS["bg"], + selectcolor=COLOURS["surface2"], + command=self._toggle_fields).grid( + row=0, column=1, sticky="w", pady=6) + + def field(label, row, show=None, width=28): + tk.Label(form, text=label, bg=COLOURS["bg"], + fg=COLOURS["text_dim"], font=FONT_SMALL, + anchor="w").grid(row=row, column=0, sticky="w", + padx=(0, 12), pady=5) + var = tk.StringVar() + ent = tk.Entry(form, textvariable=var, width=width, + bg=COLOURS["surface2"], fg=COLOURS["text"], + insertbackground=COLOURS["text"], + relief="flat", font=FONT, show=show or "") + ent.grid(row=row, column=1, sticky="ew", ipady=6, pady=5) + self._fields.append(ent) + return var + + self._fields = [] + self.smtp_host_var = field("SMTP Host", 1) + self.smtp_port_var = field("SMTP Port", 2, width=8) + self.smtp_user_var = field("SMTP Username", 3) + self.smtp_pass_var = field("SMTP Password", 4, show="•") + + # TLS toggle + tk.Label(form, text="Use STARTTLS", bg=COLOURS["bg"], + fg=COLOURS["text_dim"], font=FONT_SMALL).grid( + row=5, column=0, sticky="w", padx=(0, 12), pady=5) + self.tls_var = tk.BooleanVar(value=True) + tls_cb = tk.Checkbutton(form, variable=self.tls_var, + bg=COLOURS["bg"], + activebackground=COLOURS["bg"], + selectcolor=COLOURS["surface2"]) + tls_cb.grid(row=5, column=1, sticky="w", pady=5) + self._fields.append(tls_cb) + + self.recipients_var = field("Recipients (comma-sep)", 6) + self.send_time_var = field("Send Time (HH:MM)", 7, width=8) + self.smtp_port_var.set("587") + self.send_time_var.set("18:00") + + # Status line + self._status_var = tk.StringVar() + self._status_lbl = tk.Label(form, textvariable=self._status_var, + bg=COLOURS["bg"], fg=COLOURS["text_dim"], + font=FONT_SMALL, wraplength=380, anchor="w") + self._status_lbl.grid(row=8, column=0, columnspan=2, sticky="ew", pady=(6, 0)) + + # Buttons + btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=32, pady=14) + btn_row.pack(fill="x") + + self._save_btn = tk.Button( + btn_row, text="Save Settings", + command=self._save, + bg=COLOURS["accent"], fg=COLOURS["white"], + activebackground=COLOURS["accent_hover"], + activeforeground=COLOURS["white"], + relief="flat", font=FONT_BOLD, cursor="hand2", + padx=14, pady=8) + self._save_btn.pack(side="right", padx=(8, 0)) + + self._test_btn = tk.Button( + btn_row, text="Test Connection", + command=self._test, + bg=COLOURS["surface2"], fg=COLOURS["text"], + activebackground=COLOURS["surface2"], + activeforeground=COLOURS["accent"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=10, pady=8) + self._test_btn.pack(side="right") + + tk.Button(btn_row, text="Cancel", + command=self.destroy, + bg=COLOURS["surface"], fg=COLOURS["text_dim"], + activebackground=COLOURS["surface2"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=10, pady=8).pack(side="left") + + self._toggle_fields() + + def _toggle_fields(self): + """Enable/disable SMTP fields based on the enabled checkbox.""" + state = "normal" if self.enabled_var.get() else "disabled" + for w in self._fields: + try: + w.config(state=state) + except Exception: + pass + + def _load_existing(self): + from utils.scheduler import load_email_config + cfg = load_email_config() + if not cfg: + return + self.enabled_var.set(cfg.get("enabled", False)) + self.smtp_host_var.set(cfg.get("smtp_host", "")) + self.smtp_port_var.set(str(cfg.get("smtp_port", 587))) + self.smtp_user_var.set(cfg.get("smtp_user", "")) + self.smtp_pass_var.set(cfg.get("smtp_password", "")) + self.tls_var.set(cfg.get("use_tls", True)) + self.recipients_var.set(", ".join(cfg.get("recipients", []))) + self.send_time_var.set(cfg.get("send_time", "18:00")) + self._toggle_fields() + + def _get_fields(self): + host = self.smtp_host_var.get().strip() + port_str = self.smtp_port_var.get().strip() + user = self.smtp_user_var.get().strip() + password = self.smtp_pass_var.get() + use_tls = self.tls_var.get() + recipients = self.recipients_var.get().strip() + send_time = self.send_time_var.get().strip() + enabled = self.enabled_var.get() + + if enabled and (not host or not user or not recipients): + self._set_status("Host, username, and recipients are required when enabled.", "danger") + return None + try: + port = int(port_str) + if not 1 <= port <= 65535: + raise ValueError + except ValueError: + self._set_status("Port must be a number between 1 and 65535.", "danger") + return None + # Validate send_time + try: + h, m = map(int, send_time.split(":")) + assert 0 <= h <= 23 and 0 <= m <= 59 + except Exception: + self._set_status("Send time must be in HH:MM format (e.g. 18:00).", "danger") + return None + + return enabled, host, port, user, password, use_tls, recipients, send_time + + def _set_status(self, msg, level="dim"): + colours = {"danger": COLOURS["danger"], "success": COLOURS["success"], + "dim": COLOURS["text_dim"], "warning": COLOURS["warning"]} + self._status_lbl.config(fg=colours.get(level, COLOURS["text_dim"])) + self._status_var.set(msg) + + def _test(self): + result = self._get_fields() + if result is None: + return + enabled, host, port, user, password, use_tls, recipients, send_time = result + + self._set_status("Testing SMTP connection...", "dim") + self._test_btn.config(state="disabled") + self._save_btn.config(state="disabled") + + def _run(): + from utils.scheduler import test_smtp_connection + ok, msg = test_smtp_connection(host, port, user, password, use_tls) + level = "success" if ok else "danger" + self.after(0, lambda: self._set_status(msg, level)) + self.after(0, lambda: self._test_btn.config(state="normal")) + self.after(0, lambda: self._save_btn.config(state="normal")) + + threading.Thread(target=_run, daemon=True).start() + + def _save(self): + result = self._get_fields() + if result is None: + return + enabled, host, port, user, password, use_tls, recipients, send_time = result + + from utils.scheduler import save_email_config + save_email_config(enabled, host, port, user, password, use_tls, + recipients, send_time) + from models import log_action + log_action(self.current_user["id"], "UPDATE_EMAIL_SETTINGS", "config", + None, f"Email reports {'enabled' if enabled else 'disabled'}.") + show_info("Email settings saved successfully.") + logger.info(f"Email settings saved by {self.current_user['username']}.") + self.destroy() diff --git a/views/login_view.py b/views/login_view.py new file mode 100644 index 0000000..004b736 --- /dev/null +++ b/views/login_view.py @@ -0,0 +1,221 @@ +""" +views/login_view.py — Login screen with rate limiting. + +Calls check_login_allowed() before authenticate(). +On lockout, shows remaining wait time (updated every second). +On successful login, the failed attempt counter is cleared by authenticate(). +""" + +import tkinter as tk +from tkinter import ttk +import logging +from utils.ui_helpers import COLOURS, FONT, FONT_BOLD, FONT_TITLE, show_error + +logger = logging.getLogger("login_view") + + +class LoginView(tk.Toplevel): + def __init__(self, master, on_success_callback): + super().__init__(master) + self.on_success = on_success_callback + self._countdown_job = None # after() handle for lockout countdown + + self.title("Website Checker - Login") + self.resizable(False, False) + self.configure(bg=COLOURS["bg"]) + self._centre() + self._build_ui() + self.protocol("WM_DELETE_WINDOW", self._on_close) + self.grab_set() + + def _centre(self): + self.update_idletasks() + w, h = 420, 380 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + def _build_ui(self): + outer = tk.Frame(self, bg=COLOURS["bg"], padx=40, pady=30) + outer.pack(fill="both", expand=True) + + # Logo / Title + tk.Label(outer, text="🌐 Website Checker", + font=FONT_TITLE, + bg=COLOURS["bg"], + fg=COLOURS["accent"]).pack(pady=(0, 4)) + + tk.Label(outer, text="Shift Monitoring Tool", + font=(FONT[0], 9), + bg=COLOURS["bg"], + fg=COLOURS["text_dim"]).pack(pady=(0, 24)) + + # Form + form = tk.Frame(outer, bg=COLOURS["bg"]) + form.pack(fill="x") + form.columnconfigure(0, weight=1) + + tk.Label(form, text="Username", bg=COLOURS["bg"], + fg=COLOURS["text_dim"], font=FONT_BOLD).grid( + row=0, column=0, sticky="w", pady=(0, 2)) + self.username_var = tk.StringVar() + self.username_ent = tk.Entry( + form, textvariable=self.username_var, width=32, + bg=COLOURS["surface2"], fg=COLOURS["text"], + insertbackground=COLOURS["text"], + relief="flat", font=FONT) + self.username_ent.grid(row=1, column=0, sticky="ew", + ipady=8, pady=(0, 14)) + self.username_ent.focus_set() + + tk.Label(form, text="Password", bg=COLOURS["bg"], + fg=COLOURS["text_dim"], font=FONT_BOLD).grid( + row=2, column=0, sticky="w", pady=(0, 2)) + self.password_var = tk.StringVar() + self.password_ent = tk.Entry( + form, textvariable=self.password_var, show="•", width=32, + bg=COLOURS["surface2"], fg=COLOURS["text"], + insertbackground=COLOURS["text"], + relief="flat", font=FONT) + self.password_ent.grid(row=3, column=0, sticky="ew", + ipady=8, pady=(0, 20)) + self.password_ent.bind("", lambda _: self._login()) + + # Error / lockout label + self.error_label = tk.Label(outer, text="", + fg=COLOURS["danger"], + bg=COLOURS["bg"], font=FONT, + wraplength=320, justify="center") + self.error_label.pack(pady=(0, 8)) + + # Sign-in button + self.sign_in_btn = tk.Button( + outer, text=" Sign In ", + command=self._login, + bg=COLOURS["accent"], + fg=COLOURS["white"], + activebackground=COLOURS["accent_hover"], + activeforeground=COLOURS["white"], + relief="flat", + font=FONT_BOLD, + cursor="hand2", + padx=20, pady=8) + self.sign_in_btn.pack() + + # ─── Login ──────────────────────────────────────────────────────────────── + + def _login(self): + username = self.username_var.get().strip() + password = self.password_var.get() + + if not username or not password: + self.error_label.config(text="Please enter both username and password.") + return + + # ── Rate-limit check ────────────────────────────────────────────────── + try: + from models import check_login_allowed, LOCKOUT_MINUTES + allowed, seconds_left = check_login_allowed(username) + except Exception as e: + logger.error(f"Rate-limit check error: {e}") + show_error(f"Unable to connect to the database.\n\n{e}", + "Connection Error") + return + + if not allowed: + self._start_lockout_countdown(seconds_left) + return + + # ── Authenticate ────────────────────────────────────────────────────── + try: + from models import authenticate + user = authenticate(username, password) + except Exception as e: + logger.error(f"Authentication error: {e}") + show_error(f"Unable to connect to the database.\n\n{e}", + "Connection Error") + return + + if user: + self._cancel_countdown() + logger.info(f"Login successful for '{username}' (role={user['role']}).") + self.destroy() + self.on_success(user) + else: + # Show remaining attempts warning + try: + from models import check_login_allowed, MAX_FAILED_ATTEMPTS + _, secs = check_login_allowed(username) + if secs > 0: + self._start_lockout_countdown(secs) + else: + # Fetch fresh count to show "N attempts remaining" + from config import get_connection + conn = get_connection() + cur = conn.cursor(dictionary=True) + cur.execute( + "SELECT failed_attempts FROM users WHERE username=%s", + (username,) + ) + row = cur.fetchone() + cur.close() + conn.close() + if row: + remaining = MAX_FAILED_ATTEMPTS - (row["failed_attempts"] or 0) + remaining = max(0, remaining) + if remaining > 0: + self.error_label.config( + text=f"Invalid credentials. " + f"{remaining} attempt(s) remaining before lockout." + ) + else: + self.error_label.config( + text="Invalid credentials. Please try again.") + else: + self.error_label.config( + text="Invalid credentials. Please try again.") + except Exception: + self.error_label.config( + text="Invalid credentials. Please try again.") + + # ─── Lockout countdown ──────────────────────────────────────────────────── + + def _start_lockout_countdown(self, seconds_left: int): + """Disable the form and show a live countdown until the lockout expires.""" + self.username_ent.config(state="disabled") + self.password_ent.config(state="disabled") + self.sign_in_btn.config(state="disabled", bg=COLOURS["border"]) + self._remaining = seconds_left + self._tick_countdown() + + def _tick_countdown(self): + if self._remaining <= 0: + self._cancel_countdown() + return + + mins, secs = divmod(self._remaining, 60) + self.error_label.config( + text=f"Account temporarily locked.\n" + f"Try again in {mins}m {secs:02d}s." + ) + self._remaining -= 1 + self._countdown_job = self.after(1000, self._tick_countdown) + + def _cancel_countdown(self): + if self._countdown_job: + try: + self.after_cancel(self._countdown_job) + except Exception: + pass + self._countdown_job = None + try: + self.username_ent.config(state="normal") + self.password_ent.config(state="normal") + self.sign_in_btn.config(state="normal", bg=COLOURS["accent"]) + self.error_label.config(text="") + except Exception: + pass # widget may have been destroyed + + def _on_close(self): + self._cancel_countdown() + self.master.destroy() diff --git a/views/reports_view.py b/views/reports_view.py new file mode 100644 index 0000000..51865d6 --- /dev/null +++ b/views/reports_view.py @@ -0,0 +1,513 @@ +""" +views/reports_view.py — Reports panel (Admin only). + +Tabs +──── +1. Shift Detail — every check event, filterable by date range / user / website +2. Unchecked — sites not checked on a given date, per user +3. Summary — per-user per-day completion percentage + +All three tabs share the same Export toolbar (CSV + Excel). +""" + +import os +import tkinter as tk +from tkinter import ttk, filedialog +import logging +from datetime import date, timedelta + +from utils.ui_helpers import ( + COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, + show_error, show_info, DateEntry, +) + +logger = logging.getLogger("reports_view") + +# ─── Column definitions per report type ─────────────────────────────────────── +SHIFT_COLS = ["check_date", "checked_at", "username", "full_name", + "website_name", "url", "user_note", "status"] +UNCHECKED_COLS = ["check_date", "username", "full_name", + "website_name", "url", "status"] +SUMMARY_COLS = ["check_date", "username", "full_name", + "checked_count", "total_sites", "pct_complete"] + + +class ReportsView(ttk.Frame): + def __init__(self, parent, current_user: dict): + super().__init__(parent) + self.current_user = current_user + self._users = [] # [{id, username, full_name}, ...] + self._websites = [] # [{id, name}, ...] + self._load_filter_options() + self._build_ui() + + # ─── Bootstrap ──────────────────────────────────────────────────────────── + + def _load_filter_options(self): + try: + from models import get_report_filter_options + self._users, self._websites = get_report_filter_options() + except Exception as e: + logger.error(f"Failed to load filter options: {e}") + + # ─── Layout ─────────────────────────────────────────────────────────────── + + def _build_ui(self): + # ── Page header ─────────────────────────────────────────────────────── + hdr = ttk.Frame(self) + hdr.pack(fill="x", pady=(0, 12)) + ttk.Label(hdr, text="Reports", style="Heading.TLabel").pack(side="left") + + # ── Notebook (tabs) ─────────────────────────────────────────────────── + self.nb = ttk.Notebook(self) + self.nb.pack(fill="both", expand=True) + + self._tab_shift = self._make_tab("📋 Shift Detail", SHIFT_COLS, + self._run_shift_report) + self._tab_unchecked = self._make_tab("⚠️ Unchecked", UNCHECKED_COLS, + self._run_unchecked_report) + self._tab_summary = self._make_tab("📊 Summary", SUMMARY_COLS, + self._run_summary_report) + self._build_chart_tab() + + def _make_tab(self, label: str, columns: list, run_fn) -> dict: + """ + Build one tab with a filter strip, result treeview, and export toolbar. + Returns a dict of widget references used by the run / export functions. + """ + frame = ttk.Frame(self.nb) + self.nb.add(frame, text=label) + + # ── Filter strip ────────────────────────────────────────────────────── + filter_card = tk.Frame(frame, bg=COLOURS["surface"], pady=10) + filter_card.pack(fill="x", padx=0, pady=(0, 10)) + + widgets = {} + is_unchecked = (columns == UNCHECKED_COLS) + is_summary = (columns == SUMMARY_COLS) + + col = 0 + + def _lbl(text): + nonlocal col + tk.Label(filter_card, text=text, + bg=COLOURS["surface"], fg=COLOURS["text_dim"], + font=FONT_SMALL).grid(row=0, column=col, padx=(14, 2), pady=6, sticky="w") + col += 1 + + def _cb(values, width=18): + nonlocal col + var = tk.StringVar(value=values[0]) + cb = ttk.Combobox(filter_card, textvariable=var, + values=values, state="readonly", width=width) + cb.grid(row=0, column=col, padx=(0, 8), pady=6) + col += 1 + return var, cb + + # Date from / Date to — use DateEntry calendar pickers + today_str = date.today().isoformat() + week_ago = (date.today() - timedelta(days=6)).isoformat() + + if is_unchecked: + _lbl("Date") + de = DateEntry(filter_card, initial_date=today_str, width=11) + de.grid(row=0, column=col, padx=(0, 8), pady=6) + col += 1 + widgets["date_single"] = de + else: + _lbl("From") + de_from = DateEntry(filter_card, initial_date=week_ago, width=11) + de_from.grid(row=0, column=col, padx=(0, 4), pady=6); col += 1 + + _lbl("To") + de_to = DateEntry(filter_card, initial_date=today_str, width=11) + de_to.grid(row=0, column=col, padx=(0, 8), pady=6); col += 1 + widgets["date_from"] = de_from + widgets["date_to"] = de_to + + # User filter (all tabs) + _lbl("User") + user_labels = ["All Users"] + [ + f"{u['username']} — {u['full_name'] or ''}" for u in self._users + ] + uvar, _ = _cb(user_labels, width=22) + widgets["user_var"] = uvar + + # Website filter (shift detail only) + if not is_unchecked and not is_summary: + _lbl("Website") + site_labels = ["All Websites"] + [w["name"] for w in self._websites] + svar, _ = _cb(site_labels, width=22) + widgets["site_var"] = svar + + # Run button + run_btn = tk.Button( + filter_card, text=" Run Report ", + command=lambda w=widgets: run_fn(w), + bg=COLOURS["accent"], fg=COLOURS["white"], + activebackground=COLOURS["accent_hover"], + activeforeground=COLOURS["white"], + relief="flat", font=FONT_BOLD, cursor="hand2", padx=10, pady=4, + ) + run_btn.grid(row=0, column=col, padx=(4, 14), pady=6); col += 1 + + filter_card.columnconfigure(col, weight=1) + + # ── Status / count bar ──────────────────────────────────────────────── + status_bar = ttk.Frame(frame) + status_bar.pack(fill="x", pady=(0, 4)) + count_lbl = ttk.Label(status_bar, text="Run a report to see results.", + style="Dim.TLabel") + count_lbl.pack(side="left") + widgets["count_lbl"] = count_lbl + + # Export buttons + tk.Button( + status_bar, text="⬇ Export Excel", + command=lambda w=widgets, c=columns: self._export(w, c, "excel"), + bg=COLOURS["success"], fg=COLOURS["white"], + activebackground="#3d9140", activeforeground=COLOURS["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", padx=8, pady=4, + ).pack(side="right", padx=(4, 0)) + + tk.Button( + status_bar, text="⬇ Export CSV", + command=lambda w=widgets, c=columns: self._export(w, c, "csv"), + bg=COLOURS["surface2"], fg=COLOURS["text"], + activebackground=COLOURS["border"], activeforeground=COLOURS["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", padx=8, pady=4, + ).pack(side="right", padx=(4, 0)) + + # ── Treeview ────────────────────────────────────────────────────────── + tree_frame = ttk.Frame(frame) + tree_frame.pack(fill="both", expand=True) + + col_display = [c.replace("_", " ").title() for c in columns] + tree = ttk.Treeview(tree_frame, columns=col_display, + show="headings", selectmode="browse") + + col_widths = { + "Check Date": 95, "Checked At": 140, "Username": 110, + "Full Name": 150, "Website Name": 170, "Url": 230, + "User Note": 200, "Status": 90, + "Checked Count": 100, "Total Sites": 90, "Pct Complete": 100, + } + for disp in col_display: + w = col_widths.get(disp, 120) + tree.heading(disp, text=disp, + command=lambda d=disp, t=tree: self._sort_tree(t, d)) + tree.column(disp, width=w, anchor="center" if w < 130 else "w", + minwidth=60) + + # Colour-tag rows by status + tree.tag_configure("checked", foreground=COLOURS["success"]) + tree.tag_configure("unchecked", foreground=COLOURS["danger"]) + + vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=tree.yview) + hsb = ttk.Scrollbar(tree_frame, orient="horizontal", command=tree.xview) + tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set) + + tree.grid(row=0, column=0, sticky="nsew") + vsb.grid(row=0, column=1, sticky="ns") + hsb.grid(row=1, column=0, sticky="ew") + tree_frame.rowconfigure(0, weight=1) + tree_frame.columnconfigure(0, weight=1) + + widgets["tree"] = tree + widgets["columns"] = columns + widgets["rows"] = [] # populated after each run + + return widgets + + # ─── Report Runners ─────────────────────────────────────────────────────── + + def _resolve_user_id(self, user_var): + """Translate the combobox display string back to a DB user id or None.""" + val = user_var.get() + if val == "All Users": + return None + username = val.split(" — ")[0].strip() + for u in self._users: + if u["username"] == username: + return u["id"] + return None + + def _resolve_website_id(self, site_var): + val = site_var.get() + if val == "All Websites": + return None + for w in self._websites: + if w["name"] == val: + return w["id"] + return None + + def _populate_tree(self, widgets: dict, rows: list): + """Clear and refill the treeview; update count label.""" + tree = widgets["tree"] + columns = widgets["columns"] + tree.delete(*tree.get_children()) + + for row in rows: + values = [str(row.get(c) or "") for c in columns] + tag = "unchecked" if row.get("status") == "Not Checked" else "checked" + tree.insert("", "end", values=values, tags=(tag,)) + + widgets["rows"] = rows + widgets["count_lbl"].config( + text=f"{len(rows)} record{'s' if len(rows) != 1 else ''} found." + ) + + def _run_shift_report(self, widgets: dict): + from models import get_shift_report + try: + date_from = widgets["date_from"].get().strip() or None + date_to = widgets["date_to"].get().strip() or None + user_id = self._resolve_user_id(widgets["user_var"]) + website_id = self._resolve_website_id(widgets["site_var"]) + rows = get_shift_report(date_from, date_to, user_id, website_id) + self._populate_tree(widgets, rows) + logger.info( + f"[REPORT] Shift Detail run by {self.current_user['username']} - " + f"from={date_from} to={date_to} user={user_id} site={website_id} " + f"-> {len(rows)} rows" + ) + except Exception as e: + show_error(f"Report failed:\n{e}") + + def _run_unchecked_report(self, widgets: dict): + from models import get_unchecked_report + try: + target_date = widgets["date_single"].get().strip() or None + user_id = self._resolve_user_id(widgets["user_var"]) + rows = get_unchecked_report(target_date, user_id) + self._populate_tree(widgets, rows) + logger.info( + f"[REPORT] Unchecked run by {self.current_user['username']} - " + f"date={target_date} user={user_id} -> {len(rows)} rows" + ) + except Exception as e: + show_error(f"Report failed:\n{e}") + + def _run_summary_report(self, widgets: dict): + from models import get_summary_report + try: + date_from = widgets["date_from"].get().strip() or None + date_to = widgets["date_to"].get().strip() or None + rows = get_summary_report(date_from, date_to) + self._populate_tree(widgets, rows) + logger.info( + f"[REPORT] Summary run by {self.current_user['username']} - " + f"from={date_from} to={date_to} -> {len(rows)} rows" + ) + except Exception as e: + show_error(f"Report failed:\n{e}") + + # ─── Export ─────────────────────────────────────────────────────────────── + + def _export(self, widgets: dict, columns: list, fmt: str): + rows = widgets.get("rows", []) + if not rows: + show_error("No data to export. Please run a report first.") + return + + save_dir = filedialog.askdirectory(title="Select folder to save report") + if not save_dir: + return + + # Derive a meaningful base filename from the active tab + tab_text = self.nb.tab(self.nb.select(), "text").strip() + base = "report_" + tab_text.replace(" ", "_").replace(" ", "_") \ + .replace("/", "").replace("⚠️", "unchecked") \ + .replace("📋", "shift").replace("📊", "summary") \ + .lower() + + try: + from utils.export import export_csv, export_excel + if fmt == "csv": + path = export_csv(rows, columns, base, save_dir) + else: + sheet = tab_text.replace(" ", " ").strip() + path = export_excel(rows, columns, base, save_dir, sheet_title=sheet) + + from models import log_action + log_action( + self.current_user["id"], + f"EXPORT_{fmt.upper()}", + "reports", + None, + f"Exported '{tab_text.strip()}' ({len(rows)} rows) → {path}" + ) + show_info(f"Export complete!\n\nSaved to:\n{path}") + except Exception as e: + show_error(f"Export failed:\n{e}") + + # ─── Column Sort ────────────────────────────────────────────────────────── + + @staticmethod + def _sort_tree(tree: ttk.Treeview, col: str): + """Toggle ascending / descending sort on a treeview column.""" + items = [(tree.set(k, col), k) for k in tree.get_children("")] + reverse = getattr(tree, f"_sort_rev_{col}", False) + items.sort(reverse=reverse) + for idx, (_, k) in enumerate(items): + tree.move(k, "", idx) + setattr(tree, f"_sort_rev_{col}", not reverse) + + # ─── Chart Tab ──────────────────────────────────────────────────────────── + + def _build_chart_tab(self): + """Fourth tab: completion bar chart powered by matplotlib.""" + frame = ttk.Frame(self.nb) + self.nb.add(frame, text="📈 Completion Chart") + + # Filter strip + filter_card = tk.Frame(frame, bg=COLOURS["surface"], pady=10) + filter_card.pack(fill="x", pady=(0, 10)) + + tk.Label(filter_card, text="From", + bg=COLOURS["surface"], fg=COLOURS["text_dim"], + font=FONT_SMALL).grid(row=0, column=0, padx=(14, 2), pady=6) + + today_str = date.today().isoformat() + week_ago = (date.today() - timedelta(days=6)).isoformat() + + self._chart_from = DateEntry(filter_card, initial_date=week_ago, width=11) + self._chart_from.grid(row=0, column=1, padx=(0, 4), pady=6) + + tk.Label(filter_card, text="To", + bg=COLOURS["surface"], fg=COLOURS["text_dim"], + font=FONT_SMALL).grid(row=0, column=2, padx=(4, 2), pady=6) + + self._chart_to = DateEntry(filter_card, initial_date=today_str, width=11) + self._chart_to.grid(row=0, column=3, padx=(0, 12), pady=6) + + tk.Button( + filter_card, text=" Generate Chart ", + command=self._run_chart, + bg=COLOURS["accent"], fg=COLOURS["white"], + activebackground=COLOURS["accent_hover"], + activeforeground=COLOURS["white"], + relief="flat", font=FONT_BOLD, cursor="hand2", + padx=10, pady=4, + ).grid(row=0, column=4, padx=(0, 14), pady=6) + filter_card.columnconfigure(5, weight=1) + + # Chart canvas placeholder + self._chart_status = ttk.Label( + frame, + text="Select a date range and click Generate Chart.", + style="Dim.TLabel" + ) + self._chart_status.pack(pady=20) + self._chart_frame = ttk.Frame(frame) + self._chart_frame.pack(fill="both", expand=True, padx=8, pady=8) + + def _run_chart(self): + """Fetch summary data and render a grouped bar chart.""" + date_from = self._chart_from.get().strip() or None + date_to = self._chart_to.get().strip() or None + + try: + from models import get_summary_report + rows = get_summary_report(date_from, date_to) + except Exception as e: + show_error(f"Failed to load chart data:\n{e}") + return + + if not rows: + self._chart_status.config(text="No data found for the selected range.") + return + + self._chart_status.config(text="") + self._render_chart(rows, date_from, date_to) + logger.info(f"[CHART] Generated completion chart " + f"from={date_from} to={date_to} rows={len(rows)}") + + def _render_chart(self, rows: list, date_from, date_to): + """Build and embed a matplotlib bar chart in the chart frame.""" + # Clear previous chart + for w in self._chart_frame.winfo_children(): + w.destroy() + + try: + import matplotlib + matplotlib.use("Agg") # non-interactive backend — safe for Tkinter + import matplotlib.pyplot as plt + import matplotlib.ticker as mticker + from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg + except ImportError: + show_error( + "matplotlib is required for charts.\n" + "Install it with: pip install matplotlib" + ) + return + + C = COLOURS + + # ── Aggregate per-user average completion ────────────────────────────── + from collections import defaultdict + user_pcts: dict[str, list] = defaultdict(list) + for row in rows: + user_pcts[row["username"]].append(float(row["pct_complete"] or 0)) + + users = list(user_pcts.keys()) + avg_pct = [sum(v) / len(v) for v in user_pcts.values()] + + # Colour bars by completion level + bar_colours = [ + "#4caf50" if p >= 100 else + "#f0a500" if p >= 50 else + "#e05c5c" + for p in avg_pct + ] + + # ── Plot ────────────────────────────────────────────────────────────── + fig_bg = C["surface"] + axes_bg = C["surface2"] + + fig, ax = plt.subplots(figsize=(max(6, len(users) * 0.9 + 2), 4), + facecolor=fig_bg) + ax.set_facecolor(axes_bg) + + x = range(len(users)) + bars = ax.bar(x, avg_pct, color=bar_colours, + width=0.55, zorder=2) + + # Grid lines + ax.yaxis.set_major_locator(mticker.MultipleLocator(20)) + ax.set_ylim(0, 110) + ax.grid(axis="y", color=C["border"], linewidth=0.7, zorder=1) + ax.set_axisbelow(True) + + # Labels + ax.set_xticks(list(x)) + ax.set_xticklabels(users, rotation=30, ha="right", + color=C["text"], fontsize=9) + ax.set_ylabel("Avg Completion (%)", color=C["text_dim"], fontsize=9) + ax.tick_params(colors=C["text_dim"], which="both") + for spine in ax.spines.values(): + spine.set_edgecolor(C["border"]) + + title_range = "" + if date_from and date_to: + title_range = f" ({date_from} to {date_to})" + ax.set_title(f"User Completion{title_range}", + color=C["text"], fontsize=11, pad=10) + + # Value labels on bars + for bar, pct in zip(bars, avg_pct): + ax.text( + bar.get_x() + bar.get_width() / 2, + bar.get_height() + 1.5, + f"{pct:.0f}%", + ha="center", va="bottom", + color=C["text"], fontsize=8, fontweight="bold", + ) + + fig.tight_layout() + + # ── Embed in Tkinter ────────────────────────────────────────────────── + canvas = FigureCanvasTkAgg(fig, master=self._chart_frame) + canvas.draw() + canvas.get_tk_widget().pack(fill="both", expand=True) + plt.close(fig) # free memory diff --git a/views/settings_view.py b/views/settings_view.py new file mode 100644 index 0000000..506863e --- /dev/null +++ b/views/settings_view.py @@ -0,0 +1,287 @@ +""" +views/settings_view.py — Database connection settings dialog. + +Shown automatically on first run (no config.ini) and accessible +via the admin sidebar Settings nav item at any time. + +Writes connection details to config.ini via config.save_config(). +Does NOT store the password in plaintext beyond what config.ini holds +(which is acceptable for a locally-run desktop tool; operators should +restrict file-system access to config.ini in production). +""" + +import tkinter as tk +from tkinter import ttk +import logging +import threading + +from utils.ui_helpers import ( + COLOURS, FONT, FONT_BOLD, FONT_HEADING, FONT_SMALL, + show_error, show_info, +) + +logger = logging.getLogger("settings_view") + + +class SettingsView(tk.Toplevel): + """ + Modal dialog for DB connection settings. + + Parameters + ---------- + master : tk.Tk or tk.Toplevel parent + on_save_callback: called (no args) after settings are saved and verified + first_run : True => locked modal, no Cancel, title says "Setup" + False => dismissible, title says "Settings" + """ + + def __init__(self, master, on_save_callback, first_run: bool = False): + super().__init__(master) + self.on_save_callback = on_save_callback + self.first_run = first_run + + self.title("Database Setup" if first_run else "Connection Settings") + self.configure(bg=COLOURS["bg"]) + self.resizable(False, False) + self.grab_set() + + if first_run: + self.protocol("WM_DELETE_WINDOW", lambda: None) # prevent close + else: + self.protocol("WM_DELETE_WINDOW", self.destroy) + + self._build_ui() + self._centre() + self._load_existing() + + # ─── Layout ─────────────────────────────────────────────────────────────── + + def _centre(self): + self.update_idletasks() + w, h = 480, 480 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + def _build_ui(self): + # ── Header ──────────────────────────────────────────────────────────── + hdr = tk.Frame(self, bg=COLOURS["accent"], pady=16) + hdr.pack(fill="x") + tk.Label(hdr, + text="Database Setup" if self.first_run else "Connection Settings", + font=FONT_BOLD, bg=COLOURS["accent"], + fg=COLOURS["white"]).pack() + if self.first_run: + tk.Label(hdr, + text="Enter your MySQL connection details to get started.", + font=FONT_SMALL, bg=COLOURS["accent"], + fg=COLOURS["white"]).pack(pady=(2, 0)) + + # ── Form ────────────────────────────────────────────────────────────── + form = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=20) + form.pack(fill="both", expand=True) + form.columnconfigure(1, weight=1) + + def field(label, row, show=None, width=30): + tk.Label(form, text=label, bg=COLOURS["bg"], + fg=COLOURS["text_dim"], font=FONT_SMALL, + anchor="w").grid(row=row, column=0, + sticky="w", padx=(0, 12), pady=6) + var = tk.StringVar() + ent = tk.Entry(form, textvariable=var, width=width, + bg=COLOURS["surface2"], fg=COLOURS["text"], + insertbackground=COLOURS["text"], + relief="flat", font=FONT, + show=show or "") + ent.grid(row=row, column=1, sticky="ew", ipady=6, pady=6) + return var, ent + + self.host_var, self._host_ent = field("Host *", 0) + self.port_var, _ = field("Port *", 1, width=8) + self.database_var, _ = field("Database Name *", 2) + self.user_var, _ = field("Username *", 3) + self.password_var, _ = field("Password", 4, show="•") + + self.port_var.set("3306") # sensible default + + # ── Status line ─────────────────────────────────────────────────────── + self._status_var = tk.StringVar(value="") + self._status_lbl = tk.Label( + form, textvariable=self._status_var, + bg=COLOURS["bg"], fg=COLOURS["text_dim"], + font=FONT_SMALL, anchor="w", wraplength=380 + ) + self._status_lbl.grid(row=5, column=0, columnspan=2, + sticky="ew", pady=(4, 0)) + + # ── Hint ────────────────────────────────────────────────────────────── + hint = ( + "Settings are saved to config.ini in the application folder.\n" + "Ensure the database user has CREATE, INSERT, UPDATE, DELETE privileges." + ) + tk.Label(form, text=hint, bg=COLOURS["bg"], + fg=COLOURS["text_dim"], font=FONT_SMALL, + justify="left", wraplength=380).grid( + row=6, column=0, columnspan=2, sticky="w", pady=(8, 0)) + + # ── Buttons ─────────────────────────────────────────────────────────── + btn_row = tk.Frame(self, bg=COLOURS["bg"], padx=36, pady=16) + btn_row.pack(fill="x") + + self._save_btn = tk.Button( + btn_row, text="Save & Connect", + command=self._save, + bg=COLOURS["accent"], fg=COLOURS["white"], + activebackground=COLOURS["accent_hover"], + activeforeground=COLOURS["white"], + relief="flat", font=FONT_BOLD, + cursor="hand2", padx=14, pady=8, + ) + self._save_btn.pack(side="right", padx=(8, 0)) + + self._test_btn = tk.Button( + btn_row, text="Test Connection", + command=self._test_connection, + bg=COLOURS["surface2"], fg=COLOURS["text"], + activebackground=COLOURS["surface2"], + activeforeground=COLOURS["accent"], + relief="flat", font=FONT_SMALL, + cursor="hand2", padx=10, pady=8, + ) + self._test_btn.pack(side="right") + + if not self.first_run: + tk.Button( + btn_row, text="Cancel", + command=self.destroy, + bg=COLOURS["surface"], fg=COLOURS["text_dim"], + activebackground=COLOURS["surface2"], + relief="flat", font=FONT_SMALL, + cursor="hand2", padx=10, pady=8, + ).pack(side="left") + + # ─── Prefill from existing config ───────────────────────────────────────── + + def _load_existing(self): + from config import load_config + ini = load_config() + if ini: + self.host_var.set(ini.get("host", "")) + self.port_var.set(str(ini.get("port", 3306))) + self.database_var.set(ini.get("database", "")) + self.user_var.set(ini.get("user", "")) + self.password_var.set(ini.get("password", "")) + + # ─── Validation ─────────────────────────────────────────────────────────── + + def _get_fields(self): + """Return (host, port, database, user, password) or None on error.""" + host = self.host_var.get().strip() + port_str = self.port_var.get().strip() + database = self.database_var.get().strip() + user = self.user_var.get().strip() + password = self.password_var.get() + + if not host or not database or not user: + self._set_status("Host, Database Name, and Username are required.", "danger") + return None + try: + port = int(port_str) + if not (1 <= port <= 65535): + raise ValueError + except ValueError: + self._set_status("Port must be a number between 1 and 65535.", "danger") + return None + + return host, port, database, user, password + + def _set_status(self, message: str, level: str = "dim"): + colour_map = { + "danger": COLOURS["danger"], + "success": COLOURS["success"], + "warning": COLOURS["warning"], + "dim": COLOURS["text_dim"], + } + self._status_lbl.config(fg=colour_map.get(level, COLOURS["text_dim"])) + self._status_var.set(message) + + # ─── Test Connection ────────────────────────────────────────────────────── + + def _test_connection(self): + fields = self._get_fields() + if not fields: + return + host, port, database, user, password = fields + + self._set_status("Testing connection...", "dim") + self._test_btn.config(state="disabled") + self._save_btn.config(state="disabled") + + # Run in a background thread so the UI stays responsive + def _run(): + try: + import mysql.connector + conn = mysql.connector.connect( + host=host, port=port, database=database, + user=user, password=password, + connection_timeout=8, + ) + conn.close() + self.after(0, lambda: self._set_status( + "Connection successful!", "success")) + logger.info(f"Test connection to {host}:{port}/{database} succeeded.") + except Exception as e: + self.after(0, lambda err=e: self._set_status( + f"Connection failed: {err}", "danger")) + logger.warning(f"Test connection failed: {e}") + finally: + self.after(0, lambda: self._test_btn.config(state="normal")) + self.after(0, lambda: self._save_btn.config(state="normal")) + + threading.Thread(target=_run, daemon=True).start() + + # ─── Save ───────────────────────────────────────────────────────────────── + + def _save(self): + fields = self._get_fields() + if not fields: + return + host, port, database, user, password = fields + + self._set_status("Saving and connecting...", "dim") + self._save_btn.config(state="disabled") + self._test_btn.config(state="disabled") + + def _run(): + try: + # Verify the connection before persisting + import mysql.connector + conn = mysql.connector.connect( + host=host, port=port, database=database, + user=user, password=password, + connection_timeout=8, + ) + conn.close() + + from config import save_config + save_config(host, port, database, user, password) + logger.info(f"Settings saved: {user}@{host}:{port}/{database}") + + self.after(0, self._on_save_success) + except Exception as e: + self.after(0, lambda err=e: self._set_status( + f"Could not connect: {err}", "danger")) + logger.error(f"Settings save failed: {e}") + finally: + self.after(0, lambda: self._save_btn.config(state="normal")) + self.after(0, lambda: self._test_btn.config(state="normal")) + + threading.Thread(target=_run, daemon=True).start() + + def _on_save_success(self): + self._set_status("Saved successfully.", "success") + self.after(400, self._finish) + + def _finish(self): + self.destroy() + self.on_save_callback() diff --git a/views/user_dashboard_view.py b/views/user_dashboard_view.py new file mode 100644 index 0000000..8360cf1 --- /dev/null +++ b/views/user_dashboard_view.py @@ -0,0 +1,743 @@ +""" +views/user_dashboard_view.py — Regular user: Shift Dashboard. + +New in this version: + - Live search/filter bar + - Bulk check-off (select all visible unchecked, then mark in one action) + - Shift-end notification via plyer (falls back to Tkinter toast) +""" + +import tkinter as tk +from tkinter import ttk +import webbrowser +import logging +import threading +import datetime + +from utils.ui_helpers import ( + COLOURS, FONT, FONT_BOLD, FONT_SMALL, + scrolled_text, show_error, show_info, +) + +logger = logging.getLogger("user_dashboard_view") + +# How many minutes before shift end to fire a reminder notification +NOTIFY_MINUTES_BEFORE = 15 + + +class UserDashboardView(ttk.Frame): + def __init__(self, parent, current_user: dict): + super().__init__(parent) + self.current_user = current_user + self._all_sites = [] # full unfiltered list + self._site_check_vars = {} # website_id -> BooleanVar (bulk select) + self._notified_sites = set() # ids already notified this session + self._notify_job = None # after() handle + self._health_cache = {} # website_id -> ("ok"|"slow"|"down", ms) + self._build_ui() + self._load() + + def destroy(self): + # Cancel notification timer + if self._notify_job: + try: + self.after_cancel(self._notify_job) + except Exception: + pass + + # Remove mousewheel binding so it can't fire on a dead canvas + try: + self.canvas.unbind_all("") + except Exception: + pass + + # Remove keyboard shortcut bindings + self._unbind_shortcuts() + + # Destroy any open tooltip + self._hide_tooltip() + + super().destroy() + + # ─── Layout ─────────────────────────────────────────────────────────────── + + def _build_ui(self): + # ── Top bar ─────────────────────────────────────────────────────────── + top = ttk.Frame(self) + top.pack(fill="x", pady=(0, 8)) + + ttk.Label(top, text="My Shift — Website Checklist", + style="Heading.TLabel").pack(side="left") + ttk.Label( + top, + text=f"{self.current_user['full_name'] or self.current_user['username']}", + style="Dim.TLabel" + ).pack(side="right") + ttk.Button(top, text="↻ Refresh", style="Ghost.TButton", + command=self._load).pack(side="right", padx=(0, 8)) + + # ── Search + bulk actions bar ────────────────────────────────────────── + action_bar = tk.Frame(self, bg=COLOURS["surface"], pady=8) + action_bar.pack(fill="x", pady=(0, 8)) + + # Search box + tk.Label(action_bar, text="🔍", bg=COLOURS["surface"], + fg=COLOURS["text_dim"], font=FONT).pack(side="left", padx=(12, 4)) + self._search_var = tk.StringVar() + self._search_var.trace_add("write", lambda *_: self._apply_filter()) + search_ent = tk.Entry( + action_bar, textvariable=self._search_var, width=28, + bg=COLOURS["surface2"], fg=COLOURS["text"], + insertbackground=COLOURS["text"], + relief="flat", font=FONT + ) + search_ent.pack(side="left", ipady=5, padx=(0, 16)) + + # Bulk action buttons + tk.Button( + action_bar, text="☑ Select All Unchecked", + command=self._select_all_unchecked, + bg=COLOURS["surface2"], fg=COLOURS["text"], + activebackground=COLOURS["accent"], + activeforeground=COLOURS["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=10, pady=4, + ).pack(side="left", padx=(0, 6)) + + tk.Button( + action_bar, text="✔ Mark Selected Checked", + command=self._bulk_check, + bg=COLOURS["success"], fg=COLOURS["white"], + activebackground="#3d9140", + activeforeground=COLOURS["white"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=10, pady=4, + ).pack(side="left") + + # ── Progress bar ────────────────────────────────────────────────────── + prog_frame = ttk.Frame(self) + prog_frame.pack(fill="x", pady=(0, 6)) + self.progress_label = ttk.Label(prog_frame, text="", style="Dim.TLabel") + self.progress_label.pack(side="left") + self.progress_bar = ttk.Progressbar(prog_frame, length=200, + mode="determinate") + self.progress_bar.pack(side="right") + + ttk.Separator(self, orient="horizontal").pack(fill="x", pady=(0, 10)) + + # ── Scrollable sites list ───────────────────────────────────────────── + list_frame = ttk.Frame(self) + list_frame.pack(fill="both", expand=True) + + self.canvas = tk.Canvas(list_frame, bg=COLOURS["bg"], + highlightthickness=0) + vsb = ttk.Scrollbar(list_frame, orient="vertical", + command=self.canvas.yview) + self.canvas.configure(yscrollcommand=vsb.set) + vsb.pack(side="right", fill="y") + self.canvas.pack(side="left", fill="both", expand=True) + + self.sites_frame = ttk.Frame(self.canvas) + self.sites_frame.bind( + "", + lambda e: self.canvas.configure( + scrollregion=self.canvas.bbox("all")) + ) + self._canvas_window = self.canvas.create_window( + (0, 0), window=self.sites_frame, anchor="nw" + ) + self.canvas.bind("", self._on_canvas_resize) + + # Scope mousewheel to the canvas and its children via Enter/Leave + self.canvas.bind("", self._on_canvas_enter) + self.canvas.bind("", self._on_canvas_leave) + self._mw_binding = None # track active bind_all handle + + self._bind_shortcuts() + + def _on_canvas_resize(self, event): + self.canvas.itemconfig(self._canvas_window, width=event.width) + + def _on_canvas_enter(self, event): + """Mouse entered canvas area — activate mousewheel scrolling.""" + self._mw_binding = self.canvas.bind_all( + "", self._on_mousewheel + ) + + def _on_canvas_leave(self, event): + """Mouse left canvas area — deactivate mousewheel scrolling.""" + try: + self.canvas.unbind_all("") + except Exception: + pass + self._mw_binding = None + + def _on_mousewheel(self, event): + try: + self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") + except Exception: + pass + + # ─── Data ───────────────────────────────────────────────────────────────── + + def _load(self): + from models import get_today_checks + try: + self._all_sites = get_today_checks(self.current_user["id"]) + except Exception as e: + show_error(f"Failed to load websites:\n{e}") + return + self._site_check_vars.clear() + self._apply_filter() + self._schedule_notifications() + + def _apply_filter(self): + query = self._search_var.get().strip().lower() + if query: + filtered = [s for s in self._all_sites + if query in s["name"].lower() + or query in (s["url"] or "").lower()] + else: + filtered = self._all_sites + + self._render_sites(filtered) + + def _render_sites(self, sites: list): + for widget in self.sites_frame.winfo_children(): + widget.destroy() + + checked_count = sum(1 for s in self._all_sites if s["check_id"]) + total = len(self._all_sites) + self.progress_bar["maximum"] = total + self.progress_bar["value"] = checked_count + self.progress_label.config( + text=f"Progress: {checked_count} / {total} checked" + + (f" | Showing {len(sites)} of {total}" if len(sites) != total else "") + ) + + if not sites: + msg = ("No websites match your search." + if self._search_var.get().strip() + else "No active websites found. Please contact your administrator.") + ttk.Label(self.sites_frame, text=msg, + style="Dim.TLabel").pack(pady=40) + return + + for site in sites: + self._render_site_card(site) + + # ─── Site Card ──────────────────────────────────────────────────────────── + + def _render_site_card(self, site: dict): + wid = site["id"] + is_checked = bool(site["check_id"]) + border_col = COLOURS["success"] if is_checked else COLOURS["border"] + + card = tk.Frame( + self.sites_frame, bg=COLOURS["surface"], bd=2, relief="flat", + highlightbackground=border_col, highlightthickness=2, + ) + card.pack(fill="x", padx=8, pady=5, ipady=4) + card.columnconfigure(2, weight=1) + + # ── Bulk-select checkbox ─────────────────────────────────────────────── + sel_var = tk.BooleanVar(value=False) + self._site_check_vars[wid] = sel_var + cb = tk.Checkbutton( + card, variable=sel_var, + bg=COLOURS["surface"], + activebackground=COLOURS["surface"], + selectcolor=COLOURS["surface2"], + cursor="hand2", + ) + cb.grid(row=0, column=0, rowspan=3, padx=(10, 4), sticky="ns") + + # ── Status indicator ────────────────────────────────────────────────── + status_char = "✔" if is_checked else "○" + status_col = COLOURS["success"] if is_checked else COLOURS["text_dim"] + tk.Label(card, text=status_char, + font=(FONT[0], 16, "bold"), + bg=COLOURS["surface"], fg=status_col, + width=2).grid(row=0, column=1, rowspan=2, + padx=(4, 8), pady=8, sticky="ns") + + # ── Health indicator dot (updated asynchronously) ───────────────────── + cached = self._health_cache.get(wid) + if cached: + status_str, ms = cached + dot_col = {"ok": COLOURS["success"], + "slow": COLOURS["warning"], + "down": COLOURS["danger"]}.get(status_str, COLOURS["text_dim"]) + dot_tip = {"ok": f"Reachable ({ms}ms)", + "slow": f"Slow ({ms}ms)", + "down": "Unreachable"}.get(status_str, "Unknown") + else: + dot_col = COLOURS["text_dim"] + dot_tip = "Checking..." + + health_dot = tk.Label(card, text="●", font=(FONT[0], 9), + bg=COLOURS["surface"], fg=dot_col, + cursor="hand2") + health_dot.grid(row=0, column=5, padx=(0, 6), sticky="e") + health_dot.bind("", lambda e, t=dot_tip: self._show_tooltip(e, t)) + health_dot.bind("", self._hide_tooltip) + + # Trigger background health check if not cached + if wid not in self._health_cache: + self._health_cache[wid] = ("checking", 0) + threading.Thread( + target=self._check_site_health, + args=(wid, site["url"], health_dot), + daemon=True + ).start() + + # ── Site name + URL (clickable) ──────────────────────────────────────── + name_lbl = tk.Label(card, text=site["name"], + font=FONT_BOLD, bg=COLOURS["surface"], + fg=COLOURS["accent"], cursor="hand2", anchor="w") + name_lbl.grid(row=0, column=2, sticky="ew", padx=4, pady=(8, 1)) + name_lbl.bind("", lambda e, s=site: self._open_site(s)) + + url_lbl = tk.Label(card, text=site["url"], + font=FONT_SMALL, bg=COLOURS["surface"], + fg=COLOURS["text_dim"], cursor="hand2", anchor="w") + url_lbl.grid(row=1, column=2, sticky="ew", padx=4, pady=(0, 4)) + url_lbl.bind("", lambda e, s=site: self._open_site(s)) + + # ── Metadata badges ──────────────────────────────────────────────────── + badge_row = 2 + if site.get("site_note"): + tk.Label(card, text=f"📝 {site['site_note']}", + font=FONT_SMALL, bg=COLOURS["surface"], + fg=COLOURS["text_dim"], anchor="w", + wraplength=400).grid( + row=badge_row, column=2, sticky="ew", padx=4, pady=(0, 2)) + badge_row += 1 + + if site.get("shift_names"): + tk.Label(card, text=f"🕐 {site['shift_names']}", + font=FONT_SMALL, bg=COLOURS["surface"], + fg=COLOURS["accent"], anchor="w").grid( + row=badge_row, column=2, sticky="ew", padx=4, pady=(0, 2)) + badge_row += 1 + + ct = site.get("check_type") or "daily" + ct_text = "📅 Daily" if ct == "daily" else "🗓 Weekly" + ct_colour = COLOURS["text_dim"] if ct == "daily" else COLOURS["warning"] + tk.Label(card, text=ct_text, font=FONT_SMALL, + bg=COLOURS["surface"], fg=ct_colour, anchor="w").grid( + row=badge_row, column=2, sticky="ew", padx=4, pady=(0, 4)) + + # User note if any + if site.get("user_note"): + tk.Label(card, text=f"Your note: {site['user_note']}", + font=FONT_SMALL, bg=COLOURS["surface"], + fg=COLOURS["warning"], anchor="w", + wraplength=350).grid( + row=badge_row + 1, column=2, columnspan=2, + sticky="ew", padx=4, pady=(0, 6)) + + # ── Action buttons ───────────────────────────────────────────────────── + btn_frame = tk.Frame(card, bg=COLOURS["surface"]) + btn_frame.grid(row=0, column=3, rowspan=4, + padx=(8, 12), pady=8, sticky="ns") + + check_text = "✔ Checked" if is_checked else "✔ Mark Checked" + check_bg = COLOURS["surface2"] if is_checked else COLOURS["success"] + tk.Button( + btn_frame, text=check_text, + command=lambda s=site: self._mark_checked(s), + bg=check_bg, fg=COLOURS["white"], + activebackground=COLOURS["success"], + activeforeground=COLOURS["white"], + relief="flat", font=FONT_BOLD, cursor="hand2", + padx=10, pady=6, + ).pack(pady=(0, 6)) + + tk.Button( + btn_frame, text="📝 Add Note", + command=lambda s=site: self._open_note_dialog(s), + bg=COLOURS["surface2"], fg=COLOURS["text"], + activebackground=COLOURS["surface2"], + activeforeground=COLOURS["accent"], + relief="flat", font=FONT_SMALL, cursor="hand2", + padx=10, pady=4, + ).pack() + + # ─── Bulk Actions ───────────────────────────────────────────────────────── + + def _select_all_unchecked(self): + """Tick checkboxes for all currently visible unchecked sites.""" + visible_ids = { + s["id"] for s in self._all_sites + if not s["check_id"] + and (not self._search_var.get().strip() + or self._search_var.get().strip().lower() in s["name"].lower() + or self._search_var.get().strip().lower() in (s["url"] or "").lower()) + } + for wid, var in self._site_check_vars.items(): + if wid in visible_ids: + var.set(True) + + def _bulk_check(self): + """Mark all ticked sites as checked in a single operation.""" + selected = [wid for wid, var in self._site_check_vars.items() + if var.get()] + if not selected: + show_error("No sites selected. Tick the checkboxes first.") + return + + from models import mark_website_checked + errors = [] + for wid in selected: + try: + mark_website_checked(self.current_user["id"], wid) + except Exception as e: + errors.append(str(e)) + + if errors: + show_error(f"Some sites could not be marked:\n" + "\n".join(errors)) + else: + show_info(f"{len(selected)} site(s) marked as checked.") + + logger.info(f"Bulk check: user {self.current_user['username']} " + f"marked {len(selected)} sites.") + self._load() + + # ─── Health Pre-check ───────────────────────────────────────────────────── + + def _check_site_health(self, wid: int, url: str, dot_label: tk.Label): + """Background thread: HEAD request to url; updates health_cache + dot colour.""" + import urllib.request + import time + + if not url.startswith(("http://", "https://")): + url = "https://" + url + + try: + req = urllib.request.Request(url, method="HEAD") + req.add_header("User-Agent", "WebsiteChecker/1.0 HealthProbe") + t0 = time.monotonic() + urllib.request.urlopen(req, timeout=6) + ms = int((time.monotonic() - t0) * 1000) + status = "slow" if ms > 3000 else "ok" + except Exception: + ms = 0 + status = "down" + + self._health_cache[wid] = (status, ms) + col = {"ok": COLOURS["success"], + "slow": COLOURS["warning"], + "down": COLOURS["danger"]}.get(status, COLOURS["text_dim"]) + + # Update the dot on the main thread + try: + dot_label.after(0, lambda: dot_label.config(fg=col)) + except Exception: + pass + + def _show_tooltip(self, event, text: str): + x = event.widget.winfo_rootx() + 20 + y = event.widget.winfo_rooty() + 20 + self._tooltip = tk.Toplevel() + self._tooltip.overrideredirect(True) + self._tooltip.geometry(f"+{x}+{y}") + tk.Label(self._tooltip, text=text, + bg=COLOURS["surface2"], fg=COLOURS["text"], + relief="flat", padx=8, pady=4, + font=FONT_SMALL).pack() + + def _hide_tooltip(self, event=None): + if hasattr(self, "_tooltip"): + try: + self._tooltip.destroy() + except Exception: + pass + + # ─── Keyboard Shortcuts ──────────────────────────────────────────────────── + + def _bind_shortcuts(self): + """Bind keyboard shortcuts to this frame only (not globally).""" + self._shortcut_ids = [] + bindings = [ + ("", lambda e: self._load()), + ("", lambda e: self._focus_search()), + ("", lambda e: self._clear_search()), + ("", lambda e: self._select_all_unchecked()), + ("", lambda e: self._bulk_check()), + ] + for seq, cmd in bindings: + bid = self.bind(seq, cmd) + self._shortcut_ids.append((seq, bid)) + + def _unbind_shortcuts(self): + """Remove all shortcut bindings — called from destroy().""" + for seq, bid in getattr(self, "_shortcut_ids", []): + try: + self.unbind(seq, bid) + except Exception: + pass + self._shortcut_ids = [] + + def _focus_search(self): + """Ctrl+F — focus the search entry.""" + try: + # Walk widget tree to find the search entry + for w in self.winfo_children(): + for child in w.winfo_children(): + if isinstance(child, tk.Entry): + child.focus_set() + return + except Exception: + pass + + def _clear_search(self): + """Escape — clear the search box.""" + self._search_var.set("") + + # ─── Individual Actions ──────────────────────────────────────────────────── + + def _open_site(self, site: dict): + url = site["url"] + if not url.startswith(("http://", "https://")): + url = "https://" + url + try: + webbrowser.open(url) + logger.info(f"User {self.current_user['username']} opened URL: {url}") + except Exception as e: + show_error(f"Could not open URL:\n{e}") + + from models import get_website_credentials + creds = get_website_credentials(site["id"]) + if creds: + CredentialsPopup(self, site["name"], creds) + + def _mark_checked(self, site: dict): + try: + from models import mark_website_checked + mark_website_checked(self.current_user["id"], site["id"]) + logger.info( + f"User {self.current_user['username']} checked '{site['name']}'.") + self._load() + except Exception as e: + show_error(f"Could not mark as checked:\n{e}") + + def _open_note_dialog(self, site: dict): + NoteDialog(self, self.current_user, site, on_save=self._load) + + # ─── Shift-end Notifications ────────────────────────────────────────────── + + def _schedule_notifications(self): + """Check every 60 s whether any shift end-time triggers a reminder.""" + if self._notify_job: + try: + self.after_cancel(self._notify_job) + except Exception: + pass + self._check_notifications() + self._notify_job = self.after(60_000, self._schedule_notifications) + + def _check_notifications(self): + """Fire a desktop notification if a shift ends within NOTIFY_MINUTES_BEFORE.""" + from models import get_unchecked_sites_for_user + import datetime as dt + try: + unchecked = get_unchecked_sites_for_user(self.current_user["id"]) + except Exception: + return + + now = dt.datetime.now().time() + for site in unchecked: + end_time = site.get("end_time") + if not end_time: + continue + # MySQL returns timedelta for TIME columns + if hasattr(end_time, "total_seconds"): + total_secs = int(end_time.total_seconds()) + end_h, rem = divmod(total_secs, 3600) + end_m = rem // 60 + end_t = dt.time(end_h % 24, end_m) + else: + try: + end_t = dt.time.fromisoformat(str(end_time)[:5]) + except Exception: + continue + + # Compute minutes until shift end + now_mins = now.hour * 60 + now.minute + end_mins = end_t.hour * 60 + end_t.minute + diff = end_mins - now_mins + + key = (site["id"], end_t) + if 0 < diff <= NOTIFY_MINUTES_BEFORE and key not in self._notified_sites: + self._notified_sites.add(key) + self._fire_notification(site["name"], diff, end_t) + + def _fire_notification(self, site_name: str, minutes_left: int, end_time): + title = "Shift Reminder" + message = (f"'{site_name}' is unchecked — " + f"shift ends at {end_time.strftime('%H:%M')} " + f"({minutes_left} min remaining).") + logger.info(f"Notification: {message}") + + # Try plyer desktop notification; fall back to Tkinter toast + notified = False + try: + from plyer import notification + notification.notify( + title=title, + message=message, + app_name="Website Checker", + timeout=10, + ) + notified = True + except Exception: + pass + + if not notified: + self._show_toast(title, message) + + def _show_toast(self, title: str, message: str): + """Fallback in-app toast notification.""" + try: + toast = tk.Toplevel(self) + toast.overrideredirect(True) + toast.attributes("-topmost", True) + toast.configure(bg=COLOURS["warning"]) + + tk.Label(toast, text=title, font=FONT_BOLD, + bg=COLOURS["warning"], fg=COLOURS["white"]).pack( + padx=16, pady=(10, 2)) + tk.Label(toast, text=message, font=FONT_SMALL, + bg=COLOURS["warning"], fg=COLOURS["white"], + wraplength=300, justify="left").pack( + padx=16, pady=(0, 10)) + + # Position bottom-right + toast.update_idletasks() + sw = toast.winfo_screenwidth() + sh = toast.winfo_screenheight() + tw = toast.winfo_reqwidth() + th = toast.winfo_reqheight() + toast.geometry(f"+{sw - tw - 20}+{sh - th - 60}") + + # Auto-dismiss after 8 seconds + toast.after(8000, toast.destroy) + except Exception as e: + logger.warning(f"Toast notification failed: {e}") + + +# ─── Credentials Popup ──────────────────────────────────────────────────────── + +class CredentialsPopup(tk.Toplevel): + def __init__(self, parent, site_name, credentials: list): + super().__init__(parent) + self.title(f"Credentials — {site_name}") + self.configure(bg=COLOURS["bg"]) + self.resizable(False, False) + self.grab_set() + + ttk.Label(self, text=f"Login credentials for {site_name}", + style="Heading.TLabel").pack(padx=20, pady=(16, 8)) + + for cred in credentials: + frame = ttk.Frame(self, style="Surface.TFrame") + frame.pack(fill="x", padx=20, pady=4, ipady=6) + + label = cred.get("label") or "Default" + tk.Label(frame, text=label, font=FONT_BOLD, + bg=COLOURS["surface"], fg=COLOURS["accent"]).grid( + row=0, column=0, columnspan=4, sticky="w", padx=10, pady=(4, 2)) + + tk.Label(frame, text="Username:", bg=COLOURS["surface"], + fg=COLOURS["text_dim"]).grid( + row=1, column=0, sticky="w", padx=(10, 4)) + tk.Label(frame, text=cred["username"], bg=COLOURS["surface"], + fg=COLOURS["text"], font=FONT_BOLD).grid( + row=1, column=1, sticky="w", padx=(0, 20)) + + tk.Label(frame, text="Password:", bg=COLOURS["surface"], + fg=COLOURS["text_dim"]).grid( + row=1, column=2, sticky="w", padx=(0, 4)) + pw_var = tk.StringVar(value="••••••••") + tk.Label(frame, textvariable=pw_var, bg=COLOURS["surface"], + fg=COLOURS["text"], font=FONT_BOLD).grid( + row=1, column=3, sticky="w") + + revealed = [False] + def toggle(c=cred, v=pw_var, r=revealed): + r[0] = not r[0] + v.set(c["password"] if r[0] else "••••••••") + tk.Button(frame, text="👁", command=toggle, + bg=COLOURS["surface"], fg=COLOURS["text_dim"], + relief="flat", cursor="hand2").grid( + row=1, column=4, padx=(8, 10)) + + ttk.Button(self, text="Close", command=self.destroy).pack(pady=16) + self._centre() + + def _centre(self): + self.update_idletasks() + w = self.winfo_reqwidth() + 40 + h = self.winfo_reqheight() + 20 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + +# ─── Note Dialog ────────────────────────────────────────────────────────────── + +class NoteDialog(tk.Toplevel): + def __init__(self, parent, current_user, site, on_save): + super().__init__(parent) + self.current_user = current_user + self.site = site + self.on_save = on_save + self.title(f"Note — {site['name']}") + self.configure(bg=COLOURS["bg"]) + self.resizable(False, False) + self.grab_set() + self._build_ui() + self._centre() + + def _centre(self): + self.update_idletasks() + w, h = 460, 280 + x = (self.winfo_screenwidth() - w) // 2 + y = (self.winfo_screenheight() - h) // 2 + self.geometry(f"{w}x{h}+{x}+{y}") + + def _build_ui(self): + ttk.Label(self, text=f"Note for: {self.site['name']}", + style="Heading.TLabel").pack( + padx=20, pady=(16, 8), anchor="w") + frame, self.note_txt = scrolled_text(self, height=6, width=50) + frame.pack(padx=20, pady=4, fill="x") + if self.site.get("user_note"): + self.note_txt.insert("1.0", self.site["user_note"]) + + btn_frame = ttk.Frame(self) + btn_frame.pack(fill="x", padx=20, pady=(12, 16)) + ttk.Button(btn_frame, text="Save Note", + command=self._save).pack(side="right", padx=(6, 0)) + ttk.Button(btn_frame, text="Cancel", style="Ghost.TButton", + command=self.destroy).pack(side="right") + + def _save(self): + note = self.note_txt.get("1.0", "end-1c").strip() + try: + if self.site.get("check_id"): + from models import update_check_note + update_check_note(self.current_user["id"], + self.site["id"], note) + else: + from models import mark_website_checked + mark_website_checked(self.current_user["id"], + self.site["id"], note) + logger.info(f"Note saved for '{self.site['name']}' " + f"by {self.current_user['username']}.") + show_info("Note saved successfully.") + self.on_save() + self.destroy() + except Exception as e: + show_error(f"Failed to save note:\n{e}")