- `_database_table_scripts.html` → `static/js/database_table.js` (1 314 L).
Le Jinja du bloc était confiné à la construction de l'objet de config
(4 clés + `{% if collection_data %}`) → config JSON `#db-config`
null-vs-objet : `new DBInstance(container, PAGE_COLLECTION_ID, DB_CONFIG)`
remplace les 2 branches Jinja (le `else` était déjà un literal null).
- Loader DB_CONFIG : JSON.parse du bloc, `null` si absent (parité stricte
avec le else d'origine) ; acrlade try corrigée par node --check avant
commit.
- 2 tests adaptés (lisaient le template source → static/js/database_table.js)
; `FlowDeckDB` / `db-board` / `db-cal-grid` / `db-gallery` plus dans le
HTML → asserts sur le JS extrait.
BILAN A27 : 11 874 L extraites en 4 phases (4 243 + 2 516 + 3 801 + 1 314),
inline 13 904 → 2 022 L (-85 %), 22 fichiers static/js/*.js, node --check
vert partout, eslint 0 erreur / 285 warnings. Reste : base 1 523 L
structurel ({% block %}/{% for %} — inline par nature), ~500 L de petits
blocs hors cibles, nettoyage des 285 warnings.
suite **1089/1089** · ruff OK · docs à jour
424 lines
16 KiB
Python
424 lines
16 KiB
Python
"""FlowDeck — v5.8.0 Calendrier & Rappels.
|
|
|
|
Covers: the recurrence engine (pure), migrations 11/12, meta-key validation
|
|
on the rows API, the server-expanded calendar endpoint, per-user timezones,
|
|
the reminder scanner (deterministic, injected clock), assignment
|
|
notifications and the enriched Meeting notes template.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime as dt
|
|
import json
|
|
import os
|
|
import secrets
|
|
import tempfile
|
|
|
|
import pytest
|
|
from conftest import anon, login_test_client
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
|
db_path = db_file.name
|
|
db_file.close()
|
|
|
|
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
|
|
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
|
|
os.environ["RATE_LIMIT_ENABLED"] = "false"
|
|
os.environ["PUBLIC_API_INSECURE_OK"] = "true"
|
|
|
|
from app.config import settings
|
|
settings.database_url = f"sqlite:///{db_path}"
|
|
settings.rate_limit_enabled = False
|
|
settings.public_api_insecure_ok = True
|
|
|
|
from app.db import init_db
|
|
from app.main import app
|
|
init_db()
|
|
|
|
yield login_test_client(TestClient(app))
|
|
|
|
try:
|
|
os.unlink(db_path)
|
|
except PermissionError:
|
|
pass
|
|
|
|
|
|
def _login(client):
|
|
from app.auth.session import SessionManager
|
|
from app.db import get_conn
|
|
|
|
with get_conn() as conn:
|
|
login = f"v58u_{secrets.token_hex(4)}"
|
|
conn.execute(
|
|
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
|
|
"VALUES (?, 'V58 User', ?, ?, 1)",
|
|
(login, f"{login}@test.com", ""),
|
|
)
|
|
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
|
conn.commit()
|
|
session = SessionManager.create_session({"id": uid, "login": login, "is_admin": 1})
|
|
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
|
|
return session, csrf, uid
|
|
|
|
|
|
def _add_collection(client, name="Cal"):
|
|
return client.post("/db/api", json={"name": name}).json()["id"]
|
|
|
|
|
|
def _add_prop(client, cid, name, ptype, **extra):
|
|
body = {"name": name, "prop_type": ptype}
|
|
body.update(extra)
|
|
r = client.post(f"/db/{cid}/properties/api", json=body)
|
|
assert r.status_code == 200, r.text
|
|
return r.json()["id"]
|
|
|
|
|
|
# ── Recurrence engine (pure) ──
|
|
|
|
def test_expand_rule_daily_weekly_monthly():
|
|
from app.services.recurrence import expand_rule
|
|
w = (dt.date(2026, 9, 1), dt.date(2026, 9, 30))
|
|
assert expand_rule("2026-09-05", {"freq": "daily"}, *w)[:2] == ["2026-09-05", "2026-09-06"]
|
|
assert expand_rule("2026-09-07", {"freq": "weekly"}, *w) == \
|
|
["2026-09-07", "2026-09-14", "2026-09-21", "2026-09-28"]
|
|
assert expand_rule("2026-09-07", {"freq": "weekly", "interval": 2}, *w) == \
|
|
["2026-09-07", "2026-09-21"]
|
|
# Monthly anchored on the 31st clamps to shorter months.
|
|
months = expand_rule("2026-01-31", {"freq": "monthly"}, dt.date(2026, 1, 1), dt.date(2026, 4, 30))
|
|
assert months == ["2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30"]
|
|
|
|
|
|
def test_expand_rule_count_until_window():
|
|
from app.services.recurrence import expand_rule
|
|
assert expand_rule("2026-09-05", {"freq": "daily", "count": 3},
|
|
dt.date(2026, 9, 1), dt.date(2026, 9, 30)) == \
|
|
["2026-09-05", "2026-09-06", "2026-09-07"]
|
|
assert expand_rule("2026-08-01", {"freq": "daily", "count": 10},
|
|
dt.date(2026, 9, 1), dt.date(2026, 9, 30)) == []
|
|
assert expand_rule("2026-09-05", {"freq": "daily", "until": "2026-09-08"},
|
|
dt.date(2026, 9, 1), dt.date(2026, 9, 30)) == \
|
|
["2026-09-05", "2026-09-06", "2026-09-07", "2026-09-08"]
|
|
|
|
|
|
def test_validate_rule_errors():
|
|
from app.services.recurrence import validate_rule
|
|
assert validate_rule({"freq": "yearly"})[0] is False
|
|
assert validate_rule({"freq": "daily", "count": 2, "until": "2026-01-01"})[0] is False
|
|
assert validate_rule({"freq": "weekly", "byweekday": [9]})[0] is False
|
|
assert validate_rule({"freq": "daily", "timezone": "Mars/Olympus"})[0] is False
|
|
assert validate_rule({"freq": "weekly", "byweekday": [0, 4], "timezone": "Europe/Paris"})[0] is True
|
|
assert validate_rule(None)[0] is True
|
|
|
|
|
|
# ── Schema / migrations ──
|
|
|
|
def test_migration_v58_schema(client):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
|
version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
|
assert "reminder_log" in tables
|
|
assert "timezone" in ucols
|
|
assert version >= 12
|
|
|
|
|
|
def test_meeting_template_enriched(client):
|
|
from app.db import get_conn
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT schema_json, description FROM database_templates WHERE name='Meeting notes'"
|
|
).fetchone()
|
|
schema = json.loads(row["schema_json"])
|
|
names = [p["name"] for p in schema]
|
|
assert "Agenda" in names and "Notes" in names
|
|
assert "agenda" in row["description"].lower()
|
|
|
|
|
|
# ── Meta-key validation on the rows API ──
|
|
|
|
def test_recurrence_meta_roundtrip_and_rejects(client):
|
|
cid = _add_collection(client)
|
|
did = _add_prop(client, cid, "When", "date")
|
|
rid = client.post(f"/db/{cid}/pages/api", json={
|
|
"title": "Standup",
|
|
"properties": {
|
|
str(did): "2026-09-15",
|
|
"__recurrence__": {str(did): {"freq": "daily"}},
|
|
},
|
|
}).json()["id"]
|
|
page = client.get(f"/db/pages/{rid}/api").json()
|
|
pv = json.loads(page["property_values_json"])
|
|
assert pv["__recurrence__"][str(did)]["freq"] == "daily"
|
|
|
|
# Bad freq → 400
|
|
r = client.put(f"/db/pages/{rid}/api", json={"properties": {
|
|
"__recurrence__": {str(did): {"freq": "fortnightly"}},
|
|
}})
|
|
assert r.status_code == 400
|
|
|
|
# Reminder on a non-date property → 400
|
|
tid = _add_prop(client, cid, "Text", "text")
|
|
r = client.put(f"/db/pages/{rid}/api", json={"properties": {
|
|
"__reminder__": {str(tid): {"value": 10, "unit": "minutes"}},
|
|
}})
|
|
assert r.status_code == 400
|
|
|
|
# Unknown timezone → 400
|
|
r = client.put(f"/db/pages/{rid}/api", json={"properties": {
|
|
"__timezone__": {str(did): "Mars/Olympus"},
|
|
}})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_reminder_meta_roundtrip(client):
|
|
cid = _add_collection(client)
|
|
did = _add_prop(client, cid, "When", "date")
|
|
rid = client.post(f"/db/{cid}/pages/api", json={
|
|
"title": "Row",
|
|
"properties": {
|
|
str(did): "2026-10-01T09:00",
|
|
"__reminder__": {str(did): {"value": 2, "unit": "hours"}},
|
|
},
|
|
}).json()["id"]
|
|
pv = json.loads(client.get(f"/db/pages/{rid}/api").json()["property_values_json"])
|
|
assert pv["__reminder__"][str(did)] == {"value": 2, "unit": "hours"}
|
|
|
|
|
|
# ── Calendar API (server expansion) ──
|
|
|
|
def test_calendar_api_expands_recurrences(client):
|
|
cid = _add_collection(client)
|
|
did = _add_prop(client, cid, "When", "date")
|
|
client.post(f"/db/{cid}/pages/api", json={
|
|
"title": "Weekly sync",
|
|
"properties": {
|
|
str(did): "2026-09-07",
|
|
"__recurrence__": {str(did): {"freq": "weekly"}},
|
|
},
|
|
})
|
|
client.post(f"/db/{cid}/pages/api", json={ # plain one-off event
|
|
"title": "Party", "properties": {str(did): "2026-09-20T18:30"},
|
|
})
|
|
r = client.get(f"/db/{cid}/calendar/api",
|
|
params={"start": "2026-09-01", "end": "2026-09-30"})
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
evs = data["events"]
|
|
syncs = [e for e in evs if e["title"] == "Weekly sync"]
|
|
assert [e["date"] for e in syncs] == ["2026-09-07", "2026-09-14", "2026-09-21", "2026-09-28"]
|
|
assert all(e["recurring"] for e in syncs)
|
|
party = [e for e in evs if e["title"] == "Party"][0]
|
|
assert party["date"] == "2026-09-20" and party["time"] == "18:30" and not party["recurring"]
|
|
|
|
|
|
def test_calendar_api_param_errors(client):
|
|
cid = _add_collection(client)
|
|
_add_prop(client, cid, "When", "date")
|
|
assert client.get(f"/db/{cid}/calendar/api", params={"start": "bad"}).status_code == 400
|
|
assert client.get(f"/db/{cid}/calendar/api",
|
|
params={"start": "2026-09-01", "end": "2028-01-01"}).status_code == 400
|
|
assert client.get("/db/999999/calendar/api",
|
|
params={"start": "2026-09-01", "end": "2026-09-30"}).status_code == 404
|
|
cid2 = client.post("/db/api", json={"name": "NoDate"}).json()["id"]
|
|
r = client.get(f"/db/{cid2}/calendar/api", params={"start": "2026-09-01", "end": "2026-09-30"})
|
|
assert r.status_code == 200 and r.json()["events"] == []
|
|
|
|
|
|
def test_calendar_api_row_timezone(client):
|
|
cid = _add_collection(client)
|
|
did = _add_prop(client, cid, "When", "date")
|
|
client.post(f"/db/{cid}/pages/api", json={
|
|
"title": "TZ row",
|
|
"properties": {
|
|
str(did): "2026-09-10",
|
|
"__timezone__": {str(did): "Europe/Paris"},
|
|
"__recurrence__": {str(did): {"freq": "weekly", "timezone": "Europe/Paris"}},
|
|
},
|
|
})
|
|
evs = client.get(f"/db/{cid}/calendar/api",
|
|
params={"start": "2026-09-01", "end": "2026-09-30"}).json()["events"]
|
|
assert evs and all(e["timezone"] == "Europe/Paris" for e in evs)
|
|
|
|
|
|
# ── Timezone settings endpoints ──
|
|
|
|
def test_user_timezone_endpoints(client):
|
|
session, csrf, uid = _login(client)
|
|
r = client.get("/api/notifications/timezone", cookies={"flowdeck_session": session})
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["timezone"] == ""
|
|
assert "Europe/Paris" in data["zones"]
|
|
|
|
r = client.post("/api/notifications/timezone", json={"timezone": "Europe/Paris"},
|
|
cookies={"flowdeck_session": session}, headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 200
|
|
assert client.get("/api/notifications/timezone",
|
|
cookies={"flowdeck_session": session}).json()["timezone"] == "Europe/Paris"
|
|
|
|
r = client.post("/api/notifications/timezone", json={"timezone": "Mars/Olympus"},
|
|
cookies={"flowdeck_session": session}, headers={"X-CSRF-Token": csrf})
|
|
assert r.status_code == 400
|
|
|
|
|
|
def test_notifications_unauth(client):
|
|
anon(client)
|
|
assert client.get("/api/notifications/timezone").status_code == 401
|
|
|
|
|
|
# ── Reminder scanner (deterministic clock) ──
|
|
|
|
def _mk_row_with_reminder(client, reminder, base="2026-09-15T09:00", freq=None):
|
|
cid = _add_collection(client)
|
|
did = _add_prop(client, cid, "When", "date")
|
|
props = {str(did): base, "__reminder__": {str(did): reminder}}
|
|
if freq:
|
|
props["__recurrence__"] = {str(did): freq}
|
|
page = client.post(f"/db/{cid}/pages/api", json={"title": "Event", "properties": props})
|
|
assert page.status_code == 200, page.text
|
|
return cid, page.json()["id"]
|
|
|
|
|
|
def test_reminder_scan_fires_once(client):
|
|
from app.db import get_conn
|
|
from app.services.reminders import scan_and_fire
|
|
|
|
_login(client) # an admin user must exist to receive the notification
|
|
_mk_row_with_reminder(client, {"value": 30, "unit": "minutes"})
|
|
now = dt.datetime(2026, 9, 15, 8, 35, tzinfo=dt.UTC) # event 09:00 - 30min = 08:30
|
|
fired = scan_and_fire(now=now)
|
|
assert fired >= 1
|
|
with get_conn() as conn:
|
|
n = conn.execute("SELECT COUNT(*) c FROM notifications WHERE ntype='reminder'").fetchone()["c"]
|
|
log = conn.execute("SELECT COUNT(*) c FROM reminder_log").fetchone()["c"]
|
|
assert n >= 1 and log >= 1
|
|
|
|
# Dedup: a second scan for the same occurrence fires nothing.
|
|
assert scan_and_fire(now=now + dt.timedelta(minutes=5)) == 0
|
|
|
|
|
|
def test_reminder_scan_not_yet_due(client):
|
|
from app.db import get_conn
|
|
from app.services.reminders import scan_and_fire
|
|
|
|
_mk_row_with_reminder(client, {"value": 30, "unit": "minutes"})
|
|
now = dt.datetime(2026, 9, 15, 8, 0, tzinfo=dt.UTC) # fire_at = 08:30
|
|
assert scan_and_fire(now=now) == 0
|
|
with get_conn() as conn:
|
|
assert conn.execute("SELECT COUNT(*) c FROM notifications WHERE ntype='reminder'").fetchone()["c"] == 0
|
|
|
|
|
|
def test_reminder_recurring_occurrence(client):
|
|
from app.db import get_conn
|
|
from app.services.reminders import scan_and_fire
|
|
|
|
_login(client)
|
|
_mk_row_with_reminder(
|
|
client, {"value": 1, "unit": "days"},
|
|
base="2026-09-01T09:00", freq={"freq": "weekly"},
|
|
)
|
|
# Weekly anchored on 2026-09-01 (Tuesday): occurrences 09-01, 09-08, 09-15…
|
|
# 1-day reminder for the 09-15 occurrence fires on 09-14 at/after 09:00.
|
|
now = dt.datetime(2026, 9, 14, 9, 5, tzinfo=dt.UTC)
|
|
assert scan_and_fire(now=now) >= 1
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT message FROM notifications WHERE ntype='reminder' ORDER BY id DESC LIMIT 1"
|
|
).fetchone()
|
|
assert "2026-09-15" in row["message"]
|
|
|
|
|
|
def test_reminder_targets_assigned_person(client):
|
|
from app.db import get_conn
|
|
from app.services.reminders import scan_and_fire
|
|
|
|
session, csrf, uid = _login(client)
|
|
cid = _add_collection(client)
|
|
did = _add_prop(client, cid, "When", "date")
|
|
pid = _add_prop(client, cid, "Owner", "person")
|
|
client.post(f"/db/{cid}/pages/api", json={"title": "Mine", "properties": {
|
|
str(did): "2026-09-20T10:00",
|
|
str(pid): [{"id": uid, "login": "x"}],
|
|
"__reminder__": {str(did): {"value": 15, "unit": "minutes"}},
|
|
}})
|
|
now = dt.datetime(2026, 9, 20, 9, 46, tzinfo=dt.UTC) # fire_at 09:45
|
|
assert scan_and_fire(now=now) >= 1
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT user_id FROM notifications WHERE ntype='reminder' AND is_read=0"
|
|
).fetchone()
|
|
assert row["user_id"] == uid
|
|
|
|
|
|
def test_parse_lead_units():
|
|
from app.services.reminders import parse_lead
|
|
assert parse_lead({"value": 30, "unit": "minutes"}) == 1800
|
|
assert parse_lead({"value": 2, "unit": "hours"}) == 7200
|
|
assert parse_lead({"value": 1, "unit": "days"}) == 86400
|
|
assert parse_lead({"unit": "none"}) is None
|
|
assert parse_lead({"value": 0, "unit": "minutes"}) is None
|
|
assert parse_lead(None) is None
|
|
|
|
|
|
# ── Assignment notifications ──
|
|
|
|
def test_assignment_notification_on_update(client):
|
|
from app.db import get_conn
|
|
|
|
session, csrf, actor = _login(client)
|
|
with get_conn() as conn:
|
|
login = f"v58b_{secrets.token_hex(4)}"
|
|
conn.execute("INSERT INTO users (login, full_name, email) VALUES (?, 'Bob', '[email protected]')",
|
|
(login,))
|
|
bob = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
|
conn.commit()
|
|
|
|
cid = _add_collection(client)
|
|
pid = _add_prop(client, cid, "Owner", "person")
|
|
rid = client.post(f"/db/{cid}/pages/api", json={"title": "Task", "properties": {}}).json()["id"]
|
|
# Assign Bob.
|
|
r = client.put(f"/db/pages/{rid}/api", json={"properties": {
|
|
str(pid): [{"id": bob, "login": "bob"}],
|
|
}}, cookies={"flowdeck_session": session})
|
|
assert r.status_code == 200
|
|
with get_conn() as conn:
|
|
note = conn.execute(
|
|
"SELECT * FROM notifications WHERE user_id=? AND ntype='assignment'", (bob,)
|
|
).fetchone()
|
|
assert note is not None and note["title"] == "Assigned to you" and note["resource_id"] == rid
|
|
|
|
# Re-assigning the same person does not duplicate.
|
|
client.put(f"/db/pages/{rid}/api", json={"properties": {
|
|
str(pid): [{"id": bob, "login": "bob"}],
|
|
}}, cookies={"flowdeck_session": session})
|
|
with get_conn() as conn:
|
|
n = conn.execute(
|
|
"SELECT COUNT(*) c FROM notifications WHERE user_id=? AND ntype='assignment'", (bob,)
|
|
).fetchone()["c"]
|
|
assert n == 1
|
|
|
|
|
|
def test_notification_prefs_extended_keys(client):
|
|
session, csrf, uid = _login(client)
|
|
r = client.get("/api/notifications/prefs", cookies={"flowdeck_session": session})
|
|
prefs = r.json()["prefs"]
|
|
assert prefs["reminders"] is True and prefs["assignments"] is True
|
|
r = client.post("/api/notifications/prefs", json={"reminders": False},
|
|
cookies={"flowdeck_session": session}, headers={"X-CSRF-Token": csrf})
|
|
assert r.json()["prefs"]["reminders"] is False
|
|
assert r.json()["prefs"]["assignments"] is True
|
|
|
|
|
|
# ── Front-end wiring (rendered template markers) ──
|
|
|
|
def test_calendar_front_end_wired(client):
|
|
import pathlib
|
|
src = pathlib.Path("static/js/database_table.js").read_text(encoding="utf-8")
|
|
assert "calendar/api" in src
|
|
assert "data-cal-mode" in src and "refreshCalendar" in src
|
|
assert "openEventEditor" in src and "__recurrence__" in src and "__reminder__" in src
|