Files
WebChecker/utils/scheduler.py
T

451 lines
18 KiB
Python

"""
utils/scheduler.py — Daily completion report email scheduler.
Runs a background daemon thread that wakes every minute, checks whether
the configured send_time (HH:MM) has been reached today, and sends the
summary report via SMTP if it hasn't been sent yet.
Configuration (config.ini [email] section):
enabled = true/false
smtp_host = smtp.example.com
smtp_port = 587
smtp_user = sender@example.com
smtp_password= secret
use_tls = true
recipients = admin@example.com, manager@example.com
send_time = 18:00 (24-hour HH:MM, local time)
Call start() once after login succeeds (admin only).
Call stop() on logout/shutdown.
"""
import configparser
import datetime
import logging
import os
import smtplib
import threading
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formatdate, make_msgid, formataddr
logger = logging.getLogger("scheduler")
CONFIG_FILE = "config.ini"
_scheduler_thread: "threading.Thread | None" = None
_stop_event = threading.Event()
# ─── Config helpers ───────────────────────────────────────────────────────────
def load_email_config() -> dict:
from utils.config_crypto import decrypt_value
cfg = configparser.ConfigParser()
if not os.path.exists(CONFIG_FILE):
return {}
cfg.read(CONFIG_FILE, encoding="utf-8")
if "email" not in cfg:
return {}
s = cfg["email"]
# security field: "starttls" | "ssl" | "none"
# Falls back from legacy use_tls boolean for existing configs.
security = s.get("security", "")
if not security:
security = "starttls" if s.getboolean("use_tls", fallback=True) else "none"
return {
"enabled": s.getboolean("enabled", fallback=False),
"smtp_host": s.get("smtp_host", ""),
"smtp_port": s.getint("smtp_port", fallback=587),
"smtp_user": s.get("smtp_user", ""),
"smtp_password": decrypt_value(s.get("smtp_password", "")),
"security": security,
"use_tls": security == "starttls", # kept for compatibility
"recipients": [r.strip() for r in s.get("recipients", "").split(",") if r.strip()],
"send_time": s.get("send_time", "18:00"),
}
def save_email_config(enabled: bool, smtp_host: str, smtp_port: int,
smtp_user: str, smtp_password: str, security: str,
recipients: str, send_time: str):
from utils.config_crypto import encrypt_value
cfg = configparser.ConfigParser()
cfg.read(CONFIG_FILE, encoding="utf-8")
# Preserve last_sent_date if present
last_sent = cfg.get("email", "last_sent_date", fallback="")
cfg["email"] = {
"enabled": str(enabled).lower(),
"smtp_host": smtp_host,
"smtp_port": str(smtp_port),
"smtp_user": smtp_user,
"smtp_password": encrypt_value(smtp_password),
"security": security,
"use_tls": str(security == "starttls").lower(), # legacy compat
"recipients": recipients,
"send_time": send_time,
}
if last_sent:
cfg["email"]["last_sent_date"] = last_sent
with open(CONFIG_FILE, "w", encoding="utf-8") as fh:
cfg.write(fh)
logger.info(f"Email configuration saved (security={security}, encrypted).")
def _make_smtp_server(smtp_host: str, smtp_port: int,
security: str) -> "smtplib.SMTP":
"""
Open and return a ready-to-login SMTP connection.
security: "starttls" | "ssl" | "none"
Explicit ehlo() calls are required on Windows — smtplib's automatic
greeting is unreliable there and causes 'Connection unexpectedly closed'
on many servers (Office 365, Exchange, Google Workspace) when omitted.
"""
if security == "ssl":
server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=15)
server.ehlo()
elif security == "starttls":
server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
server.ehlo()
server.starttls()
server.ehlo() # re-identify after TLS upgrade — mandatory
else:
server = smtplib.SMTP(smtp_host, smtp_port, timeout=15)
server.ehlo()
return server
def test_smtp_connection(smtp_host: str, smtp_port: int,
smtp_user: str, smtp_password: str,
security: str) -> tuple:
"""
Step-by-step SMTP diagnostic. Returns (success: bool, message: str).
Each step is attempted independently so the error message tells the
admin exactly where the failure occurred:
Step 1 — DNS resolution
Step 2 — TCP connect
Step 3 — TLS handshake (if applicable)
Step 4 — Authentication
"""
import socket
# Step 1: DNS resolution
try:
addr = socket.getaddrinfo(smtp_host, smtp_port,
socket.AF_UNSPEC, socket.SOCK_STREAM)
if not addr:
raise OSError("No addresses returned")
ip = addr[0][4][0]
logger.info(f"SMTP test: {smtp_host} resolved to {ip}")
except OSError as e:
return False, (
f"Step 1 FAILED — DNS: Cannot resolve '{smtp_host}'.\n"
f"Check the hostname and your network connection.\n({e})"
)
# Step 2: TCP connect (raw socket, before any SMTP protocol)
try:
sock = socket.create_connection((smtp_host, smtp_port), timeout=8)
sock.close()
logger.info(f"SMTP test: TCP connect to {smtp_host}:{smtp_port} OK")
except OSError as e:
return False, (
f"Step 2 FAILED — TCP: Cannot reach {smtp_host}:{smtp_port}.\n"
f"The port may be blocked by a firewall or the server is down.\n({e})"
)
# Steps 3 + 4: SMTP protocol, TLS handshake, authentication
try:
server = _make_smtp_server(smtp_host, smtp_port, security)
logger.info(f"SMTP test: TLS/connection OK (security={security})")
except smtplib.SMTPConnectError as e:
return False, (
f"Step 3 FAILED — SMTP connect: {e}\n"
f"Try a different Security mode or port."
)
except smtplib.SMTPException as e:
return False, (
f"Step 3 FAILED — TLS handshake: {e}\n"
f"Try switching Security mode (e.g. SSL/TLS on port 465)."
)
except OSError as e:
return False, (
f"Step 3 FAILED — connection dropped: {e}\n"
f"Try switching Security mode or port."
)
try:
server.login(smtp_user, smtp_password)
server.quit()
logger.info("SMTP test: authentication OK")
return True, (
f"All steps passed.\n"
f"Connected to {smtp_host}:{smtp_port} "
f"({security.upper()}) and authenticated successfully."
)
except smtplib.SMTPAuthenticationError as e:
try:
server.quit()
except Exception:
pass
return False, (
f"Step 4 FAILED — Authentication: username or password rejected.\n"
f"For Gmail/Google Workspace use an App Password, not your account password.\n({e})"
)
except smtplib.SMTPException as e:
return False, f"Step 4 FAILED — SMTP error during login: {e}"
except Exception as e:
return False, f"Step 4 FAILED — Unexpected error: {e}"
def send_test_email(smtp_host: str, smtp_port: int,
smtp_user: str, smtp_password: str,
security: str, recipients: list) -> tuple:
"""
Send a real test email through the full pipeline.
Returns (success: bool, message: str).
"""
try:
subject = "Website Checker — SMTP Test"
body = (
"<html><body style='font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e'>"
"<h3 style='color:#5b4de8'>Website Checker — SMTP Test</h3>"
"<p>This is a test email confirming your SMTP configuration is working.</p>"
"<p>You can now enable daily reports from the Email Settings panel.</p>"
"</body></html>"
)
plain = (
"Website Checker — SMTP Test\n\n"
"This is a test email confirming your SMTP configuration is working.\n"
"You can now enable daily reports from the Email Settings panel."
)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = formataddr(("Website Checker", smtp_user))
msg["To"] = ", ".join(recipients)
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker")
msg["X-Mailer"] = "WebChecker"
# Plain-text part must come first; HTML second.
# Spam filters heavily penalise HTML-only messages with no text/plain alternative.
msg.attach(MIMEText(plain, "plain", "utf-8"))
msg.attach(MIMEText(body, "html", "utf-8"))
server = _make_smtp_server(smtp_host, smtp_port, security)
server.login(smtp_user, smtp_password)
server.sendmail(smtp_user, recipients, msg.as_string())
server.quit()
logger.info(f"Test email sent to {recipients} via {smtp_host}:{smtp_port}")
return True, f"Test email sent successfully to: {', '.join(recipients)}"
except smtplib.SMTPAuthenticationError as e:
return False, (
f"Authentication failed — check username and password.\n"
f"For Gmail/Google Workspace use an App Password.\n({e})"
)
except smtplib.SMTPConnectError as e:
return False, f"Could not connect to {smtp_host}:{smtp_port}{e}"
except smtplib.SMTPException as e:
return False, f"SMTP error: {e}"
except OSError as e:
return False, f"Network error: {e}"
except Exception as e:
return False, str(e)
# ─── Report builder ───────────────────────────────────────────────────────────
def _build_html_report() -> str:
"""Build a simple HTML daily summary table."""
try:
from models import get_admin_dashboard_stats
stats = get_admin_dashboard_stats()
except Exception as e:
return f"<p>Error generating report: {e}</p>"
today = datetime.date.today().strftime("%A, %d %B %Y")
rows = stats.get("user_stats", [])
table_rows = ""
skipped_no_shift = 0
for r in rows:
total = int(r.get("total_sites") or 0)
# Skip users with no shifts scheduled today — they have no expected
# work for this day and showing them as "0 / 0 — 0%" is misleading.
if total == 0:
skipped_no_shift += 1
continue
pct = float(r.get("pct_complete") or 0)
color = "#388e3c" if pct >= 100 else "#f57c00" if pct > 0 else "#d32f2f"
table_rows += (
f"<tr>"
f"<td style='padding:8px 12px'>{r['username']}</td>"
f"<td style='padding:8px 12px'>{r['full_name'] or ''}</td>"
f"<td style='padding:8px 12px;text-align:center'>{int(r['checked_count'] or 0)}</td>"
f"<td style='padding:8px 12px;text-align:center'>{total}</td>"
f"<td style='padding:8px 12px;text-align:center;"
f"color:{color};font-weight:bold'>{pct:.0f}%</td>"
f"</tr>"
)
if not table_rows:
table_rows = (
"<tr><td colspan='5' style='padding:12px;color:#6b6b80;"
"text-align:center'>No users with active shifts today.</td></tr>"
)
no_shift_note = (
f"<p style='color:#6b6b80;font-size:11px'>"
f"{skipped_no_shift} user(s) had no shifts scheduled today and are "
f"excluded from this report.</p>"
) if skipped_no_shift else ""
return f"""
<html><body style="font-family:Segoe UI,Arial,sans-serif;color:#1a1a2e">
<h2 style="color:#5b4de8">Website Checker — Daily Report</h2>
<p style="color:#6b6b80">{today}</p>
<table border="0" cellspacing="0" cellpadding="0"
style="border-collapse:collapse;width:100%;max-width:600px">
<thead>
<tr style="background:#e0dff8">
<th style="padding:10px 12px;text-align:left">Username</th>
<th style="padding:10px 12px;text-align:left">Full Name</th>
<th style="padding:10px 12px;text-align:center">Checked</th>
<th style="padding:10px 12px;text-align:center">Total</th>
<th style="padding:10px 12px;text-align:center">Completion</th>
</tr>
</thead>
<tbody>{table_rows}</tbody>
</table>
{no_shift_note}
<p style="color:#6b6b80;font-size:12px;margin-top:24px">
Sent automatically by Website Checker at {datetime.datetime.now().strftime('%H:%M')}.
</p>
</body></html>
"""
def _send_report(cfg: dict):
"""Build and send the daily report email."""
html = _build_html_report()
today = datetime.date.today().strftime("%d %b %Y")
subject = f"Website Checker — Daily Report {today}"
smtp_user = cfg["smtp_user"]
plain = (
f"Website Checker — Daily Report {today}\n\n"
"Please view this report in an HTML-capable email client for full formatting.\n"
"This email was sent automatically by Website Checker."
)
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = formataddr(("Website Checker", smtp_user))
msg["To"] = ", ".join(cfg["recipients"])
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=smtp_user.split("@")[-1] if "@" in smtp_user else "webchecker")
msg["X-Mailer"] = "WebChecker"
# Plain-text part must come first; HTML second.
# Spam filters heavily penalise HTML-only messages with no text/plain alternative.
msg.attach(MIMEText(plain, "plain", "utf-8"))
msg.attach(MIMEText(html, "html", "utf-8"))
try:
security = cfg.get("security", "starttls")
server = _make_smtp_server(
cfg["smtp_host"], cfg["smtp_port"], security)
server.login(cfg["smtp_user"], cfg["smtp_password"])
server.sendmail(cfg["smtp_user"], cfg["recipients"], msg.as_string())
server.quit()
logger.info(f"Daily report emailed to: {cfg['recipients']}")
except smtplib.SMTPAuthenticationError as e:
logger.error(f"Failed to send daily report — authentication error: {e}")
except smtplib.SMTPConnectError as e:
logger.error(f"Failed to send daily report — connection error: {e}")
except smtplib.SMTPException as e:
logger.error(f"Failed to send daily report — SMTP error: {e}")
except OSError as e:
logger.error(f"Failed to send daily report — network error: {e}")
except Exception as 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 ───────────────────────────────────────────────────────────
def _scheduler_loop():
# 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
if _stop_event.is_set():
break
cfg = load_email_config()
if not cfg.get("enabled") or not cfg.get("smtp_host") or not cfg.get("recipients"):
continue
try:
send_h, send_m = map(int, cfg["send_time"].split(":"))
except Exception:
continue
now = datetime.datetime.now()
today = now.date()
if (now.hour > send_h or (now.hour == send_h and now.minute >= send_m)):
if last_sent_date != today:
last_sent_date = today
_set_last_sent_date(today)
logger.info(f"Scheduler: sending daily report at {now.strftime('%H:%M')}.")
_send_report(cfg)
def start():
"""Start the background scheduler thread. Call once after successful login."""
global _scheduler_thread, _stop_event
# 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()
logger.info("Email scheduler started.")
def stop():
"""Signal the scheduler to stop cleanly."""
global _stop_event
_stop_event.set()
logger.info("Email scheduler stopped.")