v5.8.0 (release 5.11.7) — Calendrier & Rappels :
- moteur de recurrence RRULE subset (daily/weekly/monthly, interval,
count, until, byweekday, timezone) — app/services/recurrence.py
- vues calendar Jour / Semaine / Mois avec expansion des occurrences
cote serveur (GET /db/{id}/calendar/api) et popover evenement
(Time / Timezone / Repeat / Remind)
- rappels avant echeance (scan 60 s, table reminder_log, in-app +
email, cible = personnes assignees) — app/services/reminders.py
- fuseaux horaires : users.timezone + reglages in-app, timezone par
evenement, liste de zones (GET /db/timezones/api)
- notifications d'assignation sur PUT /db/pages/{id}/api et
preferences etendues (reminders, assignments)
- template Meeting notes enrichi (Agenda, Notes — migration 12)
- migrations 11-12 ; 20 tests dedies ; suite complete 523 verte
v5.7.0 (release 5.11.6, termine avant cette session, reste dans
l'arbre sans commit) — Database Avancée Pt.2 :
- proprietes person + auto-proprietes (created/last-edited time & by)
- groupes de proprietes, vues sauvegardees par utilisateur
- swimlanes, WIP limits, cartes configurables, calendar drag & drop,
gallery couvertures ; 12 tests dedies
214 lines
7.0 KiB
Python
214 lines
7.0 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(
|
|
f"SELECT id, login, email FROM users WHERE lower(login) IN ({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, "reminders": True, "assignments": 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)),
|
|
"reminders": bool(prefs.get("reminders", True)),
|
|
"assignments": bool(prefs.get("assignments", 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
|
|
|
|
|
|
def _person_ids(value) -> list[int]:
|
|
"""Extract user ids from a stored ``person`` property value."""
|
|
ids: list[int] = []
|
|
if isinstance(value, dict):
|
|
value = [value]
|
|
if isinstance(value, list):
|
|
for person in value:
|
|
if isinstance(person, dict) and person.get("id"):
|
|
try:
|
|
ids.append(int(person["id"]))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return ids
|
|
|
|
|
|
def notify_assignment(collection_id: int, page_id: int, page_title: str,
|
|
old_props: dict, new_props: dict, actor_id: int | None,
|
|
conn=None) -> list[int]:
|
|
"""Notify users newly assigned via a ``person`` property.
|
|
|
|
Compares old vs new property values keyed by property id; users present
|
|
in the new value but not the old one get an in-app + (opt-in) email
|
|
notification. Returns the notified user ids.
|
|
"""
|
|
def _run(c):
|
|
props = c.execute(
|
|
"SELECT id, name FROM collection_properties "
|
|
"WHERE collection_id=? AND prop_type='person'",
|
|
(collection_id,),
|
|
).fetchall()
|
|
newly: list[int] = []
|
|
for p in props:
|
|
key = str(p["id"])
|
|
before = set(_person_ids((old_props or {}).get(key)))
|
|
after = _person_ids((new_props or {}).get(key))
|
|
for uid in after:
|
|
if uid not in before and uid != actor_id and uid not in newly:
|
|
newly.append(uid)
|
|
for uid in newly:
|
|
create_notification(
|
|
uid, actor_id, "assignment",
|
|
title="Assigned to you",
|
|
message=page_title or "Untitled",
|
|
resource_type="db_page",
|
|
resource_id=page_id,
|
|
url=f"/collections/{collection_id}",
|
|
conn=c, commit=False,
|
|
)
|
|
mailer.notify_user(
|
|
uid, subject="[FlowDeck] Assigned to you",
|
|
body_text=page_title or "Untitled",
|
|
cta_url=f"/collections/{collection_id}",
|
|
prefs_key="assignments",
|
|
)
|
|
return newly
|
|
|
|
if conn is not None:
|
|
return _run(conn)
|
|
with get_conn() as c:
|
|
result = _run(c)
|
|
c.commit()
|
|
return result
|