Found by actually seeding the pre-migration schema and rolling forward, rather than by checking that the column disappeared. The column disappeared correctly; project_members came back empty. batch_alter_table emulates ALTER on SQLite by rebuilding the table - create a new one, copy the rows, DROP the original, rename. server/alembic/env.py:22 imports the engine from server/db.py, which registers a connect listener setting PRAGMA foreign_keys=ON, so that DROP TABLE cascaded through project_members.user_id (ondelete="CASCADE") and took every membership row with it. No error, nothing in the log, and the users table looked perfect afterwards. Production would have escaped it - Postgres does a real ALTER TABLE DROP COLUMN and touches nothing else - so this was a local-dev and test-fixture data loss, which is worse in one specific way: the tests CLAUDE.md requires run against a throwaway SQLite database, so the suite would have been validating behaviour against silently emptied membership tables. Wrapping the batch in PRAGMA foreign_keys=OFF is not the fix: that pragma is a no-op inside a transaction and alembic runs migrations in one. The rebuild is simply unnecessary - SQLite has had native ALTER TABLE DROP COLUMN since 3.35 (2021), this runtime has 3.42, and Postgres has always had it. Plain op.drop_column touches one table and cascades nowhere. The reasoning is written into the migration's docstring as a DO NOT, because batch_alter_table is the reflexive thing to reach for when a migration has to work on SQLite and the failure is invisible. Re-verified with memberships in the fixture: 3/3 users survive, roles intact (admin still admin) 2/2 project_members survive downgrade -1 -> column back, nullable; upgrade -> gone again Also closed BL-026: notify.send_now removed. Nothing referenced it and its docstring described itself entirely in terms of password resets. send_email, which it wrapped, is untouched and still used by the outbox. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
165 lines
6.3 KiB
Python
165 lines
6.3 KiB
Python
"""Notifications: admin-configurable email + an outbox.
|
|
|
|
Email notifications are OFF by default and controlled from the admin console (a
|
|
toggle stored in `app_settings`). Even when enabled, mail is only sent if SMTP is
|
|
configured. The SMTP PASSWORD is read from the `SMTP_PASSWORD` environment variable
|
|
and is NEVER stored in the database or shown in the UI.
|
|
|
|
Every notable event (e.g. a WP assignment) writes a `notifications` row — an in-app
|
|
record — and, when email is on + SMTP is set, the row is delivered by email in a
|
|
background task. Notification bodies carry customer CONTEXT — the WP number, its
|
|
title, where the work happens — and a deep link, never customer document CONTENT
|
|
(scope text, descriptions, comments, attachments). Decided 2026-08-20; the link is
|
|
the summary of everything a body leaves out.
|
|
"""
|
|
import os
|
|
import smtplib
|
|
import socket
|
|
import uuid
|
|
import logging
|
|
from email.message import EmailMessage
|
|
from typing import Optional
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from . import models
|
|
|
|
log = logging.getLogger("wpsuite.notify")
|
|
|
|
SETTINGS_KEY = "notifications"
|
|
DEFAULTS = {
|
|
"email_enabled": False, # master toggle — OFF until SMTP is sorted
|
|
"smtp_host": "",
|
|
"smtp_port": 587,
|
|
"smtp_use_tls": True,
|
|
"smtp_username": "",
|
|
"from_addr": "",
|
|
"from_name": "Work Package Suite",
|
|
"app_base_url": "", # e.g. https://wp.controls.dev — used to build email links
|
|
# Feature flags (admin console). BIM/VDC is off until it's ready for the field:
|
|
# with it off, the SOP creator hides the BIM section entirely and every SOP is
|
|
# install-only, so no project can be put on the BIM path by accident.
|
|
"bim_enabled": False,
|
|
# Localization defaults for dates, times and numbers. Empty = use each
|
|
# browser's own locale / timezone. A user's own preference wins over these.
|
|
"default_locale": "", # BCP47, e.g. en-US
|
|
"default_timezone": "", # IANA, e.g. America/Chicago
|
|
}
|
|
|
|
|
|
# Settings the app needs before anyone is signed in, or that carry no secrets and
|
|
# are safe for any authenticated user to read (feature flags + localization
|
|
# defaults). Self-service password reset is gone with D13 — the login page links to
|
|
# Okta instead, so there is nothing left for the client to feature-detect.
|
|
PUBLIC_KEYS = ("bim_enabled", "default_locale", "default_timezone")
|
|
|
|
|
|
def get_settings(db: Session) -> dict:
|
|
row = db.get(models.AppSetting, SETTINGS_KEY)
|
|
s = dict(DEFAULTS)
|
|
if row and row.value:
|
|
s.update({k: row.value[k] for k in row.value if k in DEFAULTS})
|
|
return s
|
|
|
|
|
|
def save_settings(db: Session, patch: dict) -> dict:
|
|
cur = get_settings(db)
|
|
for k in DEFAULTS:
|
|
if k in patch and patch[k] is not None:
|
|
cur[k] = patch[k]
|
|
row = db.get(models.AppSetting, SETTINGS_KEY)
|
|
if row:
|
|
row.value = cur
|
|
else:
|
|
db.add(models.AppSetting(key=SETTINGS_KEY, value=cur))
|
|
db.commit()
|
|
return cur
|
|
|
|
|
|
def public_settings(db: Session) -> dict:
|
|
"""Settings safe to return to the admin UI — no secrets."""
|
|
s = get_settings(db)
|
|
s["smtp_password_set"] = bool(os.getenv("SMTP_PASSWORD"))
|
|
return s
|
|
|
|
|
|
def app_flags(db: Session) -> dict:
|
|
"""Feature flags for any signed-in user (no secrets, no SMTP detail)."""
|
|
s = get_settings(db)
|
|
return {k: s.get(k) for k in PUBLIC_KEYS}
|
|
|
|
|
|
def smtp_ready(s: dict) -> bool:
|
|
return bool(s.get("smtp_host") and s.get("from_addr"))
|
|
|
|
|
|
def send_email(s: dict, to_addr: str, subject: str, body: str) -> None:
|
|
"""Send one email via SMTP. Raises on any failure (caller records it)."""
|
|
if not to_addr:
|
|
raise ValueError("no recipient email")
|
|
msg = EmailMessage()
|
|
from_name = s.get("from_name") or ""
|
|
msg["From"] = f"{from_name} <{s['from_addr']}>" if from_name else s["from_addr"]
|
|
msg["To"] = to_addr
|
|
msg["Subject"] = subject
|
|
msg.set_content(body)
|
|
host = s["smtp_host"]
|
|
port = int(s.get("smtp_port") or 587)
|
|
user = s.get("smtp_username") or ""
|
|
pw = os.getenv("SMTP_PASSWORD", "")
|
|
# local_hostname pins the EHLO name. Without it smtplib calls getfqdn() on
|
|
# EVERY connect, and that reverse-DNS lookup stalls ~5s per send whenever DNS
|
|
# is slow or unreachable - sends are sequential background tasks, so a batch
|
|
# of notifications trickled out one per five seconds. gethostname() never
|
|
# touches the network. Found 2026-08-20 when the office link dropped.
|
|
with smtplib.SMTP(host, port, timeout=15,
|
|
local_hostname=(socket.gethostname() or "wp-suite")) as srv:
|
|
if s.get("smtp_use_tls", True):
|
|
srv.starttls()
|
|
if user:
|
|
srv.login(user, pw)
|
|
srv.send_message(msg)
|
|
|
|
|
|
def enqueue(db: Session, *, user: "models.User", kind: str, subject: str, body: str,
|
|
link: str = "", wp_id: Optional[str] = None, project_id: Optional[str] = None) -> "models.Notification":
|
|
"""Record a notification. Marked 'pending' only if email is enabled + SMTP ready +
|
|
the recipient has an email; otherwise 'skipped' (still an in-app record). Does NOT
|
|
commit — the caller commits with its own transaction. Returns the row."""
|
|
s = get_settings(db)
|
|
deliverable = bool(s.get("email_enabled")) and smtp_ready(s) and bool(user.email)
|
|
n = models.Notification(
|
|
id="ntf_" + uuid.uuid4().hex[:12],
|
|
user_id=user.id, email=user.email or "", kind=kind,
|
|
wp_id=wp_id, project_id=project_id, subject=subject[:300], body=body,
|
|
link=link[:500], status="pending" if deliverable else "skipped",
|
|
)
|
|
db.add(n)
|
|
return n
|
|
|
|
|
|
def deliver(notif_id: str) -> None:
|
|
"""Background task: send one pending notification, on its own DB session."""
|
|
from .db import SessionLocal
|
|
db = SessionLocal()
|
|
try:
|
|
n = db.get(models.Notification, notif_id)
|
|
if not n or n.status != "pending":
|
|
return
|
|
s = get_settings(db)
|
|
if not (s.get("email_enabled") and smtp_ready(s) and n.email):
|
|
n.status = "skipped"
|
|
db.commit()
|
|
return
|
|
try:
|
|
send_email(s, n.email, n.subject, n.body)
|
|
n.status = "sent"
|
|
n.sent_at = models.utcnow()
|
|
except Exception as e: # noqa: BLE001 — record any SMTP failure, don't crash the worker
|
|
n.status = "failed"
|
|
n.error = str(e)[:400]
|
|
log.warning("notification %s failed to send: %s", notif_id, e)
|
|
db.commit()
|
|
finally:
|
|
db.close()
|