04/21 Fisrt commit

This commit is contained in:
2026-04-21 17:16:37 -04:00
parent f94d86f04b
commit 7548dfc9bf
23 changed files with 6860 additions and 1 deletions
+135 -1
View File
@@ -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.
+454
View File
@@ -0,0 +1,454 @@
"""
app.py — Main application shell.
Bootstraps the root window, handles login flow,
and renders the appropriate panel based on user role.
Session timeout: IDLE_TIMEOUT_MS controls how long (ms) the app waits
before auto-locking back to the login screen. Default = 30 minutes.
"""
import tkinter as tk
from tkinter import ttk, messagebox
import logging
import sys
from config import initialize_database, DB_CONFIG
from utils.ui_helpers import ThemeManager, COLOURS, FONT_BOLD, FONT_SMALL
logger = logging.getLogger("app")
# ─── Session Configuration ────────────────────────────────────────────────────
IDLE_TIMEOUT_MS = 30 * 60 * 1000 # 30 minutes in milliseconds
IDLE_WARNING_MS = 60 * 1000 # warn 1 minute before timeout
class App(tk.Tk):
def __init__(self):
super().__init__()
self.withdraw() # Hide root until login succeeds
self.title("Website Checker")
self.configure(bg=COLOURS["bg"])
self.minsize(960, 620)
self.theme = ThemeManager(self, initial="light")
self.current_user = None
self._idle_timer = None # after() handle for timeout
self._warning_timer = None # after() handle for 1-min warning
# Bind all user activity events to reset the idle timer
for event in ("<Motion>", "<KeyPress>", "<ButtonPress>", "<MouseWheel>"):
self.bind_all(event, self._reset_idle_timer, add="+")
self._boot()
# ─── Boot Sequence ────────────────────────────────────────────────────────
def _boot(self):
"""Show DB setup dialog if needed, then initialise DB and show login."""
from config import config_exists
if not config_exists():
self._show_setup(on_complete=self._init_db)
else:
self._init_db()
def _show_setup(self, on_complete):
from views.settings_view import SettingsView
SettingsView(self, on_save_callback=on_complete, first_run=True)
def _init_db(self):
"""Initialise database schema, then proceed to login."""
try:
initialize_database()
except Exception as e:
messagebox.showerror(
"Database Error",
f"Cannot connect to the database.\n\n"
f"Host: {DB_CONFIG['host']}\nDB: {DB_CONFIG['database']}\n\n"
f"Error: {e}\n\n"
f"Please check your connection settings."
)
# Re-open setup so the user can correct the credentials
self._show_setup(on_complete=self._init_db)
return
self._show_login()
def _show_login(self):
from views.login_view import LoginView
LoginView(self, on_success_callback=self._on_login_success)
# ─── Post-login ───────────────────────────────────────────────────────────
def _on_login_success(self, user: dict):
self.current_user = user
self.deiconify()
self._centre()
self._build_shell()
self._reset_idle_timer() # start session timeout clock
# Start the email scheduler for admin accounts
if user.get("role") == "admin":
from utils.scheduler import start as _sched_start
_sched_start()
def _centre(self):
self.update_idletasks()
w, h = 1100, 700
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self.geometry(f"{w}x{h}+{x}+{y}")
# ─── Application Shell ────────────────────────────────────────────────────
def _build_shell(self):
# Clear any previous content
for child in self.winfo_children():
child.destroy()
# ── Sidebar ──────────────────────────────────────────────────────────
sidebar = tk.Frame(self, bg=COLOURS["surface"], width=200)
sidebar.pack(side="left", fill="y")
sidebar.pack_propagate(False)
# App branding
brand = tk.Frame(sidebar, bg=COLOURS["surface"], pady=20)
brand.pack(fill="x")
tk.Label(brand, text="🌐", font=("Segoe UI", 24),
bg=COLOURS["surface"], fg=COLOURS["accent"]).pack()
tk.Label(brand, text="Website\nChecker",
font=FONT_BOLD,
bg=COLOURS["surface"],
fg=COLOURS["text"]).pack()
ttk.Separator(sidebar, orient="horizontal").pack(fill="x", pady=8)
# Content area
self.content = ttk.Frame(self)
self.content.pack(side="left", fill="both", expand=True, padx=20, pady=20)
# ── Navigation Buttons ────────────────────────────────────────────────
self._nav_buttons = {}
self._active_section = None
if self.current_user["role"] == "admin":
nav_items = [
("🏠 Dashboard", "dashboard", self._show_dashboard),
("👤 Users", "users", self._show_users),
("🌐 Websites", "websites", self._show_websites),
("🗓 Shifts", "shift_mgmt", self._show_shift_mgmt),
("📋 Activity Log", "log", self._show_log),
("📊 My Shifts", "shifts", self._show_shifts),
("📑 Reports", "reports", self._show_reports),
("📧 Email Reports","email", self._show_email_settings),
("⚙ Settings", "settings", self._show_settings),
]
else:
nav_items = [
("📊 My Shifts", "shifts", self._show_shifts),
]
for label, key, cmd in nav_items:
btn = tk.Button(
sidebar,
text=label,
command=cmd,
bg=COLOURS["surface"],
fg=COLOURS["text"],
activebackground=COLOURS["accent"],
activeforeground=COLOURS["white"],
relief="flat",
anchor="w",
font=FONT_BOLD,
padx=20,
pady=12,
cursor="hand2",
)
btn.pack(fill="x")
self._nav_buttons[key] = btn
# Spacer + user info at bottom
ttk.Separator(sidebar, orient="horizontal").pack(fill="x", side="bottom", pady=8)
user_frame = tk.Frame(sidebar, bg=COLOURS["surface"], pady=10)
user_frame.pack(side="bottom", fill="x")
tk.Label(user_frame,
text=(self.current_user["full_name"] or self.current_user["username"]),
font=FONT_BOLD,
bg=COLOURS["surface"],
fg=COLOURS["text"],
wraplength=160).pack(padx=12)
tk.Label(user_frame,
text=self.current_user["role"].capitalize(),
font=FONT_SMALL,
bg=COLOURS["surface"],
fg=COLOURS["text_dim"]).pack(padx=12, pady=(0, 4))
logout_btn = tk.Button(
user_frame,
text="⇠ Sign Out",
command=self._logout,
bg=COLOURS["danger"],
fg=COLOURS["white"],
activebackground="#c94444",
activeforeground=COLOURS["white"],
relief="flat",
font=FONT_SMALL,
cursor="hand2",
pady=6,
)
logout_btn.pack(fill="x", padx=12, pady=4)
# Change password button (all roles)
tk.Button(
user_frame,
text="🔑 Change Password",
command=self._show_change_password,
bg=COLOURS["surface2"],
fg=COLOURS["text_dim"],
activebackground=COLOURS["accent"],
activeforeground=COLOURS["white"],
relief="flat",
font=FONT_SMALL,
cursor="hand2",
pady=4,
).pack(fill="x", padx=12, pady=(0, 4))
# Theme toggle button
self._theme_btn = tk.Button(
user_frame,
text="☀ Light Mode" if self.theme.is_dark else "🌙 Dark Mode",
command=self._toggle_theme,
bg=COLOURS["surface2"],
fg=COLOURS["text_dim"],
activebackground=COLOURS["accent"],
activeforeground=COLOURS["white"],
relief="flat",
font=FONT_SMALL,
cursor="hand2",
pady=4,
)
self._theme_btn.pack(fill="x", padx=12, pady=(0, 8))
# ── Default view ──────────────────────────────────────────────────────
if self.current_user["role"] == "admin":
self._show_dashboard()
else:
self._show_shifts()
def _clear_content(self):
for child in self.content.winfo_children():
child.destroy()
def _set_active_nav(self, key: str):
for k, btn in self._nav_buttons.items():
if k == key:
btn.config(bg=COLOURS["accent"], fg=COLOURS["white"])
else:
btn.config(bg=COLOURS["surface"], fg=COLOURS["text"])
self._active_section = key
# ─── Section Renderers ────────────────────────────────────────────────────
def _show_dashboard(self):
self._clear_content()
self._set_active_nav("dashboard")
from views.admin_dashboard_view import AdminDashboardView
AdminDashboardView(self.content, self.current_user).pack(fill="both", expand=True)
def _show_users(self):
self._clear_content()
self._set_active_nav("users")
from views.admin_users_view import AdminUsersView
AdminUsersView(self.content, self.current_user).pack(fill="both", expand=True)
def _show_websites(self):
self._clear_content()
self._set_active_nav("websites")
from views.admin_websites_view import AdminWebsitesView
AdminWebsitesView(self.content, self.current_user).pack(fill="both", expand=True)
def _show_shift_mgmt(self):
self._clear_content()
self._set_active_nav("shift_mgmt")
from views.admin_shifts_view import AdminShiftsView
AdminShiftsView(self.content, self.current_user).pack(fill="both", expand=True)
def _show_log(self):
self._clear_content()
self._set_active_nav("log")
from views.admin_log_view import AdminLogView
AdminLogView(self.content, self.current_user).pack(fill="both", expand=True)
def _show_shifts(self):
self._clear_content()
self._set_active_nav("shifts")
from views.user_dashboard_view import UserDashboardView
UserDashboardView(self.content, self.current_user).pack(fill="both", expand=True)
def _show_reports(self):
self._clear_content()
self._set_active_nav("reports")
from views.reports_view import ReportsView
ReportsView(self.content, self.current_user).pack(fill="both", expand=True)
def _show_settings(self):
self._clear_content()
self._set_active_nav("settings")
from views.settings_view import SettingsView
SettingsView(self, on_save_callback=self._on_settings_saved, first_run=False)
def _show_email_settings(self):
self._clear_content()
self._set_active_nav("email")
from views.email_settings_view import EmailSettingsView
EmailSettingsView(self, self.current_user)
def _on_settings_saved(self):
"""Called after settings are saved — reload config and reconnect."""
from config import reload_db_config
reload_db_config()
self._init_db()
# ─── Session Timeout ──────────────────────────────────────────────────────
def _reset_idle_timer(self, event=None):
"""Cancel any pending timeout/warning timers and restart them."""
if not self.current_user:
return # not logged in — nothing to time out
if self._idle_timer:
self.after_cancel(self._idle_timer)
if self._warning_timer:
self.after_cancel(self._warning_timer)
# Schedule 1-minute warning before timeout
warning_delay = IDLE_TIMEOUT_MS - IDLE_WARNING_MS
if warning_delay > 0:
self._warning_timer = self.after(warning_delay, self._on_idle_warning)
self._idle_timer = self.after(IDLE_TIMEOUT_MS, self._on_session_timeout)
def _on_idle_warning(self):
"""Show a non-blocking warning that the session is about to expire."""
if not self.current_user:
return
# Use a non-modal label overlay so it doesn't block the timer
self._timeout_warning = tk.Toplevel(self)
self._timeout_warning.title("Session Expiring")
self._timeout_warning.configure(bg=COLOURS["warning"])
self._timeout_warning.resizable(False, False)
self._timeout_warning.attributes("-topmost", True)
w, h = 340, 120
x = (self.winfo_screenwidth() - w) // 2
y = (self.winfo_screenheight() - h) // 2
self._timeout_warning.geometry(f"{w}x{h}+{x}+{y}")
tk.Label(
self._timeout_warning,
text="Session Expiring",
font=FONT_BOLD, bg=COLOURS["warning"], fg=COLOURS["white"]
).pack(pady=(16, 4))
tk.Label(
self._timeout_warning,
text="Your session will expire in 1 minute\ndue to inactivity.",
font=FONT_SMALL, bg=COLOURS["warning"], fg=COLOURS["white"],
justify="center"
).pack()
tk.Button(
self._timeout_warning,
text=" Stay Logged In ",
command=self._dismiss_warning,
bg=COLOURS["white"], fg=COLOURS["warning"],
relief="flat", font=FONT_BOLD, cursor="hand2",
padx=10, pady=6
).pack(pady=12)
def _dismiss_warning(self):
"""User clicked 'Stay Logged In' — close the warning and reset timer."""
if hasattr(self, "_timeout_warning") and self._timeout_warning.winfo_exists():
self._timeout_warning.destroy()
self._reset_idle_timer()
def _on_session_timeout(self):
"""Auto-logout after idle timeout."""
if not self.current_user:
return
if hasattr(self, "_timeout_warning") and self._timeout_warning.winfo_exists():
self._timeout_warning.destroy()
username = self.current_user["username"]
from models import log_action
log_action(self.current_user["id"], "SESSION_TIMEOUT", "users",
self.current_user["id"],
f"Session timed out for user '{username}' after inactivity.")
logger.info(f"Session timeout: auto-logged out user '{username}'.")
messagebox.showinfo(
"Session Expired",
"Your session has expired due to inactivity.\nPlease log in again."
)
self._logout()
# ─── Change Password ──────────────────────────────────────────────────────
def _show_change_password(self):
from views.change_password_view import ChangePasswordView
ChangePasswordView(self, self.current_user)
# ─── Theme Toggle ─────────────────────────────────────────────────────────
def _toggle_theme(self):
"""Flip dark↔light, rebuild the shell, restore active section."""
active = self._active_section
self.theme.toggle(rebuild_callback=self._build_shell)
# Re-navigate to the section that was active before the rebuild
if active and active in self._nav_buttons:
nav_map = {
"dashboard": self._show_dashboard,
"users": self._show_users,
"websites": self._show_websites,
"shift_mgmt": self._show_shift_mgmt,
"log": self._show_log,
"shifts": self._show_shifts,
"reports": self._show_reports,
"email": self._show_email_settings,
"settings": self._show_settings,
}
if active in nav_map:
nav_map[active]()
# ─── Logout ───────────────────────────────────────────────────────────────
def _logout(self):
# Cancel any running idle timers
if self._idle_timer:
self.after_cancel(self._idle_timer)
self._idle_timer = None
if self._warning_timer:
self.after_cancel(self._warning_timer)
self._warning_timer = None
# Stop background scheduler
try:
from utils.scheduler import stop as _sched_stop
_sched_stop()
except Exception:
pass
from models import log_action
if self.current_user:
log_action(self.current_user["id"], "LOGOUT", "users",
self.current_user["id"],
f"User '{self.current_user['username']}' logged out.")
logger.info(f"User '{self.current_user['username']}' signed out.")
self.current_user = None
for child in self.winfo_children():
child.destroy()
self.withdraw()
self._show_login()
# ─── Entry Point ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
app = App()
app.mainloop()
+10
View File
@@ -0,0 +1,10 @@
[database]
host = 67.217.62.199
port = 3306
database = webchecker
user = webchecker
password = 7x+MxGmks_3U
[crypto]
salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ=
+315
View File
@@ -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()
+1282
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# utils package
+125
View File
@@ -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:")
+110
View File
@@ -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
+219
View File
@@ -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"<p>Error generating report: {e}</p>"
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"<tr>"
f"<td style='padding:8px 12px'>{r['username']}</td>"
f"<td style='padding:8px 12px'>{r['full_name'] or ''}</td>"
f"<td style='padding:8px 12px;text-align:center'>{int(r['checked_count'] or 0)}</td>"
f"<td style='padding:8px 12px;text-align:center'>{int(r['total_sites'] or 0)}</td>"
f"<td style='padding:8px 12px;text-align:center;"
f"color:{color};font-weight:bold'>{pct:.0f}%</td>"
f"</tr>"
)
return f"""
<html><body style="font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e">
<h2 style="color:#5b4de8">Website Checker — Daily Report</h2>
<p style="color:#6b6b80">{today}</p>
<table border="0" cellspacing="0" cellpadding="0"
style="border-collapse:collapse;width:100%;max-width:600px">
<thead>
<tr style="background:#e0dff8">
<th style="padding:10px 12px;text-align:left">Username</th>
<th style="padding:10px 12px;text-align:left">Full Name</th>
<th style="padding:10px 12px;text-align:center">Checked</th>
<th style="padding:10px 12px;text-align:center">Total</th>
<th style="padding:10px 12px;text-align:center">Completion</th>
</tr>
</thead>
<tbody>{table_rows}</tbody>
</table>
<p style="color:#6b6b80;font-size:12px;margin-top:24px">
Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.
</p>
</body></html>
"""
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.")
+490
View File
@@ -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("<Escape>", 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("<Configure>",
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("<MouseWheel>", _safe_scroll)
def _leave(e):
try:
canvas.unbind_all("<MouseWheel>")
except Exception:
pass
canvas.bind("<Enter>", _enter)
canvas.bind("<Leave>", _leave)
return canvas, inner
+1
View File
@@ -0,0 +1 @@
# views package
+267
View File
@@ -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("<MouseWheel>")
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(
"<Configure>",
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(
"<Configure>",
lambda e: self._canvas.itemconfig(self._canvas_win, width=e.width)
)
self._canvas.bind("<Enter>", self._on_canvas_enter)
self._canvas.bind("<Leave>", self._on_canvas_leave)
def _on_canvas_enter(self, event):
self._canvas.bind_all(
"<MouseWheel>",
lambda e: self._safe_scroll(e)
)
def _on_canvas_leave(self, event):
try:
self._canvas.unbind_all("<MouseWheel>")
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))
+50
View File
@@ -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}")
+587
View File
@@ -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("<Double-1>", 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("<Double-Button-1>", 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("<Double-Button-1>", 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
+219
View File
@@ -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("<Double-1>", 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}")
+337
View File
@@ -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("<Double-1>", 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("<Configure>",
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("<MouseWheel>",
lambda ev: _safe_scroll(ev))
def _leave(e):
try:
canvas.unbind_all("<MouseWheel>")
except Exception:
pass
def _safe_scroll(ev):
try:
canvas.yview_scroll(int(-1 * (ev.delta / 120)), "units")
except Exception:
pass
canvas.bind("<Enter>", _enter)
canvas.bind("<Leave>", _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}")
+249
View File
@@ -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("<Return>", 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()) # 04
# 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)
+239
View File
@@ -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()
+221
View File
@@ -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("<Return>", 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()
+513
View File
@@ -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
+287
View File
@@ -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()
+743
View File
@@ -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("<MouseWheel>")
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(
"<Configure>",
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("<Configure>", self._on_canvas_resize)
# Scope mousewheel to the canvas and its children via Enter/Leave
self.canvas.bind("<Enter>", self._on_canvas_enter)
self.canvas.bind("<Leave>", 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(
"<MouseWheel>", self._on_mousewheel
)
def _on_canvas_leave(self, event):
"""Mouse left canvas area — deactivate mousewheel scrolling."""
try:
self.canvas.unbind_all("<MouseWheel>")
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("<Enter>", lambda e, t=dot_tip: self._show_tooltip(e, t))
health_dot.bind("<Leave>", 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("<Button-1>", 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("<Button-1>", 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 = [
("<F5>", lambda e: self._load()),
("<Control-f>", lambda e: self._focus_search()),
("<Escape>", lambda e: self._clear_search()),
("<Control-a>", lambda e: self._select_all_unchecked()),
("<Control-Return>", 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}")