04/22 Upgraded code commit

This commit is contained in:
2026-04-22 12:22:26 -04:00
parent 7548dfc9bf
commit c0e965f10b
9 changed files with 714 additions and 19 deletions
+313
View File
@@ -0,0 +1,313 @@
# CLAUDE.md — Website Checker: Complete Technical Knowledge Base
This document captures the full architecture, design decisions, database schema, module inventory,
feature set, and operational notes for the **Website Checker** desktop application.
---
## 1. Application Overview
Website Checker is a Python/Tkinter desktop application for shift-based website monitoring.
Regular users log in, view websites assigned to their shift, open each site, and mark it checked.
Administrators manage users, websites, shifts, credentials, and run reports.
- **UI:** Python `tkinter` + `ttk`
- **Database:** Remote MySQL 5.7+ / MariaDB 10.3+ via `mysql-connector-python`
- **Entry point:** `app.py``class App(tk.Tk)`
- **Default window:** 1100×700, minimum 960×620
- **Default theme:** Light (toggleable to Dark)
---
## 2. Project File Structure
```
website_checker/
├── app.py Entry point, shell, navigation, session management
├── config.py DB config, connection pool, schema DDL, migrations
├── models.py All database access (data layer)
├── requirements.txt
├── CLAUDE.md This file
├── utils/
│ ├── crypto.py Fernet credential encryption
│ ├── export.py CSV + Excel export
│ ├── scheduler.py Daily email report background daemon
│ └── ui_helpers.py ThemeManager, COLOURS proxy, DateEntry, widgets
└── views/
├── admin_dashboard_view.py Admin home — KPI cards + per-user progress table
├── admin_log_view.py Activity log treeview
├── admin_shifts_view.py Shift CRUD + PDF export
├── admin_users_view.py User CRUD
├── admin_websites_view.py Website CRUD + visibility + credentials
├── change_password_view.py Self-service password change (all roles)
├── email_settings_view.py SMTP / scheduled report configuration
├── login_view.py Login form with rate-limiting countdown
├── reports_view.py Shift Detail / Unchecked / Summary / Chart tabs
├── settings_view.py DB connection settings dialog
└── user_dashboard_view.py User checklist — search, bulk check, notifications
```
---
## 3. Runtime Files
### `config.ini`
Created on first launch. Contains [database], [email], and [crypto] sections.
```ini
[database]
host=your-mysql-host
port=3306
database=website_checker
user=your-db-user
password=your-db-password
[email]
enabled=false
smtp_host=smtp.example.com
smtp_port=587
smtp_user=sender@example.com
smtp_password=secret
use_tls=true
recipients=admin@example.com
send_time=18:00
[crypto]
salt=<base64 32-byte salt — auto-generated>
```
### `app.log`
UTF-8 log file in working directory. All INFO/WARNING/ERROR from every module.
---
## 4. Database Schema
All tables use InnoDB/utf8mb4. Created by `initialize_database()` on first launch.
Safe ALTER TABLE migrations run automatically for columns added post-deployment.
### users
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO | |
| username | VARCHAR(100) UNIQUE | |
| password | VARCHAR(255) | bcrypt hash. Legacy SHA-256 (64 hex) auto-migrated on login |
| role | ENUM('admin','user') | |
| full_name | VARCHAR(200) | |
| is_active | TINYINT(1) | |
| failed_attempts | TINYINT UNSIGNED | Incremented on bad login; reset on success |
| locked_until | DATETIME NULL | Set when failed_attempts >= MAX_FAILED_ATTEMPTS (5) |
| created_at / updated_at | DATETIME | |
### websites
| Column | Type | Notes |
|---|---|---|
| id | INT PK AUTO | |
| name | VARCHAR(200) | |
| url | TEXT | |
| check_type | ENUM('daily','weekly') | weekly = once per ISO week |
| visibility | ENUM('all','assigned') | assigned = only users in website_users table |
| note | TEXT | Shown on user dashboard card |
| is_active | TINYINT(1) | Soft-delete |
| created_by | INT FK users SET NULL | |
### website_credentials
| Column | Notes |
|---|---|
| website_id FK | |
| username | Plaintext |
| password | Fernet-encrypted with `enc:` prefix |
| label | e.g. "Admin", "Read-only" |
### shift_checks
One row per user per site per day (upserted). `user_note` optional.
### activity_log
`action` codes: LOGIN, LOGOUT, SESSION_TIMEOUT, ACCOUNT_LOCKED, CREATE/UPDATE/DELETE_USER,
CHANGE_PASSWORD, CHANGE_PASSWORD_FAIL, CREATE/UPDATE/DELETE_WEBSITE, ADD/REMOVE_CREDENTIAL,
CREATE/UPDATE/DELETE_SHIFT, CHECK_WEBSITE, UPDATE_NOTE, EXPORT_CSV, EXPORT_EXCEL,
EXPORT_SHIFT_PDF, UPDATE_EMAIL_SETTINGS.
### shifts
`days_of_week` is a digit string using MySQL DAYOFWEEK: 1=Sun 2=Mon 3=Tue 4=Wed 5=Thu 6=Fri 7=Sat.
E.g. "23456" = MonFri. Queried with `LOCATE(DAYOFWEEK(CURDATE()), days_of_week) > 0`.
### shift_users (junction): (shift_id, user_id) PK, both CASCADE
### shift_websites (junction): (shift_id, website_id) PK + sort_order
### login_attempts: username, attempted_at, ip_address (indexed)
### website_users (junction): (website_id, user_id) PK — for visibility='assigned'
---
## 5. Module Details
### config.py
- DB_CONFIG loaded from config.ini at import; placeholders if file missing
- get_connection() → pool (pool_size=5)
- initialize_database() → idempotent DDL + ALTER TABLE migrations
- load_config() / save_config() → config.ini read/write
- config_exists() → True if host, database, user are set
- reload_db_config() → re-reads config.ini, resets pool
### models.py
Each function opens+closes its own connection. All writes call log_action().
Password helpers:
- _hash_password(pw) → bcrypt rounds=12
- _verify_password(pw, stored) → handles bcrypt + legacy SHA-256
- _needs_rehash(stored) → True for 64-char hex (SHA-256)
Rate-limit constants: MAX_FAILED_ATTEMPTS=5, LOCKOUT_MINUTES=15
Password strength: PW_MIN_LENGTH=8, requires upper + digit + special char
### utils/crypto.py
- PBKDF2-HMAC-SHA256, 100,000 iterations, 32-byte salt from config.ini [crypto]
- encrypt(plaintext) → "enc:" + base64(Fernet token)
- decrypt(ciphertext) → plaintext; legacy plaintext (no enc: prefix) passes through unchanged
- reset_fernet() → force key reload after config.ini replacement
### utils/ui_helpers.py
- ThemeManager singleton — initial="light". toggle(rebuild_callback) flips theme.
- COLOURS = _ColourProxy(dict) — always delegates to ThemeManager.get(). Import once, always current.
- THEMES["dark"] and THEMES["light"] each have 18 colour keys + 4 cal_* keys
- DateEntry — Frame subclass; .get() → "YYYY-MM-DD", .set(str). Calendar popup with prev/next month+year.
- make_scrollable_frame() — mousewheel scoped to Enter/Leave to prevent stale-widget crashes
- scrolled_text(parent, height, width) → (frame, tk.Text)
### utils/scheduler.py
- Daemon thread; polls every 60 seconds
- Sends HTML email once per day when now >= send_time
- last_sent_date in-memory (resets on restart)
- start() / stop() called from app.py on login/logout (admin only)
### views/login_view.py
- check_login_allowed(username) called before authenticate()
- Locked: form disabled; 1-second countdown; re-enables when timer reaches 0
- Not-yet-locked failed attempt: shows "N attempt(s) remaining before lockout"
### views/user_dashboard_view.py
Key internals:
- _health_cache: {website_id: ("ok"|"slow"|"down", ms)} — daemon threads, HEAD request, 6s timeout
- Dot colours: green (<=3000ms), amber (>3000ms), red (error)
- Mousewheel: Enter/Leave scoped; unbind_all in destroy()
- Keyboard shortcuts: self.bind() stored in _shortcut_ids; unbound in destroy()
- Notifications: after(60_000) loop; plyer first, fallback to borderless Toplevel toast
### views/admin_shifts_view.py
- _export_pdf(): reportlab A4 document; one section per active shift; user+website tables
### views/reports_view.py
- All date fields use DateEntry (no manual text entry)
- Chart: matplotlib Agg + FigureCanvasTkAgg; bars green>=100% / amber>=50% / red<50%
---
## 6. Authentication & Security
| Concern | Implementation |
|---|---|
| Password hashing | bcrypt rounds=12; SHA-256 auto-rehashed on next login |
| Credential encryption | Fernet (AES-128-CBC + HMAC) via cryptography library |
| Login rate limiting | 5 attempts → 15-min lockout in DB |
| Session timeout | 30-min idle; 1-min warning; any mouse/key event resets |
| Password policy | 8+ chars, uppercase, digit, special character |
| Self-service change | Requires current password; strength meter; same-as-current guard |
---
## 7. Key Business Logic
### check_type
- daily: shown every shift day
- weekly: hidden once checked this week (YEARWEEK ISO); reappears Monday
### visibility
- all: every user in shift sees it
- assigned: only users in website_users table see it
### get_today_checks resolution
1. Check if user has active shifts today (DAYOFWEEK match)
2. If yes: union of shift websites filtered by check_type + visibility
3. If no: all active websites filtered by visibility + check_type (legacy fallback)
### Soft deletes
Websites and Shifts: is_active=0. Users: hard-delete (admin can deactivate first).
---
## 8. Configuration Constants (edit in source)
| File | Constant | Default |
|---|---|---|
| app.py | IDLE_TIMEOUT_MS | 1,800,000 (30 min) |
| app.py | IDLE_WARNING_MS | 60,000 (1 min) |
| app.py | APP_VERSION | "1.0.0" |
| app.py | VERSION_CHECK_ENABLED | False |
| models.py | MAX_FAILED_ATTEMPTS | 5 |
| models.py | LOCKOUT_MINUTES | 15 |
| models.py | PW_MIN_LENGTH | 8 |
| user_dashboard_view.py | NOTIFY_MINUTES_BEFORE | 15 |
| admin_dashboard_view.py | REFRESH_INTERVAL_MS | 60,000 |
| utils/crypto.py | _ITERATIONS | 100,000 |
---
## 9. Critical Gotchas
1. bind_all("<MouseWheel>") is NEVER used at module level. Always Enter/Leave scoped.
Every view's destroy() calls unbind_all("<MouseWheel>") and _unbind_shortcuts().
2. COLOURS is a live proxy — delegates to ThemeManager.get() on every access.
Never snapshot it into a local variable at class-creation time.
3. bcrypt is slow by design (~200-400ms at rounds=12). Expected behaviour.
4. config.ini stores DB password and SMTP password in plaintext.
Website credential passwords are Fernet-encrypted.
5. get_today_checks GROUP BY includes sc.id to prevent row collisions when a user
belongs to multiple shifts sharing the same website.
6. Weekly site logic uses YEARWEEK(..., 1) (ISO week, Monday start).
7. The email scheduler last_sent_date is in-memory — resets on restart.
A production deployment should persist it to the DB.
8. All log messages use ASCII only (hyphens not em-dashes/arrows) to prevent
cp1252 UnicodeEncodeError on Windows consoles.
---
## 10. Dependencies
```
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
reportlab>=4.0.0
```
stdlib used: tkinter, csv, smtplib, urllib.request, configparser, threading, calendar, datetime
---
## 11. First-Run Flow
1. _boot() → config_exists() → False → SettingsView (locked modal)
2. User enters DB creds → Test Connection → Save & Connect
3. reload_db_config() → initialize_database() → seeds admin/admin123 if users empty
4. Login screen shown
5. ThemeManager(root, initial="light") applied; shell built
---
## 12. Deployment Notes
- Python 3.9+ required. 3.12 tested on Windows.
- tkinter bundled on Windows/macOS; Linux: apt install python3-tk
- Create DB first: CREATE DATABASE website_checker CHARACTER SET utf8mb4;
- MySQL port 3306 must be reachable from client
- Default admin: username=admin / password=admin123 — change immediately
Binary file not shown.
+70
View File
@@ -21,6 +21,11 @@ logger = logging.getLogger("app")
IDLE_TIMEOUT_MS = 30 * 60 * 1000 # 30 minutes in milliseconds IDLE_TIMEOUT_MS = 30 * 60 * 1000 # 30 minutes in milliseconds
IDLE_WARNING_MS = 60 * 1000 # warn 1 minute before timeout IDLE_WARNING_MS = 60 * 1000 # warn 1 minute before timeout
# ─── Version Configuration ────────────────────────────────────────────────────
APP_VERSION = "1.0.0"
VERSION_CHECK_URL = "https://api.github.com/repos/your-org/website-checker/releases/latest"
VERSION_CHECK_ENABLED = False # set True and update URL when publishing releases
class App(tk.Tk): class App(tk.Tk):
def __init__(self): def __init__(self):
@@ -88,6 +93,11 @@ class App(tk.Tk):
if user.get("role") == "admin": if user.get("role") == "admin":
from utils.scheduler import start as _sched_start from utils.scheduler import start as _sched_start
_sched_start() _sched_start()
# Non-blocking version check (admin only)
if user.get("role") == "admin" and VERSION_CHECK_ENABLED:
import threading
threading.Thread(target=self._check_for_updates,
daemon=True).start()
def _centre(self): def _centre(self):
self.update_idletasks() self.update_idletasks()
@@ -388,6 +398,66 @@ class App(tk.Tk):
) )
self._logout() self._logout()
# ─── Version Check ────────────────────────────────────────────────────────
def _check_for_updates(self):
"""
Background thread: fetch the latest release tag from GitHub and
show a dismissible banner if a newer version is available.
Set VERSION_CHECK_ENABLED=True and update VERSION_CHECK_URL to activate.
"""
try:
import urllib.request
import json
req = urllib.request.Request(
VERSION_CHECK_URL,
headers={"User-Agent": "WebsiteChecker/" + APP_VERSION},
)
with urllib.request.urlopen(req, timeout=6) as resp:
data = json.loads(resp.read())
latest_tag = data.get("tag_name", "").lstrip("v")
release_url = data.get("html_url", "")
if latest_tag and latest_tag != APP_VERSION:
logger.info(f"New version available: {latest_tag} (current: {APP_VERSION})")
self.after(0, lambda: self._show_update_banner(latest_tag, release_url))
except Exception as e:
logger.debug(f"Version check failed (non-critical): {e}")
def _show_update_banner(self, latest_version: str, release_url: str):
"""Show a non-intrusive dismissible update notification at the top."""
if not self.current_user:
return
try:
banner = tk.Frame(self.content, bg=COLOURS["accent"], pady=6)
banner.pack(fill="x", side="top", before=self.content.winfo_children()[0])
tk.Label(banner,
text=f" Update available: v{latest_version} "
f"(you have v{APP_VERSION})",
bg=COLOURS["accent"], fg=COLOURS["white"],
font=FONT_SMALL).pack(side="left", padx=(8, 0))
if release_url:
import webbrowser
tk.Button(
banner, text="View Release",
command=lambda: webbrowser.open(release_url),
bg=COLOURS["white"], fg=COLOURS["accent"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=8, pady=2,
).pack(side="left", padx=8)
tk.Button(
banner, text="✕ Dismiss",
command=banner.destroy,
bg=COLOURS["accent"], fg=COLOURS["white"],
relief="flat", font=FONT_SMALL, cursor="hand2",
padx=8,
).pack(side="right", padx=8)
except Exception:
pass # UI may not be ready — silently skip
# ─── Change Password ────────────────────────────────────────────────────── # ─── Change Password ──────────────────────────────────────────────────────
def _show_change_password(self): def _show_change_password(self):
+27
View File
@@ -245,6 +245,15 @@ def initialize_database():
INDEX idx_username_time (username, attempted_at) INDEX idx_username_time (username, attempted_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
""", """,
"""
CREATE TABLE IF NOT EXISTS website_users (
website_id INT NOT NULL,
user_id INT NOT NULL,
PRIMARY KEY (website_id, user_id),
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;
""",
] ]
conn = None conn = None
@@ -294,6 +303,24 @@ def initialize_database():
conn.commit() conn.commit()
logger.info("Migration: added failed_attempts and locked_until columns to users table.") logger.info("Migration: added failed_attempts and locked_until columns to users table.")
# Add visibility column to websites if it doesn't exist
cursor.execute(
"""
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'websites'
AND COLUMN_NAME = 'visibility'
"""
)
(has_vis,) = cursor.fetchone()
if not has_vis:
cursor.execute(
"ALTER TABLE websites ADD COLUMN visibility "
"ENUM('all','assigned') NOT NULL DEFAULT 'all' AFTER check_type"
)
conn.commit()
logger.info("Migration: added visibility column to websites table.")
# Seed default admin if users table is empty # Seed default admin if users table is empty
cursor.execute("SELECT COUNT(*) FROM users") cursor.execute("SELECT COUNT(*) FROM users")
(count,) = cursor.fetchone() (count,) = cursor.fetchone()
+67 -10
View File
@@ -427,18 +427,21 @@ def get_website_by_id(website_id: int):
conn.close() conn.close()
def create_website(admin_id, name, url, check_type, note, credentials: list): def create_website(admin_id, name, url, check_type, note, credentials: list,
visibility: str = "all", assigned_user_ids: list = None):
""" """
credentials: list of dicts with keys: username, password, label credentials: list of dicts with keys: username, password, label
check_type: 'daily' or 'weekly' check_type: 'daily' or 'weekly'
visibility: 'all' (all users) or 'assigned' (only website_users)
""" """
conn = None conn = None
try: try:
conn = get_connection() conn = get_connection()
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"INSERT INTO websites (name, url, check_type, note, created_by) VALUES (%s,%s,%s,%s,%s)", "INSERT INTO websites (name, url, check_type, visibility, note, created_by) "
(name, url, check_type, note, admin_id) "VALUES (%s,%s,%s,%s,%s,%s)",
(name, url, check_type, visibility, note, admin_id)
) )
conn.commit() conn.commit()
new_id = cur.lastrowid new_id = cur.lastrowid
@@ -451,17 +454,29 @@ def create_website(admin_id, name, url, check_type, note, credentials: list):
log_action(admin_id, "ADD_CREDENTIAL", "website_credentials", new_id, log_action(admin_id, "ADD_CREDENTIAL", "website_credentials", new_id,
f"Added credential label='{cred.get('label','')}' " f"Added credential label='{cred.get('label','')}' "
f"user='{cred['username']}' for website '{name}'.") f"user='{cred['username']}' for website '{name}'.")
# Assign specific users if visibility='assigned'
if visibility == "assigned" and assigned_user_ids:
for uid in assigned_user_ids:
cur.execute(
"INSERT IGNORE INTO website_users (website_id, user_id) VALUES (%s,%s)",
(new_id, uid)
)
conn.commit() conn.commit()
cur.close() cur.close()
log_action(admin_id, "CREATE_WEBSITE", "websites", new_id, log_action(admin_id, "CREATE_WEBSITE", "websites", new_id,
f"Created website '{name}' check_type='{check_type}'.") f"Created website '{name}' check_type='{check_type}' "
f"visibility='{visibility}'.")
return new_id return new_id
finally: finally:
if conn: if conn:
conn.close() conn.close()
def update_website(admin_id, website_id, name, url, check_type, note, credentials: list): def update_website(admin_id, website_id, name, url, check_type, note,
credentials: list, visibility: str = "all",
assigned_user_ids: list = None):
conn = None conn = None
try: try:
conn = get_connection() conn = get_connection()
@@ -475,8 +490,9 @@ def update_website(admin_id, website_id, name, url, check_type, note, credential
old_creds = {(r["username"], r["label"] or "") for r in cur.fetchall()} old_creds = {(r["username"], r["label"] or "") for r in cur.fetchall()}
cur.execute( cur.execute(
"UPDATE websites SET name=%s, url=%s, check_type=%s, note=%s WHERE id=%s", "UPDATE websites SET name=%s, url=%s, check_type=%s, "
(name, url, check_type, note, website_id) "visibility=%s, note=%s WHERE id=%s",
(name, url, check_type, visibility, note, website_id)
) )
# Replace credentials # Replace credentials
cur.execute("DELETE FROM website_credentials WHERE website_id=%s", (website_id,)) cur.execute("DELETE FROM website_credentials WHERE website_id=%s", (website_id,))
@@ -488,6 +504,15 @@ def update_website(admin_id, website_id, name, url, check_type, note, credential
) )
new_creds.add((cred["username"], cred.get("label", "") or "")) new_creds.add((cred["username"], cred.get("label", "") or ""))
# Replace assigned users
cur.execute("DELETE FROM website_users WHERE website_id=%s", (website_id,))
if visibility == "assigned" and assigned_user_ids:
for uid in assigned_user_ids:
cur.execute(
"INSERT IGNORE INTO website_users (website_id, user_id) VALUES (%s,%s)",
(website_id, uid)
)
conn.commit() conn.commit()
cur.close() cur.close()
@@ -504,7 +529,8 @@ def update_website(admin_id, website_id, name, url, check_type, note, credential
f"from website id={website_id}.") f"from website id={website_id}.")
log_action(admin_id, "UPDATE_WEBSITE", "websites", website_id, log_action(admin_id, "UPDATE_WEBSITE", "websites", website_id,
f"Updated website id={website_id} check_type='{check_type}'.") f"Updated website id={website_id} check_type='{check_type}' "
f"visibility='{visibility}'.")
finally: finally:
if conn: if conn:
conn.close() conn.close()
@@ -544,6 +570,30 @@ def get_website_credentials(website_id: int):
conn.close() conn.close()
def get_website_assigned_users(website_id: int):
"""Return users explicitly assigned to a website (visibility='assigned')."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT u.id, u.username, u.full_name
FROM website_users wu
JOIN users u ON u.id = wu.user_id
WHERE wu.website_id = %s
ORDER BY u.username
""",
(website_id,)
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
# ─── Shift Check CRUD ───────────────────────────────────────────────────────── # ─── Shift Check CRUD ─────────────────────────────────────────────────────────
def get_today_checks(user_id: int): def get_today_checks(user_id: int):
@@ -617,7 +667,7 @@ def get_today_checks(user_id: int):
(user_id, user_id, user_id) (user_id, user_id, user_id)
) )
else: else:
# Legacy fallback: show all active websites (original behaviour) # Legacy fallback: show active websites, filtered by visibility
cur.execute( cur.execute(
""" """
SELECT SELECT
@@ -638,6 +688,13 @@ def get_today_checks(user_id: int):
AND sc.user_id = %s AND sc.user_id = %s
AND DATE(sc.checked_at) = CURDATE() AND DATE(sc.checked_at) = CURDATE()
WHERE w.is_active = 1 WHERE w.is_active = 1
AND (
w.visibility = 'all'
OR EXISTS (
SELECT 1 FROM website_users wu
WHERE wu.website_id = w.id AND wu.user_id = %s
)
)
AND ( AND (
w.check_type = 'daily' w.check_type = 'daily'
OR ( OR (
@@ -652,7 +709,7 @@ def get_today_checks(user_id: int):
) )
ORDER BY w.name ORDER BY w.name
""", """,
(user_id, user_id) (user_id, user_id, user_id)
) )
rows = cur.fetchall() rows = cur.fetchall()
+1
View File
@@ -4,3 +4,4 @@ openpyxl>=3.1.0
cryptography>=41.0.0 cryptography>=41.0.0
matplotlib>=3.7.0 matplotlib>=3.7.0
plyer>=2.1.0 plyer>=2.1.0
reportlab>=4.0.0
+1 -1
View File
@@ -113,7 +113,7 @@ def decrypt(ciphertext: str) -> str:
token = ciphertext[4:].encode("ascii") token = ciphertext[4:].encode("ascii")
return _get_fernet().decrypt(token).decode("utf-8") return _get_fernet().decrypt(token).decode("utf-8")
except InvalidToken: except InvalidToken:
logger.error("Credential decryption failed wrong key or corrupted data.") logger.error("Credential decryption failed - wrong key or corrupted data.")
return "" return ""
except Exception as e: except Exception as e:
logger.error(f"Credential decryption error: {e}") logger.error(f"Credential decryption error: {e}")
+145 -1
View File
@@ -51,7 +51,10 @@ class AdminShiftsView(ttk.Frame):
command=self._open_edit).pack(side="right", padx=(4, 0)) command=self._open_edit).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="✕ Delete", ttk.Button(toolbar, text="✕ Delete",
style="Danger.TButton", style="Danger.TButton",
command=self._delete_selected).pack(side="right") command=self._delete_selected).pack(side="right", padx=(4, 0))
ttk.Button(toolbar, text="⬇ Export PDF",
style="Ghost.TButton",
command=self._export_pdf).pack(side="right", padx=(0, 12))
cols = ("ID", "Shift Name", "Days", "Start", "End", cols = ("ID", "Shift Name", "Days", "Start", "End",
"Users", "Websites", "Active", "Note") "Users", "Websites", "Active", "Note")
@@ -103,6 +106,147 @@ class AdminShiftsView(ttk.Frame):
sel = self.tree.selection() sel = self.tree.selection()
return int(sel[0]) if sel else None return int(sel[0]) if sel else None
# ─── Export ───────────────────────────────────────────────────────────────
def _export_pdf(self):
"""Export the full shift schedule to a PDF file."""
from tkinter import filedialog
from models import get_all_shifts, get_shift_assigned_users, get_shift_assigned_websites
import datetime
save_path = filedialog.asksaveasfilename(
title="Save Shift Schedule PDF",
defaultextension=".pdf",
filetypes=[("PDF files", "*.pdf"), ("All files", "*.*")],
initialfile=f"shift_schedule_{datetime.date.today().isoformat()}.pdf",
)
if not save_path:
return
try:
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import cm
from reportlab.lib import colors
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
)
shifts = get_all_shifts()
doc = SimpleDocTemplate(save_path, pagesize=A4,
leftMargin=2*cm, rightMargin=2*cm,
topMargin=2*cm, bottomMargin=2*cm)
styles = getSampleStyleSheet()
ACCENT = colors.HexColor("#5b4de8")
LIGHT = colors.HexColor("#e0dff8")
DARK = colors.HexColor("#1a1a2e")
DIMGREY = colors.HexColor("#6b6b80")
title_style = ParagraphStyle("Title", parent=styles["Title"],
textColor=ACCENT, fontSize=20, spaceAfter=4)
sub_style = ParagraphStyle("Sub", parent=styles["Normal"],
textColor=DIMGREY, fontSize=10, spaceAfter=16)
h2_style = ParagraphStyle("H2", parent=styles["Heading2"],
textColor=ACCENT, fontSize=13, spaceBefore=14)
body_style = ParagraphStyle("Body", parent=styles["Normal"],
textColor=DARK, fontSize=10, leading=14)
dim_style = ParagraphStyle("Dim", parent=styles["Normal"],
textColor=DIMGREY, fontSize=9, leading=12)
story = [
Paragraph("Shift Schedule", title_style),
Paragraph(f"Generated {datetime.datetime.now().strftime('%d %B %Y, %H:%M')}",
sub_style),
HRFlowable(width="100%", thickness=1, color=ACCENT),
Spacer(1, 0.4*cm),
]
for s in shifts:
if not s["is_active"]:
continue
days_str = _days_label(s["days_of_week"])
start = str(s.get("start_time", ""))[:5]
end = str(s.get("end_time", ""))[:5]
story.append(Paragraph(s["name"], h2_style))
story.append(Paragraph(
f"<b>Days:</b> {days_str} &nbsp;&nbsp; "
f"<b>Time:</b> {start} {end} &nbsp;&nbsp; "
f"<b>Users:</b> {s['user_count']} &nbsp;&nbsp; "
f"<b>Websites:</b> {s['website_count']}",
body_style))
if s.get("note"):
story.append(Paragraph(f"<i>{s['note']}</i>", dim_style))
story.append(Spacer(1, 0.25*cm))
# Users table
users = get_shift_assigned_users(s["id"])
if users:
user_data = [["Assigned Users", ""]]
for u in users:
user_data.append([u["username"], u["full_name"] or ""])
t = Table(user_data, colWidths=[5*cm, 8*cm])
t.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), LIGHT),
("TEXTCOLOR", (0,0), (-1,0), ACCENT),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,-1), 9),
("ROWBACKGROUNDS", (0,1), (-1,-1), [colors.white, colors.HexColor("#f5f5fa")]),
("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#c5c5d8")),
("LEFTPADDING", (0,0), (-1,-1), 8),
("TOPPADDING", (0,0), (-1,-1), 4),
("BOTTOMPADDING", (0,0), (-1,-1), 4),
]))
story.append(t)
story.append(Spacer(1, 0.2*cm))
# Websites table
sites = get_shift_assigned_websites(s["id"])
if sites:
site_data = [["#", "Website", "URL"]]
for i, w in enumerate(sites, 1):
site_data.append([str(i), w["name"], w["url"]])
t = Table(site_data, colWidths=[1*cm, 5*cm, 10*cm])
t.setStyle(TableStyle([
("BACKGROUND", (0,0), (-1,0), LIGHT),
("TEXTCOLOR", (0,0), (-1,0), ACCENT),
("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
("FONTSIZE", (0,0), (-1,-1), 9),
("ROWBACKGROUNDS", (0,1), (-1,-1), [colors.white, colors.HexColor("#f5f5fa")]),
("GRID", (0,0), (-1,-1), 0.5, colors.HexColor("#c5c5d8")),
("LEFTPADDING", (0,0), (-1,-1), 6),
("TOPPADDING", (0,0), (-1,-1), 4),
("BOTTOMPADDING", (0,0), (-1,-1), 4),
]))
story.append(t)
story.append(Spacer(1, 0.5*cm))
story.append(HRFlowable(width="100%", thickness=0.5,
color=colors.HexColor("#c5c5d8")))
story.append(Spacer(1, 0.3*cm))
doc.build(story)
from models import log_action
log_action(self.current_user["id"], "EXPORT_SHIFT_PDF", "shifts",
None, f"Shift schedule exported to PDF: {save_path}")
logger.info(f"Shift schedule PDF exported: {save_path}")
from utils.ui_helpers import show_info
show_info(f"PDF exported successfully.\n\n{save_path}")
except ImportError:
from utils.ui_helpers import show_error
show_error("reportlab is required for PDF export.\n"
"Install it with: pip install reportlab")
except Exception as e:
from utils.ui_helpers import show_error
show_error(f"PDF export failed:\n{e}")
# ─── Actions ────────────────────────────────────────────────────────────── # ─── Actions ──────────────────────────────────────────────────────────────
def _open_add(self): def _open_add(self):
+88 -5
View File
@@ -97,10 +97,11 @@ class AdminWebsitesView(ttk.Frame):
if not wid: if not wid:
show_error("Please select a website to edit.") show_error("Please select a website to edit.")
return return
from models import get_website_by_id, get_website_credentials from models import get_website_by_id, get_website_credentials, get_website_assigned_users
data = get_website_by_id(wid) data = get_website_by_id(wid)
creds = get_website_credentials(wid) creds = get_website_credentials(wid)
data["credentials"] = creds data["credentials"] = creds
data["assigned_users"] = get_website_assigned_users(wid)
WebsiteDialog(self, self.current_user, website_data=data, WebsiteDialog(self, self.current_user, website_data=data,
on_save=self._load_websites) on_save=self._load_websites)
@@ -227,6 +228,66 @@ class WebsiteDialog(tk.Toplevel):
note_frame, self.note_txt = scrolled_text(form, height=4) note_frame, self.note_txt = scrolled_text(form, height=4)
note_frame.grid(row=3, column=1, sticky="ew", pady=6) note_frame.grid(row=3, column=1, sticky="ew", pady=6)
# ── Visibility ────────────────────────────────────────────────────────
ttk.Label(form, text="Visibility").grid(
row=4, column=0, sticky="w", padx=(0, 10), pady=6)
vis_frame = tk.Frame(form, bg=COLOURS["bg"])
vis_frame.grid(row=4, column=1, sticky="w", pady=6)
self.visibility_var = tk.StringVar(value="all")
for val, lbl in [("all", "👥 All Users"), ("assigned", "🔒 Assigned Only")]:
rb = tk.Radiobutton(
vis_frame, text=lbl,
variable=self.visibility_var, value=val,
bg=COLOURS["bg"], fg=COLOURS["text"],
activebackground=COLOURS["bg"],
activeforeground=COLOURS["accent"],
selectcolor=COLOURS["surface2"],
font=FONT, cursor="hand2",
command=self._on_visibility_change,
)
rb.pack(side="left", padx=(0, 16))
# ── Assigned users (shown only when visibility='assigned') ─────────────
self._user_assign_frame = ttk.Frame(self.inner)
self._user_assign_frame.pack(fill="x", padx=24, pady=(0, 4))
ttk.Label(self._user_assign_frame, text="Assigned Users",
style="Heading.TLabel").pack(anchor="w", pady=(4, 6))
# Load all active regular users for the picker
from models import get_all_users
try:
all_users = [u for u in get_all_users()
if u["is_active"] and u["role"] == "user"]
except Exception:
all_users = []
self._user_vars = {} # user_id -> BooleanVar
user_grid = tk.Frame(self._user_assign_frame, bg=COLOURS["surface"],
padx=12, pady=8)
user_grid.pack(fill="x")
for i, u in enumerate(all_users):
var = tk.BooleanVar(value=False)
self._user_vars[u["id"]] = var
col, row = i % 3, i // 3
tk.Checkbutton(
user_grid,
text=f"{u['username']} ({u['full_name'] or ''})",
variable=var,
bg=COLOURS["surface"], fg=COLOURS["text"],
activebackground=COLOURS["surface"],
activeforeground=COLOURS["accent"],
selectcolor=COLOURS["surface2"],
font=FONT_SMALL, cursor="hand2",
anchor="w",
).grid(row=row, column=col, sticky="w", padx=8, pady=2)
# Hide initially; shown when visibility='assigned'
self._user_assign_frame.pack_forget()
# ── Credentials section ─────────────────────────────────────────────── # ── Credentials section ───────────────────────────────────────────────
ttk.Separator(self.inner, orient="horizontal").pack( ttk.Separator(self.inner, orient="horizontal").pack(
fill="x", padx=24, pady=12) fill="x", padx=24, pady=12)
@@ -257,11 +318,26 @@ class WebsiteDialog(tk.Toplevel):
self.url_var.set(d.get("url") or "") self.url_var.set(d.get("url") or "")
self.check_type_var.set(d.get("check_type") or "daily") self.check_type_var.set(d.get("check_type") or "daily")
self.note_txt.insert("1.0", d.get("note") or "") self.note_txt.insert("1.0", d.get("note") or "")
vis = d.get("visibility") or "all"
self.visibility_var.set(vis)
# Pre-tick assigned users
assigned_ids = {u["id"] for u in d.get("assigned_users", [])}
for uid, var in self._user_vars.items():
var.set(uid in assigned_ids)
self._on_visibility_change()
for cred in d.get("credentials", []): for cred in d.get("credentials", []):
self._add_cred_row(cred) self._add_cred_row(cred)
else: else:
self._add_cred_row() self._add_cred_row()
def _on_visibility_change(self):
"""Show or hide the user assignment panel based on visibility selection."""
if self.visibility_var.get() == "assigned":
self._user_assign_frame.pack(fill="x", padx=24, pady=(0, 4),
before=self.creds_container)
else:
self._user_assign_frame.pack_forget()
def _add_cred_row(self, cred=None): def _add_cred_row(self, cred=None):
frame = ttk.Frame(self.creds_container, style="Surface.TFrame") frame = ttk.Frame(self.creds_container, style="Surface.TFrame")
frame.pack(fill="x", pady=4, ipady=4) frame.pack(fill="x", pady=4, ipady=4)
@@ -300,10 +376,15 @@ class WebsiteDialog(tk.Toplevel):
url = self.url_var.get().strip() url = self.url_var.get().strip()
check_type = self.check_type_var.get() check_type = self.check_type_var.get()
note = self.note_txt.get("1.0", "end-1c").strip() note = self.note_txt.get("1.0", "end-1c").strip()
visibility = self.visibility_var.get()
assigned_user_ids = [uid for uid, var in self._user_vars.items() if var.get()]
if not name or not url: if not name or not url:
show_error("Name and URL are required.") show_error("Name and URL are required.")
return return
if visibility == "assigned" and not assigned_user_ids:
show_error("Please assign at least one user, or set visibility to All Users.")
return
credentials = [] credentials = []
for label_var, user_var, pass_var, _ in self.cred_rows: for label_var, user_var, pass_var, _ in self.cred_rows:
@@ -320,16 +401,18 @@ class WebsiteDialog(tk.Toplevel):
from models import update_website from models import update_website
update_website(self.current_user["id"], update_website(self.current_user["id"],
self.website_data["id"], self.website_data["id"],
name, url, check_type, note, credentials) name, url, check_type, note, credentials,
visibility, assigned_user_ids)
logger.info(f"Website id={self.website_data['id']} updated " logger.info(f"Website id={self.website_data['id']} updated "
f"check_type='{check_type}'.") f"check_type='{check_type}' visibility='{visibility}'.")
show_info("Website updated successfully.") show_info("Website updated successfully.")
else: else:
from models import create_website from models import create_website
create_website(self.current_user["id"], create_website(self.current_user["id"],
name, url, check_type, note, credentials) name, url, check_type, note, credentials,
visibility, assigned_user_ids)
logger.info(f"New website '{name}' created " logger.info(f"New website '{name}' created "
f"check_type='{check_type}'.") f"check_type='{check_type}' visibility='{visibility}'.")
show_info("Website created successfully.") show_info("Website created successfully.")
self.on_save() self.on_save()
self.destroy() self.destroy()