feat(calendar): v5.8.0 calendrier & rappels + v5.7.0 database avancee (pt.2)
FlowDeck CI / lint (push) Successful in 49s
FlowDeck CI / test (push) Successful in 5m26s
FlowDeck CI / docker (push) Successful in 43s

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:
2026-09-13 22:49:14 -04:00
parent 055956a351
commit d4adf89db5
18 changed files with 3301 additions and 366 deletions
+94
View File
@@ -1,5 +1,99 @@
# Changelog - FlowDeck
## v5.11.7 (2026-09-13) — v5.8.0 Calendrier & Rappels
> Calendrier complet (jour / semaine / mois) avec récurrences expandues
> côté serveur, rappels avant échéance (in-app + email), fuseaux horaires
> par utilisateur et par événement, notifications d'assignation et centre
> de notifications enrichi.
- **Moteur de récurrence** — `app/services/recurrence.py` : sous-ensemble
RRULE (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0,
timezone IANA) ; expansion virtuelle par fenêtre, jamais persistée,
bornée (garde anti-explosion). `validate_rule()` pour le 400 en écriture.
- **Vues Jour / Semaine / Mois** — la vue calendar de
`_database_table_scripts.html` gagne un sélecteur de mode (persisté dans
`viewConfig.calendar_mode`), navigation ‹/› adaptée au mode, vue Jour en
agenda trié par heure, Semaine en 7 colonnes. Les événements viennent de
`GET /db/{id}/calendar/api` (expansion serveur des occurrences).
- **Récurrences dans l'UI** — double-clic sur un événement : popover Time /
Timezone / Repeat (Every + Ends never|after count|on date) / Remind.
Règle stockée dans `property_values_json` sous `__recurrence__` (clé méta
par id de propriété date), badge ↻ sur les chips et cellules date.
- **Rappels** — `app/services/reminders.py` : `scan_and_fire(now)`
déterministe (lead minutes/heures/jours, occurrence suivante
timezone-aware), `reminder_scheduler()` toutes les 60 s ; dédup via la
table `reminder_log (page_id, occurrence_date)` ; cible = personnes
assignées, repli premier admin ; notifie en in-app + email (pref
`reminders`). Config `reminders_enabled` /
`reminder_scan_interval_seconds`.
- **Fuseaux horaires** — colonne `users.timezone` ; réglable dans Settings →
Notifications (picker IANA via `GET/POST /api/notifications/timezone`,
liste `GET /db/timezones/api`) ; priorité de résolution par ligne :
`__timezone__` de l'événement → règle de récurrence → timezone de
l'utilisateur.
- **Notifications d'assignation** — `notify_assignment()` compare les
propriétés `person` avant/après sur `PUT /db/pages/{id}/api` : les
nouvellement assignés reçoivent une notif in-app + email (pref
`assignments`). Préférences étendues : `comments`, `mentions`,
`reminders`, `assignments`.
- **Template Meeting notes** — propriétés `Agenda` et `Notes` ajoutées
(migration 12 ; ne touche pas une version personnalisée du template).
- **Migrations 11 & 12** — `reminder_log`, `users.timezone`, enrichissement
du template seed.
- **Tests** — `tests/test_v58_calendar_reminders.py` : **20 tests**
(moteur pur, schéma, validation des méta-clés 400, calendar/api,
timezone user, scanner avec horloge injectée + dédup + récurrence +
cible assignée, notifications d'assignation, prefs, câblage front).
Suite complète **523 verte**, `ruff check` OK.
- **Version** — 5.11.7.
## v5.11.6 (2026-09-13) — v5.7.0 Database Avancée (Pt. 2)
> Complète la parité Notion des bases de données : personnes et propriétés
> automatiques, groupes de propriétés, vues sauvegardées par utilisateur,
> swimlanes Kanban + WIP limits, cartes configurables, calendar avec
> glisser-déposer et gallery avec couvertures.
- **Types propriété** — `person`, `created_time`, `created_by`,
`last_edited_time`, `last_edited_by` câblés de bout en bout.
`apply_auto_properties()` (property_types) remplit/rafraîchit les valeurs
automatiques à la création et à la mise à jour des lignes ; `created_*` est
figé, `last_edited_*` est rafraîchi. Nouvel endpoint
`GET /db/{collection_id}/members/api` (membres du workspace, repli tous
utilisateurs) alimente le sélecteur `person` (chips + avatars).
- **Groupes de propriétés** — colonne `collection_properties.group_name`,
en-tête de table avec sections pliables (`db-group-row` + chevron) et
`POST /db/{collection_id}/property-groups/api` pour réassigner les colonnes.
- **Vues sauvegardées par utilisateur** — `collection_views.created_by` ;
`GET /db/{id}/views/api` ne renvoie que les vues de l'utilisateur (les vues
partagées `NULL` restent visibles), `save-as`/`duplicate` attribuent le
propriétaire, `DELETE /db/views/{id}/api` et renommage.
- **Swimlanes Kanban** — `sub_group_by` : une rangée de colonnes par lane
(2e dimension de groupement).
- **WIP limits** — `wip_limits` par colonne, colonne surlignée + compteur
`WIP n/limit` au dépassement.
- **Cartes configurables** — `card_properties` (propriétés affichées),
`card_size` (compact/détaillé) et couverture `cover_mode`
(`none`/`icon`/`color`/`property` via `cover_property`).
- **Calendar drag & drop** — vue mois (`date_property` configurable),
navigation ‹/›/Today, glisser une carte sur un jour = reschedule immédiat.
- **Gallery avec couvertures** — vignettes image (propriété `files`/`url`),
icône, couleur ou cover de ligne ; tailles small/medium/large.
- **Composant multi-vues** — `_database_table_scripts.html` réécrit :
barre de vues (ajout/suppression/duplication/paramètres), vues
table/board/calendar/gallery/list, éditeurs de cellules par type
(select/status/date/number/checkbox/multi_select/person), cellules
read-only pour les types automatiques, popover de configuration.
- **Correctif** — `PUT /db/pages/{id}/api` fusionne désormais les valeurs
soumises avec l'existant (PATCH partiel) au lieu de remplacer toute la
ligne ; `cover_url` par ligne (`collection_pages.cover_url`).
- **Migration 10** — `collection_properties.group_name`,
`collection_views.created_by`/`updated_at`, `collection_pages.cover_url`.
- **Tests** — `tests/test_v57_db_advanced.py` : **12 tests**. Suite complète
**503 verte**, `ruff check` OK.
- **Version** — 5.11.6.
## v5.11.5 (2026-09-13) — v5.6.0 Import de données (Phase 5 — durcissement)
> Finalise la solution d'import : ré-import incrémental, lots, URL, dépôt forge,
+41 -24
View File
@@ -613,27 +613,42 @@ Détails livrés :
| 4 | Phase 4 — Signets/dev/divers | S | 🟠 moyen (Gitea natif, Raindrop) |
| 5 | Phase 3 — Documents | M | 🟠 moyen (docx, HTML) |
### v5.7.0 — Database Avancée (Pt. 2)
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro.
### v5.7.0 — Database Avancée (Pt. 2) ✅ (2026-09-13)
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro. **COMPLETED**.
- [ ] **Types propriété — 🟡 partiellement fait** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` sont **déjà déclarés** dans `PROPERTY_TYPES` (`app/services/property_types.py:43-98`) ; reste à câbler : auto-valeurs (`created_time/by`, `last_edited_time/by`), sélecteur `person` (membres du workspace) et rendu/édition dans l'UI
- [ ] **Groupes de propriétés** — sections pliables dans le header de DB
- [ ] **Vues sauvegardées par utilisateur** — save view (per-user, pas workspace-wide)
- [ ] **Swimlanes Kanban** — sous-groupes horizontaux (2e dimension de groupement)
- [ ] **WIP limits** — par colonne, alerte visuelle au dépassement
- [ ] **Cartes configurables** — propriétés affichées par carte, couverture (image/icône/couleur), mode compact / détaillé
- [ ] **Calendar avec drag & drop** — reschedule direct sur la grille
- [ ] **Gallery avec couvertures** — image/icône comme vignette de carte
- [x] **Types propriété** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` câblés : auto-valeurs calculées serveur (`apply_auto_properties`) à la création/mise à jour, sélecteur `person` (membres du workspace via `GET /db/{id}/members/api`), rendu chips/avatars et édition dédiée dans l'UI
- [x] **Groupes de propriétés** — colonne `collection_properties.group_name`, header de table avec sections pliables (`db-group-row` + toggle) et `POST /db/{id}/property-groups/api`
- [x] **Vues sauvegardées par utilisateur** — `collection_views.created_by` ; `GET /db/{id}/views/api` filtre par propriétaire (les vues partagées `NULL` restent visibles), `save-as`/`duplicate`/`DELETE` + renommage
- [x] **Swimlanes Kanban** — `sub_group_by` (2e dimension de groupement) : une rangée de colonnes par lane
- [x] **WIP limits** — `wip_limits` par colonne, alerte visuelle au dépassement (`wip-exceeded`)
- [x] **Cartes configurables** — `card_properties`, `card_size` (compact/détaillé), couverture `cover_mode` (`none`/`icon`/`color`/`property`) via `cover_property`
- [x] **Calendar avec drag & drop** — grille mensuelle, `date_property`, navigation mois/Today, drop d'une carte sur un jour = reschedule
- [x] **Gallery avec couvertures** — cartes avec vignette (image de propriété, icône, couleur, ou cover de ligne), tailles small/medium/large
### v5.8.0 — Calendrier & Rappels
> **Objectif** : calendrier complet + notifications proactives.
Détails livrés :
- Migration 10 : `collection_properties.group_name`, `collection_views.created_by`/`updated_at`, `collection_pages.cover_url`
- `app/services/property_types.py` : `user_ref()`, `apply_auto_properties()`, validation `person`
- `collections.py` : auto-props create/update/sub-item, PATCH partiel fusionné, membres, groupes, vues per-user (list/save/duplicate/delete), config de vue étendue (`sub_group_by`, `wip_limits`, `card_size`, `cover_mode`, `card_properties`, `date_property`, `property_groups`)
- `dashboard.py` : auto-props à la création de ligne, `views` exposées dans `table-data` et le contexte de page
- `_database_table_scripts.html` réécrit en composant multi-vues (table/board/calendar/gallery/list) + barre de vues, éditeurs de cellules par type, picker personne, popovers de configuration
- **12 tests** `tests/test_v57_db_advanced.py` ; suite complète **503 verte** ; `ruff check` OK
- [ ] **Vues Jour / Semaine / Mois** — calendrier complet (actuellement mois seulement)
- [ ] **Récurrence d'événements** — daily/weekly/monthly/custom (RRULE)
- [ ] **Support timezone** — par utilisateur + par événement
- [ ] **Rappels** — in-app + email (avant échéance : N minutes/heures/jours)
- [ ] **Centre de notifications** — cloche in-app (mentions, assignations, commentaires, rappels)
- [ ] **Template Meeting Notes** — Attendees, Agenda, Notes, Action Items
### v5.8.0 — Calendrier & Rappels ✅ (2026-09-13)
> **Objectif** : calendrier complet + notifications proactives. **COMPLETED**.
- [x] **Vues Jour / Semaine / Mois** — sélecteur de mode dans la vue calendar (persisté en config de vue), navigation ‹/› et Today adaptées, vue Jour en agenda horodaté, Semaine en 7 colonnes ; événements servis par `GET /db/{id}/calendar/api` avec expansion serveur des occurrences
- [x] **Récurrence d'événements** — moteur `app/services/recurrence.py` (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0, timezone) ; règle stockée dans `property_values_json.__recurrence__` ; popover événement (double-clic) avec Repeat/Every/Ends ; validation serveur (400)
- [x] **Support timezone** — colonne `users.timezone` + picker dans Settings → Notifications (`GET/POST /api/notifications/timezone`, zones via `GET /db/timezones/api`) ; timezone par événement (`__timezone__`) > règle de récurrence > préférence utilisateur
- [x] **Rappels** — `app/services/reminders.py` : lead minutes/heures/jours avant chaque occurrence, scan toutes les 60 s (`reminder_scheduler`), dédup `reminder_log`, notifie les personnes assignées (repli admin), in-app + email (pref `reminders`) ; stockés dans `property_values_json.__reminder__`, édités dans le popover événement
- [x] **Centre de notifications** — la cloche (v4.9.0) couvre désormais rappels, assignations et commentaires ; nouvelles préférences `reminders` + `assignments` ; notification d'assignation émise par `notify_assignment()` sur `PUT /db/pages/{id}/api` (comparaison avant/après des propriétés `person`)
- [x] **Template Meeting Notes** — propriétés `Agenda` et `Notes` ajoutées au template seed (migration 12, préserve les personnalisations) ; le schéma Attendees/Date/Status/Action items était déjà là
Détails livrés :
- Migrations 11 (`reminder_log`, `users.timezone`) et 12 (template Meeting notes)
- `app/config.py` : `reminders_enabled`, `reminder_scan_interval_seconds`
- `_database_table_scripts.html` : modes jour/semaine/mois, chips d'occurrences (badge ↻ + heure), popover Time/Timezone/Repeat/Remind, badges ↻/🔔 dans les cellules date, drag & drop de reschedule préservé
- **20 tests** `tests/test_v58_calendar_reminders.py` ; suite complète **523 verte** ; `ruff check` OK
- **Version** — 5.11.7
### v5.9.0 — AI Writing Assist (éditeur) ✅ (2026-09-10)
> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.10.0 (Agent IA). **COMPLETED**.
@@ -712,7 +727,9 @@ Détails livrés :
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables)
9. **v5.7.0 → v5.8.0 → v5.11.0 → v5.12.0 → v5.14.0 → v6.0.0** (database avancée, calendrier/rappels, wiki-links, templates/lock, synced blocks, pro)
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés)
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés)
11. **v5.11.0 → v5.12.0 → v5.14.0 → v6.0.0** (wiki-links, templates/lock, synced blocks, pro)
---
## Résumé des phases
@@ -724,11 +741,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
+ UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ v5.14 ⬜ v6.0 ⬜
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ · v5.7 ✅ v5.14 ⬜ v6.0 ⬜
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced Pro + Agent
& Tests Templates & COMPLETED Automations Interactions Média riche blocks
Collaboration Realtime, de bloc (undo/ (embed,
DB avancée, redo, drag&drop, bookmark,
Calendrier, AI duplicate) lightbox…)
Collaboration Realtime, de bloc (undo/ (embed, + DB
DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2)
*Dernière mise à jour: 2026-09-13 — **v5.6.0 Import de données COMPLETED (Phases 0→5)** : socle unifié, 20 sources, assistant `/import` (dry-run, mapping, multi-fichiers, URL, forge), re-sync, relations Notion, rapports exportables ; 43 tests dédiés, 491 tests suite complète ; prochaine priorité : v5.7.0 Database Avancée Pt.2*
*Dernière mise à jour: 2026-09-13 — **v5.8.0 Calendrier & Rappels COMPLETED** : vues jour/semaine/mois, récurrences RRULE (daily/weekly/monthly + count/until/byweekday) expandues côté serveur, rappels in-app + email (scan 60 s, dédup reminder_log), fuseaux horaires par utilisateur et par événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés, **523 tests suite complète** ; prochaine priorité : v5.11.0 Wiki-links & mentions de page*
+1 -1
View File
@@ -1 +1 @@
5.11.5
5.11.7
+4
View File
@@ -60,6 +60,10 @@ class Settings(BaseSettings):
project_sync_enabled: bool = True
project_sync_interval_hours: int = 1
# Reminders (v5.8.0) — background scan for due date reminders
reminders_enabled: bool = True
reminder_scan_interval_seconds: int = 60
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
# email notifications are skipped (only in-app notifications are delivered).
smtp_host: str = ""
+8 -4
View File
@@ -88,13 +88,17 @@ async def lifespan(_app: FastAPI):
from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler())
logger.info("FlowDeck v5.11.5 started on port %d", settings.app_port)
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler())
logger.info("FlowDeck v5.11.7 started on port %d", settings.app_port)
try:
yield
finally:
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
task.cancel()
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
try:
await task
except asyncio.CancelledError:
@@ -103,7 +107,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="5.11.5",
version="5.11.7",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+90
View File
@@ -419,3 +419,93 @@ def _migration_import_framework(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_import_jobs_created ON import_jobs(created_at)"
)
@register(10, "v5.7.0: property groups, per-user views, row covers")
def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
"""v5.7.0 — Database Avancée (Pt. 2).
``collection_properties.group_name`` — groups properties into collapsible
sections in the table header (Notion property groups).
``collection_views.created_by`` — owner of a saved view; ``NULL`` means
a shared/legacy view visible to everyone, otherwise it is personal to a user.
``collection_pages.cover_url`` — per-row cover image (gallery/board
cards), independent from the block-page ``pages.cover_url``.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
if "group_name" not in _pcols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
)
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
if "created_by" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
if "updated_at" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
if "cover_url" not in _cpcols:
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
@register(11, "v5.8.0: reminder log + user timezones")
def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
"""v5.8.0 — Calendrier & Rappels.
``reminder_log`` — dedup ledger: one row per (page, occurrence date) so
a reminder fires exactly once even across restarts.
``users.timezone`` — personal IANA timezone used for "today" in calendar
views and reminder firing (empty = UTC).
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS reminder_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
occurrence_date TEXT NOT NULL,
fired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, occurrence_date)
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
)
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
if "timezone" not in _ucols:
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
@register(12, "v5.8.0: enrich Meeting notes template")
def _migration_v58_meeting_template(conn: sqlite3.Connection) -> None:
"""v5.8.0 — the seeded 'Meeting notes' database template gains Agenda and
Notes text properties. Only refreshed when the row still matches the old
built-in schema (user edits are never clobbered)."""
import json as _json
row = conn.execute(
"SELECT schema_json FROM database_templates WHERE name='Meeting notes'"
).fetchone()
if not row:
return
try:
schema = _json.loads(row[0] or "[]")
except (ValueError, TypeError):
return
names = [p.get("name") for p in schema]
if "Agenda" in names or "Notes" in names:
return
if names != ["Title", "Date", "Attendees", "Status", "Action items"]:
return # customised — leave alone
idx = names.index("Action items")
schema[idx:idx] = [
{"name": "Agenda", "type": "text"},
{"name": "Notes", "type": "text"},
]
conn.execute(
"UPDATE database_templates SET schema_json=? WHERE name='Meeting notes'",
(_json.dumps(schema),),
)
conn.execute(
"UPDATE database_templates SET description=? WHERE name='Meeting notes'",
("Notes de réunion avec participants, agenda, notes et actions.",),
)
+390 -32
View File
@@ -8,10 +8,38 @@ import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event
from app.services.db_templates import materialize_properties
from app.services.property_types import validate_property_rule
from app.services.property_types import (
AUTO_TYPES,
apply_auto_properties,
validate_property_rule,
)
from app.services.recurrence import (
RECURRENCE_KEY,
expand_rule,
is_valid_timezone,
parse_date,
validate_rule,
)
from app.services.reminders import REMINDER_KEY, parse_lead
def _current_user(request: Request) -> dict:
"""Resolve the session user, falling back to the local admin (single-user)."""
s = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
def _collection_properties(conn, collection_id: int) -> list[dict]:
return [
dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
@@ -42,7 +70,7 @@ def _validate_page_properties(conn, collection_id: int, properties: dict, exclud
for prop in props:
ptype = prop["prop_type"]
if ptype == "title":
if ptype == "title" or ptype in AUTO_TYPES:
continue
pid = prop["id"]
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
@@ -77,6 +105,61 @@ def _validate_page_properties(conn, collection_id: int, properties: dict, exclud
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
"""Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
alongside real property values. Raises HTTPException(400) on bad shape.
Each meta key maps a date-property id to a rule/reminder object. We verify
the target is actually a date property and the payload parses.
"""
date_ids = {
str(r["id"]) for r in conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
(collection_id,),
).fetchall()
}
rec = properties.get(RECURRENCE_KEY)
if rec not in (None, {}):
if not isinstance(rec, dict):
raise HTTPException(status_code=400, detail="Recurrence must be an object")
for prop_id, rule in rec.items():
if rule is None:
continue
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
ok, msg = validate_rule(rule)
if not ok:
raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
rem = properties.get(REMINDER_KEY)
if rem not in (None, {}):
if not isinstance(rem, dict):
raise HTTPException(status_code=400, detail="Reminder must be an object")
for prop_id, reminder in rem.items():
if reminder is None:
continue
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Reminder target must be a date property")
if not isinstance(reminder, dict):
raise HTTPException(status_code=400, detail="Reminder must be an object")
if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
if parse_lead(reminder) is None and reminder.get("unit") != "none":
raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
from app.services.recurrence import TIMEZONE_KEY
tzmap = properties.get(TIMEZONE_KEY)
if tzmap not in (None, {}):
if not isinstance(tzmap, dict):
raise HTTPException(status_code=400, detail="Timezone map must be an object")
for prop_id, value in tzmap.items():
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Timezone target must be a date property")
if value and not is_valid_timezone(str(value)):
raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
# ── API: List & Create (no path params) ──
@@ -394,27 +477,39 @@ async def update_page_api(request: Request, page_id: int):
title = body.get("title", existing["title"])
icon = body.get("icon", existing["icon"])
cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
position = body.get("position", existing["position"])
parent_id = body.get("parent_id", existing["parent_id"])
try:
stored = json.loads(existing["property_values_json"])
except (json.JSONDecodeError, TypeError):
stored = {}
if "properties" in body:
props = body["properties"]
# Partial PATCH semantics: merge submitted values over stored ones.
props = dict(stored)
props.update(body["properties"])
else:
try:
props = json.loads(existing["property_values_json"])
except (json.JSONDecodeError, TypeError):
props = {}
props = stored
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
_validate_meta_keys(conn, existing["collection_id"], props)
apply_auto_properties(
_collection_properties(conn, existing["collection_id"]),
props,
_current_user(request),
is_create=False,
)
property_values = json.dumps(props)
conn.execute(
"""UPDATE collection_pages
SET title=?, icon=?, position=?, parent_id=?, property_values_json=?,
SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(title, icon, position, parent_id, property_values, page_id),
(title, icon, cover_url, position, parent_id, property_values, page_id),
)
conn.commit()
@@ -425,6 +520,11 @@ async def update_page_api(request: Request, page_id: int):
"icon": icon,
"properties": props,
})
# Notify newly assigned people (person properties) — v5.8.0.
from app.services.notifications import notify_assignment
user = _current_user(request)
notify_assignment(existing["collection_id"], page_id, title,
stored, props, user.get("id"))
return {"id": page_id, "status": "updated"}
@@ -513,6 +613,173 @@ async def list_properties_api(request: Request, collection_id: int):
return {"properties": [dict(r) for r in rows]}
@router.get("/{collection_id}/members/api")
async def list_collection_members_api(request: Request, collection_id: int):
"""API: list workspace members available for a ``person`` property.
Resolves the collection's workspace and returns its members (falling back to
every active user for standalone databases without a workspace).
"""
with get_conn() as conn:
coll = conn.execute(
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
if ws_id:
rows = conn.execute(
"""SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
FROM workspace_members wm JOIN users u ON wm.user_id=u.id
WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
(ws_id,),
).fetchall()
else:
rows = []
if not rows:
rows = conn.execute(
"""SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
FROM users WHERE is_active=1 ORDER BY full_name, login"""
).fetchall()
return {"members": [dict(r) for r in rows]}
@router.get("/{collection_id}/calendar/api")
async def collection_calendar_api(request: Request, collection_id: int,
start: str = "", end: str = "",
date_property: str = ""):
"""API (v5.8.0): expanded calendar events for a window [start, end].
Returns every occurrence (recurrence-aware, virtual — never persisted)
of the rows in the collection whose ``date_property`` falls inside the
inclusive window. Rows without a rule yield their base date.
"""
user = _current_user(request)
s = parse_date(start)
e = parse_date(end)
if s is None or e is None or s > e:
raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
if (e - s).days > 370:
raise HTTPException(status_code=400, detail="window too large (max 370 days)")
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
props = _collection_properties(conn, collection_id)
date_props = [p for p in props if p["prop_type"] == "date"]
target = None
if date_property:
target = next((p for p in date_props
if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
if target is None:
raise HTTPException(status_code=400, detail="Unknown date property")
elif date_props:
target = date_props[0]
if target is None:
return {"events": [], "timezone": "", "property": None}
urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
rows = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchall()
pid = str(target["id"])
events: list[dict] = []
for r in rows:
try:
pv = json.loads(r["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
base_value = pv.get(pid)
if base_value is None:
base_value = pv.get(target["name"])
if parse_date(base_value) is None:
continue
rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
rule = rec_all.get(pid) or rec_all.get(target["name"])
row_tz = user_tz
if isinstance(rule, dict) and rule.get("timezone"):
row_tz = rule["timezone"]
tzmap = pv.get("__timezone__")
if isinstance(tzmap, dict):
ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
if ev_tz:
row_tz = str(ev_tz)
if rule:
dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
else:
d = parse_date(base_value)
dates = [d.isoformat()] if d and s <= d <= e else []
for iso in dates:
events.append({
"date": iso,
"page_id": r["id"],
"title": r["title"],
"icon": r["icon"],
"recurring": bool(rule),
"time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
"timezone": row_tz,
})
events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
@router.get("/timezones/api")
async def timezones_api(request: Request):
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
user = _current_user(request)
with get_conn() as conn:
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
from app.services.recurrence import common_timezones
return {
"timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
"zones": common_timezones(),
}
@router.post("/{collection_id}/property-groups/api")
async def set_property_groups_api(request: Request, collection_id: int):
"""API: (re)assign properties to collapsible groups in the table header.
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
omitted from any group have their group cleared. Empty group names clear.
"""
try:
body = await request.json()
except Exception:
body = {}
groups = body.get("groups", [])
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
conn.execute(
"UPDATE collection_properties SET group_name='' WHERE collection_id=?",
(collection_id,),
)
for grp in groups:
gname = (grp.get("name") or "").strip()
if not gname:
continue
for pid in grp.get("property_ids", []) or []:
conn.execute(
"UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?",
(gname, pid, collection_id),
)
conn.commit()
return {"status": "updated"}
@router.post("/{collection_id}/properties/api")
async def create_property_api(request: Request, collection_id: int):
"""API: create a new property on a collection."""
@@ -531,6 +798,7 @@ async def create_property_api(request: Request, collection_id: int):
required = int(body.get("required", False))
visible = int(body.get("visible_in_views", True))
validation_json = json.dumps(body.get("validation", {}))
group_name = (body.get("group_name") or "").strip()
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -546,16 +814,17 @@ async def create_property_api(request: Request, collection_id: int):
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
position, required, visible_in_views, validation_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
position, required, visible_in_views, validation_json, group_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, name, prop_type, options_json, number_format, max_pos,
required, visible, validation_json),
required, visible, validation_json, group_name),
)
conn.commit()
except Exception:
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"}
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type,
"group_name": group_name, "status": "created"}
@router.put("/properties/{prop_id}/api")
@@ -582,12 +851,15 @@ async def update_property_api(request: Request, prop_id: int):
validation_json = json.dumps(body.get("validation", {}))
else:
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "")
conn.execute(
"""UPDATE collection_properties
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?,
validation_json=?, group_name=?
WHERE id=?""",
(name, options_json, number_format, required, visible, validation_json, prop_id),
(name, options_json, number_format, required, visible, validation_json,
group_name, prop_id),
)
conn.commit()
@@ -812,18 +1084,22 @@ async def update_view_config(request: Request, view_id: int):
raise HTTPException(status_code=404, detail="View not found")
config = json.loads(existing["config_json"])
for key in ("group_by", "card_size", "cover_property", "visible_properties",
"filters", "sorts", "filter_conjunction", "date_property", "date_range_property"):
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
"filter_conjunction", "date_property", "date_range_property",
"property_groups", "view_type"):
if key in body:
config[key] = body[key]
new_type = body.get("view_type") or existing["view_type"]
conn.execute(
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name) WHERE id=?",
(json.dumps(config), body.get("name"), view_id),
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(config), body.get("name"), new_type, view_id),
)
conn.commit()
return {"id": view_id, "status": "updated", "config": config}
return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
@router.post("/{collection_id}/views/save-as")
@@ -836,6 +1112,8 @@ async def save_view_as(request: Request, collection_id: int):
name = body.get("name", "New View")
config = body.get("config", {})
user = _current_user(request)
user_id = user.get("id") if user else None
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -847,26 +1125,90 @@ async def save_view_as(request: Request, collection_id: int):
(collection_id,),
).fetchone()[0]
view_type = body.get("view_type", "table")
cur = conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
(collection_id, name, body.get("view_type", "table"), json.dumps(config), max_pos),
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)""",
(collection_id, name, view_type, json.dumps(config), max_pos, user_id),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "saved"}
return {"id": cur.lastrowid, "name": name, "view_type": view_type,
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
@router.get("/{collection_id}/views/api")
async def list_views_api(request: Request, collection_id: int):
"""API: list all views for a collection."""
"""API: list views for a collection visible to the current user.
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
personal views (``created_by = user``) only to their owner.
"""
user = _current_user(request)
user_id = user.get("id") if user else None
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
if user_id is not None:
rows = conn.execute(
"""SELECT * FROM collection_views
WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
ORDER BY position""",
(collection_id, user_id),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
(collection_id,),
).fetchall()
return {"views": [dict(r) for r in rows]}
@router.delete("/views/{view_id}/api")
async def delete_view_api(request: Request, view_id: int):
"""API: delete a saved view."""
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="View not found")
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
conn.commit()
return {"id": view_id, "status": "deleted"}
@router.post("/views/{view_id}/duplicate")
async def duplicate_view_api(request: Request, view_id: int):
"""API: duplicate a view (config + type), owned by the current user."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="View not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
(existing["collection_id"],),
).fetchone()[0]
user = _current_user(request)
user_id = user.get("id") if user else None
name = body.get("name") or (existing["name"] + " copy")
cur = conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)""",
(existing["collection_id"], name, existing["view_type"],
existing["config_json"], max_pos, user_id),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "view_type": existing["view_type"],
"status": "duplicated"}
# ── v1.8.0 Sub-items & Dependencies ──
@@ -903,9 +1245,17 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
(page_id,),
).fetchone()[0]
sub_props = body.get("properties", {}) or {}
apply_auto_properties(
_collection_properties(conn, collection_id),
sub_props,
_current_user(request),
is_create=True,
)
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
(collection_id, title, page_id, max_pos, json.dumps(sub_props)),
)
conn.commit()
new_id = cur.lastrowid
@@ -2036,6 +2386,7 @@ async def create_page_api(request: Request, collection_id: int):
icon = body.get("icon", "📄")
property_values = body.get("properties", {})
cover_url = body.get("cover_url", "")
gitea_issue_id = body.get("gitea_issue_id")
gitea_issue_number = body.get("gitea_issue_number")
@@ -2045,6 +2396,13 @@ async def create_page_api(request: Request, collection_id: int):
raise HTTPException(status_code=404, detail="Collection not found")
_validate_page_properties(conn, collection_id, property_values)
_validate_meta_keys(conn, collection_id, property_values)
apply_auto_properties(
_collection_properties(conn, collection_id),
property_values,
_current_user(request),
is_create=True,
)
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
@@ -2053,9 +2411,9 @@ async def create_page_api(request: Request, collection_id: int):
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, gitea_issue_id, gitea_issue_number, property_values_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, max_pos, gitea_issue_id, gitea_issue_number,
(collection_id, title, icon, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number,
json.dumps(property_values)),
)
conn.commit()
+35 -7
View File
@@ -385,10 +385,17 @@ async def view_page_root(request: Request, page_id: int):
(page["collection_id"],),
).fetchall()
]
cviews = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(page["collection_id"],),
).fetchall()
]
collection_data = {
"collection": dict(col),
"properties": props,
"pages": cpages,
"views": cviews,
}
page_data["collection_id"] = page["collection_id"]
@@ -2471,10 +2478,18 @@ async def api_collection_table_data(collection_id: int):
).fetchall()
]
views = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
return {
"collection": dict(coll),
"properties": properties,
"pages": pages,
"views": views,
}
@@ -2503,20 +2518,31 @@ async def api_create_collection_page(collection_id: int, request: Request):
).fetchone()[0]
# Load default property values from collection properties
props = conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
props = [
dict(r) for r in conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
default_values = {}
for p in props:
if p["prop_type"] == "title":
default_values[str(p["id"])] = title
# Caller-provided values (e.g. board "add card in column X") win.
incoming = body.get("properties") or {}
if isinstance(incoming, dict):
default_values.update(incoming)
from app.services.property_types import apply_auto_properties
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1}
apply_auto_properties(props, default_values, user, is_create=True)
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, property_values_json)
VALUES (?, ?, ?, ?, ?)""",
(collection_id, title, icon, max_pos, _json.dumps(default_values)),
(collection_id, title, icon, cover_url, position, property_values_json)
VALUES (?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, body.get("cover_url", ""), max_pos,
_json.dumps(default_values)),
)
page_id = cur.lastrowid
conn.commit()
@@ -2524,6 +2550,8 @@ async def api_create_collection_page(collection_id: int, request: Request):
return {
"id": page_id,
"title": title,
"icon": icon,
"position": max_pos,
"property_values_json": default_values,
"status": "created",
}
+27 -1
View File
@@ -97,13 +97,39 @@ async def set_prefs(request: Request):
from app.services import notifications as notif
body = await request.json() if request.headers.get("content-type") else {}
prefs = notif.get_user_prefs(user["id"])
for key in ("comments", "mentions"):
for key in ("comments", "mentions", "reminders", "assignments"):
if key in body:
prefs[key] = bool(body[key])
notif.set_user_prefs(user["id"], prefs)
return {"status": "ok", "prefs": prefs}
@router.get("/timezone")
async def get_timezone(request: Request):
"""Return the current user's IANA timezone ('' = UTC)."""
user = _current_user(request)
with get_conn() as conn:
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user["id"],)).fetchone()
tz = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
from app.services.recurrence import common_timezones
return {"timezone": tz, "zones": common_timezones()}
@router.post("/timezone")
async def set_timezone(request: Request):
"""Update the current user's IANA timezone (empty string = UTC)."""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
tz = (body.get("timezone") or "").strip()
from app.services.recurrence import is_valid_timezone
if tz and not is_valid_timezone(tz):
raise HTTPException(status_code=400, detail=f"Unknown timezone '{tz}'")
with get_conn() as conn:
conn.execute("UPDATE users SET timezone=? WHERE id=?", (tz, user["id"]))
conn.commit()
return {"status": "ok", "timezone": tz}
@router.get("/users/search")
async def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions."""
+3 -1
View File
@@ -95,7 +95,7 @@ SEED_TEMPLATES: list[dict] = [
{
"name": "Meeting notes",
"icon": "🗒️",
"description": "Notes de réunion avec participants et décisions.",
"description": "Notes de réunion avec participants, agenda, notes et actions.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Date", "type": "date"},
@@ -104,6 +104,8 @@ SEED_TEMPLATES: list[dict] = [
{"name": "Scheduled", "color": "gray"},
{"name": "Done", "color": "green"},
]},
{"name": "Agenda", "type": "text"},
{"name": "Notes", "type": "text"},
{"name": "Action items", "type": "multi_select"},
],
},
+68 -2
View File
@@ -121,13 +121,15 @@ def get_user_prefs(user_id: int, conn=None) -> dict:
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
).fetchone()
if not row or not row["notification_prefs"]:
return {"comments": True, "mentions": True}
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))}
"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:
@@ -145,3 +147,67 @@ def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
)
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
+52 -3
View File
@@ -136,6 +136,9 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
datetime.fromisoformat(value.replace("Z", "+00:00"))
except (ValueError, TypeError):
return False, f"'{value}' is not a valid ISO 8601 date"
elif prop_type == "person":
if not isinstance(value, list):
return False, "Person must be a list of workspace members"
elif prop_type == "url":
if not isinstance(value, str):
return False, "URL must be a string"
@@ -227,17 +230,63 @@ def validate_property_rule(
return True, ""
def user_ref(user: dict | None) -> dict | None:
"""Normalize a session user dict into the stored ``person`` value shape."""
if not user:
return None
return {
"id": user.get("id"),
"login": user.get("login") or user.get("full_name") or "",
"full_name": user.get("full_name") or "",
"avatar_url": user.get("avatar_url") or "",
"avatar_color": user.get("avatar_color") or "#3A3A3A",
}
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(UTC).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
if user:
return {"id": user.get("id"), "login": user.get("login")}
return None
return user_ref(user)
return None
def apply_auto_properties(
properties: list[dict],
values: dict,
user: dict | None = None,
*,
is_create: bool = False,
now: str | None = None,
) -> dict:
"""Fill/refresh auto-property values (``created_time``, ``created_by``,
``last_edited_time``, ``last_edited_by``) in ``values`` (keyed by property id).
``created_*`` are only written on creation (or when missing); ``last_edited_*``
are refreshed on every call. Returns the mutated dict.
"""
if values is None:
values = {}
stamp = now or datetime.now(UTC).isoformat()
for prop in properties or []:
ptype = prop.get("prop_type")
if ptype not in AUTO_TYPES:
continue
pid = str(prop.get("id"))
if ptype == "created_time":
if is_create or pid not in values or values.get(pid) in (None, ""):
values[pid] = stamp
elif ptype == "last_edited_time":
values[pid] = stamp
elif ptype == "created_by":
if is_create or pid not in values or values.get(pid) in (None, ""):
values[pid] = user_ref(user)
elif ptype == "last_edited_by":
values[pid] = user_ref(user)
return values
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
+273
View File
@@ -0,0 +1,273 @@
"""FlowDeck — Recurring events engine (v5.8.0 Calendrier & Rappels).
A recurring row stores its rule in ``property_values_json`` under the special
key ``__recurrence__``::
"__recurrence__": { "<date_prop_id>": {"freq": "weekly", "interval": 1,
"count": null, "until": null, "byweekday": [0,2,4],
"timezone": "Europe/Paris"} }
Only a RRULE subset is supported (matching the roadmap: daily/weekly/monthly
+ custom interval, COUNT, UNTIL and BYDAY for weekly). Expansion is computed
on the fly for a visible window — occurrences are virtual, never persisted.
All pure-Python (stdlib only) so it is trivially testable.
"""
from __future__ import annotations
import calendar as _cal
import datetime as dt
import logging
logger = logging.getLogger(__name__)
FREQS = ("daily", "weekly", "monthly")
# Monday-first weekday numbering (matches the UI calendar grid).
DOW_NAMES = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6}
RECURRENCE_KEY = "__recurrence__"
TIMEZONE_KEY = "__timezone__"
try: # Python >= 3.9 ships zoneinfo; keep a graceful fallback anyway.
from zoneinfo import ZoneInfo, available_timezones
def _zone(tz_name: str):
try:
return ZoneInfo(tz_name)
except Exception:
return None
def is_valid_timezone(tz_name: str) -> bool:
if not tz_name:
return True
try:
return tz_name in available_timezones() or tz_name == "UTC"
except Exception:
return False
except Exception: # pragma: no cover - environment without tz database
def _zone(tz_name: str):
return None
def is_valid_timezone(tz_name: str) -> bool:
return True # no tz database → accept anything (zoneinfo absent)
def parse_date(value) -> dt.date | None:
"""Parse a stored date property value ('YYYY-MM-DD[THH:MM]') into a date."""
s = str(value or "").strip()
if not s:
return None
s = s.replace(" ", "T")
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d"):
try:
return dt.datetime.strptime(s[:19], fmt).date()
except ValueError:
continue
try:
return dt.date.fromisoformat(s[:10])
except ValueError:
return None
def validate_rule(rule) -> tuple[bool, str]:
"""Validate a recurrence rule dict. Returns (ok, error_message)."""
if rule is None:
return True, ""
if not isinstance(rule, dict):
return False, "Recurrence must be an object"
freq = rule.get("freq")
if freq not in FREQS:
return False, f"freq must be one of {FREQS}"
interval = rule.get("interval", 1)
if not isinstance(interval, int) or isinstance(interval, bool) or interval < 1 or interval > 365:
return False, "interval must be an integer between 1 and 365"
count = rule.get("count")
if count is not None:
if not isinstance(count, int) or isinstance(count, bool) or count < 1 or count > 1000:
return False, "count must be an integer between 1 and 1000"
until = rule.get("until")
if until not in (None, "") and parse_date(until) is None:
return False, "until must be a YYYY-MM-DD date"
if count is not None and until not in (None, ""):
return False, "count and until are mutually exclusive"
bywd = rule.get("byweekday")
if bywd not in (None, []):
if not isinstance(bywd, list) or any(
(not isinstance(d, int) or isinstance(d, bool) or d < 0 or d > 6) for d in bywd
):
return False, "byweekday must be a list of integers 0-6 (Monday=0)"
if freq != "weekly":
return False, "byweekday is only valid for weekly recurrence"
tz = rule.get("timezone")
if tz not in (None, "") and not is_valid_timezone(tz):
return False, f"unknown timezone '{tz}'"
return True, ""
def _generate_occurrences(base: dt.date, rule: dict):
"""Yield every occurrence date in chronological order, honouring
freq/interval/count/until/byweekday. Infinite rules yield forever —
the caller must bound the consumption (see expand_rule)."""
freq = rule.get("freq", "daily")
interval = max(1, int(rule.get("interval") or 1))
count = rule.get("count")
until = parse_date(rule.get("until")) if rule.get("until") else None
weekdays = sorted(set(rule.get("byweekday") or [])) if freq == "weekly" else []
produced = 0
def limit_hit(d: dt.date) -> bool:
return (count is not None and produced >= count) or bool(until and d > until)
if freq == "daily":
d = base
while not limit_hit(d):
yield d
produced += 1
d += dt.timedelta(days=interval)
elif freq == "weekly":
# Monday=0 weekday numbering (matches the UI calendar grid).
days = weekdays or [(base.isoweekday() - 1) % 7]
week0 = base - dt.timedelta(days=(base.isoweekday() - 1) % 7)
w = week0
while True:
for off in days:
d = w + dt.timedelta(days=off)
if d < base or limit_hit(d):
continue
yield d
produced += 1
w += dt.timedelta(weeks=interval)
if limit_hit(w):
return
else: # monthly
i = 0
while True:
d = _nth_month_occurrence(base, i * interval)
if limit_hit(d):
return
yield d
produced += 1
i += 1
def expand_rule(base_value, rule: dict, window_start: dt.date, window_end: dt.date,
max_occurrences: int = 500) -> list[str]:
"""Return ISO dates ('YYYY-MM-DD') of occurrences inside the inclusive
window [window_start, window_end] for a rule anchored at ``base_value``.
Occurrences before the window are skipped (but still count against
``count``); the expansion is hard-capped so pathological rules can never
blow up a request.
"""
base = parse_date(base_value)
ok, _err = validate_rule(rule)
if base is None or rule is None or not ok:
return []
out: list[str] = []
scanned = 0
for d in _generate_occurrences(base, rule or {}):
if d > window_end:
break
scanned += 1
if scanned > 200000: # safety valve for old, very long-running rules
break
if d >= window_start:
out.append(d.isoformat())
if len(out) >= max_occurrences:
break
return out
def _nth_month_occurrence(base: dt.date, months_offset: int) -> dt.date:
month_index = (base.year * 12 + (base.month - 1)) + months_offset
year, month0 = divmod(month_index, 12)
month = month0 + 1
day = min(base.day, _cal.monthrange(year, month)[1])
return dt.date(year, month, day)
def now_in_tz(tz_name: str | None = None) -> dt.datetime:
"""Current time in the given IANA timezone (fallback: UTC)."""
zone = _zone(tz_name) if tz_name else None
if zone is not None:
return dt.datetime.now(zone)
return dt.datetime.now(dt.UTC)
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
return now_in_tz(tz_name).date()
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
"""Combine an occurrence date + 'HH:MM' time into an aware datetime in
``tz_name`` (fallback naive→UTC). Default time 09:00 when none stored."""
hh, mm = 9, 0
if time_str and len(time_str) >= 16:
try:
hh, mm = int(time_str[11:13]), int(time_str[14:16])
except ValueError:
pass
zone = _zone(tz_name) if tz_name else None
naive = dt.datetime(d.year, d.month, d.day, hh, mm)
if zone is not None:
return naive.replace(tzinfo=zone)
return naive.replace(tzinfo=dt.UTC)
def next_occurrences(base_value, rule: dict, tz_name: str | None,
after: dt.datetime, horizon_days: int = 365,
max_count: int = 50) -> list[tuple[dt.date, dt.datetime]]:
"""Upcoming occurrences (date, aware event datetime) strictly on/after
``after`` for a row anchored at ``base_value``. Non-recurring rules
(rule falsy) yield the single base occurrence. Bounded by horizon."""
base = parse_date(base_value)
if base is None:
return []
time_str = str(base_value or "")
start = after.date()
if not rule:
event = _zone_dt(base, time_str, tz_name)
return [(base, event)] if event >= after else []
ok, _ = validate_rule(rule)
if not ok:
return []
out: list[tuple[dt.date, dt.datetime]] = []
window_end = start + dt.timedelta(days=horizon_days)
for iso in expand_rule(base_value, rule, start, window_end, max_occurrences=max_count):
d = dt.date.fromisoformat(iso)
event = _zone_dt(d, time_str, tz_name)
if event >= after:
out.append((d, event))
if len(out) >= max_count:
break
return out
# A pragmatic list for pickers (zoneinfo's full list is ~600 entries).
COMMON_TIMEZONES = [
"UTC", "Europe/Paris", "Europe/London", "Europe/Berlin", "Europe/Madrid",
"Europe/Rome", "Europe/Amsterdam", "Europe/Lisbon", "Europe/Zurich",
"Europe/Stockholm", "Europe/Warsaw", "Europe/Moscow", "Europe/Istanbul",
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
"America/Toronto", "America/Vancouver", "America/Mexico_City", "America/Sao_Paulo",
"America/Buenos_Aires", "Asia/Dubai", "Asia/Karachi", "Asia/Kolkata",
"Asia/Bangkok", "Asia/Singapore", "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul",
"Australia/Sydney", "Australia/Perth", "Pacific/Auckland", "Africa/Cairo",
"Africa/Lagos", "Africa/Johannesburg", "Africa/Algiers", "Africa/Casablanca",
]
def common_timezones() -> list[str]:
"""Timezone list for pickers: curated common zones + all installed ones
whose region matches, deduplicated and sorted."""
try:
from zoneinfo import available_timezones
all_zones = sorted(available_timezones())
# Merge: keep curated first, then everything else available.
extra = [z for z in all_zones if z not in COMMON_TIMEZONES]
return COMMON_TIMEZONES + extra
except Exception:
return list(COMMON_TIMEZONES)
+225
View File
@@ -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))))
File diff suppressed because it is too large Load Diff
+59 -2
View File
@@ -274,6 +274,47 @@
</label>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Reminders</div>
<div class="setting-desc">Upcoming events &amp; date reminders from your databases</div>
</div>
<div class="setting-control">
<label class="toggle-switch sm">
<input type="checkbox" x-model="notifPrefs.reminders" @change="saveNotifPrefs()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Assignments</div>
<div class="setting-desc">When a database row is assigned to you</div>
</div>
<div class="setting-control">
<label class="toggle-switch sm">
<input type="checkbox" x-model="notifPrefs.assignments" @change="saveNotifPrefs()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-group">
<h3>Timezone</h3>
<div class="setting-row">
<div>
<div class="setting-label">Your timezone</div>
<div class="setting-desc">Used by calendar views (today, week start) and reminder firing. Empty = UTC.</div>
</div>
<div class="setting-control">
<select class="settings-input" style="max-width:260px" x-model="userTimezone" @change="saveTimezone()">
<option value="">UTC</option>
<template x-for="z in userZones" :key="z">
<option :value="z" x-text="z"></option>
</template>
</select>
</div>
</div>
</div>
</div>
@@ -915,7 +956,8 @@ function settingsInit() {
defaultView: localStorage.getItem('fd_default_view') || 'tree',
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
notifPrefs: {comments: true, mentions: true},
notifPrefs: {comments: true, mentions: true, reminders: true, assignments: true},
userTimezone: '', userZones: [],
// Auth & Integrations
authMethod: '{{ auth_method }}',
giteaLinked: false,
@@ -984,7 +1026,13 @@ function settingsInit() {
try {
var r = await fetch('/api/notifications/prefs', {credentials:'same-origin'});
var d = await r.json();
this.notifPrefs = Object.assign({comments:true, mentions:true}, d.prefs || {});
this.notifPrefs = Object.assign({comments:true, mentions:true, reminders:true, assignments:true}, d.prefs || {});
} catch(e) {}
try {
var tz = await fetch('/api/notifications/timezone', {credentials:'same-origin'});
var td = await tz.json();
this.userTimezone = td.timezone || '';
this.userZones = td.zones || [];
} catch(e) {}
},
async saveNotifPrefs() {
@@ -995,6 +1043,15 @@ function settingsInit() {
});
} catch(e) {}
},
async saveTimezone() {
try {
var r = await fetch('/api/notifications/timezone', {
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({timezone: this.userTimezone})
});
if (r.ok && typeof toast === 'function') toast('Timezone saved');
} catch(e) {}
},
async loadTags() {
try {
+277
View File
@@ -0,0 +1,277 @@
"""FlowDeck — v5.7.0 Database Avancée (Pt. 2).
Covers: person + auto properties (created/last-edited time & by), property
groups, per-user saved views, swimlanes/WIP/card/calendar view config, row
covers and the multi-view editor rendering.
"""
from __future__ import annotations
import json
import os
import secrets
import tempfile
import pytest
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"
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db
from app.main import app
init_db()
yield TestClient(app)
os.unlink(db_path)
def _add_collection(client, name="DB"):
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"]
def _props(client, cid):
return client.get(f"/db/{cid}/properties/api").json()["properties"]
def _login(client):
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
login = f"v57admin_{secrets.token_hex(4)}"
conn.execute(
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
"VALUES (?, 'V57 Admin', ?, ?, 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
# ── Schema / migration ──
def test_migration_v57_columns(client):
from app.db import get_conn
with get_conn() as conn:
pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
assert "group_name" in pcols
assert "created_by" in vcols
assert "cover_url" in cpcols
assert version >= 10
# ── Person property ──
def test_person_property_value_roundtrip(client):
cid = _add_collection(client)
pid = _add_prop(client, cid, "Assignee", "person")
r = client.post(f"/db/{cid}/pages/api", json={
"title": "Row", "properties": {str(pid): [{"id": 7, "login": "alice"}]}
})
assert r.status_code == 200
page = client.get(f"/db/pages/{r.json()['id']}/api").json()
pv = json.loads(page["property_values_json"])
assert pv[str(pid)] == [{"id": 7, "login": "alice"}]
def test_person_property_rejects_non_list(client):
cid = _add_collection(client)
pid = _add_prop(client, cid, "Owner", "person")
r = client.post(f"/db/{cid}/pages/api", json={
"title": "Row", "properties": {str(pid): "alice"}
})
assert r.status_code == 400
assert "person" in r.json()["detail"].lower()
def test_collection_members_endpoint(client):
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT INTO users (login, full_name, email, avatar_color) VALUES ('bob', 'Bob', '[email protected]', '#123456')"
)
conn.commit()
cid = _add_collection(client)
r = client.get(f"/db/{cid}/members/api")
assert r.status_code == 200
members = r.json()["members"]
assert any(m["login"] == "bob" for m in members)
# ── Auto properties ──
def test_auto_properties_filled_on_create_and_update(client):
cid = _add_collection(client)
ct = _add_prop(client, cid, "Created", "created_time")
cb = _add_prop(client, cid, "Created by", "created_by")
lt = _add_prop(client, cid, "Edited", "last_edited_time")
lb = _add_prop(client, cid, "Edited by", "last_edited_by")
r = client.post(f"/db/{cid}/pages/api", json={"title": "Task"})
assert r.status_code == 200
page_id = r.json()["id"]
pv = json.loads(client.get(f"/db/pages/{page_id}/api").json()["property_values_json"])
assert pv[str(ct)]
assert pv[str(lt)]
assert pv[str(cb)]["login"] == "admin"
assert pv[str(lb)]["login"] == "admin"
created = pv[str(ct)]
# Partial update keeps created_time and refreshes last_edited_time.
r = client.put(f"/db/pages/{page_id}/api", json={"properties": {}})
assert r.status_code == 200
pv2 = json.loads(client.get(f"/db/pages/{page_id}/api").json()["property_values_json"])
assert pv2[str(ct)] == created
assert pv2[str(lt)] >= created
def test_partial_update_merges_properties(client):
cid = _add_collection(client)
a = _add_prop(client, cid, "A", "text")
b = _add_prop(client, cid, "B", "text")
r = client.post(f"/db/{cid}/pages/api", json={
"title": "Row", "properties": {str(a): "one", str(b): "two"}
})
pid = r.json()["id"]
client.put(f"/db/pages/{pid}/api", json={"properties": {str(a): "changed"}})
pv = json.loads(client.get(f"/db/pages/{pid}/api").json()["property_values_json"])
assert pv[str(a)] == "changed"
assert pv[str(b)] == "two"
# ── Property groups ──
def test_property_group_assignment(client):
cid = _add_collection(client)
p1 = _add_prop(client, cid, "Name2", "text", group_name="Basics")
p2 = _add_prop(client, cid, "Status2", "status")
props = {p["id"]: p for p in _props(client, cid)}
assert props[p1]["group_name"] == "Basics"
r = client.post(f"/db/{cid}/property-groups/api", json={
"groups": [{"name": "Workflow", "property_ids": [p1, p2]}]
})
assert r.status_code == 200
props = {p["id"]: p for p in _props(client, cid)}
assert props[p1]["group_name"] == "Workflow"
assert props[p2]["group_name"] == "Workflow"
# Clearing groups
client.post(f"/db/{cid}/property-groups/api", json={"groups": []})
props = {p["id"]: p for p in _props(client, cid)}
assert props[p1]["group_name"] == ""
# ── Per-user saved views ──
def test_saved_views_are_per_user(client):
cookie, csrf, uid = _login(client)
cid = _add_collection(client)
r = client.post(f"/db/{cid}/views/save-as",
json={"name": "My Board", "view_type": "board", "config": {"group_by": 1}},
cookies={"flowdeck_session": cookie}, headers={"X-CSRF-Token": csrf})
assert r.status_code == 200
view = r.json()
assert view["created_by"] == uid
assert view["view_type"] == "board"
# The owner sees it.
views = client.get(f"/db/{cid}/views/api", cookies={"flowdeck_session": cookie}).json()["views"]
assert any(v["id"] == view["id"] for v in views)
# Duplicate + delete.
dup = client.post(f"/db/views/{view['id']}/duplicate", json={},
cookies={"flowdeck_session": cookie}, headers={"X-CSRF-Token": csrf}).json()
assert dup["id"] != view["id"]
d = client.delete(f"/db/views/{view['id']}/api", cookies={"flowdeck_session": cookie},
headers={"X-CSRF-Token": csrf})
assert d.status_code == 200
views = client.get(f"/db/{cid}/views/api", cookies={"flowdeck_session": cookie}).json()["views"]
assert not any(v["id"] == view["id"] for v in views)
def test_view_config_extended_keys(client):
cid = _add_collection(client)
views = client.get(f"/db/{cid}/views/api").json()["views"]
vid = views[0]["id"]
payload = {
"sub_group_by": 3, "wip_limits": {"In progress": 2}, "card_size": "small",
"cover_mode": "color", "card_properties": [1, 2], "date_property": 4,
}
r = client.put(f"/db/views/{vid}/config", json=payload)
assert r.status_code == 200
cfg = r.json()["config"]
for k, v in payload.items():
assert cfg[k] == v
# ── Row cover ──
def test_row_cover_url(client):
cid = _add_collection(client)
r = client.post(f"/db/{cid}/pages/api", json={"title": "Row", "cover_url": "https://x.test/a.png"})
pid = r.json()["id"]
page = client.get(f"/db/pages/{pid}/api").json()
assert page["cover_url"] == "https://x.test/a.png"
client.put(f"/db/pages/{pid}/api", json={"cover_url": "https://x.test/b.png"})
page = client.get(f"/db/pages/{pid}/api").json()
assert page["cover_url"] == "https://x.test/b.png"
# ── Table-data payload includes views ──
def test_table_data_includes_views(client):
cid = _add_collection(client)
data = client.get(f"/api/collections/{cid}/table-data").json()
assert "views" in data
assert isinstance(data["views"], list)
assert len(data["views"]) >= 1
# ── Full-page editor renders the multi-view component ──
def test_collection_page_renders_multi_view(client):
cookie, csrf, uid = _login(client)
page = client.post(
"/board/api/pages?title=My%20DB&section=Private&parent_id=0",
headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": cookie},
).json()["id"]
r = client.post(f"/api/pages/{page}/convert-to-database", json={"name": "My DB"},
headers={"X-CSRF-Token": csrf}, cookies={"flowdeck_session": cookie})
assert r.status_code == 200
resp = client.get(f"/pages/{page}", cookies={"flowdeck_session": cookie})
assert resp.status_code == 200
assert "db-view-bar" in resp.text
assert "FlowDeckDB" in resp.text
# New view types + settings are available client-side.
assert "db-board" in resp.text
assert "db-cal-grid" in resp.text
assert "db-gallery" in resp.text
+419
View File
@@ -0,0 +1,419 @@
"""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 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"
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db
from app.main import app
init_db()
yield 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):
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("app/templates/_database_table_scripts.html").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