04/21 Fisrt commit
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -0,0 +1,10 @@
|
|||||||
|
[database]
|
||||||
|
host = 67.217.62.199
|
||||||
|
port = 3306
|
||||||
|
database = webchecker
|
||||||
|
user = webchecker
|
||||||
|
password = 7x+MxGmks_3U
|
||||||
|
|
||||||
|
[crypto]
|
||||||
|
salt = GE453Sa0afGLfzq8EPKdXjvZyAzK499XzWxWq/ZdnFQ=
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# utils package
|
||||||
+125
@@ -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
@@ -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
|
||||||
@@ -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.")
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
# views package
|
||||||
@@ -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))
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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}")
|
||||||
@@ -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}")
|
||||||
@@ -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()) # 0–4
|
||||||
|
|
||||||
|
# Update requirement labels
|
||||||
|
for key, lbl in self._req_labels.items():
|
||||||
|
if met[key]:
|
||||||
|
lbl.config(fg=COLOURS["success"], text=f"✔ {lbl.cget('text')[2:]}")
|
||||||
|
else:
|
||||||
|
lbl.config(fg=COLOURS["text_dim"], text=f" {lbl.cget('text')[2:]}")
|
||||||
|
|
||||||
|
# Re-set correct prefix each time
|
||||||
|
req_texts = {
|
||||||
|
"length": f"At least {PW_MIN_LENGTH} characters",
|
||||||
|
"upper": "Uppercase letter",
|
||||||
|
"digit": "Number",
|
||||||
|
"special": "Special character",
|
||||||
|
}
|
||||||
|
for key, lbl in self._req_labels.items():
|
||||||
|
prefix = "✔ " if met[key] else " "
|
||||||
|
lbl.config(text=prefix + req_texts[key])
|
||||||
|
|
||||||
|
# Colour segments
|
||||||
|
colours = ["#e05c5c", "#f0a500", "#f0a500", "#7c6af7", "#4caf50"]
|
||||||
|
labels = ["Very Weak", "Weak", "Fair", "Strong", "Very Strong"]
|
||||||
|
|
||||||
|
for i, seg in enumerate(self._segments):
|
||||||
|
seg.config(bg=colours[score - 1] if i < score and score > 0
|
||||||
|
else COLOURS["surface2"])
|
||||||
|
|
||||||
|
if pw:
|
||||||
|
self._strength_lbl.config(
|
||||||
|
text=labels[score - 1] if score > 0 else "",
|
||||||
|
fg=colours[score - 1] if score > 0 else COLOURS["text_dim"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._strength_lbl.config(text="")
|
||||||
|
|
||||||
|
# ─── Submit ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _submit(self):
|
||||||
|
old_pw = self.old_var.get()
|
||||||
|
new_pw = self.new_var.get()
|
||||||
|
cfm_pw = self.cfm_var.get()
|
||||||
|
|
||||||
|
if not old_pw or not new_pw or not cfm_pw:
|
||||||
|
self._status_var.set("All fields are required.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if new_pw != cfm_pw:
|
||||||
|
self._status_var.set("New password and confirmation do not match.")
|
||||||
|
return
|
||||||
|
|
||||||
|
from models import change_password
|
||||||
|
try:
|
||||||
|
success, message = change_password(
|
||||||
|
self.current_user["id"], old_pw, new_pw
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._status_var.set(f"Error: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if success:
|
||||||
|
show_info(message)
|
||||||
|
logger.info(
|
||||||
|
f"Password changed by user '{self.current_user['username']}'.")
|
||||||
|
self.destroy()
|
||||||
|
else:
|
||||||
|
self._status_var.set(message)
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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}")
|
||||||
Reference in New Issue
Block a user