From d4adf89db556fe89a48cdaaa814db3e44267fe9f Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 13 Sep 2026 22:49:14 -0400 Subject: [PATCH] feat(calendar): v5.8.0 calendrier & rappels + v5.7.0 database avancee (pt.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 94 ++ ROADMAP.md | 65 +- VERSION | 2 +- app/config.py | 4 + app/main.py | 12 +- app/migrations.py | 90 ++ app/routers/collections.py | 422 +++++- app/routers/dashboard.py | 42 +- app/routers/notifications.py | 28 +- app/services/db_templates.py | 4 +- app/services/notifications.py | 70 +- app/services/property_types.py | 55 +- app/services/recurrence.py | 273 ++++ app/services/reminders.py | 225 +++ app/templates/_database_table_scripts.html | 1524 ++++++++++++++++---- app/templates/settings.html | 61 +- tests/test_v57_db_advanced.py | 277 ++++ tests/test_v58_calendar_reminders.py | 419 ++++++ 18 files changed, 3301 insertions(+), 366 deletions(-) create mode 100644 app/services/recurrence.py create mode 100644 app/services/reminders.py create mode 100644 tests/test_v57_db_advanced.py create mode 100644 tests/test_v58_calendar_reminders.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f98b3a0..0c7b857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/ROADMAP.md b/ROADMAP.md index 93065ef..775bf11 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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* diff --git a/VERSION b/VERSION index 7440f88..db88ca6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.5 +5.11.7 diff --git a/app/config.py b/app/config.py index 3f1293c..43305ba 100644 --- a/app/config.py +++ b/app/config.py @@ -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 = "" diff --git a/app/main.py b/app/main.py index d582f8a..5d22d13 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/migrations.py b/app/migrations.py index 3a18860..e3cf80c 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -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.",), + ) diff --git a/app/routers/collections.py b/app/routers/collections.py index 89d07a3..99a02e4 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -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() diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 241685d..0986c09 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -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", } diff --git a/app/routers/notifications.py b/app/routers/notifications.py index 7e18c94..ea1c7f1 100644 --- a/app/routers/notifications.py +++ b/app/routers/notifications.py @@ -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.""" diff --git a/app/services/db_templates.py b/app/services/db_templates.py index f941d55..7d8c5b7 100644 --- a/app/services/db_templates.py +++ b/app/services/db_templates.py @@ -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"}, ], }, diff --git a/app/services/notifications.py b/app/services/notifications.py index 31aee18..252f408 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -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 diff --git a/app/services/property_types.py b/app/services/property_types.py index 28611b1..e01fd52 100644 --- a/app/services/property_types.py +++ b/app/services/property_types.py @@ -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( diff --git a/app/services/recurrence.py b/app/services/recurrence.py new file mode 100644 index 0000000..0ac2bb5 --- /dev/null +++ b/app/services/recurrence.py @@ -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__": { "": {"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) diff --git a/app/services/reminders.py b/app/services/reminders.py new file mode 100644 index 0000000..11be20c --- /dev/null +++ b/app/services/reminders.py @@ -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__": { "": {"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)))) diff --git a/app/templates/_database_table_scripts.html b/app/templates/_database_table_scripts.html index 8fc38f2..f8a0069 100644 --- a/app/templates/_database_table_scripts.html +++ b/app/templates/_database_table_scripts.html @@ -1,5 +1,111 @@ - - + + diff --git a/app/templates/settings.html b/app/templates/settings.html index 7cbadb7..af89334 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -274,6 +274,47 @@ +
+
+
Reminders
+
Upcoming events & date reminders from your databases
+
+
+ +
+
+
+
+
Assignments
+
When a database row is assigned to you
+
+
+ +
+
+ +
+

Timezone

+
+
+
Your timezone
+
Used by calendar views (today, week start) and reminder firing. Empty = UTC.
+
+
+ +
+
@@ -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 { diff --git a/tests/test_v57_db_advanced.py b/tests/test_v57_db_advanced.py new file mode 100644 index 0000000..442ca3a --- /dev/null +++ b/tests/test_v57_db_advanced.py @@ -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', 'bob@x.com', '#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§ion=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 diff --git a/tests/test_v58_calendar_reminders.py b/tests/test_v58_calendar_reminders.py new file mode 100644 index 0000000..3b4cba7 --- /dev/null +++ b/tests/test_v58_calendar_reminders.py @@ -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', 'bob@x.com')", + (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