feat(email): each account gets its own email, in its own zone (milestone 495)
Milestone 495 "Email goes to each account, in its own time zone", on the operator's ruling (System 156): notifications go to each user, times in their zone, defaults on, toggled per user or per role. - users.notify_alerts / notify_report (migration 0036): None follows the role's default, notify.<kind>.<role> in settings, all on to start. - mail.pick_recipients: active accounts that want the email and have an address mail can reach (an SSO placeholder like name@oidc is skipped and flagged), each in its own zone with no UTC beside it, then the Also send to addresses in their chosen zone with UTC beside. One copy per address, all over one SMTP connection. - Alerts, the weekly report and the test email (to you, in your zone, plus the typed addresses) send that way. The save-time smtp.tz capture is gone. - Notifications: a role × email table of defaults, who each email reaches and who can't be reached, and Also send to with its own zone, which starts as the first admin's to open the page. - Account page: your address and your two switches. Users: an Emailed column, and an admin sets an account's switches on its page. - The Alerts page counts the accounts it emails. zone_select macro and formatting.zones_by_region shared by Account and Notifications. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+31
-12
@@ -2,8 +2,6 @@
|
|||||||
page writes its times in. Any role reaches it, for themselves only."""
|
page writes its times in. Any role reaches it, for themselves only."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from itertools import groupby
|
|
||||||
|
|
||||||
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
|
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
|
||||||
|
|
||||||
from steward.auth.middleware import require_role
|
from steward.auth.middleware import require_role
|
||||||
@@ -15,14 +13,6 @@ account_bp = Blueprint("account", __name__, url_prefix="/account")
|
|||||||
_SOURCES = {"local": "a Steward password", "ldap": "LDAP", "oidc": "single sign-on"}
|
_SOURCES = {"local": "a Steward password", "ldap": "LDAP", "oidc": "single sign-on"}
|
||||||
|
|
||||||
|
|
||||||
def _zones_by_region() -> list[tuple[str, list[str]]]:
|
|
||||||
"""The Account page's choices: UTC alone, then every zone under its
|
|
||||||
region ('America', 'Europe', …)."""
|
|
||||||
named = [z for z in formatting.zone_names() if "/" in z]
|
|
||||||
return [("", ["UTC"])] + [(region, list(zones)) for region, zones
|
|
||||||
in groupby(named, key=lambda z: z.split("/", 1)[0])]
|
|
||||||
|
|
||||||
|
|
||||||
async def _me(db) -> User:
|
async def _me(db) -> User:
|
||||||
# A share link opens widgets read-only; it has no account.
|
# A share link opens widgets read-only; it has no account.
|
||||||
user_id = session.get("user_id")
|
user_id = session.get("user_id")
|
||||||
@@ -36,14 +26,18 @@ async def _me(db) -> User:
|
|||||||
|
|
||||||
@account_bp.get("/")
|
@account_bp.get("/")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def account(error: str | None = None, status: int = 200):
|
async def account(error: str | None = None, status: int = 200, email_error: str | None = None):
|
||||||
|
from steward.core.mail import role_defaults
|
||||||
|
from steward.core.settings import get_all_settings
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
user = await _me(db)
|
user = await _me(db)
|
||||||
|
defaults = role_defaults(user, await get_all_settings(db))
|
||||||
tz = formatting.zone(user.time_zone)
|
tz = formatting.zone(user.time_zone)
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"account/index.html", user=user, source=_SOURCES.get(user.auth_source, user.auth_source),
|
"account/index.html", user=user, source=_SOURCES.get(user.auth_source, user.auth_source),
|
||||||
zone_name=user.time_zone or "UTC", zone_label=formatting.tz_label(tz),
|
zone_name=user.time_zone or "UTC", zone_label=formatting.tz_label(tz),
|
||||||
zones=_zones_by_region(), error=error), status
|
zones=formatting.zones_by_region(), defaults=defaults, error=error,
|
||||||
|
email_error=email_error), status
|
||||||
|
|
||||||
|
|
||||||
@account_bp.post("/")
|
@account_bp.post("/")
|
||||||
@@ -65,6 +59,31 @@ async def account_save():
|
|||||||
return redirect("/account/")
|
return redirect("/account/")
|
||||||
|
|
||||||
|
|
||||||
|
@account_bp.post("/email")
|
||||||
|
@require_role(UserRole.viewer)
|
||||||
|
async def account_email():
|
||||||
|
"""Your address and whether you're emailed alerts and the weekly report
|
||||||
|
(milestone 495)."""
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from steward.core.audit import log_audit
|
||||||
|
from steward.core.mail import apply_email_form
|
||||||
|
form = await request.form
|
||||||
|
try:
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
async with db.begin():
|
||||||
|
user = await _me(db)
|
||||||
|
changes, error = apply_email_form(user, form)
|
||||||
|
if error:
|
||||||
|
raise ValueError(error)
|
||||||
|
except ValueError as exc:
|
||||||
|
return await account(status=400, email_error=str(exc))
|
||||||
|
except IntegrityError:
|
||||||
|
return await account(status=400, email_error="Another account already uses that address.")
|
||||||
|
if changes:
|
||||||
|
await log_audit(current_app, user.id, user.username, "account.email", detail=changes)
|
||||||
|
return redirect("/account/#email")
|
||||||
|
|
||||||
|
|
||||||
@account_bp.post("/time-zone/adopt")
|
@account_bp.post("/time-zone/adopt")
|
||||||
@require_role(UserRole.viewer)
|
@require_role(UserRole.viewer)
|
||||||
async def adopt_time_zone():
|
async def adopt_time_zone():
|
||||||
|
|||||||
@@ -102,9 +102,14 @@ def _parse_ansible_action(form) -> tuple[dict | None, str | None]:
|
|||||||
return action, None
|
return action, None
|
||||||
|
|
||||||
|
|
||||||
def _notify() -> list[str]:
|
async def _notify() -> list[str]:
|
||||||
"""Where alerts' notifications go now, from the settings the app runs with."""
|
"""Where alerts' notifications go now, from the settings the app runs with
|
||||||
return alert_svc.notify_targets(current_app.config.get("SMTP", {}), current_app.config.get("WEBHOOK", {}))
|
and the accounts that get alert email."""
|
||||||
|
from steward.core.mail import audience
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
accounts = len((await audience(db))["alerts"])
|
||||||
|
return alert_svc.notify_targets(current_app.config.get("SMTP", {}), current_app.config.get("WEBHOOK", {}),
|
||||||
|
accounts)
|
||||||
|
|
||||||
|
|
||||||
@alerts_bp.get("/")
|
@alerts_bp.get("/")
|
||||||
@@ -123,7 +128,7 @@ async def list_alerts():
|
|||||||
rule_state = {r.id: alert_svc.alert_label(states[r.id]) for r in rules if r.id in states}
|
rule_state = {r.id: alert_svc.alert_label(states[r.id]) for r in rules if r.id in states}
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"alerts/list.html", active=active, rules=rules, rule_state=rule_state, last_fired=fired,
|
"alerts/list.html", active=active, rules=rules, rule_state=rule_state, last_fired=fired,
|
||||||
notify=_notify(), operators=_OPERATORS,
|
notify=await _notify(), operators=_OPERATORS,
|
||||||
poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60))
|
poll_interval=current_app.config.get("MONITORS_POLL_INTERVAL", 60))
|
||||||
|
|
||||||
|
|
||||||
@@ -135,7 +140,7 @@ async def active_fragment():
|
|||||||
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
|
active = await alert_svc.active_rows(db, datetime.now(timezone.utc))
|
||||||
rules = await alert_svc.list_rules(db)
|
rules = await alert_svc.list_rules(db)
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"alerts/_active.html", active=active, notify=_notify(), rules_total=len(rules),
|
"alerts/_active.html", active=active, notify=await _notify(), rules_total=len(rules),
|
||||||
rules_off=sum(1 for r in rules if not r.enabled))
|
rules_off=sum(1 for r in rules if not r.enabled))
|
||||||
|
|
||||||
|
|
||||||
@@ -246,7 +251,7 @@ async def new_rule():
|
|||||||
metrics=alert_svc.metric_catalog().get(first_source, []),
|
metrics=alert_svc.metric_catalog().get(first_source, []),
|
||||||
catalog=alert_svc.metric_catalog(),
|
catalog=alert_svc.metric_catalog(),
|
||||||
ansible_sources=_ansible_source_names(),
|
ansible_sources=_ansible_source_names(),
|
||||||
notify=_notify(),
|
notify=await _notify(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -264,7 +269,7 @@ async def edit_rule(rule_id: str):
|
|||||||
fired = (await alert_svc.last_fired(db, rule.id)).get(rule.id)
|
fired = (await alert_svc.last_fired(db, rule.id)).get(rule.id)
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"alerts/rules_form.html",
|
"alerts/rules_form.html",
|
||||||
rule=rule, history_count=history_count, fired=fired, notify=_notify(),
|
rule=rule, history_count=history_count, fired=fired, notify=await _notify(),
|
||||||
operators=_OPERATORS,
|
operators=_OPERATORS,
|
||||||
source_modules=alert_svc.source_modules(),
|
source_modules=alert_svc.source_modules(),
|
||||||
initial_source=rule.source_module,
|
initial_source=rule.source_module,
|
||||||
|
|||||||
@@ -257,17 +257,19 @@ async def active_count(db) -> int:
|
|||||||
)).scalar_one()
|
)).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
def notify_targets(smtp: dict, webhook: dict) -> list[str]:
|
def notify_targets(smtp: dict, webhook: dict, accounts: int = 0) -> list[str]:
|
||||||
"""Where an alert's notifications go, in words, by the same tests
|
"""Where an alert's notifications go, in words, by the same tests
|
||||||
core.notifications.dispatch_notifications sends by: email when a server
|
core.notifications.dispatch_notifications sends by: email when a server
|
||||||
and recipients are set, a webhook when its URL is. Empty: nobody (#5315)."""
|
is set and `accounts` (that get alerts) or Also send to addresses exist,
|
||||||
|
a webhook when its URL is. Empty: nobody (#5315, milestone 495)."""
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
out = []
|
out = []
|
||||||
recipients = smtp.get("recipients") or []
|
recipients = smtp.get("recipients") or []
|
||||||
if isinstance(recipients, str):
|
if isinstance(recipients, str):
|
||||||
recipients = [r.strip() for r in recipients.split(",") if r.strip()]
|
recipients = [r.strip() for r in recipients.split(",") if r.strip()]
|
||||||
if smtp.get("host") and recipients:
|
who = ([f"{accounts} account{'s' if accounts != 1 else ''}"] if accounts else []) + recipients
|
||||||
out.append("email to " + ", ".join(recipients))
|
if smtp.get("host") and who:
|
||||||
|
out.append("email to " + ", ".join(who))
|
||||||
if url := webhook.get("url"):
|
if url := webhook.get("url"):
|
||||||
out.append("a webhook to " + (urlsplit(url).hostname or url))
|
out.append("a webhook to " + (urlsplit(url).hostname or url))
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -119,9 +119,11 @@ ACTIONS = {
|
|||||||
"auth.login_failed": "failed to sign in",
|
"auth.login_failed": "failed to sign in",
|
||||||
"auth.setup": "set up Steward",
|
"auth.setup": "set up Steward",
|
||||||
"account.time_zone": "set their time zone",
|
"account.time_zone": "set their time zone",
|
||||||
|
"account.email": "changed their email settings",
|
||||||
"user.created": "added user",
|
"user.created": "added user",
|
||||||
"user.renamed": "renamed user",
|
"user.renamed": "renamed user",
|
||||||
"user.role_changed": "changed the role of user",
|
"user.role_changed": "changed the role of user",
|
||||||
|
"user.email_settings": "changed the email settings of user",
|
||||||
"user.password_reset": "set a new password for user",
|
"user.password_reset": "set a new password for user",
|
||||||
"user.disabled": "disabled user",
|
"user.disabled": "disabled user",
|
||||||
"user.enabled": "enabled user",
|
"user.enabled": "enabled user",
|
||||||
|
|||||||
@@ -492,9 +492,12 @@ async def _dispatch_notification(
|
|||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from steward.core.mail import recipients
|
||||||
smtp_cfg = app.config.get("SMTP", {})
|
smtp_cfg = app.config.get("SMTP", {})
|
||||||
webhook_cfg = app.config.get("WEBHOOK", {})
|
webhook_cfg = app.config.get("WEBHOOK", {})
|
||||||
results = await dispatch_notifications(smtp_cfg, webhook_cfg, alert_data)
|
async with app.db_sessionmaker() as db:
|
||||||
|
to = await recipients(db, "alerts", smtp_cfg) if smtp_cfg.get("host") else []
|
||||||
|
results = await dispatch_notifications(smtp_cfg, webhook_cfg, alert_data, to)
|
||||||
|
|
||||||
# Update event row with delivery results in a new transaction
|
# Update event row with delivery results in a new transaction
|
||||||
async with app.db_sessionmaker() as db:
|
async with app.db_sessionmaker() as db:
|
||||||
|
|||||||
@@ -87,6 +87,15 @@ def zone_names() -> tuple[str, ...]:
|
|||||||
return tuple(sorted(n for n in available_timezones() if "/" in n or n == "UTC"))
|
return tuple(sorted(n for n in available_timezones() if "/" in n or n == "UTC"))
|
||||||
|
|
||||||
|
|
||||||
|
def zones_by_region() -> list[tuple[str, list[str]]]:
|
||||||
|
"""A zone picker's choices (the zone_select macro): UTC alone, then every
|
||||||
|
zone under its region ('America', 'Europe', …)."""
|
||||||
|
from itertools import groupby
|
||||||
|
named = [z for z in zone_names() if "/" in z]
|
||||||
|
return [("", ["UTC"])] + [(region, list(zones)) for region, zones
|
||||||
|
in groupby(named, key=lambda z: z.split("/", 1)[0])]
|
||||||
|
|
||||||
|
|
||||||
def tz_label(tz: tzinfo | None = None, now: datetime | None = None) -> str:
|
def tz_label(tz: tzinfo | None = None, now: datetime | None = None) -> str:
|
||||||
"""The zone's short name as it stands now ('EDT', 'CET', 'UTC'), for a
|
"""The zone's short name as it stands now ('EDT', 'CET', 'UTC'), for a
|
||||||
column header that says which zone its times are in."""
|
column header that says which zone its times are in."""
|
||||||
|
|||||||
+126
-12
@@ -9,6 +9,7 @@ light theme resolved to plain values from theme.css (palette()).
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import re
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone, tzinfo
|
from datetime import datetime, timedelta, timezone, tzinfo
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
@@ -55,6 +56,118 @@ def palette() -> dict[str, str]:
|
|||||||
return colours
|
return colours
|
||||||
|
|
||||||
|
|
||||||
|
# ── Who gets an email (milestone 495) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
KINDS = ("alerts", "report")
|
||||||
|
_REAL_ADDRESS = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Recipient:
|
||||||
|
"""One address an email goes to, and the zone its times are written in.
|
||||||
|
`user` is the account's username, "" for an "Also send to" address."""
|
||||||
|
address: str
|
||||||
|
tz: tzinfo
|
||||||
|
show_utc: bool
|
||||||
|
user: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def is_real_address(address: str | None) -> bool:
|
||||||
|
"""Whether mail can reach it: an SSO account its provider gave no email
|
||||||
|
holds a placeholder like "name@oidc", which has no domain to deliver to."""
|
||||||
|
return bool(address and _REAL_ADDRESS.fullmatch(address.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
def wants(user, kind: str, settings: dict) -> bool:
|
||||||
|
"""Whether an account is emailed `kind` ("alerts" or "report"): its own
|
||||||
|
choice, or else its role's default."""
|
||||||
|
choice = getattr(user, f"notify_{kind}")
|
||||||
|
if choice is None:
|
||||||
|
return bool(settings.get(f"notify.{kind}.{user.role.value}", True))
|
||||||
|
return choice
|
||||||
|
|
||||||
|
|
||||||
|
def pick_recipients(users, kind: str, settings: dict, extra: list[str], extra_tz: str | None) -> list[Recipient]:
|
||||||
|
"""Each active account that wants `kind` and has a real address, in its own
|
||||||
|
zone (UTC when it has none, and never UTC beside a zone it chose), then the
|
||||||
|
"Also send to" addresses in `extra_tz` with UTC beside. An address is sent
|
||||||
|
to once."""
|
||||||
|
from steward.core.formatting import zone
|
||||||
|
out: dict[str, Recipient] = {}
|
||||||
|
for u in sorted(users, key=lambda u: u.username.lower()):
|
||||||
|
if u.is_active and wants(u, kind, settings) and is_real_address(u.email):
|
||||||
|
out.setdefault(u.email.strip().lower(), Recipient(u.email.strip(), zone(u.time_zone), False, u.username))
|
||||||
|
for address in extra or []:
|
||||||
|
if is_real_address(address):
|
||||||
|
out.setdefault(address.strip().lower(), Recipient(address.strip(), zone(extra_tz), True))
|
||||||
|
return list(out.values())
|
||||||
|
|
||||||
|
|
||||||
|
# An account's choice for each kind of email, as a form sends it.
|
||||||
|
CHOICES = {"": None, "on": True, "off": False}
|
||||||
|
|
||||||
|
|
||||||
|
def role_defaults(user, settings: dict) -> dict[str, bool]:
|
||||||
|
"""{kind: whether this account's role gets it by default}."""
|
||||||
|
return {kind: bool(settings.get(f"notify.{kind}.{user.role.value}", True)) for kind in KINDS}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_email_form(user, form) -> tuple[dict, str | None]:
|
||||||
|
"""Apply an email form to `user`: "email" (when the form has it) and
|
||||||
|
"notify_alerts" / "notify_report" ("", "on" or "off"). Returns
|
||||||
|
({field: [before, after]} for what changed, None), or ({}, why) having
|
||||||
|
changed nothing."""
|
||||||
|
wanted = {}
|
||||||
|
if "email" in form:
|
||||||
|
address = (form.get("email") or "").strip()
|
||||||
|
if not is_real_address(address) or len(address) > 255:
|
||||||
|
return {}, "That isn't an email address mail can reach."
|
||||||
|
wanted["email"] = address
|
||||||
|
for kind in KINDS:
|
||||||
|
value = form.get(f"notify_{kind}", "")
|
||||||
|
if value not in CHOICES:
|
||||||
|
return {}, "Pick On, Off or the role's default."
|
||||||
|
wanted[f"notify_{kind}"] = CHOICES[value]
|
||||||
|
changes = {k: [getattr(user, k), v] for k, v in wanted.items() if getattr(user, k) != v}
|
||||||
|
for k, (_, v) in changes.items():
|
||||||
|
setattr(user, k, v)
|
||||||
|
return changes, None
|
||||||
|
|
||||||
|
|
||||||
|
async def recipients(db, kind: str, smtp_cfg: dict) -> list[Recipient]:
|
||||||
|
"""pick_recipients from the accounts and settings as they are now."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from steward.core.settings import get_all_settings
|
||||||
|
from steward.models.users import User
|
||||||
|
users = (await db.execute(select(User).where(User.is_active.is_(True)))).scalars().all()
|
||||||
|
return pick_recipients(users, kind, await get_all_settings(db),
|
||||||
|
smtp_cfg.get("recipients") or [], smtp_cfg.get("tz"))
|
||||||
|
|
||||||
|
|
||||||
|
async def audience(db) -> dict:
|
||||||
|
"""Who the emails reach, for the Notifications page and the Alerts page:
|
||||||
|
{"alerts": [User], "report": [User]} the active accounts that get each,
|
||||||
|
and "unreachable": the active accounts mail can't reach because their
|
||||||
|
address isn't real."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from steward.core.settings import get_all_settings
|
||||||
|
from steward.models.users import User
|
||||||
|
users = sorted((await db.execute(select(User).where(User.is_active.is_(True)))).scalars().all(),
|
||||||
|
key=lambda u: u.username.lower())
|
||||||
|
settings = await get_all_settings(db)
|
||||||
|
out = {kind: [u for u in users if wants(u, kind, settings) and is_real_address(u.email)]
|
||||||
|
for kind in KINDS}
|
||||||
|
out["unreachable"] = [u for u in users if not is_real_address(u.email)]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def send(cfg: dict, messages: list[EmailMessage]) -> None:
|
||||||
|
"""Send every message over one SMTP connection, off the event loop."""
|
||||||
|
import asyncio
|
||||||
|
from steward.core.notifications import _smtp_send_sync
|
||||||
|
await asyncio.get_running_loop().run_in_executor(None, _smtp_send_sync, cfg, messages)
|
||||||
|
|
||||||
|
|
||||||
def _render(name: str, **ctx) -> tuple[str, str]:
|
def _render(name: str, **ctx) -> tuple[str, str]:
|
||||||
ctx = {"c": palette(), **ctx}
|
ctx = {"c": palette(), **ctx}
|
||||||
return (_env.get_template(f"{name}.txt").render(ctx),
|
return (_env.get_template(f"{name}.txt").render(ctx),
|
||||||
@@ -65,12 +178,12 @@ def sender(cfg: dict) -> str:
|
|||||||
return cfg.get("from") or cfg.get("username") or "steward@localhost"
|
return cfg.get("from") or cfg.get("username") or "steward@localhost"
|
||||||
|
|
||||||
|
|
||||||
def message(cfg: dict, subject: str, text: str, html: str) -> EmailMessage:
|
def message(cfg: dict, to: str, subject: str, text: str, html: str) -> EmailMessage:
|
||||||
msg = EmailMessage()
|
msg = EmailMessage()
|
||||||
# A header may not hold a line break; a rule name can (MCP takes any string).
|
# A header may not hold a line break; a rule name can (MCP takes any string).
|
||||||
msg["Subject"] = " ".join(subject.split())
|
msg["Subject"] = " ".join(subject.split())
|
||||||
msg["From"] = sender(cfg)
|
msg["From"] = sender(cfg)
|
||||||
msg["To"] = ", ".join(cfg["recipients"])
|
msg["To"] = to
|
||||||
msg.set_content(text)
|
msg.set_content(text)
|
||||||
msg.add_alternative(html, subtype="html")
|
msg.add_alternative(html, subtype="html")
|
||||||
return msg
|
return msg
|
||||||
@@ -88,8 +201,9 @@ def _lower_first(label: str) -> str:
|
|||||||
return label if label[:2].isupper() else label[:1].lower() + label[1:]
|
return label if label[:2].isupper() else label[:1].lower() + label[1:]
|
||||||
|
|
||||||
|
|
||||||
def alert_facts(alert: dict, tz: tzinfo) -> dict:
|
def alert_facts(alert: dict, tz: tzinfo, show_utc: bool = True) -> dict:
|
||||||
"""What an alert email says, in words, from the dict alerts dispatch."""
|
"""What an alert email says, in words, from the dict alerts dispatch, its
|
||||||
|
time in `tz` (and in UTC beside it when `show_utc` and they differ)."""
|
||||||
from steward.alerts.service import _BINARY, condition_text
|
from steward.alerts.service import _BINARY, condition_text
|
||||||
from steward.core.formatting import metric_label, metric_value, status_info
|
from steward.core.formatting import metric_label, metric_value, status_info
|
||||||
tone, label = status_info(alert["state"])
|
tone, label = status_info(alert["state"])
|
||||||
@@ -115,27 +229,27 @@ def alert_facts(alert: dict, tz: tzinfo) -> dict:
|
|||||||
"rule": alert["rule_name"], "resource": resource,
|
"rule": alert["rule_name"], "resource": resource,
|
||||||
"reading": reading or "no data",
|
"reading": reading or "no data",
|
||||||
"condition": condition_text(metric, alert["operator"], alert["threshold"]),
|
"condition": condition_text(metric, alert["operator"], alert["threshold"]),
|
||||||
"when": local, "when_utc": utc if utc != local else "",
|
"when": local, "when_utc": utc if show_utc and utc != local else "",
|
||||||
"note": alert.get("note") or "",
|
"note": alert.get("note") or "",
|
||||||
"subject": f"[Steward] {label}: {alert['rule_name']} — {resource}"
|
"subject": f"[Steward] {label}: {alert['rule_name']} — {resource}"
|
||||||
+ (f" {reading}" if reading and state in ("firing", "test") else ""),
|
+ (f" {reading}" if reading and state in ("firing", "test") else ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def alert_email(cfg: dict, alert: dict) -> tuple[str, str, str]:
|
def alert_email(cfg: dict, alert: dict, to: Recipient) -> tuple[str, str, str]:
|
||||||
"""(subject, text, html) for an alert, in the zone the SMTP settings were
|
"""(subject, text, html) for an alert, written for one recipient in their
|
||||||
saved in, linking to Steward when its address is set."""
|
zone, linking to Steward when its address is set."""
|
||||||
from steward.core.formatting import zone
|
facts = alert_facts(alert, to.tz, to.show_utc)
|
||||||
facts = alert_facts(alert, zone(cfg.get("tz")))
|
|
||||||
base = (cfg.get("base_url") or "").rstrip("/")
|
base = (cfg.get("base_url") or "").rstrip("/")
|
||||||
text, html = _render("alert", a=facts, link=f"{base}/alerts/" if base else "")
|
text, html = _render("alert", a=facts, link=f"{base}/alerts/" if base else "")
|
||||||
return facts["subject"], text, html
|
return facts["subject"], text, html
|
||||||
|
|
||||||
|
|
||||||
def report_email(cfg: dict, data: dict, tz: tzinfo) -> tuple[str, str, str]:
|
def report_email(cfg: dict, data: dict, to: Recipient) -> tuple[str, str, str]:
|
||||||
"""(subject, text, html) for the weekly report built by
|
"""(subject, text, html) for the weekly report built by
|
||||||
reports.build_report_data, its dates in the schedule's zone."""
|
reports.build_report_data, its dates in the recipient's zone."""
|
||||||
from steward.core.formatting import status_info
|
from steward.core.formatting import status_info
|
||||||
|
tz = to.tz
|
||||||
end = data["generated_at"].astimezone(tz)
|
end = data["generated_at"].astimezone(tz)
|
||||||
start = end - timedelta(days=7)
|
start = end - timedelta(days=7)
|
||||||
period = (f"{start.day} {start:%b} – {end.day} {end:%b %Y}" if start.year == end.year
|
period = (f"{start.day} {start:%b} – {end.day} {end:%b %Y}" if start.year == end.year
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
@@ -17,12 +16,15 @@ async def dispatch_notifications(
|
|||||||
smtp_cfg: dict,
|
smtp_cfg: dict,
|
||||||
webhook_cfg: dict,
|
webhook_cfg: dict,
|
||||||
alert_data: dict,
|
alert_data: dict,
|
||||||
|
recipients: list = (),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Dispatch all configured notification channels. Returns results dict."""
|
"""Dispatch all configured notification channels: email to each of
|
||||||
|
`recipients` (mail.Recipient, from mail.recipients) when a server is set.
|
||||||
|
Returns results dict."""
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
if smtp_cfg.get("host") and smtp_cfg.get("recipients"):
|
if smtp_cfg.get("host") and recipients:
|
||||||
results["email"] = await _send_email(smtp_cfg, alert_data)
|
results["email"] = await _send_email(smtp_cfg, alert_data, recipients)
|
||||||
|
|
||||||
if webhook_cfg.get("url"):
|
if webhook_cfg.get("url"):
|
||||||
results["webhook"] = await _send_webhook(webhook_cfg, alert_data)
|
results["webhook"] = await _send_webhook(webhook_cfg, alert_data)
|
||||||
@@ -41,20 +43,19 @@ def smtp_mode(port, tls) -> str:
|
|||||||
return "starttls" if tls else "plain"
|
return "starttls" if tls else "plain"
|
||||||
|
|
||||||
|
|
||||||
async def _send_email(cfg: dict, alert: dict) -> dict:
|
async def _send_email(cfg: dict, alert: dict, recipients: list) -> dict:
|
||||||
from steward.core.mail import alert_email, message
|
"""Each recipient their own copy, in their own zone, over one connection."""
|
||||||
|
from steward.core.mail import alert_email, message, send
|
||||||
try:
|
try:
|
||||||
subject, text, html = alert_email(cfg, alert)
|
messages = [message(cfg, to.address, *alert_email(cfg, alert, to)) for to in recipients]
|
||||||
msg = message(cfg, subject, text, html)
|
await send(cfg, messages)
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
await loop.run_in_executor(None, _smtp_send_sync, cfg, msg)
|
|
||||||
return {"sent": True, "error": None}
|
return {"sent": True, "error": None}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(f"Email notification failed: {exc}")
|
logger.error(f"Email notification failed: {exc}")
|
||||||
return {"sent": False, "error": str(exc)}
|
return {"sent": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None:
|
def _smtp_send_sync(cfg: dict, messages: list[EmailMessage]) -> None:
|
||||||
port = int(cfg.get("port") or 587)
|
port = int(cfg.get("port") or 587)
|
||||||
mode = smtp_mode(port, cfg.get("tls", True))
|
mode = smtp_mode(port, cfg.get("tls", True))
|
||||||
ctx = ssl.create_default_context()
|
ctx = ssl.create_default_context()
|
||||||
@@ -67,6 +68,7 @@ def _smtp_send_sync(cfg: dict, msg: EmailMessage) -> None:
|
|||||||
smtp.starttls(context=ctx)
|
smtp.starttls(context=ctx)
|
||||||
if cfg.get("username"):
|
if cfg.get("username"):
|
||||||
smtp.login(cfg["username"], cfg.get("password", ""))
|
smtp.login(cfg["username"], cfg.get("password", ""))
|
||||||
|
for msg in messages:
|
||||||
smtp.send_message(msg)
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+13
-15
@@ -4,7 +4,6 @@ Weekly digest report: uptime, active alerts, alert events, top metrics.
|
|||||||
Called by the scheduler (hourly check) or triggered manually from Settings.
|
Called by the scheduler (hourly check) or triggered manually from Settings.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@ from sqlalchemy import and_, case, func, select
|
|||||||
|
|
||||||
from steward.alerts import service as alert_svc
|
from steward.alerts import service as alert_svc
|
||||||
from steward.core.formatting import zone
|
from steward.core.formatting import zone
|
||||||
from steward.core.mail import message, report_email
|
from steward.core.mail import message, recipients, report_email, send
|
||||||
from steward.core.settings import get_setting
|
from steward.core.settings import get_setting
|
||||||
from steward.models.alerts import AlertEvent
|
from steward.models.alerts import AlertEvent
|
||||||
from steward.models.metrics import PluginMetric
|
from steward.models.metrics import PluginMetric
|
||||||
@@ -118,10 +117,16 @@ async def build_report_data(app) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
async def send_report(app) -> tuple[bool, str]:
|
async def send_report(app) -> tuple[bool, str]:
|
||||||
"""Build and email the weekly digest. Returns (success, message)."""
|
"""Email the weekly digest to each account that gets it, and the "Also
|
||||||
|
send to" addresses, each in its own zone. Returns (success, message)."""
|
||||||
smtp_cfg = app.config.get("SMTP", {})
|
smtp_cfg = app.config.get("SMTP", {})
|
||||||
if not smtp_cfg.get("host") or not smtp_cfg.get("recipients"):
|
if not smtp_cfg.get("host"):
|
||||||
return False, "SMTP not configured — set host and recipients in Settings → Notifications."
|
return False, "No mail server is set. Set one in Settings → Notifications."
|
||||||
|
async with app.db_sessionmaker() as db:
|
||||||
|
to = await recipients(db, "report", smtp_cfg)
|
||||||
|
if not to:
|
||||||
|
return False, ("Nobody gets the weekly report: no account has it on with a real email "
|
||||||
|
"address, and there are no Also send to addresses.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await build_report_data(app)
|
data = await build_report_data(app)
|
||||||
@@ -129,20 +134,13 @@ async def send_report(app) -> tuple[bool, str]:
|
|||||||
logger.exception("Failed to build report data")
|
logger.exception("Failed to build report data")
|
||||||
return False, f"Data collection failed: {exc}"
|
return False, f"Data collection failed: {exc}"
|
||||||
|
|
||||||
async with app.db_sessionmaker() as db:
|
|
||||||
tz = zone(await get_setting(db, "reports.schedule_tz"))
|
|
||||||
subject, text, html = report_email(smtp_cfg, data, tz)
|
|
||||||
msg = message(smtp_cfg, subject, text, html)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
await send(smtp_cfg, [message(smtp_cfg, r.address, *report_email(smtp_cfg, data, r)) for r in to])
|
||||||
from steward.core.notifications import _smtp_send_sync
|
|
||||||
await loop.run_in_executor(None, _smtp_send_sync, smtp_cfg, msg)
|
|
||||||
logger.info("Weekly report sent to %s", smtp_cfg["recipients"])
|
|
||||||
return True, f"Report sent to {', '.join(smtp_cfg['recipients'])}."
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Failed to send weekly report: %s", exc)
|
logger.error("Failed to send weekly report: %s", exc)
|
||||||
return False, f"Send failed: {exc}"
|
return False, f"Send failed: {exc}"
|
||||||
|
logger.info("Weekly report sent to %d address(es)", len(to))
|
||||||
|
return True, f"Report sent to {', '.join(r.address for r in to)}."
|
||||||
|
|
||||||
|
|
||||||
def report_due(now: datetime, day: int, hour: int, tz) -> bool:
|
def report_due(now: datetime, day: int, hour: int, tz) -> bool:
|
||||||
|
|||||||
@@ -63,7 +63,16 @@ DEFAULTS: dict[str, Any] = {
|
|||||||
"smtp.password": "",
|
"smtp.password": "",
|
||||||
"smtp.recipients": [],
|
"smtp.recipients": [],
|
||||||
"smtp.from": "",
|
"smtp.from": "",
|
||||||
"smtp.tz": "", # the zone of whoever saved Notifications: alert emails give times in it
|
# The zone "Also send to" addresses read times in: the first admin to open
|
||||||
|
# Notifications, changeable there. Accounts read their own (milestone 495).
|
||||||
|
"smtp.tz": "",
|
||||||
|
# Whether an account of each role is emailed, unless it chooses otherwise.
|
||||||
|
"notify.alerts.admin": True,
|
||||||
|
"notify.alerts.operator": True,
|
||||||
|
"notify.alerts.viewer": True,
|
||||||
|
"notify.report.admin": True,
|
||||||
|
"notify.report.operator": True,
|
||||||
|
"notify.report.viewer": True,
|
||||||
"webhook.url": "",
|
"webhook.url": "",
|
||||||
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
|
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
|
||||||
"ansible.sources": [],
|
"ansible.sources": [],
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Each account's email switches: alert emails and the weekly report
|
||||||
|
(milestone 495). NULL follows the account's role default.
|
||||||
|
|
||||||
|
Revision ID: 0036_user_email_choices
|
||||||
|
Revises: 0035_drop_plugin_catalog
|
||||||
|
Create Date: 2026-10-08
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "0036_user_email_choices"
|
||||||
|
down_revision: Union[str, None] = "0035_drop_plugin_catalog"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("notify_alerts", sa.Boolean, nullable=True))
|
||||||
|
op.add_column("users", sa.Column("notify_report", sa.Boolean, nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "notify_report")
|
||||||
|
op.drop_column("users", "notify_alerts")
|
||||||
@@ -40,3 +40,7 @@ class User(Base):
|
|||||||
# "America/New_York". Their browser's zone is saved the first time they
|
# "America/New_York". Their browser's zone is saved the first time they
|
||||||
# sign in without one; they change it on the Account page. None reads as UTC.
|
# sign in without one; they change it on the Account page. None reads as UTC.
|
||||||
time_zone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
time_zone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
# Whether this account is emailed alerts and the weekly report (milestone
|
||||||
|
# 495). None follows its role's default (notify.<kind>.<role> in settings).
|
||||||
|
notify_alerts: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
notify_report: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
|
||||||
|
|||||||
+42
-13
@@ -216,18 +216,31 @@ async def save_thresholds():
|
|||||||
@settings_bp.get("/notifications/")
|
@settings_bp.get("/notifications/")
|
||||||
@require_role(UserRole.admin)
|
@require_role(UserRole.admin)
|
||||||
async def notifications():
|
async def notifications():
|
||||||
|
from steward.core.formatting import zones_by_region
|
||||||
|
from steward.core.mail import audience
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
settings = await get_all_settings(db)
|
settings = await get_all_settings(db)
|
||||||
|
if not settings["smtp.tz"] and session.get("tz"):
|
||||||
|
# "Also send to" reads times in the first admin's zone until one is chosen.
|
||||||
|
async with db.begin():
|
||||||
|
await set_setting(db, "smtp.tz", session["tz"])
|
||||||
|
settings["smtp.tz"] = session["tz"]
|
||||||
|
await _reload_app_config()
|
||||||
|
who = await audience(db)
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
webhook_host = urlsplit(settings.get("webhook.url") or "").hostname or ""
|
webhook_host = urlsplit(settings.get("webhook.url") or "").hostname or ""
|
||||||
return await render_template("settings/notifications.html", settings=settings,
|
return await render_template("settings/notifications.html", settings=settings,
|
||||||
webhook_host=webhook_host)
|
webhook_host=webhook_host, audience=who,
|
||||||
|
roles=list(UserRole), zones=zones_by_region())
|
||||||
|
|
||||||
|
|
||||||
@settings_bp.post("/notifications/")
|
@settings_bp.post("/notifications/")
|
||||||
@require_role(UserRole.admin)
|
@require_role(UserRole.admin)
|
||||||
async def save_notifications():
|
async def save_notifications():
|
||||||
|
from steward.core.formatting import zone_names
|
||||||
|
from steward.core.mail import KINDS
|
||||||
form = await request.form
|
form = await request.form
|
||||||
|
extra_tz = form.get("smtp.tz", "")
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
async with db.begin():
|
async with db.begin():
|
||||||
recipients_raw = form.get("smtp.recipients", "")
|
recipients_raw = form.get("smtp.recipients", "")
|
||||||
@@ -240,7 +253,12 @@ async def save_notifications():
|
|||||||
await set_setting(db, "smtp.password", form.get("smtp.password"))
|
await set_setting(db, "smtp.password", form.get("smtp.password"))
|
||||||
await set_setting(db, "smtp.recipients", recipients)
|
await set_setting(db, "smtp.recipients", recipients)
|
||||||
await set_setting(db, "smtp.from", form.get("smtp.from", "").strip())
|
await set_setting(db, "smtp.from", form.get("smtp.from", "").strip())
|
||||||
await set_setting(db, "smtp.tz", session.get("tz") or "")
|
if extra_tz in zone_names():
|
||||||
|
await set_setting(db, "smtp.tz", extra_tz)
|
||||||
|
for kind in KINDS:
|
||||||
|
for role in UserRole:
|
||||||
|
key = f"notify.{kind}.{role.value}"
|
||||||
|
await set_setting(db, key, key in form)
|
||||||
if "webhook.remove" in form:
|
if "webhook.remove" in form:
|
||||||
await set_setting(db, "webhook.url", "")
|
await set_setting(db, "webhook.url", "")
|
||||||
elif form.get("webhook.url", "").strip():
|
elif form.get("webhook.url", "").strip():
|
||||||
@@ -295,31 +313,42 @@ async def test_webhook():
|
|||||||
@settings_bp.post("/notifications/test-email")
|
@settings_bp.post("/notifications/test-email")
|
||||||
@require_role(UserRole.admin)
|
@require_role(UserRole.admin)
|
||||||
async def test_email():
|
async def test_email():
|
||||||
"""Send a sample alert email using the SMTP settings in the form (unsaved);
|
"""Send a sample alert email using the SMTP settings in the form (unsaved):
|
||||||
a blank password uses the saved one."""
|
to you, in your zone, and to the Also send to addresses in the form, in
|
||||||
from steward.core.mail import alert_email
|
the zone chosen for them. A blank password uses the saved one."""
|
||||||
|
from steward.core.formatting import zone
|
||||||
|
from steward.core.mail import Recipient, alert_email, is_real_address, pick_recipients
|
||||||
from steward.core.notifications import _send_email, smtp_mode
|
from steward.core.notifications import _send_email, smtp_mode
|
||||||
|
from steward.models.users import User
|
||||||
form = await request.form
|
form = await request.form
|
||||||
async with current_app.db_sessionmaker() as db:
|
async with current_app.db_sessionmaker() as db:
|
||||||
saved = to_smtp_cfg(await get_all_settings(db))
|
saved = to_smtp_cfg(await get_all_settings(db))
|
||||||
|
me = await db.get(User, session.get("user_id"))
|
||||||
try:
|
try:
|
||||||
port = int(form.get("smtp.port") or 587)
|
port = int(form.get("smtp.port") or 587)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return await _notify_test_result("email", False, "The port must be a number.")
|
return await _notify_test_result("email", False, "The port must be a number.")
|
||||||
|
extra = [r.strip() for r in form.get("smtp.recipients", "").split(",") if r.strip()]
|
||||||
cfg = {"host": (form.get("smtp.host") or "").strip(), "port": port,
|
cfg = {"host": (form.get("smtp.host") or "").strip(), "port": port,
|
||||||
"tls": "smtp.tls" in form, "username": form.get("smtp.username", ""),
|
"tls": "smtp.tls" in form, "username": form.get("smtp.username", ""),
|
||||||
"password": form.get("smtp.password") or saved["password"],
|
"password": form.get("smtp.password") or saved["password"],
|
||||||
"from": (form.get("smtp.from") or "").strip(),
|
"from": (form.get("smtp.from") or "").strip(), "base_url": saved["base_url"]}
|
||||||
"tz": session.get("tz") or "", "base_url": saved["base_url"],
|
to = ([Recipient(me.email.strip(), zone(me.time_zone), False, me.username)]
|
||||||
"recipients": [r.strip() for r in form.get("smtp.recipients", "").split(",") if r.strip()]}
|
if me is not None and is_real_address(me.email) else [])
|
||||||
if not cfg["host"] or not cfg["recipients"]:
|
to += [r for r in pick_recipients([], "alerts", {}, extra, form.get("smtp.tz") or saved["tz"])
|
||||||
return await _notify_test_result("email", False, "Enter an SMTP host and at least one recipient.")
|
if r.address.lower() not in {t.address.lower() for t in to}]
|
||||||
|
if not cfg["host"]:
|
||||||
|
return await _notify_test_result("email", False, "Enter an SMTP host.")
|
||||||
|
if not to:
|
||||||
|
return await _notify_test_result(
|
||||||
|
"email", False, "Your account has no email address mail can reach, and no Also send to "
|
||||||
|
"addresses are entered. Set your address on the Account page.")
|
||||||
alert = _sample_alert()
|
alert = _sample_alert()
|
||||||
sent = await _send_email(cfg, alert)
|
sent = await _send_email(cfg, alert, to)
|
||||||
how = {"ssl": "implicit TLS", "starttls": "STARTTLS", "plain": "no TLS"}[smtp_mode(port, cfg["tls"])]
|
how = {"ssl": "implicit TLS", "starttls": "STARTTLS", "plain": "no TLS"}[smtp_mode(port, cfg["tls"])]
|
||||||
result = (f"Sent to {', '.join(cfg['recipients'])} via {cfg['host']}:{port} ({how})."
|
result = (f"Sent to {', '.join(r.address for r in to)} via {cfg['host']}:{port} ({how})."
|
||||||
if sent["sent"] else f"{cfg['host']}:{port} ({how}): {sent['error']}")
|
if sent["sent"] else f"{cfg['host']}:{port} ({how}): {sent['error']}")
|
||||||
return await _notify_test_result("email", sent["sent"], result, alert_email(cfg, alert)[1])
|
return await _notify_test_result("email", sent["sent"], result, alert_email(cfg, alert, to[0])[1])
|
||||||
|
|
||||||
|
|
||||||
# ── Ansible Sources ───────────────────────────────────────────────────────────
|
# ── Ansible Sources ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ from sqlalchemy import func, or_, select
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from steward.auth.middleware import require_role
|
from steward.auth.middleware import require_role
|
||||||
|
from steward.core import mail
|
||||||
from steward.core.audit import audit_request
|
from steward.core.audit import audit_request
|
||||||
|
from steward.core.settings import get_all_settings
|
||||||
from steward.models.alerts import AlertRule, MaintenanceWindow
|
from steward.models.alerts import AlertRule, MaintenanceWindow
|
||||||
from steward.models.ansible import AnsibleRun
|
from steward.models.ansible import AnsibleRun
|
||||||
from steward.models.api_tokens import ApiToken
|
from steward.models.api_tokens import ApiToken
|
||||||
@@ -102,9 +104,13 @@ async def users_list():
|
|||||||
users = (await db.execute(select(User))).scalars().all()
|
users = (await db.execute(select(User))).scalars().all()
|
||||||
last = await _last_sign_ins(db)
|
last = await _last_sign_ins(db)
|
||||||
tokens = await _token_counts(db)
|
tokens = await _token_counts(db)
|
||||||
|
settings = await get_all_settings(db)
|
||||||
users = sorted(users, key=lambda u: (not u.is_active, u.username.lower()))
|
users = sorted(users, key=lambda u: (not u.is_active, u.username.lower()))
|
||||||
|
gets = {u.id: [label for kind, label in (("alerts", "alerts"), ("report", "report"))
|
||||||
|
if u.is_active and mail.wants(u, kind, settings)] for u in users}
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"settings/users.html", users=users, last=last, tokens=tokens, sources=SOURCES,
|
"settings/users.html", users=users, last=last, tokens=tokens, sources=SOURCES,
|
||||||
|
gets=gets, reachable={u.id for u in users if mail.is_real_address(u.email)},
|
||||||
admins=sum(1 for u in users if u.role == UserRole.admin and u.is_active),
|
admins=sum(1 for u in users if u.role == UserRole.admin and u.is_active),
|
||||||
disabled=sum(1 for u in users if not u.is_active))
|
disabled=sum(1 for u in users if not u.is_active))
|
||||||
|
|
||||||
@@ -150,6 +156,7 @@ async def user_create():
|
|||||||
|
|
||||||
|
|
||||||
_DONE = {"created": "Account added.", "saved": "Saved.", "role": "Role changed.",
|
_DONE = {"created": "Account added.", "saved": "Saved.", "role": "Role changed.",
|
||||||
|
"email": "Email settings saved.",
|
||||||
"password": "Password set. It works from their next sign-in.",
|
"password": "Password set. It works from their next sign-in.",
|
||||||
"disabled": "Account disabled. They are signed out at their next click.",
|
"disabled": "Account disabled. They are signed out at their next click.",
|
||||||
"enabled": "Account enabled."}
|
"enabled": "Account enabled."}
|
||||||
@@ -162,9 +169,11 @@ async def _user_page(user_id: str, error: str | None = None, status: int = 200):
|
|||||||
last = (await _last_sign_ins(db)).get(user.id)
|
last = (await _last_sign_ins(db)).get(user.id)
|
||||||
tokens = (await _token_counts(db)).get(user.id, 0)
|
tokens = (await _token_counts(db)).get(user.id, 0)
|
||||||
holds = await held_by(db, user.id)
|
holds = await held_by(db, user.id)
|
||||||
|
defaults = mail.role_defaults(user, await get_all_settings(db))
|
||||||
me = session.get("user_id")
|
me = session.get("user_id")
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"settings/user.html", user=user, last_sign_in=last, tokens=tokens, holds=holds,
|
"settings/user.html", user=user, last_sign_in=last, tokens=tokens, holds=holds,
|
||||||
|
defaults=defaults, reachable=mail.is_real_address(user.email),
|
||||||
source=SOURCES.get(user.auth_source, user.auth_source), roles=list(UserRole),
|
source=SOURCES.get(user.auth_source, user.auth_source), roles=list(UserRole),
|
||||||
min_password=MIN_PASSWORD, error=error, done=_DONE.get(request.args.get("done", "")),
|
min_password=MIN_PASSWORD, error=error, done=_DONE.get(request.args.get("done", "")),
|
||||||
demote_refused=refusal("demote", user, me, admins),
|
demote_refused=refusal("demote", user, me, admins),
|
||||||
@@ -231,6 +240,23 @@ async def user_role(user_id: str):
|
|||||||
return redirect(f"/settings/users/{user_id}?done=role")
|
return redirect(f"/settings/users/{user_id}?done=role")
|
||||||
|
|
||||||
|
|
||||||
|
@users_bp.post("/<user_id>/email")
|
||||||
|
@require_role(UserRole.admin)
|
||||||
|
async def user_email(user_id: str):
|
||||||
|
"""Whether this account is emailed alerts and the weekly report (milestone
|
||||||
|
495); its address is edited with its name."""
|
||||||
|
form = {k: v for k, v in (await request.form).items() if k != "email"}
|
||||||
|
async with current_app.db_sessionmaker() as db:
|
||||||
|
async with db.begin():
|
||||||
|
user = await _target(db, user_id)
|
||||||
|
changes, error = mail.apply_email_form(user, form)
|
||||||
|
if error:
|
||||||
|
return await _user_page(user_id, error, 400)
|
||||||
|
if changes:
|
||||||
|
await audit_request("user.email_settings", "user", user_id, {"username": user.username} | changes)
|
||||||
|
return redirect(f"/settings/users/{user_id}?done=email")
|
||||||
|
|
||||||
|
|
||||||
@users_bp.post("/<user_id>/password")
|
@users_bp.post("/<user_id>/password")
|
||||||
@require_role(UserRole.admin)
|
@require_role(UserRole.admin)
|
||||||
async def user_password(user_id: str):
|
async def user_password(user_id: str):
|
||||||
|
|||||||
@@ -123,3 +123,15 @@
|
|||||||
<a class="sort-link{% if active %} sort-active{% endif %}" href="{{ url }}">{{ label }}<span class="sort-arrow" aria-hidden="true">{% if active %}{{ '↓' if desc else '↑' }}{% endif %}</span></a>
|
<a class="sort-link{% if active %} sort-active{% endif %}" href="{{ url }}">{{ label }}<span class="sort-arrow" aria-hidden="true">{% if active %}{{ '↓' if desc else '↑' }}{% endif %}</span></a>
|
||||||
</th>
|
</th>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# A time-zone picker: `zones` is formatting.zones_by_region(), `selected` an
|
||||||
|
IANA name ("UTC" for none). #}
|
||||||
|
{% macro zone_select(name, id, selected, zones) -%}
|
||||||
|
<select class="input" id="{{ id }}" name="{{ name }}">
|
||||||
|
{% for region, names in zones %}
|
||||||
|
{% if region %}<optgroup label="{{ region }}">{% endif %}
|
||||||
|
{% for z in names %}<option value="{{ z }}"{% if z == selected %} selected{% endif %}>{{ z.split('/', 1)[1]|replace('_', ' ') if region else z }}</option>{% endfor %}
|
||||||
|
{% if region %}</optgroup>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{%- endmacro %}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{# The email form for one account (milestone 495): its address, and whether
|
||||||
|
it gets alert emails and the weekly report. Used by the Account page (your
|
||||||
|
own) and an account's page in Settings › Users (an admin's view).
|
||||||
|
`defaults` is {kind: bool}, the account's role defaults. #}
|
||||||
|
{% macro email_choices(user, defaults, action, error=none, with_address=true) -%}
|
||||||
|
<form method="post" action="{{ action }}" class="form-page" id="email">{{ csrf_field() }}
|
||||||
|
<section class="card form-section" aria-labelledby="email-title">
|
||||||
|
<h2 id="email-title" class="section-title">Email</h2>
|
||||||
|
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
|
||||||
|
{% if with_address %}
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="email-address">Address</label>
|
||||||
|
<input class="input" id="email-address" type="email" name="email" maxlength="255" value="{{ user.email }}" required>
|
||||||
|
{% if user.auth_source != "local" %}<span class="field-hint">Your sign-in provider set this first; change it if mail doesn't reach it.</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="form-pair">
|
||||||
|
{% for kind, label in (("alerts", "Alert emails"), ("report", "Weekly report")) %}
|
||||||
|
{% set choice = user["notify_" ~ kind] %}
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label" for="email-{{ kind }}">{{ label }}</label>
|
||||||
|
<select class="input" id="email-{{ kind }}" name="notify_{{ kind }}">
|
||||||
|
<option value=""{% if choice is none %} selected{% endif %}>As for {{ user.role.value }}s ({{ "on" if defaults[kind] else "off" }})</option>
|
||||||
|
<option value="on"{% if choice is sameas true %} selected{% endif %}>On</option>
|
||||||
|
<option value="off"{% if choice is sameas false %} selected{% endif %}>Off</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<span class="field-hint">Times in these emails are in {{ user.time_zone or "UTC" }}. A role's default is set in Settings › Notifications.</span>
|
||||||
|
<button type="submit" class="btn btn-ghost">Save email settings</button>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
{# account/index.html — the signed-in user's own account (milestone 490): who
|
{# account/index.html — the signed-in user's own account (milestone 490): who
|
||||||
they are, and the time zone every page writes its times in. #}
|
they are, and the time zone every page writes its times in. #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_macros.html" import page_header %}
|
{% from "_macros.html" import page_header, zone_select %}
|
||||||
|
{% from "account/_email_choices.html" import email_choices with context %}
|
||||||
{% block title %}Your account — Steward{% endblock %}
|
{% block title %}Your account — Steward{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ page_header("Your account") }}
|
{{ page_header("Your account") }}
|
||||||
@@ -17,16 +18,12 @@
|
|||||||
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
|
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label" for="acct-tz">Show times in</label>
|
<label class="field-label" for="acct-tz">Show times in</label>
|
||||||
<select class="input" id="acct-tz" name="time_zone">
|
{{ zone_select("time_zone", "acct-tz", zone_name, zones) }}
|
||||||
{% for region, names in zones %}
|
|
||||||
{% if region %}<optgroup label="{{ region }}">{% endif %}
|
|
||||||
{% for z in names %}<option value="{{ z }}"{% if z == zone_name %} selected{% endif %}>{{ z.split('/', 1)[1]|replace('_', ' ') if region else z }}</option>{% endfor %}
|
|
||||||
{% if region %}</optgroup>{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<span class="field-hint">Every page writes its times in this zone, on a 24-hour clock.</span>
|
<span class="field-hint">Every page writes its times in this zone, on a 24-hour clock.</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<button type="submit" class="btn">Save</button>
|
<button type="submit" class="btn">Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{{ email_choices(user, defaults, "/account/email", email_error) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# steward/templates/settings/notifications.html #}
|
{# steward/templates/settings/notifications.html #}
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_macros.html" import crumbs %}
|
{% from "_macros.html" import crumbs, zone_select %}
|
||||||
{% block title %}Settings — Notifications — Steward{% endblock %}
|
{% block title %}Settings — Notifications — Steward{% endblock %}
|
||||||
{% from "settings/_tabs.html" import settings_tabs %}
|
{% from "settings/_tabs.html" import settings_tabs %}
|
||||||
{% block section_tabs %}{{ settings_tabs("notifications") }}{% endblock %}
|
{% block section_tabs %}{{ settings_tabs("notifications") }}{% endblock %}
|
||||||
@@ -51,23 +51,68 @@
|
|||||||
placeholder="steward@example.com">
|
placeholder="steward@example.com">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label>Recipients <span style="color:var(--text-muted);font-size:0.8rem;">(comma-separated)</span></label>
|
|
||||||
<input type="text" name="smtp.recipients"
|
|
||||||
value="{{ settings['smtp.recipients'] | join(', ') }}"
|
|
||||||
placeholder="admin@example.com, ops@example.com">
|
|
||||||
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
|
|
||||||
Alert emails give times in {{ tz_label() }}, your zone when you save, with UTC beside them.
|
|
||||||
{% if not settings['general.public_base_url'] %}Set Steward's address in <a href="/settings/">General</a> and they link back here.{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" class="btn btn-ghost" hx-post="/settings/notifications/test-email"
|
<button type="button" class="btn btn-ghost" hx-post="/settings/notifications/test-email"
|
||||||
hx-include="closest form" hx-target="#email-test-result" hx-swap="innerHTML">
|
hx-include="closest form" hx-target="#email-test-result" hx-swap="innerHTML">
|
||||||
Send a test email</button>
|
Send a test email</button>
|
||||||
|
<p class="field-hint">It goes to you, at your account's address and in your zone, and to the Also send to addresses below.</p>
|
||||||
<div id="email-test-result"></div>
|
<div id="email-test-result"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Who gets email (milestone 495) ───────────────────────────────────────── #}
|
||||||
|
<section class="card" aria-labelledby="notify-who">
|
||||||
|
<h2 id="notify-who" class="section-title" style="margin-bottom:0.75rem;">Who gets email</h2>
|
||||||
|
<p class="field-hint" style="margin-bottom:0.75rem;">
|
||||||
|
Each account gets its own copy at its own address, with times in its own zone. These are the defaults
|
||||||
|
for each role; an account's own choice, on its Account page or in <a href="/settings/users/">Users</a>, wins.</p>
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Role</th><th>Alert emails</th><th>Weekly report</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in roles %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ r.value|capitalize }}</td>
|
||||||
|
{% for kind in ("alerts", "report") %}
|
||||||
|
{% set key = "notify." ~ kind ~ "." ~ r.value %}
|
||||||
|
<td><label class="field-check"><input type="checkbox" name="{{ key }}" {% if settings[key] %}checked{% endif %}
|
||||||
|
aria-label="{{ 'Alert emails' if kind == 'alerts' else 'Weekly report' }} for {{ r.value }}s"><span>On</span></label></td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p class="page-verdict" style="margin-top:0.75rem;">
|
||||||
|
Alerts reach {{ audience.alerts|length }} account{{ 's' if audience.alerts|length != 1 }}
|
||||||
|
{%- if audience.alerts %} ({{ audience.alerts|map(attribute='username')|join(', ') }}){% endif %}
|
||||||
|
· the weekly report reaches {{ audience.report|length }}
|
||||||
|
{%- if audience.report %} ({{ audience.report|map(attribute='username')|join(', ') }}){% endif %}.
|
||||||
|
</p>
|
||||||
|
{% if audience.unreachable %}
|
||||||
|
<p class="field-hint tone-warn">
|
||||||
|
{{ audience.unreachable|length }} account{{ 's have' if audience.unreachable|length != 1 else ' has' }} no address mail can reach:
|
||||||
|
{% for u in audience.unreachable %}<a href="/settings/users/{{ u.id }}">{{ u.username }}</a>{{ ", " if not loop.last }}{% endfor %}.
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# ── Also send to ─────────────────────────────────────────────────────────── #}
|
||||||
|
<section class="card" aria-labelledby="notify-extra">
|
||||||
|
<h2 id="notify-extra" class="section-title" style="margin-bottom:0.75rem;">Also send to</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp-recipients">Addresses <span style="color:var(--text-muted);font-size:0.8rem;">(comma-separated)</span></label>
|
||||||
|
<input type="text" id="smtp-recipients" name="smtp.recipients"
|
||||||
|
value="{{ settings['smtp.recipients'] | join(', ') }}"
|
||||||
|
placeholder="ops@example.com, pager@example.com">
|
||||||
|
<p class="field-hint">For a mailbox that isn't a person. These get alerts and the weekly report.</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="smtp-tz">Their times are in</label>
|
||||||
|
{{ zone_select("smtp.tz", "smtp-tz", settings['smtp.tz'] or "UTC", zones) }}
|
||||||
|
<p class="field-hint">With UTC beside, since nobody's own zone is known for them.</p>
|
||||||
|
</div>
|
||||||
|
{% if not settings['general.public_base_url'] %}
|
||||||
|
<p class="field-hint">Set Steward's address in <a href="/settings/general/">General</a> and every email links back here.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
{# ── Webhook ───────────────────────────────────────────────────────────── #}
|
{# ── Webhook ───────────────────────────────────────────────────────────── #}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="section-title" style="margin-bottom:1.25rem;">Webhook</h2>
|
<h2 class="section-title" style="margin-bottom:1.25rem;">Webhook</h2>
|
||||||
@@ -94,7 +139,7 @@
|
|||||||
<code>{{ '{{ alert.rule_name }}' }}</code> is escaped to sit inside a JSON string, so quotes or
|
<code>{{ '{{ alert.rule_name }}' }}</code> is escaped to sit inside a JSON string, so quotes or
|
||||||
line breaks in a name can't break the body; a missing value reads "no data".
|
line breaks in a name can't break the body; a missing value reads "no data".
|
||||||
For a bare JSON value use <code>{{ '{{ raw.value | tojson }}' }}</code> (a number, or null).
|
For a bare JSON value use <code>{{ '{{ raw.value | tojson }}' }}</code> (a number, or null).
|
||||||
Fields: state, rule_name, resource, metric, value, threshold, source_module, note, timestamp.</p>
|
Fields: state, rule_name, resource, metric, value, operator, threshold, source_module, note, timestamp.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-ghost" hx-post="/settings/notifications/test-webhook"
|
<button type="button" class="btn btn-ghost" hx-post="/settings/notifications/test-webhook"
|
||||||
|
|||||||
@@ -54,7 +54,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
|
<p style="font-size:0.78rem;color:var(--text-muted);margin-top:0.25rem;">
|
||||||
Requires SMTP configured in <a href="/settings/notifications/">Notifications</a>.
|
It goes to each account that has the weekly report on, in that account's own zone, and to the
|
||||||
|
Also send to addresses; who that is, and the mail server, are in <a href="/settings/notifications/">Notifications</a>.
|
||||||
{% if last_sent %}Last sent {{ last_sent|when }}.{% endif %}
|
{% if last_sent %}Last sent {{ last_sent|when }}.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% from "_macros.html" import crumbs, page_header, status_badge %}
|
{% from "_macros.html" import crumbs, page_header, status_badge %}
|
||||||
{% from "settings/_tabs.html" import settings_tabs %}
|
{% from "settings/_tabs.html" import settings_tabs %}
|
||||||
|
{% from "account/_email_choices.html" import email_choices with context %}
|
||||||
{% block title %}{{ user.username }} — Users — Steward{% endblock %}
|
{% block title %}{{ user.username }} — Users — Steward{% endblock %}
|
||||||
{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %}
|
{% block section_tabs %}{{ settings_tabs("users") }}{% endblock %}
|
||||||
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "/settings/users/"), (user.username, "")]) }}{% endblock %}
|
{% block breadcrumb %}{{ crumbs([("Settings", "/settings/"), ("Users", "/settings/users/"), (user.username, "")]) }}{% endblock %}
|
||||||
@@ -41,6 +42,9 @@
|
|||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{% if not reachable %}<p class="field-hint tone-warn">{{ user.email }} isn't an address mail can reach, so this account gets no email. Set a real one above.</p>{% endif %}
|
||||||
|
{{ email_choices(user, defaults, "/settings/users/" ~ user.id ~ "/email", with_address=false) }}
|
||||||
|
|
||||||
<section class="card form-section" aria-labelledby="u-role">
|
<section class="card form-section" aria-labelledby="u-role">
|
||||||
<h2 id="u-role" class="section-title">Role</h2>
|
<h2 id="u-role" class="section-title">Role</h2>
|
||||||
{% if not local %}
|
{% if not local %}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<div class="card-flush">
|
<div class="card-flush">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th>User</th><th>Signs in with</th><th>Role</th><th>Last sign-in</th><th class="num">AI tokens</th></tr></thead>
|
<thead><tr><th>User</th><th>Signs in with</th><th>Role</th><th>Emailed</th><th>Last sign-in</th><th class="num">AI tokens</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for u in users %}
|
{% for u in users %}
|
||||||
<tr class="row-link{% if not u.is_active %} row-off{% endif %}">
|
<tr class="row-link{% if not u.is_active %} row-off{% endif %}">
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ sources.get(u.auth_source, u.auth_source) }}</td>
|
<td>{{ sources.get(u.auth_source, u.auth_source) }}</td>
|
||||||
<td>{{ u.role.value|capitalize }}{% if not u.is_active %} {{ status_badge("inactive", "Disabled") }}{% endif %}</td>
|
<td>{{ u.role.value|capitalize }}{% if not u.is_active %} {{ status_badge("inactive", "Disabled") }}{% endif %}</td>
|
||||||
|
<td>{% if u.id not in reachable %}<span class="tone-warn" title="{{ u.email }} isn't an address mail can reach">no address</span>
|
||||||
|
{%- elif gets[u.id] %}{{ gets[u.id]|join(", ") }}{% else %}—{% endif %}</td>
|
||||||
<td class="nowrap">{% if last.get(u.id) %}{{ last[u.id]|ago }}{% else %}—{% endif %}</td>
|
<td class="nowrap">{% if last.get(u.id) %}{{ last[u.id]|ago }}{% else %}—{% endif %}</td>
|
||||||
<td class="num">{{ tokens.get(u.id, 0) or "—" }}</td>
|
<td class="num">{{ tokens.get(u.id, 0) or "—" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ def test_where_alerts_go_is_said_as_the_dispatcher_decides():
|
|||||||
"""#5315: email needs a server and recipients; a webhook its URL."""
|
"""#5315: email needs a server and recipients; a webhook its URL."""
|
||||||
assert alert_svc.notify_targets({}, {}) == []
|
assert alert_svc.notify_targets({}, {}) == []
|
||||||
assert alert_svc.notify_targets({"host": "mx", "recipients": []}, {}) == []
|
assert alert_svc.notify_targets({"host": "mx", "recipients": []}, {}) == []
|
||||||
|
assert alert_svc.notify_targets({"host": "mx", "recipients": []}, {}, accounts=3) == ["email to 3 accounts"]
|
||||||
|
assert alert_svc.notify_targets({"host": "mx", "recipients": ["ops@x"]}, {}, accounts=1) == [
|
||||||
|
"email to 1 account, ops@x"]
|
||||||
|
assert alert_svc.notify_targets({"host": "", "recipients": []}, {}, accounts=3) == [] # no server, no email
|
||||||
assert alert_svc.notify_targets({"host": "mx", "recipients": ["a@x", "b@x"]}, {}) == ["email to a@x, b@x"]
|
assert alert_svc.notify_targets({"host": "mx", "recipients": ["a@x", "b@x"]}, {}) == ["email to a@x, b@x"]
|
||||||
assert alert_svc.notify_targets({"host": "mx", "recipients": "a@x, b@x"}, {"url": "https://hooks.example/abc"}) \
|
assert alert_svc.notify_targets({"host": "mx", "recipients": "a@x, b@x"}, {"url": "https://hooks.example/abc"}) \
|
||||||
== ["email to a@x, b@x", "a webhook to hooks.example"]
|
== ["email to a@x, b@x", "a webhook to hooks.example"]
|
||||||
|
|||||||
+104
-17
@@ -12,8 +12,10 @@ from steward.core import reports
|
|||||||
ALERT = {"rule_name": 'Disk <b>"root"</b> on web01', "state": "FIRING", "metric": "disk_used_pct",
|
ALERT = {"rule_name": 'Disk <b>"root"</b> on web01', "state": "FIRING", "metric": "disk_used_pct",
|
||||||
"value": 93.5, "operator": ">=", "threshold": 90.0, "resource": "web01",
|
"value": 93.5, "operator": ">=", "threshold": 90.0, "resource": "web01",
|
||||||
"source_module": "host_agent", "note": None, "timestamp": "2026-10-08T17:58:00+00:00"}
|
"source_module": "host_agent", "note": None, "timestamp": "2026-10-08T17:58:00+00:00"}
|
||||||
CFG = {"host": "mx", "port": 587, "recipients": ["a@example.com"], "from": "alerts@example.com",
|
CFG = {"host": "mx", "port": 587, "from": "alerts@example.com", "base_url": "https://steward.example/"}
|
||||||
"tz": "America/New_York", "base_url": "https://steward.example/"}
|
NY = ZoneInfo("America/New_York")
|
||||||
|
EXTRA_NY = mail.Recipient("ops@example.com", NY, True) # an Also send to address: UTC beside
|
||||||
|
PERSON_NY = mail.Recipient("ann@example.com", NY, False, "ann")
|
||||||
REPORT = {"generated_at": datetime(2026, 10, 11, 12, 0, tzinfo=timezone.utc),
|
REPORT = {"generated_at": datetime(2026, 10, 11, 12, 0, tzinfo=timezone.utc),
|
||||||
"uptime_summary": [{"name": "web01", "pct_7d": 99.95}, {"name": "nas <x>", "pct_7d": None}],
|
"uptime_summary": [{"name": "web01", "pct_7d": 99.95}, {"name": "nas <x>", "pct_7d": None}],
|
||||||
"active_alerts": [{"state": "firing", "name": "Disk root", "resource": "web01",
|
"active_alerts": [{"state": "firing", "name": "Disk root", "resource": "web01",
|
||||||
@@ -43,11 +45,11 @@ def test_every_colour_is_a_theme_value():
|
|||||||
assert all(re.fullmatch(r"#[0-9A-Fa-f]{6}", v) for v in colours.values()), colours
|
assert all(re.fullmatch(r"#[0-9A-Fa-f]{6}", v) for v in colours.values()), colours
|
||||||
|
|
||||||
|
|
||||||
async def _sent(monkeypatch, cfg, alert):
|
async def _sent(monkeypatch, cfg, alert, to=(EXTRA_NY,)):
|
||||||
out = []
|
out = []
|
||||||
monkeypatch.setattr(n, "_smtp_send_sync", lambda c, msg: out.append(msg))
|
monkeypatch.setattr(n, "_smtp_send_sync", lambda c, msgs: out.extend(msgs))
|
||||||
assert (await n._send_email(cfg, alert))["sent"]
|
assert (await n._send_email(cfg, alert, list(to)))["sent"]
|
||||||
return out[0]
|
return out[0] if len(to) == 1 else out
|
||||||
|
|
||||||
|
|
||||||
async def test_an_alert_email_is_html_with_a_plain_text_twin(monkeypatch):
|
async def test_an_alert_email_is_html_with_a_plain_text_twin(monkeypatch):
|
||||||
@@ -59,7 +61,17 @@ async def test_an_alert_email_is_html_with_a_plain_text_twin(monkeypatch):
|
|||||||
"Thu 8 Oct 2026 13:58 EDT", "Thu 8 Oct 2026 17:58 UTC", "https://steward.example/alerts/"):
|
"Thu 8 Oct 2026 13:58 EDT", "Thu 8 Oct 2026 17:58 UTC", "https://steward.example/alerts/"):
|
||||||
assert fact in part, (fact, part)
|
assert fact in part, (fact, part)
|
||||||
assert msg["Subject"] == '[Steward] Firing: Disk <b>"root"</b> on web01 — web01 94%'
|
assert msg["Subject"] == '[Steward] Firing: Disk <b>"root"</b> on web01 — web01 94%'
|
||||||
assert msg["From"] == "alerts@example.com"
|
assert (msg["From"], msg["To"]) == ("alerts@example.com", "ops@example.com")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_each_recipient_gets_their_own_copy_in_their_own_zone(monkeypatch):
|
||||||
|
berlin = mail.Recipient("bo@example.com", ZoneInfo("Europe/Berlin"), False, "bo")
|
||||||
|
ann, bo = await _sent(monkeypatch, CFG, ALERT, to=(PERSON_NY, berlin))
|
||||||
|
assert (ann["To"], bo["To"]) == ("ann@example.com", "bo@example.com")
|
||||||
|
ann_text, bo_text = (m.get_body(("plain",)).get_content() for m in (ann, bo))
|
||||||
|
assert "13:58 EDT" in ann_text and "19:58 CEST" in bo_text
|
||||||
|
# A person whose zone is known is never shown UTC beside it.
|
||||||
|
assert "UTC" not in ann_text + bo_text
|
||||||
|
|
||||||
|
|
||||||
async def test_a_name_with_markup_is_escaped_in_the_html(monkeypatch):
|
async def test_a_name_with_markup_is_escaped_in_the_html(monkeypatch):
|
||||||
@@ -72,12 +84,12 @@ async def test_a_name_with_markup_is_escaped_in_the_html(monkeypatch):
|
|||||||
async def test_opening_an_email_fetches_nothing(monkeypatch):
|
async def test_opening_an_email_fetches_nothing(monkeypatch):
|
||||||
msg = await _sent(monkeypatch, CFG, ALERT)
|
msg = await _sent(monkeypatch, CFG, ALERT)
|
||||||
assert not foreign_or_fetched(msg.get_body(("html",)).get_content(), "https://steward.example")
|
assert not foreign_or_fetched(msg.get_body(("html",)).get_content(), "https://steward.example")
|
||||||
_, _, html = mail.report_email(CFG, REPORT, ZoneInfo("UTC"))
|
_, _, html = mail.report_email(CFG, REPORT, mail.Recipient("x@example.com", timezone.utc, False))
|
||||||
assert not foreign_or_fetched(html, "https://steward.example")
|
assert not foreign_or_fetched(html, "https://steward.example")
|
||||||
|
|
||||||
|
|
||||||
def test_without_steward_s_address_there_is_no_link_and_utc_is_said_once():
|
def test_without_steward_s_address_there_is_no_link_and_utc_is_said_once():
|
||||||
_, text, html = mail.alert_email({"recipients": []}, ALERT)
|
_, text, html = mail.alert_email({}, ALERT, mail.Recipient("x@example.com", timezone.utc, True))
|
||||||
assert "View in Steward" not in text + html and "http" not in text + html
|
assert "View in Steward" not in text + html and "http" not in text + html
|
||||||
assert text.count("17:58 UTC") == 1
|
assert text.count("17:58 UTC") == 1
|
||||||
|
|
||||||
@@ -95,7 +107,7 @@ def test_each_state_reads_as_a_sentence():
|
|||||||
|
|
||||||
|
|
||||||
def test_the_report_has_its_sections_in_both_parts():
|
def test_the_report_has_its_sections_in_both_parts():
|
||||||
subject, text, html = mail.report_email(CFG, REPORT, ZoneInfo("America/New_York"))
|
subject, text, html = mail.report_email(CFG, REPORT, PERSON_NY)
|
||||||
assert subject == "[Steward] Weekly report, 4 Oct – 11 Oct 2026"
|
assert subject == "[Steward] Weekly report, 4 Oct – 11 Oct 2026"
|
||||||
for part in (text, html):
|
for part in (text, html):
|
||||||
for fact in ("1 monitor is down now", "Disk used >= 90%" if part is html else "Disk used >= 90%",
|
for fact in ("1 monitor is down now", "Disk used >= 90%" if part is html else "Disk used >= 90%",
|
||||||
@@ -122,13 +134,88 @@ async def test_the_report_is_sent_from_the_configured_address(monkeypatch):
|
|||||||
async def _data(app):
|
async def _data(app):
|
||||||
return REPORT
|
return REPORT
|
||||||
|
|
||||||
async def _setting(db, key):
|
async def _recipients(db, kind, smtp_cfg):
|
||||||
return "America/New_York"
|
assert kind == "report"
|
||||||
|
return [PERSON_NY, EXTRA_NY]
|
||||||
|
|
||||||
out = []
|
out = []
|
||||||
monkeypatch.setattr(reports, "build_report_data", _data)
|
monkeypatch.setattr(reports, "build_report_data", _data)
|
||||||
monkeypatch.setattr(reports, "get_setting", _setting)
|
monkeypatch.setattr(reports, "recipients", _recipients)
|
||||||
monkeypatch.setattr(n, "_smtp_send_sync", lambda c, msg: out.append(msg))
|
monkeypatch.setattr(n, "_smtp_send_sync", lambda c, msgs: out.extend(msgs))
|
||||||
ok, _ = await reports.send_report(_App())
|
ok, said = await reports.send_report(_App())
|
||||||
assert ok and out[0]["From"] == "alerts@example.com"
|
assert ok and said == "Report sent to ann@example.com, ops@example.com."
|
||||||
assert out[0].get_content_type() == "multipart/alternative"
|
assert [(m["From"], m["To"]) for m in out] == [("alerts@example.com", "ann@example.com"),
|
||||||
|
("alerts@example.com", "ops@example.com")]
|
||||||
|
assert all(m.get_content_type() == "multipart/alternative" for m in out)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_nobody_to_send_the_report_to_is_said(monkeypatch):
|
||||||
|
class _App:
|
||||||
|
config = {"SMTP": CFG}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def db_sessionmaker():
|
||||||
|
class _S:
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *exc):
|
||||||
|
return False
|
||||||
|
return _S()
|
||||||
|
|
||||||
|
async def _nobody(db, kind, smtp_cfg):
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(reports, "recipients", _nobody)
|
||||||
|
ok, said = await reports.send_report(_App())
|
||||||
|
assert not ok and said.startswith("Nobody gets the weekly report")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Who gets an email (milestone 495) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def _user(name, role="viewer", email=None, tz=None, active=True, alerts=None, report=None):
|
||||||
|
from steward.models.users import User, UserRole
|
||||||
|
return User(username=name, email=email if email is not None else f"{name}@example.com",
|
||||||
|
password_hash="x", role=UserRole(role), is_active=active, time_zone=tz,
|
||||||
|
notify_alerts=alerts, notify_report=report)
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_account_follows_its_own_choice_or_else_its_role():
|
||||||
|
settings = {"notify.alerts.viewer": False, "notify.alerts.admin": True}
|
||||||
|
users = [_user("adm", "admin"), # role on
|
||||||
|
_user("vic"), # role off
|
||||||
|
_user("val", alerts=True), # role off, chose on
|
||||||
|
_user("ada", "admin", alerts=False), # role on, chose off
|
||||||
|
_user("gone", "admin", active=False), # disabled
|
||||||
|
_user("sso", "admin", email="sso@oidc")] # no address mail can reach
|
||||||
|
got = mail.pick_recipients(users, "alerts", settings, [], None)
|
||||||
|
assert [r.user for r in got] == ["adm", "val"]
|
||||||
|
# A role with no setting yet gets email: the defaults start on.
|
||||||
|
assert [r.user for r in mail.pick_recipients([_user("op", "operator")], "report", {}, [], None)] == ["op"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_person_reads_their_own_zone_and_extra_addresses_read_the_chosen_one():
|
||||||
|
users = [_user("ann", "admin", tz="America/New_York"), _user("utc", "admin")]
|
||||||
|
got = mail.pick_recipients(users, "alerts", {}, ["ops@example.com", "ANN@example.com", "nonsense"],
|
||||||
|
"Europe/Berlin")
|
||||||
|
assert [(r.address, str(r.tz), r.show_utc) for r in got] == [
|
||||||
|
("ann@example.com", "America/New_York", False),
|
||||||
|
("utc@example.com", "UTC", False),
|
||||||
|
("ops@example.com", "Europe/Berlin", True)] # ANN@ is ann, once; "nonsense" is no address
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_email_form_changes_only_what_it_says_and_nothing_on_a_bad_value():
|
||||||
|
u = _user("ann", alerts=None, report=True)
|
||||||
|
changes, error = mail.apply_email_form(u, {"email": "new@example.com", "notify_alerts": "off",
|
||||||
|
"notify_report": "on"})
|
||||||
|
assert error is None and changes == {"email": ["ann@example.com", "new@example.com"],
|
||||||
|
"notify_alerts": [None, False]}
|
||||||
|
assert (u.email, u.notify_alerts, u.notify_report) == ("new@example.com", False, True)
|
||||||
|
for bad in ({"email": "ann@oidc", "notify_alerts": "", "notify_report": ""},
|
||||||
|
{"notify_alerts": "maybe", "notify_report": ""}):
|
||||||
|
changes, error = mail.apply_email_form(u, bad)
|
||||||
|
assert changes == {} and error
|
||||||
|
assert (u.email, u.notify_alerts) == ("new@example.com", False)
|
||||||
|
# Without an address in the form (an admin's view), the address is left alone.
|
||||||
|
assert mail.apply_email_form(u, {"notify_alerts": "", "notify_report": ""})[0] == {
|
||||||
|
"notify_alerts": [False, None], "notify_report": [True, None]}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
holds, SMTP picks implicit TLS / STARTTLS / plain from port and box and never
|
holds, SMTP picks implicit TLS / STARTTLS / plain from port and box and never
|
||||||
waits without a timeout, and a webhook error never carries the URL."""
|
waits without a timeout, and a webhook error never carries the URL."""
|
||||||
import json
|
import json
|
||||||
|
from datetime import timezone
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from steward.core import notifications as n
|
from steward.core import notifications as n
|
||||||
|
from steward.core.mail import Recipient
|
||||||
|
|
||||||
ALERT = {"rule_name": 'Disk "root" on\nweb01 \\ backslash', "state": "FIRING", "metric": "disk_used_pct",
|
ALERT = {"rule_name": 'Disk "root" on\nweb01 \\ backslash', "state": "FIRING", "metric": "disk_used_pct",
|
||||||
"value": 93.5, "operator": ">=", "threshold": 90.0, "resource": "web01", "source_module": "host_agent",
|
"value": 93.5, "operator": ">=", "threshold": 90.0, "resource": "web01", "source_module": "host_agent",
|
||||||
@@ -99,21 +101,24 @@ def test_smtp_send_connects_by_mode_with_a_timeout(monkeypatch, port, tls, expec
|
|||||||
monkeypatch.setattr(n.smtplib, "SMTP", SMTP)
|
monkeypatch.setattr(n.smtplib, "SMTP", SMTP)
|
||||||
monkeypatch.setattr(n.smtplib, "SMTP_SSL", SMTP_SSL)
|
monkeypatch.setattr(n.smtplib, "SMTP_SSL", SMTP_SSL)
|
||||||
_FakeSMTP.made = []
|
_FakeSMTP.made = []
|
||||||
msg = n.EmailMessage()
|
msgs = [n.EmailMessage(), n.EmailMessage()]
|
||||||
msg["From"] = "steward@example.com"
|
msgs[0]["From"], msgs[1]["From"] = "steward@example.com", "other@example.com"
|
||||||
n._smtp_send_sync({"host": "mx", "port": port, "tls": tls, "username": "u", "password": "p"}, msg)
|
n._smtp_send_sync({"host": "mx", "port": port, "tls": tls, "username": "u", "password": "p"}, msgs)
|
||||||
(smtp,) = _FakeSMTP.made
|
(smtp,) = _FakeSMTP.made # every message over one connection
|
||||||
assert smtp.calls == expected + [("login", "u", "p"), ("send", "steward@example.com")]
|
assert smtp.calls == expected + [("login", "u", "p"), ("send", "steward@example.com"),
|
||||||
|
("send", "other@example.com")]
|
||||||
|
|
||||||
|
|
||||||
async def test_email_from_falls_back_and_a_line_break_in_the_name_still_sends(monkeypatch):
|
async def test_email_from_falls_back_and_a_line_break_in_the_name_still_sends(monkeypatch):
|
||||||
sent = []
|
sent = []
|
||||||
monkeypatch.setattr(n, "_smtp_send_sync", lambda cfg, msg: sent.append((msg["From"], msg["Subject"])))
|
monkeypatch.setattr(n, "_smtp_send_sync",
|
||||||
base = {"host": "mx", "port": 587, "recipients": ["a@example.com"]}
|
lambda cfg, msgs: sent.extend((m["From"], m["Subject"]) for m in msgs))
|
||||||
|
to = [Recipient("a@example.com", timezone.utc, False)]
|
||||||
|
base = {"host": "mx", "port": 587}
|
||||||
for cfg in ({**base, "from": "alerts@example.com", "username": "u@example.com"},
|
for cfg in ({**base, "from": "alerts@example.com", "username": "u@example.com"},
|
||||||
{**base, "from": "", "username": "u@example.com"},
|
{**base, "from": "", "username": "u@example.com"},
|
||||||
{**base, "from": "", "username": ""}):
|
{**base, "from": "", "username": ""}):
|
||||||
assert (await n._send_email(cfg, ALERT))["sent"]
|
assert (await n._send_email(cfg, ALERT, to))["sent"]
|
||||||
assert [f for f, _ in sent] == ["alerts@example.com", "u@example.com", "steward@localhost"]
|
assert [f for f, _ in sent] == ["alerts@example.com", "u@example.com", "steward@localhost"]
|
||||||
# The rule name has a line break; the subject folds it to a space rather than failing.
|
# The rule name has a line break; the subject folds it to a space rather than failing.
|
||||||
assert sent[0][1] == '[Steward] Firing: Disk "root" on web01 \\ backslash — web01 94%'
|
assert sent[0][1] == '[Steward] Firing: Disk "root" on web01 \\ backslash — web01 94%'
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ def test_test_email_uses_the_form_settings_and_names_the_tls_mode(app, monkeypat
|
|||||||
from steward.core import notifications
|
from steward.core import notifications
|
||||||
sent = []
|
sent = []
|
||||||
|
|
||||||
async def fake_send(cfg, alert):
|
async def fake_send(cfg, alert, to):
|
||||||
sent.append(cfg)
|
sent.append((cfg, [r.address for r in to]))
|
||||||
return {"sent": True, "error": None}
|
return {"sent": True, "error": None}
|
||||||
|
|
||||||
monkeypatch.setattr(notifications, "_send_email", fake_send)
|
monkeypatch.setattr(notifications, "_send_email", fake_send)
|
||||||
@@ -110,13 +110,14 @@ def test_test_email_uses_the_form_settings_and_names_the_tls_mode(app, monkeypat
|
|||||||
assert "alert-success" in page
|
assert "alert-success" in page
|
||||||
assert "Sent to a@example.com, b@example.com via mx.example:465 (implicit TLS)." in page
|
assert "Sent to a@example.com, b@example.com via mx.example:465 (implicit TLS)." in page
|
||||||
assert "Rule: Test alert" in page
|
assert "Rule: Test alert" in page
|
||||||
assert sent == [{"host": "mx.example", "port": 465, "tls": False, "username": "u",
|
# The admin's own address (uuid@test) isn't one mail can reach, so only the typed ones.
|
||||||
"password": "pw", "from": "alerts@example.com", "tz": "", "base_url": "",
|
assert sent == [({"host": "mx.example", "port": 465, "tls": False, "username": "u",
|
||||||
"recipients": ["a@example.com", "b@example.com"]}]
|
"password": "pw", "from": "alerts@example.com", "base_url": ""},
|
||||||
|
["a@example.com", "b@example.com"])]
|
||||||
|
|
||||||
resp = await client.post("/settings/notifications/test-email", form={
|
resp = await client.post("/settings/notifications/test-email", form={
|
||||||
"smtp.host": "", "smtp.port": "587", "smtp.recipients": ""}, headers=csrf)
|
"smtp.host": "", "smtp.port": "587", "smtp.recipients": ""}, headers=csrf)
|
||||||
assert "Enter an SMTP host and at least one recipient." in await resp.get_data(as_text=True)
|
assert "Enter an SMTP host." in await resp.get_data(as_text=True)
|
||||||
assert len(sent) == 1
|
assert len(sent) == 1
|
||||||
|
|
||||||
assert [d for _, d in await _audits(app, uid)] == [
|
assert [d for _, d in await _audits(app, uid)] == [
|
||||||
|
|||||||
@@ -168,3 +168,33 @@ def test_only_an_admin_reaches_users(app):
|
|||||||
assert (await client.post("/settings/users/new", headers=CSRF, form={})).status_code == 403
|
assert (await client.post("/settings/users/new", headers=CSRF, form={})).status_code == 403
|
||||||
|
|
||||||
asyncio.run(_go())
|
asyncio.run(_go())
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_account_sets_its_own_address_and_switches_and_an_admin_can_too(app):
|
||||||
|
async def _go():
|
||||||
|
viewer, vid = await _client(app, role="viewer")
|
||||||
|
page = await (await viewer.get("/account/")).get_data(as_text=True)
|
||||||
|
assert 'action="/account/email"' in page and "As for viewers (on)" in page
|
||||||
|
|
||||||
|
resp = await viewer.post("/account/email", headers=CSRF, form={
|
||||||
|
"email": "nope@oidc", "notify_alerts": "off", "notify_report": ""})
|
||||||
|
assert resp.status_code == 400 and "mail can reach" in await resp.get_data(as_text=True)
|
||||||
|
address = f"v-{vid[:8]}@example.com"
|
||||||
|
assert (await viewer.post("/account/email", headers=CSRF, form={
|
||||||
|
"email": address, "notify_alerts": "off", "notify_report": ""})).status_code == 302
|
||||||
|
me = await _user(app, vid)
|
||||||
|
assert (me.email, me.notify_alerts, me.notify_report) == (address, False, None)
|
||||||
|
assert [a for a, _ in await _audits(app, vid)] == ["account.email"]
|
||||||
|
|
||||||
|
admin, aid = await _client(app)
|
||||||
|
resp = await admin.post(f"/settings/users/{vid}/email", headers=CSRF,
|
||||||
|
form={"email": "ignored@example.com", "notify_alerts": "", "notify_report": "off"})
|
||||||
|
assert resp.status_code == 302
|
||||||
|
me = await _user(app, vid)
|
||||||
|
assert (me.email, me.notify_alerts, me.notify_report) == (address, None, False) # address kept
|
||||||
|
assert [a for a, _ in await _audits(app, aid)] == ["user.email_settings"]
|
||||||
|
|
||||||
|
page = await (await admin.get("/settings/notifications/")).get_data(as_text=True)
|
||||||
|
assert "Who gets email" in page and 'name="notify.alerts.viewer"' in page and "Also send to" in page
|
||||||
|
|
||||||
|
asyncio.run(_go())
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ def test_user_model_fields():
|
|||||||
"""User model has all required fields."""
|
"""User model has all required fields."""
|
||||||
cols = {c.key for c in User.__table__.columns}
|
cols = {c.key for c in User.__table__.columns}
|
||||||
assert cols == {"id", "username", "email", "password_hash", "role", "created_at", "is_active",
|
assert cols == {"id", "username", "email", "password_hash", "role", "created_at", "is_active",
|
||||||
"auth_source", "external_id", "time_zone"}
|
"auth_source", "external_id", "time_zone", "notify_alerts", "notify_report"}
|
||||||
|
|
||||||
|
|
||||||
def test_user_role_enum():
|
def test_user_role_enum():
|
||||||
|
|||||||
Reference in New Issue
Block a user