- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table) - Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens - Active sessions management: list/revoke via /api/settings/sessions with device info - Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project) - Automatic daily backups: backup_db(), prune, scheduler + admin API - Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects() - GitHubAdapter implements ForgeAdapter contract, transport injection for mocking - Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs - Linting config: ruff (Python) + eslint (JS) - Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky) - Bumped version to 5.9.1 Co-authored-by: Bruno <[email protected]>
148 lines
4.6 KiB
Python
148 lines
4.6 KiB
Python
"""FlowDeck — Notification service (v4.9.0 collaboration).
|
|
|
|
Creates in-app notifications (mentions, comments, page changes) and triggers
|
|
email delivery via :mod:`app.services.mailer` when the target user has opted in.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
|
|
from app.db import get_conn
|
|
from app.services import mailer
|
|
|
|
|
|
def create_notification(
|
|
user_id: int,
|
|
actor_id: int | None,
|
|
ntype: str,
|
|
title: str,
|
|
message: str,
|
|
resource_type: str = "page",
|
|
resource_id: int = 0,
|
|
url: str = "",
|
|
conn=None,
|
|
commit: bool = True,
|
|
) -> int | None:
|
|
"""Insert a notification row. Returns the new id (or None if skipped).
|
|
|
|
``conn`` may be supplied to join an existing transaction (caller controls
|
|
commit). Otherwise a dedicated connection is opened and committed.
|
|
"""
|
|
if not user_id:
|
|
return None
|
|
if conn is not None:
|
|
cur = conn.execute(
|
|
"""INSERT INTO notifications
|
|
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
|
|
VALUES (?,?,?,?,?,?,?,?)""",
|
|
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
|
|
)
|
|
if commit:
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
with get_conn() as conn:
|
|
cur = conn.execute(
|
|
"""INSERT INTO notifications
|
|
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
|
|
VALUES (?,?,?,?,?,?,?,?)""",
|
|
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
|
|
)
|
|
conn.commit()
|
|
return cur.lastrowid
|
|
|
|
|
|
_MENTION_RE = re.compile(r"(?:^|\s)@([\w\-\.]+)")
|
|
|
|
|
|
def extract_mentions(text: str) -> list[str]:
|
|
"""Return the set of @login handles mentioned in ``text`` (lowercase)."""
|
|
return list(dict.fromkeys(m.lower() for m in _MENTION_RE.findall(text or "")))
|
|
|
|
|
|
def process_mentions(
|
|
text: str,
|
|
actor_id: int,
|
|
ntype: str,
|
|
title: str,
|
|
message: str,
|
|
resource_type: str = "page",
|
|
resource_id: int = 0,
|
|
url: str = "",
|
|
conn=None,
|
|
) -> list[int]:
|
|
"""Create notifications for every user @-mentioned in ``text``.
|
|
|
|
Returns the list of mentioned user ids that were notified.
|
|
"""
|
|
handles = extract_mentions(text)
|
|
if not handles:
|
|
return []
|
|
notified = []
|
|
if conn is not None:
|
|
_do_mentions(conn, handles, actor_id, ntype, title, message,
|
|
resource_type, resource_id, url, notified)
|
|
return notified
|
|
with get_conn() as conn:
|
|
_do_mentions(conn, handles, actor_id, ntype, title, message,
|
|
resource_type, resource_id, url, notified)
|
|
conn.commit()
|
|
return notified
|
|
|
|
|
|
def _do_mentions(conn, handles, actor_id, ntype, title, message,
|
|
resource_type, resource_id, url, notified):
|
|
placeholders = ",".join("?" * len(handles))
|
|
rows = conn.execute(
|
|
"SELECT id, login, email FROM users WHERE lower(login) IN (%s)" % placeholders,
|
|
handles,
|
|
).fetchall()
|
|
for row in rows:
|
|
if row["id"] == actor_id:
|
|
continue
|
|
create_notification(
|
|
row["id"], actor_id, ntype, title, message,
|
|
resource_type, resource_id, url, conn=conn, commit=False,
|
|
)
|
|
notified.append(row["id"])
|
|
mailer.notify_user(
|
|
row["id"], subject=title, body_text=message, cta_url=url or "",
|
|
)
|
|
|
|
|
|
def get_user_prefs(user_id: int, conn=None) -> dict:
|
|
if conn is not None:
|
|
row = conn.execute(
|
|
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
|
|
).fetchone()
|
|
else:
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
|
|
).fetchone()
|
|
if not row or not row["notification_prefs"]:
|
|
return {"comments": True, "mentions": True}
|
|
try:
|
|
prefs = json.loads(row["notification_prefs"])
|
|
except (TypeError, json.JSONDecodeError):
|
|
prefs = {}
|
|
return {"comments": bool(prefs.get("comments", True)),
|
|
"mentions": bool(prefs.get("mentions", True))}
|
|
|
|
|
|
def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
|
|
if conn is not None:
|
|
conn.execute(
|
|
"UPDATE users SET notification_prefs=? WHERE id=?",
|
|
(json.dumps(prefs), user_id),
|
|
)
|
|
conn.commit()
|
|
else:
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE users SET notification_prefs=? WHERE id=?",
|
|
(json.dumps(prefs), user_id),
|
|
)
|
|
conn.commit()
|
|
return prefs
|