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
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
"""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))))
|
||||
Reference in New Issue
Block a user