04/23 Fixed bugs
This commit is contained in:
Binary file not shown.
@@ -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):
|
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
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = get_connection()
|
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,))
|
cur.execute("DELETE FROM users WHERE id=%s", (user_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
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:
|
finally:
|
||||||
if conn:
|
if conn:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -649,6 +681,13 @@ def get_today_checks(user_id: int):
|
|||||||
WHERE s.is_active = 1
|
WHERE s.is_active = 1
|
||||||
AND w.is_active = 1
|
AND w.is_active = 1
|
||||||
AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0
|
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 (
|
AND (
|
||||||
w.check_type = 'daily'
|
w.check_type = 'daily'
|
||||||
OR (
|
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
|
GROUP BY w.id, sc.id, sc.checked_at, sc.user_note, sc.user_id
|
||||||
ORDER BY sort_order, w.name
|
ORDER BY sort_order, w.name
|
||||||
""",
|
""",
|
||||||
(user_id, user_id, user_id)
|
(user_id, user_id, user_id, user_id)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Legacy fallback: show active websites, filtered by visibility
|
# 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()
|
conn = get_connection()
|
||||||
cur = conn.cursor(dictionary=True)
|
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 ""
|
user_filter = "AND u.id = %s" if user_id else ""
|
||||||
params_inner = [date_val or "CURDATE()"]
|
|
||||||
if user_id:
|
# Use a bind param only when a specific date is provided.
|
||||||
params_inner.append(user_id)
|
# 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(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT
|
SELECT
|
||||||
%s AS check_date,
|
{date_expr} AS check_date,
|
||||||
u.username,
|
u.username,
|
||||||
COALESCE(u.full_name, u.username) AS full_name,
|
COALESCE(u.full_name, u.username) AS full_name,
|
||||||
w.name AS website_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
|
SELECT 1 FROM shift_checks sc
|
||||||
WHERE sc.website_id = w.id
|
WHERE sc.website_id = w.id
|
||||||
AND sc.user_id = u.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
|
ORDER BY u.username, w.name
|
||||||
""",
|
""",
|
||||||
([date_val or "CURDATE()"] + ([user_id] if user_id else []) + [date_val or "CURDATE()"])
|
params
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
cur.close()
|
cur.close()
|
||||||
|
|||||||
+41
-2
@@ -175,10 +175,38 @@ def _send_report(cfg: dict):
|
|||||||
logger.error(f"Failed to send daily report email: {e}")
|
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 ───────────────────────────────────────────────────────────
|
# ─── Scheduler loop ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _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():
|
while not _stop_event.is_set():
|
||||||
_stop_event.wait(60) # sleep 60 seconds between checks
|
_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 (now.hour > send_h or (now.hour == send_h and now.minute >= send_m)):
|
||||||
if last_sent_date != today:
|
if last_sent_date != today:
|
||||||
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')}.")
|
logger.info(f"Scheduler: sending daily report at {now.strftime('%H:%M')}.")
|
||||||
_send_report(cfg)
|
_send_report(cfg)
|
||||||
|
|
||||||
@@ -207,7 +236,17 @@ def _scheduler_loop():
|
|||||||
def start():
|
def start():
|
||||||
"""Start the background scheduler thread. Call once after successful login."""
|
"""Start the background scheduler thread. Call once after successful login."""
|
||||||
global _scheduler_thread, _stop_event
|
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,
|
_scheduler_thread = threading.Thread(target=_scheduler_loop,
|
||||||
name="EmailScheduler", daemon=True)
|
name="EmailScheduler", daemon=True)
|
||||||
_scheduler_thread.start()
|
_scheduler_thread.start()
|
||||||
|
|||||||
@@ -352,8 +352,13 @@ class WebsiteDialog(tk.Toplevel):
|
|||||||
def _on_visibility_change(self):
|
def _on_visibility_change(self):
|
||||||
"""Show or hide the user assignment panel based on visibility selection."""
|
"""Show or hide the user assignment panel based on visibility selection."""
|
||||||
if self.visibility_var.get() == "assigned":
|
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),
|
self._user_assign_frame.pack(fill="x", padx=24, pady=(0, 4),
|
||||||
before=self.creds_container)
|
before=self._cred_body)
|
||||||
else:
|
else:
|
||||||
self._user_assign_frame.pack_forget()
|
self._user_assign_frame.pack_forget()
|
||||||
|
|
||||||
|
|||||||
@@ -851,17 +851,32 @@ def _read_doc(path: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _read_excel(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:
|
try:
|
||||||
import openpyxl
|
import openpyxl
|
||||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||||
lines = []
|
lines = []
|
||||||
|
total_chars = 0
|
||||||
for sheet in wb.worksheets:
|
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):
|
for row in sheet.iter_rows(values_only=True):
|
||||||
row_str = "\t".join(
|
row_str = "\t".join(
|
||||||
str(v) if v is not None else "" for v in row)
|
str(v) if v is not None else "" for v in row)
|
||||||
if row_str.strip():
|
if row_str.strip():
|
||||||
lines.append(row_str)
|
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)
|
return "\n".join(lines)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
|
|||||||
Reference in New Issue
Block a user