Files
flowdeck/app/services/reminders.py
T
bruno d4adf89db5
FlowDeck CI / lint (push) Successful in 49s
FlowDeck CI / test (push) Successful in 5m26s
FlowDeck CI / docker (push) Successful in 43s
feat(calendar): v5.8.0 calendrier & rappels + v5.7.0 database avancee (pt.2)
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
2026-09-13 22:49:14 -04:00

226 lines
9.2 KiB
Python

"""FlowDeck — Reminder service (v5.8.0 Calendrier & Rappels).
Reminders live on database rows, next to the recurrence rule, inside
``property_values_json`` under the special key ``__reminder__``::
"__reminder__": { "<date_prop_id>": {"value": 30, "unit": "minutes"} }
A background loop scans rows with reminders, computes the next occurrence
(recurrence-aware, timezone-aware), and fires an in-app notification (+ email
when the user opted in) at ``event - lead``. Fired reminders are deduplicated
through the ``reminder_log`` table keyed by (page_id, occurrence date).
The scan is a plain synchronous function (:func:`scan_and_fire`) so tests can
drive it deterministically with an injected ``now``.
"""
from __future__ import annotations
import datetime as dt
import json
import logging
from app.db import get_conn
from app.services.recurrence import (
RECURRENCE_KEY,
TIMEZONE_KEY,
next_occurrences,
parse_date,
)
logger = logging.getLogger(__name__)
REMINDER_KEY = "__reminder__"
_UNITS = {"minutes": 60, "hours": 3600, "days": 86400}
# Fire late is still useful; ignore events that happened long ago.
FIRE_GRACE_SECONDS = 3600
def parse_lead(reminder) -> int | None:
"""Return the lead in seconds for a reminder dict, or None if disabled."""
if not isinstance(reminder, dict):
return None
unit = reminder.get("unit", "none")
if unit not in _UNITS:
return None
try:
value = int(reminder.get("value", 0))
except (TypeError, ValueError):
return None
if value <= 0:
return None
return value * _UNITS[unit]
def lead_human(reminder: dict) -> str:
try:
value = int(reminder.get("value", 0))
except (TypeError, ValueError):
return ""
unit = reminder.get("unit", "")
if value == 1 and unit.endswith("s"):
unit = unit[:-1]
return f"{value} {unit}"
def _person_targets(conn, collection_id: int, props: dict) -> list[int]:
"""User ids assigned via any ``person`` property of the row."""
targets: list[int] = []
person_props = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='person'",
(collection_id,),
).fetchall()
for pp in person_props:
value = props.get(str(pp["id"])) or props.get(pp["name"] if "name" in pp.keys() else None)
if isinstance(value, list):
for person in value:
if isinstance(person, dict) and person.get("id"):
targets.append(int(person["id"]))
return targets
def _row_tz(conn, collection_id: int, props: dict) -> str:
"""Effective timezone of a row: per-event ``__timezone__`` first, then the
recurrence rule tz, then the created_by user's personal timezone,
then '' (UTC)."""
tzmap = props.get(TIMEZONE_KEY)
if isinstance(tzmap, dict):
for value in tzmap.values():
if value:
return str(value)
rec = props.get(RECURRENCE_KEY) or {}
for rule in rec.values():
if isinstance(rule, dict) and rule.get("timezone"):
return rule["timezone"]
auto_props = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='created_by'",
(collection_id,),
).fetchall()
for ap in auto_props:
value = props.get(str(ap["id"]))
people = value if isinstance(value, list) else ([value] if isinstance(value, dict) else [])
for person in people:
if isinstance(person, dict) and person.get("id"):
row = conn.execute(
"SELECT timezone FROM users WHERE id=?", (person["id"],)
).fetchone()
if row and row["timezone"]:
return row["timezone"]
return ""
def scan_and_fire(now: dt.datetime | None = None) -> int:
"""Fire every reminder that is due. Returns the number of notifications
created. Safe to call repeatedly (dedup via reminder_log)."""
now = now or dt.datetime.now(dt.UTC)
fired = 0
with get_conn() as conn:
pages = conn.execute(
"SELECT id, collection_id, title, property_values_json FROM collection_pages"
).fetchall()
for page in pages:
try:
props = json.loads(page["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
reminders = props.get(REMINDER_KEY)
if not isinstance(reminders, dict):
continue
rec_all = props.get(RECURRENCE_KEY) if isinstance(props.get(RECURRENCE_KEY), dict) else {}
for prop_key, reminder in reminders.items():
lead = parse_lead(reminder)
if lead is None:
continue
base_value = props.get(str(prop_key))
if not base_value and str(prop_key).isdigit():
# fall back to name-keyed storage
pname = conn.execute(
"SELECT name FROM collection_properties WHERE id=?", (int(prop_key),)
).fetchone()
if pname:
base_value = props.get(pname["name"])
if parse_date(base_value) is None:
continue
rule = rec_all.get(str(prop_key))
if rule is None and not str(prop_key).isdigit():
pid = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
(page["collection_id"], str(prop_key)),
).fetchone()
if pid:
rule = rec_all.get(str(pid["id"]))
tz_name = _row_tz(conn, page["collection_id"], props)
# Look for the occurrence whose reminder moment is in
# [now - grace, now].
events = next_occurrences(
base_value, rule, tz_name or None,
after=now - dt.timedelta(seconds=lead + FIRE_GRACE_SECONDS),
horizon_days=400, max_count=2000,
)
due = None
for _d, event in events:
fire_at = event - dt.timedelta(seconds=lead)
if fire_at <= now and now < event + dt.timedelta(seconds=FIRE_GRACE_SECONDS):
due = (event, fire_at)
break
if due is None:
continue
event, _fire_at = due
if event < now - dt.timedelta(seconds=FIRE_GRACE_SECONDS):
continue
target_ids = _person_targets(conn, page["collection_id"], props)
if not target_ids:
row = conn.execute(
"SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1"
).fetchone()
target_ids = [row["id"]] if row else []
for uid in dict.fromkeys(target_ids):
cur = conn.execute(
"INSERT OR IGNORE INTO reminder_log (page_id, occurrence_date) VALUES (?, ?)",
(page["id"], event.date().isoformat()),
)
if cur.rowcount != 1:
continue
from app.services import mailer, notifications
lead_text = lead_human(reminder)
notifications.create_notification(
uid, None, "reminder",
title=f"Reminder: {page['title'] or 'Untitled'}",
message=(f"Due in {lead_text} — {event.strftime('%Y-%m-%d %H:%M')}"
if lead else
f"Happening now — {event.strftime('%Y-%m-%d %H:%M')}"),
resource_type="db_page",
resource_id=page["id"],
url=f"/collections/{page['collection_id']}",
conn=conn, commit=False,
)
mailer.notify_user(
uid,
subject=f"[FlowDeck] Reminder: {page['title'] or 'Untitled'}",
body_text=f"« {page['title'] or 'Untitled'} » le {event.strftime('%Y-%m-%d %H:%M')} ({tz_name or 'UTC'}).",
cta_url=f"/collections/{page['collection_id']}",
prefs_key="reminders",
)
fired += 1
conn.commit()
return fired
async def reminder_scheduler(interval_seconds: int = 60):
"""Background loop: scan for due reminders once a minute."""
import asyncio
from app.config import settings
if not getattr(settings, "reminders_enabled", True):
logger.info("Reminder scheduler disabled via settings")
return
await asyncio.sleep(5)
while True:
try:
n = scan_and_fire()
if n:
logger.info("Reminders: fired %d notification(s)", n)
except Exception as exc: # pragma: no cover - defensive only
logger.warning("Reminder scan failed: %s", exc)
await asyncio.sleep(max(10, int(getattr(settings, "reminder_scan_interval_seconds", interval_seconds))))