Files
WebChecker--Web-app-/models.py
T
2026-06-04 14:28:35 -04:00

1752 lines
62 KiB
Python

"""
models.py — Data-access layer for all entities.
Ported 1-for-1 from the desktop version; all function signatures preserved.
"""
import datetime
import hashlib
import logging
import bcrypt
from config import get_connection
from utils.crypto import encrypt as _enc, decrypt as _dec
logger = logging.getLogger("models")
# ─── Password Helpers ─────────────────────────────────────────────────────────
def _hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
def _verify_password(password: str, stored: str) -> bool:
if len(stored) == 64 and stored.isalnum():
return hashlib.sha256(password.encode("utf-8")).hexdigest() == stored
try:
return bcrypt.checkpw(password.encode("utf-8"), stored.encode("utf-8"))
except Exception:
return False
def _needs_rehash(stored: str) -> bool:
return len(stored) == 64 and stored.isalnum()
# ─── Activity Log ─────────────────────────────────────────────────────────────
def log_action(user_id, action, entity=None, entity_id=None, detail=None):
"""Insert a record into activity_log."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO activity_log (user_id, action, entity, entity_id, detail) "
"VALUES (%s,%s,%s,%s,%s)",
(user_id, action, entity, entity_id, detail),
)
conn.commit()
cur.close()
logger.info(f"[LOG] user_id={user_id} action={action} entity={entity} id={entity_id} - {detail}")
except Exception as e:
logger.error(f"Failed to write activity log: {e}")
finally:
if conn:
conn.close()
# ─── Authentication ────────────────────────────────────────────────────────────
def authenticate(username: str, password: str, ip_address: str = None):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT * FROM users WHERE username=%s AND is_active=1", (username,))
user = cur.fetchone()
if not user or not _verify_password(password, user["password"]):
logger.warning(f"Failed login attempt for username='{username}'.")
cur.close()
record_failed_attempt(username, ip_address)
return None
if _needs_rehash(user["password"]):
new_hash = _hash_password(password)
cur.execute("UPDATE users SET password=%s WHERE id=%s", (new_hash, user["id"]))
conn.commit()
logger.info(f"Password rehashed to bcrypt for user '{username}'.")
cur.close()
clear_failed_attempts(user["id"])
log_action(user["id"], "LOGIN", "users", user["id"], f"User '{username}' logged in.")
return user
finally:
if conn:
conn.close()
# ─── Login Rate Limiting ───────────────────────────────────────────────────────
MAX_FAILED_ATTEMPTS = 5
LOCKOUT_MINUTES = 15
def check_login_allowed(username: str) -> tuple:
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT failed_attempts, locked_until FROM users WHERE username=%s", (username,))
row = cur.fetchone()
cur.close()
if not row:
return True, 0
locked_until = row.get("locked_until")
if locked_until:
now = datetime.datetime.now()
if now < locked_until:
remaining = int((locked_until - now).total_seconds())
return False, remaining
return True, 0
finally:
if conn:
conn.close()
def record_failed_attempt(username: str, ip_address: str = None):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT id, failed_attempts FROM users WHERE username=%s", (username,))
row = cur.fetchone()
cur.execute(
"INSERT INTO login_attempts (username, ip_address) VALUES (%s,%s)",
(username, ip_address),
)
if row:
new_count = (row["failed_attempts"] or 0) + 1
locked_until = None
if new_count >= MAX_FAILED_ATTEMPTS:
locked_until = datetime.datetime.now() + datetime.timedelta(minutes=LOCKOUT_MINUTES)
logger.warning(f"Account '{username}' locked until {locked_until}.")
log_action(row["id"], "ACCOUNT_LOCKED", "users", row["id"],
f"Account locked after {new_count} failed login attempts.")
cur.execute(
"UPDATE users SET failed_attempts=%s, locked_until=%s WHERE id=%s",
(new_count, locked_until, row["id"]),
)
conn.commit()
cur.close()
except Exception as e:
logger.error(f"record_failed_attempt error: {e}")
finally:
if conn:
conn.close()
def clear_failed_attempts(user_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("UPDATE users SET failed_attempts=0, locked_until=NULL WHERE id=%s", (user_id,))
conn.commit()
cur.close()
except Exception as e:
logger.error(f"clear_failed_attempts error: {e}")
finally:
if conn:
conn.close()
# ─── Change Password ───────────────────────────────────────────────────────────
PW_MIN_LENGTH = 8
PW_REQUIRE_UPPER = True
PW_REQUIRE_DIGIT = True
PW_REQUIRE_SPECIAL = True
_SPECIAL_CHARS = set("!@#$%^&*()_+-=[]{}|;':\",./<>?")
def validate_password_strength(password: str) -> list:
errors = []
if len(password) < PW_MIN_LENGTH:
errors.append(f"At least {PW_MIN_LENGTH} characters")
if PW_REQUIRE_UPPER and not any(c.isupper() for c in password):
errors.append("At least one uppercase letter")
if PW_REQUIRE_DIGIT and not any(c.isdigit() for c in password):
errors.append("At least one number")
if PW_REQUIRE_SPECIAL and not any(c in _SPECIAL_CHARS for c in password):
errors.append("At least one special character (!@#$%^&* etc.)")
return errors
def change_password(user_id: int, old_password: str, new_password: str) -> tuple:
issues = validate_password_strength(new_password)
if issues:
return False, "New password does not meet requirements: " + "; ".join(issues)
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT password FROM users WHERE id=%s AND is_active=1", (user_id,))
row = cur.fetchone()
if not row:
cur.close()
return False, "User account not found."
if not _verify_password(old_password, row["password"]):
cur.close()
log_action(user_id, "CHANGE_PASSWORD_FAIL", "users", user_id,
"Incorrect current password provided.")
return False, "Current password is incorrect."
if old_password == new_password:
cur.close()
return False, "New password must differ from the current password."
cur.execute("UPDATE users SET password=%s WHERE id=%s", (_hash_password(new_password), user_id))
conn.commit()
cur.close()
log_action(user_id, "CHANGE_PASSWORD", "users", user_id, "Password changed successfully.")
logger.info(f"Password changed for user_id={user_id}.")
return True, "Password changed successfully."
except Exception as e:
logger.error(f"change_password error: {e}")
return False, f"An error occurred: {e}"
finally:
if conn:
conn.close()
# ─── User CRUD ────────────────────────────────────────────────────────────────
def get_all_users():
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT id, username, role, full_name, email, is_active, created_at "
"FROM users ORDER BY username"
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_user_by_id(user_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT id, username, role, full_name, email, is_active FROM users WHERE id=%s",
(user_id,),
)
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
def update_user_profile(user_id: int, full_name: str, email: str):
"""Allow a user to update their own full name and email address."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"UPDATE users SET full_name=%s, email=%s WHERE id=%s",
(full_name.strip() or None, email.strip().lower() or None, user_id),
)
conn.commit()
cur.close()
finally:
if conn:
conn.close()
def create_user(admin_id, username, password, role, full_name, email=None):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO users (username, password, role, full_name, email) VALUES (%s,%s,%s,%s,%s)",
(username, _hash_password(password), role, full_name, email or None),
)
conn.commit()
new_id = cur.lastrowid
cur.close()
log_action(admin_id, "CREATE_USER", "users", new_id,
f"Created user '{username}' role='{role}' email='{email or ''}'.")
return new_id
finally:
if conn:
conn.close()
def update_user(admin_id, user_id, username, role, full_name, is_active, password=None, email=None):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
# Guard: prevent demoting or deactivating the last active admin.
cur.execute("SELECT role FROM users WHERE id=%s", (user_id,))
target = cur.fetchone()
if target and target["role"] == "admin":
removing_admin = (role != "admin") or (not is_active)
if removing_admin:
cur.execute(
"SELECT COUNT(*) AS n FROM users WHERE role='admin' AND is_active=1"
)
if cur.fetchone()["n"] <= 1:
cur.close()
raise ValueError(
"Cannot demote or deactivate the last active administrator. "
"Promote another user to admin first."
)
cur = conn.cursor() # switch back to plain cursor for the UPDATE
if password:
cur.execute(
"UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, "
"email=%s, password=%s WHERE id=%s",
(username, role, full_name, is_active, email or None, _hash_password(password), user_id),
)
else:
cur.execute(
"UPDATE users SET username=%s, role=%s, full_name=%s, is_active=%s, "
"email=%s WHERE id=%s",
(username, role, full_name, is_active, email or None, user_id),
)
conn.commit()
cur.close()
log_action(admin_id, "UPDATE_USER", "users", user_id, f"Updated user id={user_id}.")
finally:
if conn:
conn.close()
def delete_user(admin_id, user_id):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
if admin_id == user_id:
cur.close()
raise ValueError("You cannot delete your own account.")
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")
if cur.fetchone()["n"] <= 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}.")
logger.info(f"User id={user_id} deleted by admin_id={admin_id}.")
finally:
if conn:
conn.close()
# ─── Website CRUD ─────────────────────────────────────────────────────────────
def get_all_websites():
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT w.*, u.username AS creator
FROM websites w
LEFT JOIN users u ON u.id = w.created_by
WHERE w.is_active = 1
ORDER BY w.name
"""
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_existing_website_urls() -> set:
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT url FROM websites")
rows = cur.fetchall()
cur.close()
return {(r[0] or "").strip().lower() for r in rows}
finally:
if conn:
conn.close()
def get_existing_website_names() -> set:
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT name FROM websites")
rows = cur.fetchall()
cur.close()
return {(r[0] or "").strip().lower() for r in rows}
finally:
if conn:
conn.close()
def get_website_by_id(website_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT * FROM websites WHERE id=%s", (website_id,))
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
def create_website(admin_id, name, url, check_type, note, credentials: list,
visibility: str = "all", assigned_user_ids: list = None):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO websites (name, url, check_type, visibility, note, created_by) "
"VALUES (%s,%s,%s,%s,%s,%s)",
(name, url, check_type, visibility, note, admin_id),
)
conn.commit()
new_id = cur.lastrowid
for cred in credentials:
cur.execute(
"INSERT INTO website_credentials (website_id, username, password, label) "
"VALUES (%s,%s,%s,%s)",
(new_id, cred["username"], _enc(cred["password"]), cred.get("label", "")),
)
log_action(admin_id, "ADD_CREDENTIAL", "website_credentials", new_id,
f"Added credential label='{cred.get('label','')}' user='{cred['username']}' for website '{name}'.")
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()
cur.close()
log_action(admin_id, "CREATE_WEBSITE", "websites", new_id,
f"Created website '{name}' check_type='{check_type}' visibility='{visibility}'.")
return new_id
finally:
if conn:
conn.close()
def update_website(admin_id, website_id, name, url, check_type, note,
credentials: list, visibility: str = "all",
assigned_user_ids: list = None):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT username, label FROM website_credentials WHERE website_id=%s",
(website_id,),
)
old_creds = {(r["username"], r["label"] or "") for r in cur.fetchall()}
cur.execute(
"UPDATE websites SET name=%s, url=%s, check_type=%s, visibility=%s, note=%s WHERE id=%s",
(name, url, check_type, visibility, note, website_id),
)
cur.execute("DELETE FROM website_credentials WHERE website_id=%s", (website_id,))
new_creds = set()
for cred in credentials:
cur.execute(
"INSERT INTO website_credentials (website_id, username, password, label) "
"VALUES (%s,%s,%s,%s)",
(website_id, cred["username"], _enc(cred["password"]), cred.get("label", "")),
)
new_creds.add((cred["username"], cred.get("label", "") or ""))
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()
cur.close()
for user, label in (new_creds - old_creds):
log_action(admin_id, "ADD_CREDENTIAL", "website_credentials", website_id,
f"Added credential label='{label}' user='{user}' for website id={website_id}.")
for user, label in (old_creds - new_creds):
log_action(admin_id, "REMOVE_CREDENTIAL", "website_credentials", website_id,
f"Removed credential label='{label}' user='{user}' from website id={website_id}.")
log_action(admin_id, "UPDATE_WEBSITE", "websites", website_id,
f"Updated website id={website_id} check_type='{check_type}' visibility='{visibility}'.")
finally:
if conn:
conn.close()
def delete_website(admin_id, website_id):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("UPDATE websites SET is_active=0 WHERE id=%s", (website_id,))
conn.commit()
cur.close()
log_action(admin_id, "DELETE_WEBSITE", "websites", website_id,
f"Soft-deleted website id={website_id}.")
finally:
if conn:
conn.close()
def get_website_credentials(website_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT * FROM website_credentials WHERE website_id=%s", (website_id,))
rows = cur.fetchall()
cur.close()
for row in rows:
row["password"] = _dec(row["password"])
return rows
finally:
if conn:
conn.close()
def get_website_assigned_users(website_id: int):
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 ─────────────────────────────────────────────────────────
def get_today_checks(user_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT COUNT(*) AS cnt
FROM shifts s
JOIN shift_users su ON su.shift_id = s.id
WHERE su.user_id = %s
AND s.is_active = 1
AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0
""",
(user_id,),
)
has_shifts = cur.fetchone()["cnt"] > 0
if has_shifts:
cur.execute(
"""
SELECT
w.id, w.name, w.url, w.check_type,
w.note AS site_note,
MIN(sw.sort_order) AS sort_order,
GROUP_CONCAT(DISTINCT s.name ORDER BY s.name SEPARATOR ', ') AS shift_names,
sc.id AS check_id, sc.checked_at, sc.user_note,
sc.user_id AS checked_by_id
FROM shift_websites sw
JOIN shifts s ON s.id = sw.shift_id
JOIN websites w ON w.id = sw.website_id
JOIN shift_users su ON su.shift_id = s.id AND su.user_id = %s
LEFT JOIN shift_checks sc
ON sc.website_id = w.id AND sc.user_id = %s AND DATE(sc.checked_at) = CURDATE()
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 (w.check_type = 'weekly'
AND NOT EXISTS (
SELECT 1 FROM shift_checks sc2
WHERE sc2.website_id=w.id AND sc2.user_id=%s
AND YEARWEEK(sc2.checked_at,1)=YEARWEEK(CURDATE(),1)
)))
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),
)
else:
cur.execute(
"""
SELECT w.id, w.name, w.url, w.check_type,
w.note AS site_note, 0 AS sort_order, NULL AS shift_names,
sc.id AS check_id, sc.checked_at, sc.user_note, sc.user_id AS checked_by_id
FROM websites w
LEFT JOIN shift_checks sc
ON sc.website_id=w.id AND sc.user_id=%s AND DATE(sc.checked_at)=CURDATE()
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 (w.check_type='daily'
OR (w.check_type='weekly'
AND NOT EXISTS (
SELECT 1 FROM shift_checks sc2
WHERE sc2.website_id=w.id AND sc2.user_id=%s
AND YEARWEEK(sc2.checked_at,1)=YEARWEEK(CURDATE(),1)
)))
ORDER BY w.name
""",
(user_id, user_id, user_id),
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def mark_website_checked(user_id, website_id, user_note=""):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"DELETE FROM shift_checks WHERE website_id=%s AND user_id=%s AND DATE(checked_at)=CURDATE()",
(website_id, user_id),
)
cur.execute(
"INSERT INTO shift_checks (website_id, user_id, user_note) VALUES (%s,%s,%s)",
(website_id, user_id, user_note),
)
conn.commit()
new_id = cur.lastrowid
cur.close()
log_action(user_id, "CHECK_WEBSITE", "websites", website_id,
f"User {user_id} checked website {website_id}. Note: {user_note}")
return new_id
finally:
if conn:
conn.close()
def unmark_website_checked(user_id, website_id):
"""Remove today's check record — web-only feature for undo support."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"DELETE FROM shift_checks WHERE website_id=%s AND user_id=%s AND DATE(checked_at)=CURDATE()",
(website_id, user_id),
)
conn.commit()
cur.close()
log_action(user_id, "UNCHECK_WEBSITE", "websites", website_id,
f"User {user_id} unchecked website {website_id}.")
finally:
if conn:
conn.close()
def update_check_note(user_id, website_id, user_note):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"UPDATE shift_checks SET user_note=%s "
"WHERE website_id=%s AND user_id=%s AND DATE(checked_at)=CURDATE()",
(user_note, website_id, user_id),
)
conn.commit()
cur.close()
log_action(user_id, "UPDATE_NOTE", "shift_checks", website_id,
f"Updated note for website {website_id}.")
finally:
if conn:
conn.close()
def get_activity_log(limit=200, search: str = ""):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
params = []
sql = (
"SELECT al.id, al.user_id, al.action, al.entity, al.entity_id,"
" al.detail, al.logged_at AS created_at, u.username"
" FROM activity_log al"
" LEFT JOIN users u ON u.id = al.user_id"
)
if search:
sql += (
" WHERE al.action LIKE %s OR u.username LIKE %s"
" OR al.entity LIKE %s OR al.detail LIKE %s"
)
like = f"%{search}%"
params.extend([like, like, like, like])
sql += " ORDER BY al.logged_at DESC LIMIT %s"
params.append(limit)
cur.execute(sql, params)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
# ─── Reports ──────────────────────────────────────────────────────────────────
def get_shift_report(date_from=None, date_to=None, user_id=None, website_id=None):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
conditions, params = [], []
if date_from:
conditions.append("DATE(sc.checked_at) >= %s"); params.append(str(date_from))
if date_to:
conditions.append("DATE(sc.checked_at) <= %s"); params.append(str(date_to))
if user_id:
conditions.append("sc.user_id = %s"); params.append(user_id)
if website_id:
conditions.append("sc.website_id = %s"); params.append(website_id)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
cur.execute(
f"""
SELECT DATE(sc.checked_at) AS check_date, sc.checked_at,
u.username, COALESCE(u.full_name, u.username) AS full_name,
w.name AS website_name, w.url,
COALESCE(sc.user_note,'') AS user_note, 'Checked' AS status
FROM shift_checks sc
JOIN users u ON u.id=sc.user_id
JOIN websites w ON w.id=sc.website_id
{where}
ORDER BY sc.checked_at DESC, u.username, w.name
""",
params,
)
rows = cur.fetchall()
cur.close()
logger.info(f"Shift report queried - {len(rows)} rows returned.")
return rows
finally:
if conn:
conn.close()
def get_unchecked_report(target_date=None, user_id=None):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
user_filter = "AND u.id = %s" if user_id else ""
if target_date:
date_val, date_expr, date_params = str(target_date), "%s", [str(target_date)]
dow_expr, dow_params = "DAYOFWEEK(%s)", [str(target_date)]
else:
date_expr, date_params = "CURDATE()", []
dow_expr, dow_params = "DAYOFWEEK(CURDATE())", []
user_filter_params = [user_id] if user_id else []
params = date_params + user_filter_params + dow_params + date_params
cur.execute(
f"""
SELECT {date_expr} AS check_date,
u.username, COALESCE(u.full_name, u.username) AS full_name,
w.name AS website_name, w.url, 'Not Checked' AS status
FROM users u
JOIN websites w ON w.is_active=1
WHERE u.is_active=1 AND u.role='user'
{user_filter}
AND EXISTS (
SELECT 1 FROM shift_websites sw
JOIN shifts s ON s.id=sw.shift_id
JOIN shift_users su ON su.shift_id=s.id AND su.user_id=u.id
WHERE sw.website_id=w.id AND s.is_active=1
AND LOCATE(CAST({dow_expr} 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=u.id))
)
AND NOT EXISTS (
SELECT 1 FROM shift_checks sc
WHERE sc.website_id=w.id AND sc.user_id=u.id AND DATE(sc.checked_at)={date_expr}
)
ORDER BY u.username, w.name
""",
params,
)
rows = cur.fetchall()
cur.close()
logger.info(f"Unchecked report queried - {len(rows)} rows returned.")
return rows
finally:
if conn:
conn.close()
def get_summary_report(date_from=None, date_to=None):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
conditions, params = [], []
if date_from:
conditions.append("DATE(sc.checked_at) >= %s"); params.append(str(date_from))
if date_to:
conditions.append("DATE(sc.checked_at) <= %s"); params.append(str(date_to))
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
cur.execute(
f"""
WITH agg AS (
SELECT DATE(sc.checked_at) AS check_date, sc.user_id,
COUNT(DISTINCT sc.website_id) AS checked_count
FROM shift_checks sc {where}
GROUP BY DATE(sc.checked_at), sc.user_id
)
SELECT agg.check_date, u.username,
COALESCE(u.full_name, u.username) AS full_name,
agg.checked_count,
(SELECT COUNT(DISTINCT sw2.website_id) FROM shift_websites sw2
JOIN shifts s2 ON s2.id=sw2.shift_id
JOIN shift_users su2 ON su2.shift_id=s2.id AND su2.user_id=u.id
WHERE s2.is_active=1
AND LOCATE(CAST(DAYOFWEEK(agg.check_date) AS CHAR),s2.days_of_week)>0
) AS total_sites,
ROUND(agg.checked_count*100.0/NULLIF((
SELECT COUNT(DISTINCT sw2.website_id) FROM shift_websites sw2
JOIN shifts s2 ON s2.id=sw2.shift_id
JOIN shift_users su2 ON su2.shift_id=s2.id AND su2.user_id=u.id
WHERE s2.is_active=1
AND LOCATE(CAST(DAYOFWEEK(agg.check_date) AS CHAR),s2.days_of_week)>0
),0),1) AS pct_complete
FROM agg
JOIN users u ON u.id=agg.user_id
ORDER BY agg.check_date DESC, u.username
""",
params,
)
rows = cur.fetchall()
cur.close()
logger.info(f"Summary report queried - {len(rows)} rows returned.")
return rows
finally:
if conn:
conn.close()
def get_report_filter_options():
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT id, username, full_name FROM users WHERE is_active=1 ORDER BY username")
users = cur.fetchall()
cur.execute("SELECT id, name FROM websites WHERE is_active=1 ORDER BY name")
websites = cur.fetchall()
cur.close()
return users, websites
finally:
if conn:
conn.close()
# ─── Shift Management ─────────────────────────────────────────────────────────
def get_all_shifts():
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT s.*, u.username AS creator,
COUNT(DISTINCT su.user_id) AS user_count,
COUNT(DISTINCT sw.website_id) AS website_count
FROM shifts s
LEFT JOIN users u ON u.id=s.created_by
LEFT JOIN shift_users su ON su.shift_id=s.id
LEFT JOIN shift_websites sw ON sw.shift_id=s.id
GROUP BY s.id ORDER BY s.name
"""
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_shift_by_id(shift_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT * FROM shifts WHERE id=%s", (shift_id,))
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
def get_shift_assigned_users(shift_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT u.id, u.username, u.full_name
FROM shift_users su JOIN users u ON u.id=su.user_id
WHERE su.shift_id=%s ORDER BY u.username
""",
(shift_id,),
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_shift_assigned_websites(shift_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT w.id, w.name, w.url, sw.sort_order
FROM shift_websites sw JOIN websites w ON w.id=sw.website_id
WHERE sw.shift_id=%s ORDER BY sw.sort_order, w.name
""",
(shift_id,),
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def create_shift(admin_id, name, days_of_week, start_time, end_time,
note, user_ids: list, website_ids: list):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO shifts (name, days_of_week, start_time, end_time, note, created_by) "
"VALUES (%s,%s,%s,%s,%s,%s)",
(name, days_of_week, start_time, end_time, note, admin_id),
)
conn.commit()
shift_id = cur.lastrowid
for uid in user_ids:
cur.execute("INSERT IGNORE INTO shift_users (shift_id, user_id) VALUES (%s,%s)", (shift_id, uid))
for idx, wid in enumerate(website_ids):
cur.execute(
"INSERT IGNORE INTO shift_websites (shift_id, website_id, sort_order) VALUES (%s,%s,%s)",
(shift_id, wid, idx),
)
conn.commit()
cur.close()
log_action(admin_id, "CREATE_SHIFT", "shifts", shift_id,
f"Created shift '{name}' days={days_of_week} users={user_ids} websites={website_ids}.")
return shift_id
finally:
if conn:
conn.close()
def update_shift(admin_id, shift_id, name, days_of_week, start_time, end_time,
note, is_active, user_ids: list, website_ids: list):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"UPDATE shifts SET name=%s, days_of_week=%s, start_time=%s, end_time=%s, "
"note=%s, is_active=%s WHERE id=%s",
(name, days_of_week, start_time, end_time, note, is_active, shift_id),
)
cur.execute("DELETE FROM shift_users WHERE shift_id=%s", (shift_id,))
for uid in user_ids:
cur.execute("INSERT IGNORE INTO shift_users (shift_id, user_id) VALUES (%s,%s)", (shift_id, uid))
cur.execute("DELETE FROM shift_websites WHERE shift_id=%s", (shift_id,))
for idx, wid in enumerate(website_ids):
cur.execute(
"INSERT IGNORE INTO shift_websites (shift_id, website_id, sort_order) VALUES (%s,%s,%s)",
(shift_id, wid, idx),
)
conn.commit()
cur.close()
log_action(admin_id, "UPDATE_SHIFT", "shifts", shift_id,
f"Updated shift id={shift_id} name='{name}' active={is_active}.")
finally:
if conn:
conn.close()
def delete_shift(admin_id, shift_id):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("UPDATE shifts SET is_active=0 WHERE id=%s", (shift_id,))
conn.commit()
cur.close()
log_action(admin_id, "DELETE_SHIFT", "shifts", shift_id, f"Soft-deleted shift id={shift_id}.")
finally:
if conn:
conn.close()
def get_user_active_shifts(user_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT s.* FROM shifts s
JOIN shift_users su ON su.shift_id=s.id
WHERE su.user_id=%s AND s.is_active=1
AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week)>0
ORDER BY s.start_time
""",
(user_id,),
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
# ─── Admin Dashboard ──────────────────────────────────────────────────────────
_admin_stats_cache: dict | None = None
_admin_stats_cached_at: datetime.datetime | None = None
_ADMIN_STATS_TTL_S = 60 # seconds
def invalidate_admin_stats_cache():
global _admin_stats_cache, _admin_stats_cached_at
_admin_stats_cache = None
_admin_stats_cached_at = None
def get_admin_dashboard_stats():
global _admin_stats_cache, _admin_stats_cached_at
now = datetime.datetime.now()
if (_admin_stats_cache is not None and _admin_stats_cached_at is not None
and (now - _admin_stats_cached_at).total_seconds() < _ADMIN_STATS_TTL_S):
return _admin_stats_cache
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT u.id AS user_id, u.username,
COALESCE(u.full_name, u.username) AS full_name,
COUNT(DISTINCT sc.website_id) AS checked_count,
(SELECT COUNT(DISTINCT sw2.website_id) FROM shift_websites sw2
JOIN shifts s2 ON s2.id=sw2.shift_id
JOIN shift_users su2 ON su2.shift_id=s2.id AND su2.user_id=u.id
WHERE s2.is_active=1
AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR),s2.days_of_week)>0
) AS total_sites,
ROUND(COUNT(DISTINCT sc.website_id)*100.0/NULLIF((
SELECT COUNT(DISTINCT sw2.website_id) FROM shift_websites sw2
JOIN shifts s2 ON s2.id=sw2.shift_id
JOIN shift_users su2 ON su2.shift_id=s2.id AND su2.user_id=u.id
WHERE s2.is_active=1
AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR),s2.days_of_week)>0
),0),1) AS pct_complete
FROM users u
LEFT JOIN shift_checks sc ON sc.user_id=u.id AND DATE(sc.checked_at)=CURDATE()
WHERE u.is_active=1 AND u.role='user'
GROUP BY u.id ORDER BY pct_complete DESC, u.username
"""
)
user_stats = cur.fetchall()
cur.execute("SELECT COUNT(*) AS n FROM websites WHERE is_active=1")
total_sites = cur.fetchone()["n"]
cur.execute("SELECT COUNT(*) AS n FROM users WHERE is_active=1 AND role='user'")
total_users = cur.fetchone()["n"]
cur.execute("SELECT COUNT(DISTINCT user_id) AS n FROM shift_checks WHERE DATE(checked_at)=CURDATE()")
active_today = cur.fetchone()["n"]
cur.execute("SELECT COUNT(*) AS n FROM bid_tracker WHERE status IN ('open','monitoring')")
open_bids = cur.fetchone()["n"]
cur.execute(
"SELECT COUNT(*) AS n FROM bid_tracker"
" WHERE status IN ('open','monitoring')"
" AND due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)"
)
bids_due_soon = cur.fetchone()["n"]
cur.execute(
"SELECT COUNT(*) AS n FROM ai_analysis_log"
" WHERE analyzed_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"
)
ai_analyses_30d = cur.fetchone()["n"]
cur.close()
result = {
"user_stats": user_stats,
"total_sites": total_sites,
"total_users": total_users,
"active_today": active_today,
"open_bids": open_bids,
"bids_due_soon": bids_due_soon,
"ai_analyses_30d": ai_analyses_30d,
}
_admin_stats_cache = result
_admin_stats_cached_at = now
return result
finally:
if conn:
conn.close()
# ─── AI Criteria CRUD ─────────────────────────────────────────────────────────
def get_all_criteria():
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT id, title, description, is_active, sort_order, created_by, created_at, updated_at "
"FROM ai_criteria ORDER BY sort_order, id"
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_active_criteria():
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT id, title, description FROM ai_criteria WHERE is_active=1 ORDER BY sort_order, id"
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def create_criterion(admin_id: int, title: str, description: str, is_active: bool, sort_order: int) -> int:
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO ai_criteria (title, description, is_active, sort_order, created_by) "
"VALUES (%s,%s,%s,%s,%s)",
(title, description, int(is_active), sort_order, admin_id),
)
conn.commit()
new_id = cur.lastrowid
cur.close()
log_action(admin_id, "CREATE_AI_CRITERION", "ai_criteria", new_id,
f"Created criterion '{title}' active={is_active} order={sort_order}.")
logger.info(f"AI criterion id={new_id} '{title}' created by admin_id={admin_id}.")
return new_id
finally:
if conn:
conn.close()
def update_criterion(admin_id: int, criterion_id: int, title: str,
description: str, is_active: bool, sort_order: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"UPDATE ai_criteria SET title=%s, description=%s, is_active=%s, sort_order=%s WHERE id=%s",
(title, description, int(is_active), sort_order, criterion_id),
)
conn.commit()
cur.close()
log_action(admin_id, "UPDATE_AI_CRITERION", "ai_criteria", criterion_id,
f"Updated criterion id={criterion_id} '{title}' active={is_active} order={sort_order}. "
f"Description: {description[:500]}")
logger.info(f"AI criterion id={criterion_id} updated by admin_id={admin_id}.")
finally:
if conn:
conn.close()
def delete_criterion(admin_id: int, criterion_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT title FROM ai_criteria WHERE id=%s", (criterion_id,))
row = cur.fetchone()
title = row["title"] if row else str(criterion_id)
cur.execute("DELETE FROM ai_criteria WHERE id=%s", (criterion_id,))
conn.commit()
cur.close()
log_action(admin_id, "DELETE_AI_CRITERION", "ai_criteria", criterion_id,
f"Deleted criterion id={criterion_id} '{title}'.")
logger.info(f"AI criterion id={criterion_id} '{title}' deleted by admin_id={admin_id}.")
finally:
if conn:
conn.close()
def save_ai_analysis(user_id: int, file_names: str, model: str,
verdict, criteria_snapshot, summary_text: str) -> int:
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO ai_analysis_log (user_id, file_names, model, verdict, criteria_snapshot, summary_text) "
"VALUES (%s,%s,%s,%s,%s,%s)",
(user_id, file_names, model, verdict, criteria_snapshot, summary_text),
)
conn.commit()
new_id = cur.lastrowid
cur.close()
logger.info(f"AI analysis saved: id={new_id} user_id={user_id} verdict={verdict}.")
return new_id
finally:
if conn:
conn.close()
def get_ai_analysis_history(user_id=None, limit: int = 100):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
if user_id:
cur.execute(
"SELECT al.id, u.username, al.file_names, al.model, al.verdict, al.analyzed_at "
"FROM ai_analysis_log al LEFT JOIN users u ON u.id=al.user_id "
"WHERE al.user_id=%s ORDER BY al.analyzed_at DESC LIMIT %s",
(user_id, limit),
)
else:
cur.execute(
"SELECT al.id, u.username, al.file_names, al.model, al.verdict, al.analyzed_at "
"FROM ai_analysis_log al LEFT JOIN users u ON u.id=al.user_id "
"ORDER BY al.analyzed_at DESC LIMIT %s",
(limit,),
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_ai_analysis_detail(analysis_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT al.*, u.username FROM ai_analysis_log al "
"LEFT JOIN users u ON u.id=al.user_id WHERE al.id=%s",
(analysis_id,),
)
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
# ─── Bid Tracker ──────────────────────────────────────────────────────────────
BID_STATUSES = ("open", "monitoring", "awarded", "no_bid", "cancelled")
def get_all_bids(status_filter: str = "", limit: int = 50, offset: int = 0) -> list:
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
parts = [
"SELECT b.*, u.username AS added_by_username, COUNT(bu.id) AS update_count"
" FROM bid_tracker b"
" LEFT JOIN users u ON u.id=b.added_by"
" LEFT JOIN bid_updates bu ON bu.bid_id=b.id"
]
params = []
if status_filter:
parts.append("WHERE b.status=%s")
params.append(status_filter)
parts.append(
"GROUP BY b.id ORDER BY b.due_date IS NULL, b.due_date ASC, b.created_at DESC"
" LIMIT %s OFFSET %s"
)
params.extend([limit, offset])
cur.execute(" ".join(parts), params)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_bid(bid_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT b.*, u.username AS added_by_username FROM bid_tracker b "
"LEFT JOIN users u ON u.id=b.added_by WHERE b.id=%s",
(bid_id,),
)
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
def create_bid(user_id: int, title: str, url: str, source: str,
solicitation_number: str, status: str, due_date, notes: str) -> int:
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO bid_tracker (title, url, source, solicitation_number, status, due_date, notes, added_by) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s)",
(title, url, source or None, solicitation_number or None,
status, due_date or None, notes or None, user_id),
)
conn.commit()
new_id = cur.lastrowid
cur.close()
log_action(user_id, "CREATE_BID", "bid_tracker", new_id, f"Created bid '{title}' status={status}.")
logger.info(f"Bid id={new_id} '{title}' created by user_id={user_id}.")
return new_id
finally:
if conn:
conn.close()
def update_bid(user_id: int, bid_id: int, title: str, url: str,
source: str, solicitation_number: str, status: str, due_date, notes: str):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"UPDATE bid_tracker SET title=%s, url=%s, source=%s, solicitation_number=%s, "
"status=%s, due_date=%s, notes=%s WHERE id=%s",
(title, url, source or None, solicitation_number or None,
status, due_date or None, notes or None, bid_id),
)
conn.commit()
cur.close()
log_action(user_id, "UPDATE_BID", "bid_tracker", bid_id, f"Updated bid '{title}' status={status}.")
logger.info(f"Bid id={bid_id} updated by user_id={user_id}.")
finally:
if conn:
conn.close()
def delete_bid(user_id: int, bid_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute("SELECT title FROM bid_tracker WHERE id=%s", (bid_id,))
row = cur.fetchone()
title = row["title"] if row else str(bid_id)
cur.execute("DELETE FROM bid_tracker WHERE id=%s", (bid_id,))
conn.commit()
cur.close()
log_action(user_id, "DELETE_BID", "bid_tracker", bid_id, f"Deleted bid '{title}'.")
logger.info(f"Bid id={bid_id} '{title}' deleted by user_id={user_id}.")
finally:
if conn:
conn.close()
def get_bid_updates(bid_id: int) -> list:
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT bu.*, u.username AS posted_by_username, "
"COALESCE(u.full_name, u.username) AS posted_by_full_name "
"FROM bid_updates bu LEFT JOIN users u ON u.id=bu.user_id "
"WHERE bu.bid_id=%s ORDER BY bu.created_at DESC",
(bid_id,),
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def add_bid_update(user_id: int, bid_id: int, content: str) -> int:
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute(
"INSERT INTO bid_updates (bid_id, user_id, content) VALUES (%s,%s,%s)",
(bid_id, user_id, content),
)
conn.commit()
new_id = cur.lastrowid
cur.close()
log_action(user_id, "ADD_BID_UPDATE", "bid_updates", new_id, f"Posted update on bid_id={bid_id}.")
logger.info(f"Bid update id={new_id} posted on bid_id={bid_id} by user_id={user_id}.")
return new_id
finally:
if conn:
conn.close()
def delete_bid_update(user_id: int, update_id: int):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM bid_updates WHERE id=%s", (update_id,))
conn.commit()
cur.close()
log_action(user_id, "DELETE_BID_UPDATE", "bid_updates", update_id,
f"Deleted bid update id={update_id}.")
logger.info(f"Bid update id={update_id} deleted by user_id={user_id}.")
finally:
if conn:
conn.close()
# ─── Application Log ──────────────────────────────────────────────────────────
def get_app_log(limit: int = 500, level_filter: str = "", search: str = "") -> list:
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
conditions, params = [], []
if level_filter:
conditions.append("level=%s"); params.append(level_filter)
if search:
conditions.append("(logger_name LIKE %s OR message LIKE %s)")
like = f"%{search}%"
params.extend([like, like])
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
params.append(limit)
cur.execute(
f"SELECT id, level, logger_name, message, logged_at FROM app_log "
f"{where} ORDER BY logged_at DESC LIMIT %s",
params,
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def purge_app_log(older_than_days: int = 30):
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM app_log WHERE logged_at < NOW() - INTERVAL %s DAY", (older_than_days,))
deleted = cur.rowcount
conn.commit()
cur.close()
logger.info(f"App log purged: {deleted} records older than {older_than_days} days deleted.")
return deleted
finally:
if conn:
conn.close()
# ─── Missed Shifts ────────────────────────────────────────────────────────────
def get_missed_shifts_today() -> list:
"""Return shift+user pairs scheduled today where the user has checked 0 sites."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"""
SELECT s.name AS shift_name,
u.id AS user_id, u.username,
COALESCE(u.full_name, u.username) AS full_name,
COUNT(DISTINCT sw.website_id) AS total_sites,
COUNT(DISTINCT sc.website_id) AS checked_sites
FROM shifts s
JOIN shift_users su ON su.shift_id = s.id
JOIN users u ON u.id = su.user_id AND u.is_active = 1
JOIN shift_websites sw ON sw.shift_id = s.id
LEFT JOIN shift_checks sc
ON sc.user_id = su.user_id
AND sc.website_id = sw.website_id
AND DATE(sc.checked_at) = CURDATE()
WHERE s.is_active = 1
AND LOCATE(CAST(DAYOFWEEK(CURDATE()) AS CHAR), s.days_of_week) > 0
GROUP BY s.id, u.id
HAVING total_sites > 0 AND checked_sites = 0
ORDER BY s.name, u.username
"""
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
# ─── Bid Reminders ────────────────────────────────────────────────────────────
def get_bids_due_soon(days: int = 7) -> list:
"""Return open/monitoring bids with due_date within the next `days` days."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT b.*, u.username AS added_by_username, u.full_name AS added_by_full_name"
" FROM bid_tracker b"
" LEFT JOIN users u ON u.id = b.added_by"
" WHERE b.status IN ('open','monitoring')"
" AND b.due_date IS NOT NULL"
" AND b.due_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL %s DAY)"
" ORDER BY b.due_date ASC",
(days,),
)
rows = cur.fetchall()
cur.close()
return rows
finally:
if conn:
conn.close()
def get_admin_emails() -> list:
"""Return email addresses of all active admin users that have one set."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT email FROM users"
" WHERE role='admin' AND is_active=1 AND email IS NOT NULL AND email != ''"
)
rows = cur.fetchall()
cur.close()
return [r["email"] for r in rows]
finally:
if conn:
conn.close()
# ─── Server-side Health Check ─────────────────────────────────────────────────
def get_website_url(website_id: int):
"""Return the URL of an active website by ID, or None if not found."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("SELECT url FROM websites WHERE id=%s AND is_active=1", (website_id,))
row = cur.fetchone()
cur.close()
return row[0] if row else None
finally:
if conn:
conn.close()
# ─── Password Reset ───────────────────────────────────────────────────────────
def get_user_by_email(email: str):
"""Return the active user matching the given email (case-insensitive), or None."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT * FROM users WHERE LOWER(email)=%s AND is_active=1 LIMIT 1",
(email.lower(),),
)
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
def create_password_reset_token(user_id: int) -> str:
"""Generate a secure 1-hour reset token; invalidates any prior token for the user."""
import secrets
token = secrets.token_urlsafe(48)
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM password_reset_tokens WHERE user_id=%s", (user_id,))
cur.execute(
"INSERT INTO password_reset_tokens (user_id, token, expires_at)"
" VALUES (%s, %s, DATE_ADD(NOW(), INTERVAL 1 HOUR))",
(user_id, token),
)
conn.commit()
cur.close()
return token
finally:
if conn:
conn.close()
def get_password_reset_user(token: str):
"""Return the user row for a valid, unexpired reset token, or None."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT u.* FROM password_reset_tokens t"
" JOIN users u ON u.id = t.user_id"
" WHERE t.token=%s AND t.expires_at > NOW()",
(token,),
)
row = cur.fetchone()
cur.close()
return row
finally:
if conn:
conn.close()
def consume_password_reset_token(token: str, new_password: str) -> bool:
"""Validate token, update the user's password, and delete the token. Returns True on success."""
conn = None
try:
conn = get_connection()
cur = conn.cursor(dictionary=True)
cur.execute(
"SELECT user_id FROM password_reset_tokens WHERE token=%s AND expires_at > NOW()",
(token,),
)
row = cur.fetchone()
if not row:
cur.close()
return False
user_id = row["user_id"]
new_hash = _hash_password(new_password)
cur = conn.cursor()
cur.execute("UPDATE users SET password=%s WHERE id=%s", (new_hash, user_id))
cur.execute("DELETE FROM password_reset_tokens WHERE token=%s", (token,))
conn.commit()
cur.close()
return True
finally:
if conn:
conn.close()
def purge_expired_reset_tokens():
"""Delete all expired password reset tokens."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM password_reset_tokens WHERE expires_at < NOW()")
conn.commit()
cur.close()
finally:
if conn:
conn.close()
def delete_ai_analysis(analysis_id: int):
"""Permanently delete an AI analysis record."""
conn = None
try:
conn = get_connection()
cur = conn.cursor()
cur.execute("DELETE FROM ai_analysis_log WHERE id=%s", (analysis_id,))
conn.commit()
cur.close()
finally:
if conn:
conn.close()