diff --git a/Website_Checker_User_Manual.docx b/Website_Checker_User_Manual.docx deleted file mode 100644 index a0aff8c..0000000 Binary files a/Website_Checker_User_Manual.docx and /dev/null differ diff --git a/models.py b/models.py index 2bbedbb..1b1b986 100644 --- a/models.py +++ b/models.py @@ -376,14 +376,46 @@ def update_user(admin_id, user_id, username, role, full_name, is_active, passwor def delete_user(admin_id, user_id): + """ + Hard-delete a user record. + Guards: + - An admin cannot delete their own account. + - The last active admin account cannot be deleted. + Raises ValueError with a descriptive message when either guard fires. + """ conn = None try: conn = get_connection() - cur = conn.cursor() + cur = conn.cursor(dictionary=True) + + # Guard 1: self-delete + if admin_id == user_id: + cur.close() + raise ValueError("You cannot delete your own account.") + + # Guard 2: prevent removing the last active admin + cur.execute( + "SELECT role FROM users WHERE id=%s", (user_id,) + ) + target = cur.fetchone() + if target and target["role"] == "admin": + cur.execute( + "SELECT COUNT(*) AS n FROM users WHERE role='admin' AND is_active=1" + ) + admin_count = cur.fetchone()["n"] + if admin_count <= 1: + cur.close() + raise ValueError( + "Cannot delete the last active administrator account. " + "Promote another user to admin first." + ) + cur.execute("DELETE FROM users WHERE id=%s", (user_id,)) conn.commit() cur.close() - log_action(admin_id, "DELETE_USER", "users", user_id, f"Deleted user id={user_id}.") + log_action(admin_id, "DELETE_USER", "users", user_id, + f"Deleted user id={user_id}.") + logger.info(f"User id={user_id} deleted by admin_id={admin_id}.") finally: if conn: conn.close() @@ -649,6 +681,13 @@ def get_today_checks(user_id: int): WHERE s.is_active = 1 AND w.is_active = 1 AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0 + AND ( + w.visibility = 'all' + OR EXISTS ( + SELECT 1 FROM website_users wu + WHERE wu.website_id = w.id AND wu.user_id = %s + ) + ) AND ( w.check_type = 'daily' OR ( @@ -664,7 +703,7 @@ def get_today_checks(user_id: int): GROUP BY w.id, sc.id, sc.checked_at, sc.user_note, sc.user_id ORDER BY sort_order, w.name """, - (user_id, user_id, user_id) + (user_id, user_id, user_id, user_id) ) else: # Legacy fallback: show active websites, filtered by visibility @@ -861,17 +900,27 @@ def get_unchecked_report(target_date=None, user_id=None): conn = get_connection() cur = conn.cursor(dictionary=True) - date_val = str(target_date) if target_date else None - user_filter = "AND u.id = %s" if user_id else "" - params_inner = [date_val or "CURDATE()"] - if user_id: - params_inner.append(user_id) + + # Use a bind param only when a specific date is provided. + # When no date is given, embed CURDATE() directly in SQL so MySQL + # evaluates it as a function — passing "CURDATE()" as a %s bind + # parameter treats it as a literal string, not a SQL function, and + # causes the NOT EXISTS filter to match nothing (returns 0 rows). + if target_date: + date_val = str(target_date) + date_expr = "%s" + date_params = [date_val] + else: + date_expr = "CURDATE()" + date_params = [] + + params = date_params + ([user_id] if user_id else []) + date_params cur.execute( f""" SELECT - %s AS check_date, + {date_expr} AS check_date, u.username, COALESCE(u.full_name, u.username) AS full_name, w.name AS website_name, @@ -887,11 +936,11 @@ def get_unchecked_report(target_date=None, user_id=None): SELECT 1 FROM shift_checks sc WHERE sc.website_id = w.id AND sc.user_id = u.id - AND DATE(sc.checked_at) = %s + AND DATE(sc.checked_at) = {date_expr} ) ORDER BY u.username, w.name """, - ([date_val or "CURDATE()"] + ([user_id] if user_id else []) + [date_val or "CURDATE()"]) + params ) rows = cur.fetchall() cur.close() diff --git a/utils/scheduler.py b/utils/scheduler.py index af40af1..c8cabff 100644 --- a/utils/scheduler.py +++ b/utils/scheduler.py @@ -175,10 +175,38 @@ def _send_report(cfg: dict): logger.error(f"Failed to send daily report email: {e}") +def _get_last_sent_date() -> "datetime.date | None": + """Read the last-sent date from config.ini [email] last_sent_date key.""" + cfg = configparser.ConfigParser() + if not os.path.exists(CONFIG_FILE): + return None + cfg.read(CONFIG_FILE, encoding="utf-8") + raw = cfg.get("email", "last_sent_date", fallback="") + if not raw: + return None + try: + return datetime.date.fromisoformat(raw) + except ValueError: + return None + + +def _set_last_sent_date(d: "datetime.date"): + """Persist the last-sent date to config.ini [email] last_sent_date key.""" + cfg = configparser.ConfigParser() + if os.path.exists(CONFIG_FILE): + cfg.read(CONFIG_FILE, encoding="utf-8") + if "email" not in cfg: + cfg["email"] = {} + cfg["email"]["last_sent_date"] = d.isoformat() + with open(CONFIG_FILE, "w", encoding="utf-8") as fh: + cfg.write(fh) + + # ─── Scheduler loop ─────────────────────────────────────────────────────────── def _scheduler_loop(): - last_sent_date = None + # Seed from persisted value so a restart after send_time does not re-send. + last_sent_date = _get_last_sent_date() while not _stop_event.is_set(): _stop_event.wait(60) # sleep 60 seconds between checks @@ -200,6 +228,7 @@ def _scheduler_loop(): if (now.hour > send_h or (now.hour == send_h and now.minute >= send_m)): if last_sent_date != today: last_sent_date = today + _set_last_sent_date(today) logger.info(f"Scheduler: sending daily report at {now.strftime('%H:%M')}.") _send_report(cfg) @@ -207,7 +236,17 @@ def _scheduler_loop(): def start(): """Start the background scheduler thread. Call once after successful login.""" global _scheduler_thread, _stop_event - _stop_event.clear() + # Guard against double-start: if a thread is already alive (e.g. admin + # logs out and back in), stop it cleanly before spawning a new one. + # Without this guard, _stop_event.clear() would unblock the sleeping + # thread while a second thread also starts, resulting in two scheduler + # threads firing simultaneously and potentially sending duplicate emails. + if _scheduler_thread is not None and _scheduler_thread.is_alive(): + logger.info("Email scheduler already running - stopping before restart.") + _stop_event.set() + _scheduler_thread.join(timeout=5) + # Create a fresh Event so there is no residual set-state from a prior stop() + _stop_event = threading.Event() _scheduler_thread = threading.Thread(target=_scheduler_loop, name="EmailScheduler", daemon=True) _scheduler_thread.start() diff --git a/views/admin_websites_view.py b/views/admin_websites_view.py index 5fa8c7f..ea9eaac 100644 --- a/views/admin_websites_view.py +++ b/views/admin_websites_view.py @@ -352,8 +352,13 @@ class WebsiteDialog(tk.Toplevel): def _on_visibility_change(self): """Show or hide the user assignment panel based on visibility selection.""" if self.visibility_var.get() == "assigned": + # Use before=self._cred_body (not self.creds_container) because + # pack's `before` argument requires the reference widget to share + # the same parent. _user_assign_frame and _cred_body are both + # children of self.inner; creds_container is a child of _cred_body, + # so referencing it across that boundary raises a TclError. self._user_assign_frame.pack(fill="x", padx=24, pady=(0, 4), - before=self.creds_container) + before=self._cred_body) else: self._user_assign_frame.pack_forget() diff --git a/views/ai_summary_view.py b/views/ai_summary_view.py index c94486b..d48f73c 100644 --- a/views/ai_summary_view.py +++ b/views/ai_summary_view.py @@ -851,17 +851,32 @@ def _read_doc(path: str) -> str: def _read_excel(path: str) -> str: + """ + Extract text from an Excel file via openpyxl (read-only mode). + Iteration stops as soon as accumulated content exceeds MAX_CHARS_PER_FILE + to avoid loading the entire workbook into memory for very large files. + The AI layer will truncate to MAX_CHARS_PER_FILE anyway, so there is no + value in continuing past that point. + """ + MAX_CHARS_PER_FILE = 14_000 # mirror the constant in AiSummaryView._run_ai try: import openpyxl wb = openpyxl.load_workbook(path, read_only=True, data_only=True) lines = [] + total_chars = 0 for sheet in wb.worksheets: - lines.append(f"[Sheet: {sheet.title}]") + header = f"[Sheet: {sheet.title}]" + lines.append(header) + total_chars += len(header) + 1 for row in sheet.iter_rows(values_only=True): row_str = "\t".join( str(v) if v is not None else "" for v in row) if row_str.strip(): lines.append(row_str) + total_chars += len(row_str) + 1 + if total_chars >= MAX_CHARS_PER_FILE: + lines.append("[... content truncated to fit token limit ...]") + return "\n".join(lines) return "\n".join(lines) except ImportError: raise ImportError(