400 lines
16 KiB
Python
400 lines
16 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 is stored in the app_settings database table (not config.ini):
|
|
|
|
Key Description
|
|
─────────────────────── ────────────────────────────────────────────
|
|
email.enabled 'true' or 'false'
|
|
email.smtp_host SMTP server hostname
|
|
email.smtp_port SMTP port number (string)
|
|
email.smtp_user Sender email address / SMTP login
|
|
email.smtp_password SMTP password (Fernet-encrypted)
|
|
email.security 'starttls' | 'ssl' | 'none'
|
|
email.recipients Comma-separated recipient addresses
|
|
email.send_time HH:MM (24-hour local time)
|
|
email.last_sent_date ISO date of last successful send (YYYY-MM-DD)
|
|
|
|
Call start() once after login succeeds (admin only).
|
|
Call stop() on logout/shutdown.
|
|
"""
|
|
|
|
import datetime
|
|
import logging
|
|
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")
|
|
|
|
_scheduler_thread: "threading.Thread | None" = None
|
|
_stop_event = threading.Event()
|
|
|
|
|
|
# ─── Config helpers ───────────────────────────────────────────────────────────
|
|
|
|
def load_email_config() -> dict:
|
|
"""Load email settings from app_settings table."""
|
|
from config import get_settings_dict
|
|
from utils.config_crypto import decrypt_value
|
|
s = get_settings_dict("email.")
|
|
security = s.get("email.security", "")
|
|
if not security:
|
|
# Legacy: fall back from boolean use_tls if security key absent
|
|
use_tls = s.get("email.use_tls", "true").lower() == "true"
|
|
security = "starttls" if use_tls else "none"
|
|
return {
|
|
"enabled": s.get("email.enabled", "false").lower() == "true",
|
|
"smtp_host": s.get("email.smtp_host", ""),
|
|
"smtp_port": int(s.get("email.smtp_port", "587") or "587"),
|
|
"smtp_user": s.get("email.smtp_user", ""),
|
|
"smtp_password": decrypt_value(s.get("email.smtp_password", "")),
|
|
"security": security,
|
|
"use_tls": security == "starttls",
|
|
"recipients": [r.strip() for r in
|
|
s.get("email.recipients", "").split(",") if r.strip()],
|
|
"send_time": s.get("email.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):
|
|
"""Persist email settings to app_settings table."""
|
|
from config import get_setting, set_setting
|
|
from utils.config_crypto import encrypt_value
|
|
# Preserve last_sent_date — do not overwrite it on a normal save
|
|
last_sent = get_setting("email.last_sent_date", "")
|
|
pairs = {
|
|
"email.enabled": str(enabled).lower(),
|
|
"email.smtp_host": smtp_host,
|
|
"email.smtp_port": str(smtp_port),
|
|
"email.smtp_user": smtp_user,
|
|
"email.smtp_password": encrypt_value(smtp_password),
|
|
"email.security": security,
|
|
"email.use_tls": str(security == "starttls").lower(),
|
|
"email.recipients": recipients,
|
|
"email.send_time": send_time,
|
|
}
|
|
for k, v in pairs.items():
|
|
set_setting(k, v)
|
|
if last_sent:
|
|
set_setting("email.last_sent_date", last_sent)
|
|
logger.info(f"Email configuration saved to app_settings (security={security}).")
|
|
|
|
|
|
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()
|
|
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).
|
|
"""
|
|
import socket
|
|
|
|
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})"
|
|
)
|
|
|
|
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})"
|
|
)
|
|
|
|
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}\nTry a different Security mode or port.")
|
|
except smtplib.SMTPException as e:
|
|
return False, (f"Step 3 FAILED — TLS handshake: {e}\nTry switching Security mode.")
|
|
except OSError as e:
|
|
return False, (f"Step 3 FAILED — connection dropped: {e}\nTry 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.\nConnected 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.\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."""
|
|
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"
|
|
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)
|
|
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"
|
|
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(smtp_user, cfg["smtp_password"])
|
|
server.sendmail(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 app_settings."""
|
|
from config import get_setting
|
|
raw = get_setting("email.last_sent_date", "")
|
|
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 app_settings."""
|
|
from config import set_setting
|
|
set_setting("email.last_sent_date", d.isoformat())
|
|
|
|
|
|
# ─── Scheduler loop ───────────────────────────────────────────────────────────
|
|
|
|
def _scheduler_loop():
|
|
last_sent_date = _get_last_sent_date()
|
|
|
|
while not _stop_event.is_set():
|
|
_stop_event.wait(60)
|
|
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
|
|
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)
|
|
_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.") |