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
226 lines
9.2 KiB
Python
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))))
|