512 lines
19 KiB
Python
512 lines
19 KiB
Python
"""Notifications: the Admin's routing rules, test send and sent log, and each person's own view on Account.
|
|||
|
|
|
||
|
|
Who gets what lives in the database (notification_rules, _recipients, _exclusions), never in the environment.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import logging
|
||
|
|
import re
|
||
|
|
import uuid
|
||
|
|
from datetime import time
|
||
|
|
from typing import Any, Literal, cast
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, Query, Request
|
||
|
|
from psycopg.rows import dict_row
|
||
|
|
from pydantic import BaseModel, ConfigDict, Field
|
||
|
|
|
||
|
|
from .. import audit
|
||
|
|
from ..auth.ldap import LdapUnavailable
|
||
|
|
from ..notify import seed as seed_rules
|
||
|
|
from ..notify import send as notify_send
|
||
|
|
from ..notify import smtp
|
||
|
|
from ..notify.catalog import EVENT_TYPES, SECURITY_TYPES
|
||
|
|
from ..notify.routing import (
|
||
|
|
DIRECTORY_DOWN,
|
||
|
|
NotifyDirectory,
|
||
|
|
Rule,
|
||
|
|
address_ok,
|
||
|
|
cn_of,
|
||
|
|
dn_under,
|
||
|
|
load_rules,
|
||
|
|
supervisor_depts,
|
||
|
|
)
|
||
|
|
from ..settings import Settings
|
||
|
|
from ..text import clean_text
|
||
|
|
from .deps import Db, Principal, admin_principal, current_principal, directory_of, get_db, password_principal
|
||
|
|
from .errors import ApiError
|
||
|
|
from .packets import WRITE
|
||
|
|
|
||
|
|
log = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
router = APIRouter(tags=["notifications"])
|
||
|
|
|
||
|
|
ADMIN = "/api/admin/notifications"
|
||
|
|
ACCOUNT = "/api/account/notifications"
|
||
|
|
RECIPIENTS_MAX = 50
|
||
|
|
EXCLUSIONS_MAX = 200
|
||
|
|
TIME_RE = re.compile(r"^([01][0-9]|2[0-3]):([0-5][0-9])$")
|
||
|
|
NAME_MAX = 256
|
||
|
|
DN_MAX = 1024
|
||
|
|
|
||
|
|
|
||
|
|
def _directory(request: Request) -> NotifyDirectory:
|
||
|
|
return cast(NotifyDirectory, directory_of(request))
|
||
|
|
|
||
|
|
|
||
|
|
def _settings(request: Request) -> Settings:
|
||
|
|
s: Settings = request.app.state.settings
|
||
|
|
return s
|
||
|
|
|
||
|
|
|
||
|
|
def _known(event_type: str) -> None:
|
||
|
|
if event_type not in EVENT_TYPES:
|
||
|
|
raise ApiError(404, "not_found", "No such notification.")
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- views
|
||
|
|
|
||
|
|
|
||
|
|
def rule_view(conn: Any, rule: Rule) -> dict[str, Any]:
|
||
|
|
et = EVENT_TYPES[rule.event_type]
|
||
|
|
last = conn.execute(
|
||
|
|
"SELECT at, result FROM notifications_sent WHERE event_type = %s ORDER BY id DESC LIMIT 1",
|
||
|
|
(rule.event_type,),
|
||
|
|
).fetchone()
|
||
|
|
return {
|
||
|
|
"event_type": rule.event_type,
|
||
|
|
"label": et.label,
|
||
|
|
"description": et.description,
|
||
|
|
"security": et.security,
|
||
|
|
"dept_scoped": et.dept_scoped,
|
||
|
|
"param": {"name": et.param, "value": rule.param_value, "default": et.param_default} if et.param else None,
|
||
|
|
"enabled": rule.enabled,
|
||
|
|
"delivery": rule.delivery,
|
||
|
|
"digest_time": rule.digest_time.strftime("%H:%M"),
|
||
|
|
"recipients": [
|
||
|
|
{
|
||
|
|
"id": r["id"],
|
||
|
|
"kind": r["kind"],
|
||
|
|
"group_dn": r["group_dn"],
|
||
|
|
"group_name": r["group_name"],
|
||
|
|
"user_id": str(r["user_id"]) if r["user_id"] else None,
|
||
|
|
"display_name": r.get("display_name"),
|
||
|
|
"address": r["address"],
|
||
|
|
}
|
||
|
|
for r in rule.recipients
|
||
|
|
],
|
||
|
|
"exclusions": [
|
||
|
|
{"user_id": str(x["user_id"]), "display_name": x.get("display_name"), "self": bool(x["self"])}
|
||
|
|
for x in rule.exclusions
|
||
|
|
],
|
||
|
|
"version": rule.version,
|
||
|
|
"last_sent": {"at": last[0], "result": last[1]} if last else None,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _rules(conn: Any, settings: Settings) -> dict[str, Rule]:
|
||
|
|
seed_rules(conn, settings)
|
||
|
|
return load_rules(conn)
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(ADMIN)
|
||
|
|
def admin_rules(
|
||
|
|
request: Request,
|
||
|
|
p: Principal = Depends(admin_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
s = _settings(request)
|
||
|
|
rules = _rules(d.conn, s)
|
||
|
|
return {
|
||
|
|
"dry_run": bool(s.notify_dry_run),
|
||
|
|
"smtp": {"host": s.smtp_host, "port": s.smtp_port, "from": s.smtp_from, "starttls": s.smtp_starttls},
|
||
|
|
"subject_prefix": smtp.subject_prefix(s),
|
||
|
|
"rules": [rule_view(d.conn, rules[k]) for k in EVENT_TYPES if k in rules],
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- update a rule
|
||
|
|
|
||
|
|
|
||
|
|
class RecipientIn(BaseModel):
|
||
|
|
model_config = ConfigDict(extra="forbid")
|
||
|
|
|
||
|
|
kind: Literal["group", "user", "mailbox", "dept_supervisor"]
|
||
|
|
group_dn: str | None = Field(default=None, max_length=DN_MAX)
|
||
|
|
group_name: str | None = Field(default=None, max_length=NAME_MAX)
|
||
|
|
user_id: uuid.UUID | None = None
|
||
|
|
address: str | None = Field(default=None, max_length=254)
|
||
|
|
|
||
|
|
|
||
|
|
class RuleIn(BaseModel):
|
||
|
|
model_config = ConfigDict(extra="forbid")
|
||
|
|
|
||
|
|
enabled: bool
|
||
|
|
delivery: Literal["immediate", "digest"]
|
||
|
|
digest_time: str = Field(max_length=5)
|
||
|
|
param: int | None = Field(default=None, ge=1, le=10000)
|
||
|
|
recipients: list[RecipientIn] = Field(max_length=RECIPIENTS_MAX)
|
||
|
|
exclusions: list[uuid.UUID] = Field(default_factory=list, max_length=EXCLUSIONS_MAX)
|
||
|
|
version: int
|
||
|
|
|
||
|
|
|
||
|
|
def _invalid(text: str) -> ApiError:
|
||
|
|
return ApiError(422, "invalid_request", text)
|
||
|
|
|
||
|
|
|
||
|
|
def _clean_recipients(conn: Any, s: Settings, event_type: str, items: list[RecipientIn]) -> list[dict[str, Any]]:
|
||
|
|
et = EVENT_TYPES[event_type]
|
||
|
|
base = s.ldap_base_dn.strip() or s.ldap_group_base_dn.strip()
|
||
|
|
out: list[dict[str, Any]] = []
|
||
|
|
seen: set[tuple[str, str]] = set()
|
||
|
|
for r in items:
|
||
|
|
row: dict[str, Any] = {"kind": r.kind, "group_dn": None, "group_name": None, "user_id": None, "address": None}
|
||
|
|
if r.kind == "group":
|
||
|
|
dn = clean_text(r.group_dn or "", DN_MAX)
|
||
|
|
if not dn or not dn.lower().startswith("cn="):
|
||
|
|
raise _invalid("A group needs its DN.")
|
||
|
|
if base and not dn_under(dn, base):
|
||
|
|
raise _invalid("The group must be in the domain (under LDAP_BASE_DN).")
|
||
|
|
row["group_dn"] = dn
|
||
|
|
row["group_name"] = clean_text(r.group_name or "", NAME_MAX) or cn_of(dn)
|
||
|
|
key = ("group", dn.lower())
|
||
|
|
elif r.kind == "user":
|
||
|
|
if r.user_id is None:
|
||
|
|
raise _invalid("A user recipient needs a user.")
|
||
|
|
if conn.execute("SELECT 1 FROM users WHERE id = %s", (r.user_id,)).fetchone() is None:
|
||
|
|
raise _invalid("No such user.")
|
||
|
|
row["user_id"] = r.user_id
|
||
|
|
key = ("user", str(r.user_id))
|
||
|
|
elif r.kind == "mailbox":
|
||
|
|
addr = (r.address or "").strip()
|
||
|
|
if not address_ok(addr):
|
||
|
|
raise _invalid(f"Not an email address: {addr[:80]}")
|
||
|
|
row["address"] = addr
|
||
|
|
key = ("mailbox", addr.lower())
|
||
|
|
else:
|
||
|
|
if not et.dept_scoped:
|
||
|
|
raise _invalid("This notification has no department, so it has no Supervisor group.")
|
||
|
|
key = ("dept_supervisor", "")
|
||
|
|
if key in seen:
|
||
|
|
continue
|
||
|
|
seen.add(key)
|
||
|
|
out.append(row)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
@router.put(f"{ADMIN}/{{event_type}}", dependencies=WRITE)
|
||
|
|
def update_rule(
|
||
|
|
event_type: str,
|
||
|
|
body: RuleIn,
|
||
|
|
request: Request,
|
||
|
|
p: Principal = Depends(admin_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
_known(event_type)
|
||
|
|
s = _settings(request)
|
||
|
|
conn = d.conn
|
||
|
|
et = EVENT_TYPES[event_type]
|
||
|
|
m = TIME_RE.match(body.digest_time)
|
||
|
|
if m is None:
|
||
|
|
raise _invalid("The digest time is HH:MM.")
|
||
|
|
digest_at = time(int(m.group(1)), int(m.group(2)))
|
||
|
|
if body.param is not None and et.param is None:
|
||
|
|
raise _invalid("This notification takes no setting.")
|
||
|
|
excl = list(dict.fromkeys(body.exclusions))
|
||
|
|
if excl and event_type in SECURITY_TYPES:
|
||
|
|
raise _invalid("Nobody can be left out of a security notification.")
|
||
|
|
recipients = _clean_recipients(conn, s, event_type, body.recipients)
|
||
|
|
if excl:
|
||
|
|
found = {r[0] for r in conn.execute("SELECT id FROM users WHERE id = ANY(%s)", (excl,)).fetchall()}
|
||
|
|
if len(found) != len(excl):
|
||
|
|
raise _invalid("An excluded person is not a known user.")
|
||
|
|
_rules(conn, s)
|
||
|
|
with conn.transaction():
|
||
|
|
before_rule = load_rules(conn, event_type).get(event_type)
|
||
|
|
assert before_rule is not None
|
||
|
|
before = rule_view(conn, before_rule)
|
||
|
|
done = conn.execute(
|
||
|
|
"UPDATE notification_rules SET enabled = %s, delivery = %s, digest_time = %s, param = %s,"
|
||
|
|
" version = version + 1, updated_at = now(), updated_by = %s WHERE event_type = %s AND version = %s"
|
||
|
|
" RETURNING version",
|
||
|
|
(
|
||
|
|
body.enabled,
|
||
|
|
body.delivery,
|
||
|
|
digest_at,
|
||
|
|
(body.param or et.param_default) if et.param else None,
|
||
|
|
p.user_id,
|
||
|
|
event_type,
|
||
|
|
body.version,
|
||
|
|
),
|
||
|
|
).fetchone()
|
||
|
|
if done is None:
|
||
|
|
raise ApiError(
|
||
|
|
409,
|
||
|
|
"version_conflict",
|
||
|
|
"Someone else changed this notification. Here is the current one.",
|
||
|
|
extra={"current": before},
|
||
|
|
)
|
||
|
|
conn.execute("DELETE FROM notification_recipients WHERE event_type = %s", (event_type,))
|
||
|
|
for r in recipients:
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO notification_recipients (event_type, kind, group_dn, group_name, user_id, address)"
|
||
|
|
" VALUES (%s, %s, %s, %s, %s, %s)",
|
||
|
|
(event_type, r["kind"], r["group_dn"], r["group_name"], r["user_id"], r["address"]),
|
||
|
|
)
|
||
|
|
conn.execute(
|
||
|
|
"DELETE FROM notification_exclusions WHERE event_type = %s AND NOT self AND NOT (user_id = ANY(%s))",
|
||
|
|
(event_type, excl),
|
||
|
|
)
|
||
|
|
for uid in excl:
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO notification_exclusions (event_type, user_id, self, created_by) VALUES (%s, %s, false, %s)"
|
||
|
|
" ON CONFLICT (event_type, user_id) DO NOTHING",
|
||
|
|
(event_type, uid, p.user_id),
|
||
|
|
)
|
||
|
|
after_rule = load_rules(conn, event_type)[event_type]
|
||
|
|
after = rule_view(conn, after_rule)
|
||
|
|
audit.append(
|
||
|
|
conn,
|
||
|
|
action="admin.notification_rule_updated",
|
||
|
|
user_id=p.user_id,
|
||
|
|
session_id=p.session_id,
|
||
|
|
role_snapshot=p.role_snapshot,
|
||
|
|
subject_type="notification_rule",
|
||
|
|
subject_id=event_type,
|
||
|
|
detail={"event_type": event_type, "before": _audit_view(before), "after": _audit_view(after)},
|
||
|
|
ip=p.ip,
|
||
|
|
)
|
||
|
|
return after
|
||
|
|
|
||
|
|
|
||
|
|
def _audit_view(v: dict[str, Any]) -> dict[str, Any]:
|
||
|
|
keep = ("enabled", "delivery", "digest_time", "param", "version")
|
||
|
|
out = {k: v[k] for k in keep}
|
||
|
|
out["recipients"] = [
|
||
|
|
{k: r[k] for k in ("kind", "group_dn", "group_name", "user_id", "address") if r.get(k)} for r in v["recipients"]
|
||
|
|
]
|
||
|
|
out["exclusions"] = [{"user_id": x["user_id"], "self": x["self"]} for x in v["exclusions"]]
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- group search, test send, sent log
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(f"{ADMIN}/groups")
|
||
|
|
def search_groups(
|
||
|
|
request: Request,
|
||
|
|
p: Principal = Depends(admin_principal), # noqa: B008
|
||
|
|
q: str = Query(..., min_length=2, max_length=100),
|
||
|
|
) -> list[dict[str, Any]]:
|
||
|
|
try:
|
||
|
|
found = _directory(request).search_groups(q.strip(), 25)
|
||
|
|
except LdapUnavailable as e:
|
||
|
|
log.warning("group search: AD unavailable: %s", e)
|
||
|
|
raise ApiError(503, "ldap_unavailable", "The directory is not available. Try again in a minute.") from None
|
||
|
|
return [{"dn": g.get("dn"), "name": g.get("name"), "description": g.get("description")} for g in found]
|
||
|
|
|
||
|
|
|
||
|
|
class TestIn(BaseModel):
|
||
|
|
model_config = ConfigDict(extra="forbid")
|
||
|
|
|
||
|
|
to: str | None = Field(default=None, max_length=254)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post(f"{ADMIN}/{{event_type}}/test", dependencies=WRITE)
|
||
|
|
def test_send(
|
||
|
|
event_type: str,
|
||
|
|
body: TestIn,
|
||
|
|
request: Request,
|
||
|
|
p: Principal = Depends(admin_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
_known(event_type)
|
||
|
|
s = _settings(request)
|
||
|
|
to = (body.to or "").strip() or None
|
||
|
|
if to is not None and not address_ok(to):
|
||
|
|
raise _invalid("Not an email address.")
|
||
|
|
conn = d.conn
|
||
|
|
_rules(conn, s)
|
||
|
|
out = notify_send.send_test(conn, s, _directory(request), event_type, to=to, requested_by=p.user_id)
|
||
|
|
audit.append(
|
||
|
|
conn,
|
||
|
|
action="admin.notification_test",
|
||
|
|
user_id=p.user_id,
|
||
|
|
session_id=p.session_id,
|
||
|
|
role_snapshot=p.role_snapshot,
|
||
|
|
subject_type="notification_rule",
|
||
|
|
subject_id=event_type,
|
||
|
|
detail={
|
||
|
|
"event_type": event_type,
|
||
|
|
"to": to,
|
||
|
|
"result": out["result"],
|
||
|
|
"recipients": len(out["recipients"]),
|
||
|
|
"sent_id": out["sent_id"],
|
||
|
|
},
|
||
|
|
ip=p.ip,
|
||
|
|
)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(f"{ADMIN}/sent")
|
||
|
|
def sent_log(
|
||
|
|
p: Principal = Depends(admin_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
event_type: str | None = Query(None, max_length=64),
|
||
|
|
limit: int = Query(50, ge=1, le=500),
|
||
|
|
before: int | None = Query(None, ge=1),
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
sql = (
|
||
|
|
"SELECT id, at, event_type, digest, test, subject, recipients, result, error FROM notifications_sent"
|
||
|
|
" WHERE (%s::text IS NULL OR event_type = %s) AND (%s::bigint IS NULL OR id < %s) ORDER BY id DESC LIMIT %s"
|
||
|
|
)
|
||
|
|
args = (event_type or None, event_type or None, before, before)
|
||
|
|
with d.conn.cursor(row_factory=dict_row) as cur:
|
||
|
|
rows = [dict(r) for r in cur.execute(sql, (*args, limit)).fetchall()]
|
||
|
|
return {"rows": rows, "next_before": rows[-1]["id"] if len(rows) == limit else None}
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------- account
|
||
|
|
|
||
|
|
|
||
|
|
def _my_groups(directory: NotifyDirectory, user_id: uuid.UUID) -> tuple[Any, set[str]]:
|
||
|
|
"""(the AdUser or None, lower-case names of the user's groups in the PacketStation OU, nested)."""
|
||
|
|
u = directory.get_user_by_guid(user_id)
|
||
|
|
if u is None:
|
||
|
|
return None, set()
|
||
|
|
return u, {g.lower() for g in directory.groups_of(u.dn)}
|
||
|
|
|
||
|
|
|
||
|
|
def account_view(conn: Any, settings: Settings, directory: NotifyDirectory, p: Principal) -> list[dict[str, Any]]:
|
||
|
|
rules = _rules(conn, settings)
|
||
|
|
email = str(p.user.get("email") or "").strip().lower()
|
||
|
|
me = str(p.user_id)
|
||
|
|
sup = supervisor_depts(p.roles)
|
||
|
|
groups: tuple[Any, set[str]] | None = None
|
||
|
|
down = False
|
||
|
|
member_cache: dict[str, bool] = {}
|
||
|
|
out: list[dict[str, Any]] = []
|
||
|
|
for key, et in EVENT_TYPES.items():
|
||
|
|
rule = rules.get(key)
|
||
|
|
if rule is None or not rule.enabled:
|
||
|
|
continue
|
||
|
|
why: list[str] = []
|
||
|
|
for r in rule.recipients:
|
||
|
|
kind = r["kind"]
|
||
|
|
if kind == "user" and str(r["user_id"]) == me:
|
||
|
|
why.append("user")
|
||
|
|
elif kind == "mailbox" and email and str(r["address"]).strip().lower() == email:
|
||
|
|
why.append("mailbox")
|
||
|
|
elif kind == "dept_supervisor" and et.dept_scoped and sup:
|
||
|
|
why.extend(f"Supervisor group of {code}" for code in sup)
|
||
|
|
elif kind == "group" and not down:
|
||
|
|
dn = str(r["group_dn"])
|
||
|
|
name = str(r["group_name"] or cn_of(dn))
|
||
|
|
try:
|
||
|
|
if dn_under(dn, settings.ldap_group_base_dn):
|
||
|
|
if groups is None:
|
||
|
|
groups = _my_groups(directory, p.user_id)
|
||
|
|
hit = name.lower() in groups[1]
|
||
|
|
else:
|
||
|
|
if dn.lower() not in member_cache:
|
||
|
|
member_cache[dn.lower()] = any(
|
||
|
|
u.guid == p.user_id for u in directory.members_of_dn(dn) if not u.disabled
|
||
|
|
)
|
||
|
|
hit = member_cache[dn.lower()]
|
||
|
|
except Exception as e:
|
||
|
|
log.warning("account notifications: directory: %s", type(e).__name__)
|
||
|
|
down = True
|
||
|
|
continue
|
||
|
|
if hit:
|
||
|
|
why.append(f"group {name}")
|
||
|
|
excl = next((x for x in rule.exclusions if str(x["user_id"]) == me), None)
|
||
|
|
opted_out = bool(excl and excl["self"])
|
||
|
|
if excl and not excl["self"]:
|
||
|
|
why.append("left out by an Admin")
|
||
|
|
if down and any(r["kind"] == "group" for r in rule.recipients):
|
||
|
|
why.append(DIRECTORY_DOWN)
|
||
|
|
reaches = excl is None and any(w not in (DIRECTORY_DOWN, "left out by an Admin") for w in why)
|
||
|
|
out.append(
|
||
|
|
{
|
||
|
|
"event_type": key,
|
||
|
|
"label": et.label,
|
||
|
|
"security": et.security,
|
||
|
|
"delivery": rule.delivery,
|
||
|
|
"reaches": reaches,
|
||
|
|
"why": list(dict.fromkeys(why)),
|
||
|
|
"opted_out": opted_out,
|
||
|
|
"can_opt_out": not et.security,
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
@router.get(ACCOUNT)
|
||
|
|
def my_notifications(
|
||
|
|
request: Request,
|
||
|
|
p: Principal = Depends(current_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
) -> list[dict[str, Any]]:
|
||
|
|
return account_view(d.conn, _settings(request), _directory(request), p)
|
||
|
|
|
||
|
|
|
||
|
|
class OptOutIn(BaseModel):
|
||
|
|
model_config = ConfigDict(extra="forbid")
|
||
|
|
|
||
|
|
opt_out: bool
|
||
|
|
|
||
|
|
|
||
|
|
@router.put(f"{ACCOUNT}/{{event_type}}", dependencies=WRITE)
|
||
|
|
def opt_out(
|
||
|
|
event_type: str,
|
||
|
|
body: OptOutIn,
|
||
|
|
request: Request,
|
||
|
|
p: Principal = Depends(password_principal), # noqa: B008
|
||
|
|
d: Db = Depends(get_db), # noqa: B008
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
_known(event_type)
|
||
|
|
if event_type in SECURITY_TYPES:
|
||
|
|
raise ApiError(403, "forbidden", "Security notifications cannot be turned off.")
|
||
|
|
conn = d.conn
|
||
|
|
s = _settings(request)
|
||
|
|
_rules(conn, s)
|
||
|
|
with conn.transaction():
|
||
|
|
if body.opt_out:
|
||
|
|
changed = conn.execute(
|
||
|
|
"INSERT INTO notification_exclusions (event_type, user_id, self, created_by) VALUES (%s, %s, true, %s)"
|
||
|
|
" ON CONFLICT (event_type, user_id) DO NOTHING",
|
||
|
|
(event_type, p.user_id, p.user_id),
|
||
|
|
).rowcount
|
||
|
|
else:
|
||
|
|
changed = conn.execute(
|
||
|
|
"DELETE FROM notification_exclusions WHERE event_type = %s AND user_id = %s AND self",
|
||
|
|
(event_type, p.user_id),
|
||
|
|
).rowcount
|
||
|
|
if changed:
|
||
|
|
audit.append(
|
||
|
|
conn,
|
||
|
|
action="account.notification_opt_out",
|
||
|
|
user_id=p.user_id,
|
||
|
|
session_id=p.session_id,
|
||
|
|
role_snapshot=p.role_snapshot,
|
||
|
|
subject_type="notification_rule",
|
||
|
|
subject_id=event_type,
|
||
|
|
detail={"event_type": event_type, "opt_out": body.opt_out},
|
||
|
|
ip=p.ip,
|
||
|
|
)
|
||
|
|
item = next((x for x in account_view(conn, s, _directory(request), p) if x["event_type"] == event_type), None)
|
||
|
|
if item is None:
|
||
|
|
# The rule is off: nothing reaches anyone, but the choice is kept
|
||
|
|
et = EVENT_TYPES[event_type]
|
||
|
|
item = {
|
||
|
|
"event_type": event_type,
|
||
|
|
"label": et.label,
|
||
|
|
"security": False,
|
||
|
|
"delivery": None,
|
||
|
|
"reaches": False,
|
||
|
|
"why": [],
|
||
|
|
"opted_out": body.opt_out,
|
||
|
|
"can_opt_out": True,
|
||
|
|
}
|
||
|
|
return item
|