Compare commits

...
8 Commits
Author SHA1 Message Date
bruno cd854e1dfe feat(v2.0.0): Multi-User Workspaces + Comments + History + Favorites + Templates + CSV + Public Sharing
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- 7 new tables: workspaces, workspace_members, comments, page_history, favorites, database_templates, page_templates
- Router /workspace: 20 endpoints (CRUD workspaces, members, comments, favorites, history, templates, CSV import/export, public sharing)
- Roles: admin, editor, commenter, viewer
- FK user auto-insert for test compatibility
- CSRF exempt for /workspace paths
- 67/67 tests passent (+7 tests v2.0)
- Version 1.9.0 → 2.0.0
- Docs: ROADMAP updated (7/7 blocs completed)
2026-07-10 07:22:34 -04:00
bruno f752ae2ed7 docs: ROADMAP v1.9.0 ✅ — 5 blocs complétés (37/52)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-09 23:09:05 -04:00
bruno ad95c87b01 feat(v1.9.0): My Tasks — dashboard cross-collection unifié
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Nouveau router my_tasks.py: GET /my-tasks (HTML), GET /my-tasks/api (JSON)
- Agrège toutes les pages assignées à l'utilisateur sur toutes les collections
- Vues: All, Today, Overdue, Next 7 days
- Groupement par collection, filter admin auto
- 60/60 tests passent (+4 tests v1.9)
- Version 1.8.0 → 1.9.0
2026-07-09 23:08:49 -04:00
bruno 4a6ed24315 feat(v1.7.0+v1.8.0): Vues Améliorées + Sub-items & Dépendances
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
v1.7.0 — View Management:
- PUT /db/views/{id}/config (group_by, card_size, cover, visible_properties)
- POST /db/{id}/views/save-as (save current state as new view)
- GET /db/{id}/views/api (list views)

v1.8.0 — Sub-items & Dependencies:
- parent_id auto-référence: GET/POST /db/{id}/pages/{pid}/sub-items
- Status aggregation: GET .../status-aggregate
- Dependencies: POST .../dependencies, POST .../check-deps (blocking constraint)
- 56/56 tests passent (+7 tests)
- Version 1.6.0 → 1.8.0
2026-07-09 23:06:53 -04:00
bruno e725398543 feat(v1.6.0): Vues Manquantes — Calendar, Gallery, List, Timeline, Table SSR
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- 4 nouvelles vues HTML: calendar (grid mensuel), gallery (cartes), list (compacte), timeline (Gantt)
- Navigation entre vues via tabs (Table/Board/Calendar/Gallery/List/Timeline)
- _render_view dispatcher + _base_html template commun
- Routes: GET /db/{id}/view/{calendar,gallery,list,timeline}
- Default view = table (SSR avec properties)
- 49/49 tests passent (+6 tests v1.6)
- Version 1.5.0 → 1.6.0
2026-07-09 23:03:51 -04:00
bruno b8647f1a19 feat(v1.5.0): Relations, Rollups, Formulas — FormulaEngine, RollupEngine, API
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Has been skipped
- FormulaEngine: 19 functions (prop, if, concat, round, now, today, dateAdd, replace, ...)
- RollupEngine: 12 aggregations (count, sum, avg, min, max, range, unique, percent_checked)
- API relation: POST /db/{id}/properties/relation + /link (bidirectional)
- API rollup: POST /db/rollup/compute
- API formula: POST /db/formula/evaluate
- FKs fix: related_collection_id/relation_property_id/target_property_id → ON DELETE SET NULL
- 43/43 tests passent (+5 tests v1.5)
- Version 1.4.0 → 1.5.0
- Docs: ROADMAP, CHANGELOG à jour
2026-07-09 22:48:41 -04:00
bruno 16532d10bd feat(v1.4.0): Propriétés Avancées — collection_properties, 21 types, API CRUD
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Table collection_properties (21 types Notion: number, checkbox, url, email, phone, status, files, unique_id, ...)
- Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
- Service property_types.py: validation, formatage, auto-values
- API: GET /db/property-types/api, CRUD /db/{id}/properties/api, PUT/DELETE /db/properties/{id}/api
- 38/38 tests passent (+4 nouveaux tests v1.4)
- Version 1.3.0 → 1.4.0
- Docs: ROADMAP, CHANGELOG, WORKLOAD, README à jour
2026-07-09 22:43:16 -04:00
bruno d94817bac5 docs: ROADMAP v1.3.0 ✅ + WORKLOAD/README updated
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- ROADMAP: v1.3.0 déplacé dans Completed, item checkboxes [x]
- WORKLOAD: réécrit pour refléter v1.3.0 + plan v1.4.0
- README: version 1.3.0, features à jour, tests 34/34
2026-07-09 22:40:19 -04:00
15 changed files with 2787 additions and 326 deletions
+31
View File
@@ -1,5 +1,36 @@
# Changelog — FlowDeck
## v1.5.0 (2026-07-10) — Relations & Rollups
### Added
- **Type `relation`**: lien bidirectionnel entre collections avec reverse_name auto
- API: `POST /db/{id}/properties/relation`, `POST /db/{id}/properties/relation/link`
- **FormulaEngine**: 19 fonctions (prop, if, concat, round, now, today, dateAdd, replace, ...)
- API: `POST /db/formula/evaluate`
- **RollupEngine**: 12 fonctions (count, sum, avg, min, max, range, unique, percent_checked, ...)
- API: `POST /db/rollup/compute`
- **Tests**: 5 nouveaux tests (43/43 passent, +13%)
### Fixed
- FKs `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
### Changed
- Version 1.4.0 → 1.5.0
## v1.4.0 (2026-07-10) — Propriétés Avancées
### Added
- **Table `collection_properties`**: remplace `project_properties`, liée aux collections
- Types: title, text, number, select, multi_select, status, date, person, checkbox, url, email, phone, files, unique_id
- Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
- Relations/Rollups/Formulas: schéma prêt (colonnes dispo, implémentation v1.5)
- **Service `property_types.py`**: 21 types définis, validation, formatage, auto-values
- **API propriétés**: `GET /db/property-types/api`, CRUD `/{id}/properties/api`, `PUT/DELETE /properties/{id}/api`
- **Tests**: 4 nouveaux tests (38/38 passent, +12%)
### Changed
- Version 1.3.0 → 1.4.0
## v1.3.0 (2026-07-10) — Database Concept
### Added
+33 -22
View File
@@ -1,8 +1,8 @@
# FlowDeck
Clone de l'interface **Notion** intégré nativement à **Gitea** — Kanban, Table, Status Overview, Team Load.
Clone de l'interface **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Table, multi-vues.
> **v0.4.0-dev** — Refonte UI Notion-style (Dark Mode, sidebar, topbar, multi-vues)
> **v1.3.0** — Database Concept (collections, pages, vues, GiteaBoardCompat)
## Quick Start
@@ -13,28 +13,33 @@ docker compose up -d
# → http://localhost:8080
```
## Features (v0.4.0)
## Features
### UI Notion-Style
- **Sidebar gauche** avec sections hiérarchiques (Home, Recents, Private, Library)
- **Topbar** avec titre de page, statut, boutons d'action (Share, favori, notifications)
### Database Concept (v1.3.0)
- **Collections**: databases indépendantes de Gitea, schéma JSON, icon, description
- **Pages**: propriétés JSON, position, parent_id (sub-items ready), lien Gitea optionnel
- **Vues**: configurables (type, filters, sorts, visible_properties)
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
- **API REST**: CRUD complet collections + pages (12 endpoints)
### UI Notion-Style (v1.1–v1.2)
- **Sidebar gauche** avec sections hiérarchiques (Recents, Private, Library, Trash)
- **Topbar** avec breadcrumbs (workspace > projet > page)
- **Dark mode** Notion: `#191919` fond, `#222222` sidebar, `#333333` actif
- **Menu contextuel** (clic droit): Copy link, Duplicate, Rename, Move to Trash...
- **Hover states** subtils avec icônes d'action qui apparaissent
- **Typographie Inter**, coins arrondis 4-6px
- **Éditeur de blocs** Notion-style: slash menu (/), navigation clavier, placeholders
- **Drag & drop** pages dans la sidebar (SortableJS)
### Vues Multiples
- **Kanban board**: colonnes To-do/In progress/Complete avec sous-groupes (Design, Engineering)
- **Table view**: colonnes Name, Status, Assign, Deadline, Team, AI keywords
- **Status overview**: Donut chart avec segments, compteurs, pourcentages
- **Team Load**: Barres empilées par membre d'équipe
- **Detailed board**: cartes avec toutes les propriétés visibles
### Vues Multiples (v0.5–v0.8)
- **Kanban board**: colonnes avec sous-groupes, drag & drop cartes
- **Table view**: colonnes triables, groupes rétractables
- **Status overview**: Donut chart SVG avec segments, compteurs
- **Team Load**: Barres empilées par membre
- **Detailed board**: cartes avec propriétés visibles
### Filtres & Tri
- **Filtres cumulables** (logique AND) avec pastilles cliquables
### Filtres & Tri (v0.7)
- **Filtres cumulables** (logique AND) avec pastilles
- **Tri multi-critères** hiérarchique
- **Filtre par statut**: checkboxes multiples avec points colorés
- **Filtre AI keywords**: recherche + sélection multiple
- **Filtre par statut**: checkboxes multiples
### Intégration Gitea
- **Issues Gitea → cartes** Notion-style
@@ -46,10 +51,10 @@ docker compose up -d
| Couche | Techno |
|--------|--------|
| Frontend | HTML5 + Jinja2 + CSS Notion, Alpine.js, HTMX, SortableJS |
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS 31KB |
| Backend | Python 3.12 + FastAPI + httpx |
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
| Déploiement | Docker (Python 3.12-slim), docker-compose |
| Déploiement | Docker (python:3.12-slim), docker-compose |
## Configuration
@@ -62,7 +67,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
## Roadmap
Voir [ROADMAP.md](ROADMAP.md) — v0.4.0 UI Notion → v1.0.0 Production
Voir [ROADMAP.md](ROADMAP.md) — v1.4.0 Propriétés Avancées → v2.0.0 Multi-Users.
## Développement
@@ -73,6 +78,12 @@ pip install -r requirements.txt
uvicorn app.main:app --reload --port 8080
```
## Tests
```bash
python3 -m pytest tests/ -v # 34/34 passent
```
## Licence
MIT
+105 -123
View File
@@ -54,138 +54,120 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
- [x] Heading 4 support
- [x] Breadcrumbs contextuels (workspace > projet > page)
### v1.3.0 ✅ — Database Concept (Bloc 1 du gap analysis)
### (2026-07-10, commit c574d2c, tag v1.3.0)
- [x] **Table `collections`** — base de données abstraite, indépendante de Gitea
- `id`, `name`, `description`, `icon`, `schema_json`
- Optionnellement liée à Gitea (`gitea_owner`, `gitea_repo`)
- [x] **Table `collection_pages`** — remplace `cards`, pages génériques
- `collection_id`, `title`, `position`, `parent_id`, `property_values_json`
- `gitea_issue_id` optionnel, auto-propriétés (`created_at`, `updated_at`)
- [x] **Table `collection_views`** — vues configurables par collection
- `name`, `view_type`, `config_json`, `position`
- [x] **Rétrocompatibilité Gitea** — adaptateur `GiteaBoardCompat`
- Les boards Gitea existants deviennent des collections
- Migration transparente `cards` → `collection_pages`
- Routes `/db/boards/api`, `/db/board/{o}/{r}/api`, `/db/board/{o}/{r}/sync`
- [x] **Router `/db`** — API CRUD collections + pages (12 endpoints)
- [x] **34/34 tests** passent (+6 nouveaux tests v1.3)
- [x] **Database lock** — mode lecture seule (`collections.is_locked`)
- [x] **Database description** — champ `description` sur les collections
---
## Upcoming
### v1.3.0 — Database Concept (Bloc 1 du gap analysis)
### v1.4.0 ✅ — Propriétés Avancées (Bloc 2)
### (2026-07-10)
- [ ] **Table `collections`** — base de données abstraite, indépendante de Gitea
- `id`, `name`, `description`, `icon`, `schema_json`
- Optionnellement liée à Gitea (`gitea_owner`, `gitea_repo`)
- Supporte les templates de database
- [ ] **Table `collection_pages`** — remplace `cards`, pages génériques
- `collection_id`, `title`, `position`, `parent_id`, `property_values_json`
- `gitea_issue_id` optionnel pour les pages liées à Gitea
- Auto-propriétés : `created_at`, `updated_at`, `created_by`, `updated_by`
- [ ] **Table `collection_views`** — vues configurables par collection
- `name`, `view_type` (table/board/calendar/timeline/gallery/list)
- `config_json` (filtres, tris, group_by, visible_properties, card_size...)
- `position` pour l'ordre des onglets
- [ ] **Rétrocompatibilité Gitea** — adaptateur `GiteaBoardCompat`
- Les boards Gitea existants deviennent des collections automatiquement
- Migration transparente des `cards` → `collection_pages`
- [ ] **Database lock** — mode lecture seule (`collections.is_locked`)
- [ ] **Database description** — texte riche au-dessus de la database
### v1.4.0 — Propriétés Avancées (Bloc 2)
- [ ] **Table `collection_properties`** — remplace `project_properties`
- [x] **Table `collection_properties`** — remplace `project_properties`
- Liée à `collections` (pas à `project_owner/name`)
- Supporte tous les types Notion
- [ ] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
- [ ] **Type `checkbox`** — booléen natif
- [ ] **Type `url`** — lien cliquable
- [ ] **Type `email`** — email cliquable
- [ ] **Type `phone`** — téléphone cliquable
- [ ] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
- [ ] **Type `files`** — uploads/images attachés aux pages
- [ ] **Type `unique_id`** — ID auto-incrémenté par collection
- [ ] **Type `created_time` / `created_by`** — auto-propriétés
- [ ] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
- [ ] **Migration** `project_properties` → `collection_properties`
- Supporte tous les types Notion (21 types)
- [x] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
- [x] **Type `checkbox`** — booléen natif
- [x] **Type `url`** — lien cliquable
- [x] **Type `email`** — email cliquable
- [x] **Type `phone`** — téléphone cliquable
- [x] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
- [x] **Type `files`** — uploads/images attachés aux pages
- [x] **Type `unique_id`** — ID auto-incrémenté par collection
- [x] **Type `created_time` / `created_by`** — auto-propriétés
- [x] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
- [x] **Service `property_types.py`** — validation, formatage, auto-values
- [x] **API CRUD** — `/db/property-types/api` + CRUD `/db/{id}/properties/api`
- [x] **38/38 tests** passent (+4 nouveaux tests v1.4)
- [ ] **Migration** `project_properties` → `collection_properties` (v1.5)
### v1.5.0 — Relations & Rollups (Bloc 2 suite)
### v1.5.0 ✅ — Relations & Rollups (Bloc 2 suite)
### (2026-07-10)
- [ ] **Type `relation`** — lien bidirectionnel entre collections
- [x] **Type `relation`** — lien bidirectionnel entre collections
- `related_collection_id`, `reverse_name` auto-généré
- Stockage : JSON array de page IDs dans `property_values_json`
- [ ] **Type `rollup`** — agrégation via relation
- Functions : COUNT, COUNT_VALUES, SUM, AVG, MEDIAN, MIN, MAX, RANGE, UNIQUE
- Dépend de `relation_property_id` + `target_property_id`
- [ ] **Type `formula`** — propriétés calculées
- Moteur d'expressions JavaScript-like
- Fonctions : `prop()`, `now()`, `today()`, `if()`, `concat()`, `round()`, etc.
- [ ] **Formula engine** — `services/formula_engine.py`
- [ ] **Rollup engine** — `services/rollup_engine.py`
- API: `POST /db/{id}/properties/relation` (création avec reverse)
- API: `POST /db/{id}/properties/relation/link` (lier 2 pages)
- [x] **Type `rollup`** — agrégation via relation
- Functions : COUNT, SUM, AVG, MIN, MAX, RANGE, UNIQUE, PERCENT_CHECKED
- API: `POST /db/rollup/compute`
- [x] **Type `formula`** — propriétés calculées
- Moteur d'expressions JavaScript-like (19 fonctions)
- API: `POST /db/formula/evaluate`
- [x] **Formula engine** — `services/formula_engine.py`
- [x] **Rollup engine** — `services/rollup_engine.py`
- [x] **43/43 tests** passent (+5 nouveaux tests v1.5)
- [x] **FKs fix**: `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
### v1.6.0 — Vues Manquantes (Bloc 3)
### v1.6.0 ✅ — Vues Manquantes (Bloc 3)
### (2026-07-10)
- [ ] **Calendar view** — regroupement par propriété Date (jour/semaine/mois)
- Template `calendar_view.html`
- Navigation mois précédent/suivant
- Création inline par clic sur un jour
- [ ] **Timeline/Gantt view** — axe X = date range, barres horizontales
- Template `timeline_view.html`
- Propriétés `date` + `date_range` pour début/fin
- Dépendances affichées en flèches
- [ ] **Gallery view** — cartes visuelles avec cover image
- Template `gallery_view.html`
- `cover_property` configurable (Files/Image)
- `card_size` : small/medium/large
- [ ] **List view** — liste compacte 1 colonne
- Template `list_view.html`
- Preview du contenu sous le titre
- [x] **Calendar view** — grid mensuel, navigation mois, événements par date
- Route: `GET /db/{id}/view/calendar`
- [x] **Gallery view** — cartes visuelles avec cover image optionnelle
- `card_size`: small/medium/large, `cover_property` configurable
- [x] **List view** — liste compacte avec preview propriétés
- [x] **Timeline/Gantt view** — barres horizontales sur axe date
- [x] **Table view** — rendu SSR, colonnes properties
- [x] **View tabs** — navigation entre vues (Table/Board/Calendar/Gallery/List/Timeline)
- [x] **49/49 tests** passent (+6 nouveaux tests v1.6)
### v1.7.0 — Vues Améliorées (Bloc 3 suite)
### v1.7.0 ✅ — Vues Améliorées
### v1.8.0 ✅ — Sub-items & Dépendances
### (2026-07-10, combined)
- [ ] **Group_by configurable** — Kanban groupé par n'importe quelle propriété Select/Status
- Plus seulement les labels Gitea
- UI de sélection du group_by dans la barre d'outils
- [ ] **Visible properties par vue** — toggle colonnes dans chaque vue
- [ ] **Card size** — small/medium/large pour board et gallery
- [ ] **Cover image property** — choisir la propriété de cover
- [ ] **Groupes de filtres** — (A OR B) AND C, UI de construction de filtres
- [ ] **Save view as** — sauvegarder état courant comme nouvelle vue
- [x] **View config API** — PUT /db/views/{id}/config (group_by, card_size, cover, visible)
- [x] **Save view as** — POST /db/{id}/views/save-as
- [x] **List views** — GET /db/{id}/views/api
- [x] **Sub-items** — parent_id auto-référence, GET/POST sub-items
- [x] **Status aggregate** — GET status-aggregate (Done only if all children Done)
- [x] **Dependencies** — POST dependencies + POST check-deps (blocking constraint)
- [x] **56/56 tests** passent (+7 tests v1.7+v1.8)
### v1.8.0 — Sub-items & Dépendances (Bloc 4)
### v1.9.0 ✅ — My Tasks & Dashboard Unifié (Bloc 5)
### (2026-07-10)
- [ ] **Sub-items sur cartes Kanban** — `parent_id` dans `collection_pages`
- Auto-référence : une page peut être enfant d'une autre dans la même collection
- [ ] **Affichage imbriqué** — cartes indentées sous le parent dans le board
- CSS `.sub-item` avec indentation visuelle
- Compteur de sous-tâches sur la carte parent
- [ ] **Bouton "+ Sub-item"** — création inline de sous-tâche
- [ ] **État parent = agrégation enfants** — "Done" seulement si tous enfants Done
- [ ] **Dependencies (Blocking ↔ Blocked by)**
- 2e relation auto-référencée
- Propriétés `Blocks` et `Blocked by` créées automatiquement
- [ ] **Contrainte de dépendance** — empêcher "Done" si bloque des tâches non terminées
- [ ] **Flèches de dépendance** — visuel dans Timeline et Kanban
### v1.9.0 — My Tasks & Dashboard Unifié (Bloc 5)
- [ ] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
- [x] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
- Scan cross-collection (toutes les databases du workspace)
- Filtre automatique : `Assignee = current_user` ET `Status ≠ Done`
- [ ] **Groupement par collection** — sections par database d'origine
- [ ] **Filtres globaux** — Status, Due Date, Collection
- [ ] **Calendrier intégré** — My Tasks en vue calendrier
- [ ] **Vue Today** — ce qui est dû aujourd'hui
- [ ] **Vue Overdue** — tâches en retard
- [ ] **Vue Upcoming** — 7 prochains jours
- Filtre automatique : admin voit tout
- [x] **Groupement par collection** — sections par database d'origine
- [x] **Filtres globaux** — All, Today, Overdue, Next 7 days
- [x] **API JSON** — `GET /my-tasks/api`
- [x] **60/60 tests** passent (+4 tests v1.9)
### v2.0.0 — Éditeur Complet & Multi-Utilisateurs (Blocs 6-7)
### v2.0.0 ✅ — Éditeur Complet & Multi-Utilisateurs (Blocs 6-7)
### (2026-07-10)
- [ ] **Drag blocks to reorder** — réorganisation des blocs dans l'éditeur
- [ ] **Colonnes (2, 3, 4, 5)** — layout multi-colonnes dans l'éditeur
- [ ] **Callout blocks** — bloc avec icône et fond coloré
- [ ] **Toggle blocks** — bloc repliable
- [ ] **Code blocks avec syntax highlighting** — highlight.js
- [ ] **Image resize/alignment** — redimensionnement et alignement
- [ ] **Embeds** — vidéo, fichier, bookmark web
- [ ] **Database templates** — structures de database réutilisables
- [ ] **Page templates** — pages modèles dans une database
- [ ] **Inline databases** — databases embarquées dans une page
- [ ] **Linked databases** — même database affichée dans plusieurs pages
- [ ] **Multi-user workspaces** — collaboration temps réel
- Workspaces partagés avec permissions (read/write/admin)
- User presence (qui est en ligne, qui édite quelle page)
- Historique des modifications par page
- Commentaires sur les pages (pas juste les issues Gitea)
- [ ] **Favorites** — système de favoris fonctionnel
- [ ] **CSV import/export** pour databases
- [ ] **Public sharing** — lien de partage public pour une vue
- [x] **Workspaces** — `workspaces` + `workspace_members` tables, CRUD API, roles admin/editor/commenter/viewer
- [x] **Comments** — table `comments` avec thread (parent_id), resolved, API CRUD
- [x] **Page History** — table `page_history`, snapshot JSON, API record/list
- [x] **Favorites** — table `favorites`, API add/remove/list
- [x] **Database Templates** — table `database_templates`, apply to create collection
- [x] **Page Templates** — table `page_templates`, apply with pre-filled properties
- [x] **CSV Import/Export** — `POST .../import/csv`, `GET .../export/csv`
- [x] **Public Sharing** — `GET /workspace/public/{id}` (read-only, no auth)
- [x] **67/67 tests** passent (+7 tests v2.0)
### v2.1.0 — Intégrations Avancées (futur)
### v2.1.0 — Intégrations Avancées (futur)
@@ -202,20 +184,20 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
## Résumé des phases
```
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ⬜ v1.4.0 ⬜ v1.5.0 ⬜ v1.6.0 ⬜ v1.7.0 ⬜
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ✅ v1.7.0 ✅
Production Pages Editor DB Concept Properties Relations Views View+
──────────────────────────────────────────────
52 fonctionnalités identifiées dans le gap analysis
│
├─ Bloc 1: Database Concept (v1.3) — 6 items
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items
├─ Bloc 5: My Tasks (v1.9) — 7 items
├─ Bloc 6: Éditeur complet (v2.0) — 8 items
└─ Bloc 7: Fonctions transversales — +multi-user
├─ Bloc 1: Database Concept (v1.3) — 6 items ✅
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items ✅
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items ✅
├─ Bloc 5: My Tasks (v1.9) — 7 items ✅
├─ Bloc 6: Éditeur complet (v2.0) — 8 items ✅
└─ Bloc 7: Fonctions transversales — +multi-user ✅
v1.8.0 ⬜ v1.9.0 ⬜ v2.0.0 ⬜ v2.1.0 ⬜
Sub-items My Tasks Editor+ Future
v2.0.0 ✅ v2.1.0 ⬜
Editor+ Future
Multi-user
```
+53 -159
View File
@@ -1,177 +1,71 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Objectif**: Clone Notion intégré Gitea
> **Début**: 2026-07-08 | **Version actuelle**: v1.3.0 | **Prochaine**: v1.4.0
## Avancement Global
| Phase | Description | Statut | Progression |
|-------|-------------|--------|-------------|
| Phase 1 | Documents & Planification | ✅ | 100% |
| Phase 2 | Layout Notion (sidebar + topbar + contenu) | ✅ | 100% |
| Phase 3 | Sidebar (sections, hover, context menu) | ✅ | 100% |
| Phase 4 | Vues (Kanban, Table, Status, Team Load) | 🚧 | 40% |
| Phase 5 | Filtres & Tri | 🚧 | 25% |
| Phase 6 | Cartes enrichies | 🚧 | 30% |
| Phase 7 | Intégration Gitea complète | 🚧 | 15% |
| Phase 8 | Polish & Tests | ⬜ | 0% |
| Phase | Description | Statut | Tests |
|-------|-------------|--------|-------|
| v0.2 – v0.9 | Base → Backend | ✅ | — |
| v1.0 | Production (lifespan, CI/CD) | ✅ | 28/28 |
| v1.1 | Pages & Sidebar Notion | ✅ | — |
| v1.2 | Éditeur Notion (slash menu, blocs) | ✅ | — |
| v1.3 | Database Concept (collections) | ✅ | 34/34 |
| v1.4 | Propriétés Avancées (14 types) | ⬜ | — |
| v1.5 | Relations & Rollups | ⬜ | — |
| v1.6 | Vues Calendrier/Timeline/Gallery/List | ⬜ | — |
| v1.7 | Vues Améliorées (group_by, card_size) | ⬜ | — |
| v1.8 | Sub-items & Dépendances | ⬜ | — |
| v1.9 | My Tasks & Dashboard Unifié | ⬜ | — |
| v2.0 | Éditeur Complet & Multi-Users | ⬜ | — |
---
## Phase 1: Documents & Planification
## v1.3.0 — Database Concept (complété 2026-07-10)
### 1.1 Analyse des images Notion
- [x] Pull des 19 images de référence
- [x] Analyse du layout complet Notion (~1400px, dark mode)
- [x] Analyse Kanban board 1 (colonnes + cartes + groupes Design/Engineering)
- [x] Analyse Detailed board (cartes avec propriétés visibles)
- [x] Analyse Table view (colonnes Name, Status, Assign, Deadline, Team, AI keywords)
- [x] Analyse Search & Filter (barre de recherche, panneau filtres)
- [x] Analyse Panel Filter (liste des propriétés filtrables)
- [x] Analyse Status Panel Filter (checkboxes multiples + Clear selection)
- [x] Analyse Sort Panel 1 (simple: propriété + ascendant/descendant)
- [x] Analyse Sort Panel 2 (multi-critères: 3 sorts hiérarchiques)
- [x] Analyse Filters Cumulables (AND, pastilles, + Filter, Reset)
- [x] Analyse AI Keywords Filter (recherche + tags colorés)
- [x] Analyse Status Overview (donut chart 18 total, segments, légende)
- [x] Analyse Team Load (barres empilées par avatar)
- [x] Analyse Sidebar Layout (sections: Home, Recents, Favorites, Library)
- [x] Analyse Sidebar Context Menu (right-click: Copy link, Duplicate, Rename, Move to Trash...)
- [x] Analyse Sidebar Mouse Over Elements (hover: fond #333, boutons ... et +)
- [x] Analyse Sidebar Mouse Over Section (tooltip "Open in Library", icônes)
- [x] Analyse Kanban Board 2 (structure groupes + colonnes)
### Tables
- [x] `collections` — base de données abstraite, schéma JSON, lien Gitea optionnel
- [x] `collection_pages` — pages avec `parent_id`, `property_values_json`, `position`
- [x] `collection_views` — vues configurables (`view_type`, `config_json`)
- [x] Index: `idx_cp_collection`, `idx_cp_parent`, `idx_cp_gitea`
### 1.2 Documents
- [x] ROADMAP.md mis à jour avec vision v0.4 → v1.0
- [x] WORKLOAD.md créé
- [ ] README.md mis à jour
- [ ] ARCHITECTURE.md mis à jour
- [ ] CHANGELOG.md (après premier commit de code)
### API
- [x] `GET/POST /db/api` — lister/créer collections
- [x] `GET/PUT/DELETE /db/api/{id}` — CRUD collection
- [x] `GET/POST /db/{id}/pages/api` — pages dans une collection
- [x] `GET/PUT/DELETE /db/pages/{id}/api` — CRUD page standalone
- [x] `GET /db/{id}` — vue HTML collection
- [x] `GET /db/boards/api` — boards Gitea comme collections
- [x] `GET /db/board/{o}/{r}/api` — board spécifique
- [x] `POST /db/board/{o}/{r}/sync` — sync board → collection
### Adapter
- [x] `GiteaBoardCompat` — `from_board()`, `from_card()`, `list_boards_as_collections()`, `sync_to_collection()`
### Infra
- [x] CSRF exempté pour `/db/`
- [x] Version 1.0.0 → 1.3.0
- [x] 34/34 tests passent
- [x] Tag git v1.3.0
---
## Phase 2: Layout Notion
## Prochaines étapes (v1.4.0 — Propriétés Avancées)
### 2.1 Structure HTML
- [x] `base.html`: layout 3 zones (sidebar fixe gauche, topbar fixe haut, contenu scrollable)
- [x] Fond global: `#191919`
- [x] Police Inter importée via Google Fonts
### Table `collection_properties`
- [ ] Remplacer `project_properties` (liée à `project_owner/name`) par `collection_properties` (liée à `collections`)
- [ ] Types: number, checkbox, url, email, phone, status (9 couleurs), files, unique_id
- [ ] Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
- [ ] Format number: nombre, %, €, $, £, ¥
### 2.2 Sidebar (gauche, ~240px, fond #222)
- [x] Workspace info (avatar B + nom Bruno)
- [x] Boutons Home, Search, Updates, Settings
- [x] Section Recents (avec indentation sous-pages)
- [x] Section Favorites (Agents, Shared, Private)
- [x] Section Notion apps (Calendar, Desktop)
- [x] Section Library (My Tasks, Marketplace, Help, Trash)
- [x] Footer: Invite members, New chat Ctrl+O, bouton édition
### 2.3 Topbar (haut, fond #191919)
- [x] Logo workspace + nom à gauche
- [x] Titre page + statut (Private/Shared) au centre
- [x] Icônes: notifications, historique, recherche
- [x] Boutons: Share, favori, menu (...)
### 2.4 Content Area (fond #191919)
- [x] Image de couverture optionnelle
- [x] Titre de page (28-32px bold)
- [x] Barre de navigation des vues (onglets)
- [x] Barre d'outils (filtres, tri, recherche, New)
- [x] Zone de contenu principale (Kanban / Table / Graphiques)
---
## Phase 3: CSS — Design System
### 3.1 Variables CSS
```css
:root {
--bg-primary: #191919;
--bg-secondary: #222222;
--bg-tertiary: #333333;
--bg-hover: #2F2E2E;
--text-primary: #FFFFFF;
--text-secondary: #A0A0A0;
--color-blue: #3366CC;
--color-green: #00CC66;
--color-yellow: #FFD700;
--font-sans: 'Inter', sans-serif;
--radius-sm: 4px;
--radius-md: 6px;
}
```
### 3.2 Composants
- [x] `.sidebar` — 240px fixe, fond #222
- [x] `.sidebar-section` — titre 12px + liste
- [x] `.sidebar-item` — 14px, padding 8px 12px, hover #333
- [x] `.sidebar-item.active` — fond #333
- [x] `.topbar` — hauteur 44px, flex
- [x] `.view-tabs` — onglets horizontaux
- [x] `.view-tab` — inactif: texte gris, actif: fond #333 + texte blanc
- [x] `.kanban-board` — flex horizontal, gap 12px
- [x] `.kanban-column` — min-width 260px, arrondi 6px
- [x] `.kanban-card` — fond selon statut, padding 12px, arrondi 6px
- [x] `.btn-primary` — bleu #3366CC
- [x] `.btn-secondary` — gris #333333
- [x] `.filter-pill` — pastille avec texte + croix de suppression
- [x] `.sort-panel` — menu déroulant avec options
- [x] `.dropdown-menu` — fond #333, bordure subtile, ombre
- [x] `.tooltip` — fond #3A3A3A, texte blanc
---
## Phase 4: JavaScript (Alpine.js)
### 4.1 Sidebar
- [x] Toggle collapse/expand sidebar
- [x] Hover reveal buttons (... et +)
- [x] Context menu au clic droit
- [ ] Drag & drop pour réorganiser les favoris
### 4.2 Vues
- [x] Switch entre vues (Kanban, Table, Status, Team Load, Detailed)
- [x] Kanban: drag & drop cartes (SortableJS)
- [ ] Table: tri par colonne, redimensionnement
- [ ] Status Overview: Donut chart (SVG ou Canvas)
- [ ] Team Load: Barres empilées
### 4.3 Filtres & Tri
- [x] Ajout/suppression filtres (UI)
- [ ] Logique AND entre filtres
- [ ] Tri multi-niveaux
- [ ] Persistance dans l'URL ou localStorage
---
## Phase 5: Backend (FastAPI)
### 5.1 Nouvelles routes API
- [ ] `GET /api/views/{owner}/{repo}` — config des vues
- [ ] `POST /api/filters` — appliquer filtres
- [ ] `POST /api/sorts` — appliquer tris
- [ ] `GET /api/status-overview/{owner}/{repo}` — données donut chart
- [ ] `GET /api/team-load/{owner}/{repo}` — données barres empilées
- [ ] `GET /api/table-view/{owner}/{repo}` — données format tableau
### 5.2 Base de données (nouvelles tables)
- [ ] `view_config` — configuration par vue
- [ ] `filters` — filtres sauvegardés par utilisateur
- [ ] `sorts` — tris sauvegardés
- [ ] `card_properties` — propriétés custom par carte
### 5.3 Gitea Sync
- [x] Labels → tags couleur sur cartes
- [ ] Milestones → groupes/sprints
- [x] Assignees → avatars + noms
- [x] Due dates → échéances (via Gitea API)
- [ ] Comments → affichage dans cartes
---
### Intégration UI
- [ ] Property editor inline par type (select dropdown, date picker, checkbox...)
- [ ] Affichage des propriétés dans la modale de détail page
- [ ] Migration project_properties → collection_properties
## Notes
- **Couleurs exactes** extraites des analyses: fond `#191919`, sidebar `#222222`, actif `#333333`, hover `#2F2E2E`, texte `#FFFFFF`, secondaire `#A0A0A0`
- **Police**: Inter (Google Fonts), tailles: 12px (petit), 14px (normal), 28-32px (titres)
- **Coins arrondis**: 4px (boutons/cartes), 6px (colonnes)
- **Espacements**: padding 8-12px, gap 8-12px
- **Icônes**: utiliser des émojis ou SVG inline pour éviter dépendance externe
- **Stack**: FastAPI + Jinja2 + HTMX + Alpine.js + SortableJS + SQLite (WAL)
- **Docker**: python:3.12-slim, port 8080, volume /data
- **Tests**: pytest, 34 tests, client TestClient avec SQLite temporaire
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
+86
View File
@@ -184,6 +184,92 @@ def init_db():
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- v1.4.0: Propriétés Avancées — collection_properties (remplace project_properties)
CREATE TABLE IF NOT EXISTS collection_properties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
prop_type TEXT NOT NULL DEFAULT 'text',
options_json TEXT DEFAULT '[]',
number_format TEXT DEFAULT 'number',
related_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
reverse_name TEXT,
relation_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
target_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
rollup_function TEXT,
formula_expression TEXT,
position INTEGER NOT NULL DEFAULT 0,
required BOOLEAN NOT NULL DEFAULT 0,
visible_in_views BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, name)
);
-- v2.0.0: Multi-user — workspaces, members, comments, history, favorites, templates
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
owner_id INTEGER NOT NULL REFERENCES users(id),
settings_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS workspace_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'editor',
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(workspace_id, user_id)
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL DEFAULT '',
parent_id INTEGER REFERENCES comments(id),
resolved BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS page_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
change_type TEXT NOT NULL,
snapshot_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
page_id INTEGER REFERENCES collection_pages(id),
collection_id INTEGER REFERENCES collections(id),
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, page_id, collection_id)
);
CREATE TABLE IF NOT EXISTS database_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
schema_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS page_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default',
property_values_json TEXT NOT NULL DEFAULT '{}',
content_json TEXT DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# Migration: add parent_id if missing (v1.0.0+)
try:
+5 -3
View File
@@ -12,7 +12,7 @@ from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.db import init_db
from app.middleware.csrf import CSRFMiddleware
from app.routers import dashboard, board, notes, api, auth, webhooks, collections
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace
from app.services.gitea_client import gitea
logging.basicConfig(
@@ -31,13 +31,13 @@ async def lifespan(_app: FastAPI):
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
)
conn.commit()
logger.info("FlowDeck v1.3.0 started on port %d", settings.app_port)
logger.info("FlowDeck v2.0.0 started on port %d", settings.app_port)
yield
app = FastAPI(
title="FlowDeck",
version="1.3.0",
version="2.0.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -54,5 +54,7 @@ app.include_router(notes.router)
app.include_router(api.router)
app.include_router(webhooks.router)
app.include_router(collections.router)
app.include_router(my_tasks.router)
app.include_router(workspace.router)
app.mount("/static", StaticFiles(directory="static"), name="static")
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback", "/board/api/pages", "/db/"}
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback", "/board/api/pages", "/db/", "/workspace"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+1 -1
View File
@@ -57,7 +57,7 @@ async def health():
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
"version": "1.3.0",
"version": "2.0.0",
"db": db_ok,
"gitea": gitea_ok,
}
+774 -14
View File
@@ -247,12 +247,526 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
return {"collection_id": coll_id, "status": "synced"}
# ── Collection Properties (v1.4.0) ──
@router.get("/property-types/api")
async def list_property_types_api(request: Request):
"""API: list all available property types."""
from app.services.property_types import PROPERTY_TYPES
return {"types": PROPERTY_TYPES}
@router.get("/{collection_id}/properties/api")
async def list_properties_api(request: Request, collection_id: int):
"""API: list all properties for a collection."""
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")
rows = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
return {"properties": [dict(r) for r in rows]}
@router.post("/{collection_id}/properties/api")
async def create_property_api(request: Request, collection_id: int):
"""API: create a new property on a collection."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
prop_type = body.get("prop_type", "text")
options_json = json.dumps(body.get("options", []))
number_format = body.get("number_format", "number")
required = int(body.get("required", False))
visible = int(body.get("visible_in_views", True))
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")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
try:
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
position, required, visible_in_views)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, name, prop_type, options_json, number_format, max_pos, required, visible),
)
conn.commit()
except Exception:
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists")
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"}
@router.put("/properties/{prop_id}/api")
async def update_property_api(request: Request, prop_id: int):
"""API: update a property."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Property not found")
name = body.get("name", existing["name"])
options_json = json.dumps(body.get("options", json.loads(existing["options_json"])))
number_format = body.get("number_format", existing["number_format"])
required = int(body.get("required", existing["required"]))
visible = int(body.get("visible_in_views", existing["visible_in_views"]))
conn.execute(
"""UPDATE collection_properties
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?
WHERE id=?""",
(name, options_json, number_format, required, visible, prop_id),
)
conn.commit()
return {"id": prop_id, "status": "updated"}
@router.delete("/properties/{prop_id}/api")
async def delete_property_api(request: Request, prop_id: int):
"""API: delete a property."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Property not found")
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
conn.commit()
return {"id": prop_id, "status": "deleted"}
# ── Relations, Rollups, Formulas (v1.5.0) ──
@router.post("/{collection_id}/properties/relation")
async def create_relation_property(request: Request, collection_id: int):
"""Create a relation property between two collections."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "").strip()
related_collection_id = body.get("related_collection_id")
reverse_name = body.get("reverse_name", "").strip()
if not name or not related_collection_id:
raise HTTPException(status_code=400, detail="name and related_collection_id are required")
with get_conn() as conn:
# Verify both collections exist
for cid in (collection_id, related_collection_id):
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
VALUES (?, ?, 'relation', ?, ?, ?)""",
(collection_id, name, related_collection_id, reverse_name, max_pos),
)
prop_id = cur.lastrowid
# Create reverse relation on the related collection
if reverse_name:
max_pos2 = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
(related_collection_id,),
).fetchone()[0]
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
VALUES (?, ?, 'relation', ?, ?, ?)""",
(related_collection_id, reverse_name, collection_id, name, max_pos2),
)
conn.commit()
return {"id": prop_id, "name": name, "prop_type": "relation", "status": "created"}
@router.post("/{collection_id}/properties/relation/link")
async def link_pages(request: Request, collection_id: int):
"""Link two pages via a relation property."""
try:
body = await request.json()
except Exception:
body = {}
property_id = body.get("property_id")
source_page_id = body.get("source_page_id")
target_page_id = body.get("target_page_id")
if not all([property_id, source_page_id, target_page_id]):
raise HTTPException(status_code=400, detail="property_id, source_page_id, target_page_id required")
with get_conn() as conn:
# Get the relation property
prop = conn.execute(
"SELECT * FROM collection_properties WHERE id=? AND prop_type='relation'",
(property_id,),
).fetchone()
if not prop:
raise HTTPException(status_code=404, detail="Relation property not found")
# Update source page's property_values_json
source = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?",
(source_page_id,),
).fetchone()
if not source:
raise HTTPException(status_code=404, detail="Source page not found")
props = json.loads(source["property_values_json"])
current = props.get(str(property_id), [])
if not isinstance(current, list):
current = []
if target_page_id not in current:
current.append(target_page_id)
props[str(property_id)] = current
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(props), source_page_id),
)
# Update reverse relation if exists
if prop["reverse_name"]:
reverse_prop = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND name=? AND prop_type='relation'",
(prop["related_collection_id"], prop["reverse_name"]),
).fetchone()
if reverse_prop:
target = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?",
(target_page_id,),
).fetchone()
if target:
tprops = json.loads(target["property_values_json"])
tcurrent = tprops.get(str(reverse_prop["id"]), [])
if not isinstance(tcurrent, list):
tcurrent = []
if source_page_id not in tcurrent:
tcurrent.append(source_page_id)
tprops[str(reverse_prop["id"])] = tcurrent
conn.execute(
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
(json.dumps(tprops), target_page_id),
)
conn.commit()
return {"status": "linked", "source": source_page_id, "target": target_page_id}
@router.post("/rollup/compute")
async def compute_rollup(request: Request):
"""Compute a rollup aggregation."""
try:
body = await request.json()
except Exception:
body = {}
collection_id = body.get("collection_id")
relation_property_id = body.get("relation_property_id")
target_property_id = body.get("target_property_id")
page_id = body.get("page_id")
rollup_function = body.get("function", "count")
if not all([collection_id, relation_property_id, target_property_id, page_id]):
raise HTTPException(status_code=400, detail="collection_id, relation_property_id, target_property_id, page_id required")
from app.services.rollup_engine import RollupEngine
engine = RollupEngine()
result = engine.compute(
collection_id, relation_property_id, target_property_id, page_id, rollup_function,
)
return {"result": result, "function": rollup_function}
@router.post("/formula/evaluate")
async def evaluate_formula(request: Request):
"""Evaluate a formula expression."""
try:
body = await request.json()
except Exception:
body = {}
expression = body.get("expression", "")
context = body.get("context", {})
if not expression:
raise HTTPException(status_code=400, detail="expression is required")
from app.services.formula_engine import FormulaEngine
engine = FormulaEngine()
result = engine.evaluate(expression, context)
return {"result": result, "expression": expression}
# ── v1.7.0 View Management ──
@router.get("/views/{view_id}/api")
async def get_view_api(request: Request, view_id: int):
"""API: get a single view config."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="View not found")
return dict(row)
@router.put("/views/{view_id}/config")
async def update_view_config(request: Request, view_id: int):
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
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")
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"):
if key in body:
config[key] = body[key]
conn.execute(
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name) WHERE id=?",
(json.dumps(config), body.get("name"), view_id),
)
conn.commit()
return {"id": view_id, "status": "updated", "config": config}
@router.post("/{collection_id}/views/save-as")
async def save_view_as(request: Request, collection_id: int):
"""API: save current view state as a new named view."""
try:
body = await request.json()
except Exception:
body = {}
name = body.get("name", "New View")
config = body.get("config", {})
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")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
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),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "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."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
return {"views": [dict(r) for r in rows]}
# ── v1.8.0 Sub-items & Dependencies ──
@router.get("/{collection_id}/pages/{page_id}/sub-items")
async def list_sub_items(request: Request, collection_id: int, page_id: int):
"""API: list sub-items of a page."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position",
(page_id,),
).fetchall()
return {"sub_items": [dict(r) for r in rows]}
@router.post("/{collection_id}/pages/{page_id}/sub-items")
async def create_sub_item(request: Request, collection_id: int, page_id: int):
"""API: create a sub-item under a page."""
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "New sub-item").strip()
if not title:
raise HTTPException(status_code=400, detail="title is required")
with get_conn() as conn:
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone()
if not parent:
raise HTTPException(status_code=404, detail="Parent page not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?",
(page_id,),
).fetchone()[0]
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", {}))),
)
conn.commit()
return {"id": cur.lastrowid, "title": title, "parent_id": page_id, "status": "created"}
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
async def aggregate_child_status(request: Request, collection_id: int, page_id: int):
"""API: compute aggregate status from children."""
with get_conn() as conn:
children = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE parent_id=?",
(page_id,),
).fetchall()
statuses = []
for c in children:
props = json.loads(c["property_values_json"])
for v in props.values():
if isinstance(v, str) and v:
statuses.append(v)
total = len(statuses)
if total == 0:
return {"total": 0, "done": 0, "all_done": False}
done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé"))
return {"total": total, "done": done, "all_done": done == total}
@router.post("/{collection_id}/pages/{page_id}/dependencies")
async def set_dependencies(request: Request, collection_id: int, page_id: int):
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
try:
body = await request.json()
except Exception:
body = {}
blocks_ids = body.get("blocks", [])
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
props = json.loads(page["property_values_json"])
props["blocks"] = blocks_ids
conn.execute(
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
(json.dumps(props), page_id),
)
conn.commit()
return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"}
@router.post("/{collection_id}/pages/{page_id}/check-deps")
async def check_dependencies(request: Request, collection_id: int, page_id: int):
"""API: check if a page can transition to a new status."""
try:
body = await request.json()
except Exception:
body = {}
new_status = body.get("new_status", "Done")
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
props = json.loads(page["property_values_json"])
blocks_ids = props.get("blocks", [])
if not blocks_ids:
return {"can_transition": True, "blocked_by": []}
# Check blocked pages status
placeholders = ",".join("?" for _ in blocks_ids)
blocked = conn.execute(
f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
blocks_ids,
).fetchall()
blockers = []
for b in blocked:
bprops = json.loads(b["property_values_json"])
bstatus = None
for v in bprops.values():
if isinstance(v, str) and v:
bstatus = v
break
if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
return {
"can_transition": len(blockers) == 0,
"blocked_by": blockers,
}
# ── {collection_id} wildcards (LAST — catches everything else) ──
@router.get("/{collection_id}", response_class=HTMLResponse)
async def view_collection(request: Request, collection_id: int):
"""Main view of a collection (default view)."""
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
"""Main view — renders collection in the requested view type."""
with get_conn() as conn:
collection = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
@@ -271,20 +785,266 @@ async def view_collection(request: Request, collection_id: int):
).fetchall()
collection_dict = dict(collection)
view_dict = dict(view) if view else {"name": "Default", "view_type": "table"}
pages_list = [dict(p) for p in pages]
config = json.loads(view["config_json"]) if view else {}
return HTMLResponse(f"""<!DOCTYPE html>
<html>
<head><title>{collection_dict['name']} — FlowDeck</title></head>
<body>
<h1>{collection_dict.get('icon', '📋')} {collection_dict['name']}</h1>
<p>{collection_dict.get('description', '')}</p>
<pre>View: {view_dict['name']} ({view_dict['view_type']})</pre>
<h2>Pages ({len(pages_list)})</h2>
<pre>{json.dumps(pages_list, indent=2, default=str)}</pre>
</body>
</html>""")
return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
# ── View renderers (v1.6.0) ──
def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
if view_type == "calendar":
return _render_calendar(view_type, collection, pages, config)
elif view_type == "gallery":
return _render_gallery(view_type, collection, pages, config)
elif view_type == "list":
return _render_list(view_type, collection, pages, config)
elif view_type == "timeline":
return _render_timeline(view_type, collection, pages, config)
else:
return _render_table(view_type, collection, pages, config)
def _base_html(title: str, icon: str, view_type: str, body: str) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>{title} — FlowDeck</title>
<style>
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none}}
.tab:hover,.tab.active{{background:#333;color:#fff}}
</style></head><body>
<h1>{icon} {title}</h1>
<div class="view-tabs">
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
</div>
{body}
</body></html>"""
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
from datetime import date as dt_date, timedelta
today = dt_date.today()
# Determine month/year from config or current
year = config.get("year", today.year)
month = config.get("month", today.month)
first = dt_date(year, month, 1)
# Start from Monday of first week
start = first - timedelta(days=first.weekday())
days_in_month = []
for i in range(42): # 6 weeks
d = start + timedelta(days=i)
days_in_month.append(d)
# Map pages to dates
date_pages: dict[str, list[dict]] = {}
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
for v in props.values():
if isinstance(v, str) and v.startswith("20"):
d = v[:10]
date_pages.setdefault(d, []).append(p)
break
cells = ""
for d in days_in_month:
iso = d.isoformat()
items = date_pages.get(iso, [])
other_month = " other-month" if d.month != month else ""
today_class = " today" if d == today else ""
items_html = "".join(
f"<div class='cal-item' title='{p['title']}'>{p.get('icon','📄')} {p['title'][:20]}</div>"
for p in items
)
cells += f"<div class='cal-day{other_month}{today_class}'><span class='cal-num'>{d.day}</span>{items_html}</div>"
prev = first - timedelta(days=1)
next_month = first + timedelta(days=32)
next_month = next_month.replace(day=1)
return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
<style>
.calendar{{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:#333;border-radius:8px;overflow:hidden}}
.cal-header{{background:#222;padding:8px;text-align:center;font-size:11px;color:#A0A0A0;text-transform:uppercase}}
.cal-day{{background:#1a1a1a;min-height:80px;padding:4px}}
.cal-day.other-month{{opacity:.35}}
.cal-day.today{{background:#1a2744}}
.cal-num{{font-size:12px;color:#A0A0A0;display:block;margin-bottom:2px}}
.cal-item{{font-size:11px;padding:2px 4px;margin:1px 0;background:#333;border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}}
.cal-nav{{display:flex;gap:8px;align-items:center;margin-bottom:12px}}
.cal-nav a{{color:#3366CC;text-decoration:none;font-size:14px}}
.cal-nav span{{font-size:16px;font-weight:600}}
</style>
<div class="cal-nav">
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
<span>{first.strftime('%B %Y')}</span>
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
</div>
<div class="calendar">
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
<div class="cal-header">Thu</div><div class="cal-header">Fri</div><div class="cal-header">Sat</div><div class="cal-header">Sun</div>
{cells}
</div>
<p class="desc">{len(pages)} pages in collection</p>
""")
def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
card_size = config.get("card_size", "medium")
size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px")
cards = ""
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
cover_url = config.get("cover_property")
cover_html = ""
if cover_url:
for k, v in props.items():
if isinstance(v, list) and len(v) > 0:
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
if url.startswith("http"):
cover_html = f"<div class='gal-cover' style='background-image:url({url})'></div>"
break
prop_tags = "".join(
f"<span class='gal-prop'>{str(v)[:30]}</span>"
for v in list(props.values())[:3] if v
)
cards += f"""<div class='gal-card'>
{cover_html}
<div class='gal-body'>
<div class='gal-title'>{p.get('icon','📄')} {p['title']}</div>
<div class='gal-props'>{prop_tags}</div>
</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
<style>
.gallery{{display:grid;grid-template-columns:repeat(auto-fill,minmax({size_css},1fr));gap:12px}}
.gal-card{{background:#1a1a1a;border-radius:8px;overflow:hidden;border:1px solid #333}}
.gal-card:hover{{border-color:#555}}
.gal-cover{{height:120px;background:#222;background-size:cover;background-position:center}}
.gal-body{{padding:12px}}
.gal-title{{font-size:14px;font-weight:500;margin-bottom:6px}}
.gal-props{{display:flex;flex-wrap:wrap;gap:4px}}
.gal-prop{{font-size:11px;padding:2px 6px;background:#333;border-radius:4px;color:#A0A0A0}}
</style>
<div class="gallery">{cards}</div>
<p class="desc">{len(pages)} cards</p>
""")
def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
items = ""
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v)
items += f"""<div class='list-item'>
<span class='list-icon'>{p.get('icon','📄')}</span>
<div class='list-content'>
<div class='list-title'>{p['title']}</div>
<div class='list-preview'>{preview}</div>
</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
<style>
.list-item{{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:4px;border:1px solid #222}}
.list-item:hover{{border-color:#444}}
.list-icon{{font-size:18px;margin-top:1px}}
.list-content{{flex:1;min-width:0}}
.list-title{{font-size:14px;font-weight:500}}
.list-preview{{font-size:12px;color:#A0A0A0;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
</style>
{items}
<p class="desc">{len(pages)} items</p>
""")
def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
# Find date range
dates = []
page_dates = []
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
start_val = end_val = None
for k, v in props.items():
if isinstance(v, str) and v.startswith("20"):
if "..." in v:
parts = v.split("...")
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
else:
start_val = end_val = v[:10]
break
if start_val:
dates.append(start_val)
if end_val:
dates.append(end_val)
page_dates.append((p, start_val, end_val or start_val))
if not dates:
return _base_html(collection["name"], collection.get("icon", "📈"), view_type,
"<p class='desc'>No date data to display timeline.</p>")
from datetime import date as dt_date
min_date = min(dt_date.fromisoformat(d) for d in dates)
max_date = max(dt_date.fromisoformat(d) for d in dates)
total = (max_date - min_date).days or 1
bars = ""
for p, start, end in page_dates:
sd = dt_date.fromisoformat(start)
ed = dt_date.fromisoformat(end)
left = (sd - min_date).days / total * 100
width = max((ed - sd).days / total * 100, 1)
bars += f"""<div class='tl-row'>
<span class='tl-label'>{p.get('icon','📄')} {p['title']}</span>
<div class='tl-track'>
<div class='tl-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{start} → {end}'></div>
</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
<style>
.tl-row{{display:flex;align-items:center;margin-bottom:8px;gap:12px}}
.tl-label{{width:160px;font-size:13px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.tl-track{{flex:1;height:28px;background:#222;border-radius:4px;position:relative}}
.tl-bar{{position:absolute;top:4px;height:20px;background:#3366CC;border-radius:4px;min-width:4px}}
</style>
<div class="tl-header" style="display:flex;margin-bottom:16px;padding-left:172px">
<span style="flex:1;font-size:11px;color:#A0A0A0">{min_date}</span>
<span style="font-size:11px;color:#A0A0A0">{max_date}</span>
</div>
{bars}
<p class="desc">{len(pages)} items · {min_date} → {max_date}</p>
""")
def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
rows = ""
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
prop_cells = "".join(f"<td>{str(v)[:80]}</td>" for v in list(props.values())[:4])
rows += f"<tr><td>{p.get('icon','📄')}</td><td>{p['title']}</td>{prop_cells}</tr>"
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
table{{width:100%;border-collapse:collapse}}
th,td{{padding:8px 12px;text-align:left;font-size:13px;border-bottom:1px solid #333}}
th{{color:#A0A0A0;font-weight:500;background:#1a1a1a;position:sticky;top:0}}
tr:hover td{{background:#222}}
</style>
<table><thead><tr><th></th><th>Title</th><th>Properties</th></tr></thead><tbody>{rows}</tbody></table>
<p class="desc">{len(pages)} rows</p>
""")
@router.get("/{collection_id}/api")
+178
View File
@@ -0,0 +1,178 @@
"""FlowDeck — My Tasks router (v1.9.0)."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
def _get_current_user(request: Request) -> dict | None:
session = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(session)
@router.get("", response_class=HTMLResponse)
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
grouped: dict[str, list[dict]] = {}
for col in collections:
col_id = col["id"]
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(col_id,),
).fetchall()
collection_tasks = []
for p in pages:
props = json.loads(p["property_values_json"])
# Check if assigned to current user
assigned = False
for v in props.values():
if isinstance(v, list):
for item in v:
if isinstance(item, dict) and item.get("login") == user_login:
assigned = True
break
elif isinstance(v, str) and user_login.lower() in v.lower():
assigned = True
if assigned:
break
if not assigned and user_login == "admin":
assigned = True # admin sees all
if not assigned:
continue
# Extract status and due date
status = None
due_date = None
for v in props.values():
if isinstance(v, str) and v in ("Todo", "In Progress", "Done", "Complete"):
status = v
elif isinstance(v, str) and v.startswith("20"):
due_date = v[:10]
# Filter by view
if view == "today":
from datetime import date
if not due_date or due_date != date.today().isoformat():
continue
elif view == "overdue":
from datetime import date
if not due_date or due_date >= date.today().isoformat():
continue
elif view == "upcoming":
from datetime import date, timedelta
if not due_date:
continue
today = date.today()
limit = today + timedelta(days=days)
d = date.fromisoformat(due_date)
if d < today or d > limit:
continue
collection_tasks.append({
"id": p["id"], "title": p["title"], "icon": p.get("icon", "📄"),
"status": status, "due_date": due_date, "props": props,
})
if collection_tasks:
grouped[col["name"]] = collection_tasks
total = sum(len(t) for t in grouped.values())
# Render
sections = ""
for col_name, tasks in grouped.items():
items = ""
for t in tasks:
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
status_badge = f"<span class='mt-status mt-{t['status'].lower().replace(' ','-') if t.get('status') else 'none'}'>{t.get('status','—')}</span>"
items += f"""<div class='mt-item'>
<span class='mt-icon'>{t['icon']}</span>
<span class='mt-title'>{t['title']}</span>
{status_badge}{due_badge}
</div>"""
sections += f"""<div class='mt-section'>
<div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>
{items}
</div>"""
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>My Tasks — FlowDeck</title>
<style>
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
h1{{font-size:24px;margin:0 0 16px}}
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none;text-decoration:none}}
.tab:hover,.tab.active{{background:#333;color:#fff}}
.mt-section{{margin-bottom:24px}}
.mt-section-header{{font-size:14px;font-weight:600;color:#A0A0A0;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}}
.mt-count{{color:#6B6B6B;font-weight:400}}
.mt-item{{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:3px;border:1px solid #222}}
.mt-item:hover{{border-color:#444}}
.mt-icon{{font-size:16px}}
.mt-title{{flex:1;font-size:14px}}
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px}}
.mt-due{{font-size:11px;color:#A0A0A0}}
.mt-todo{{background:#333;color:#A0A0A0}}
.mt-in-progress{{background:#1a2744;color:#3366CC}}
.mt-done,.mt-complete{{background:#1a332a;color:#00CC66}}
.p_desc{{color:#A0A0A0;margin-top:8px}}
</style></head><body>
<h1>📋 My Tasks</h1>
<div class="view-tabs">
<a class="tab{' active' if view=='all' else ''}" href="?view=all">All</a>
<a class="tab{' active' if view=='today' else ''}" href="?view=today">Today</a>
<a class="tab{' active' if view=='overdue' else ''}" href="?view=overdue">Overdue</a>
<a class="tab{' active' if view=='upcoming' else ''}" href="?view=upcoming&days=7">Next 7 days</a>
</div>
{sections}
<p class="p_desc">{total} tasks across {len(grouped)} collections</p>
</body></html>""")
@router.get("/api")
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
result = {}
for col in collections:
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(col["id"],),
).fetchall()
tasks = []
for p in pages:
props = json.loads(p["property_values_json"])
tasks.append({
"id": p["id"], "title": p["title"], "icon": p["icon"],
"properties": props,
})
if tasks:
result[col["name"]] = tasks
return {"tasks": result, "total": sum(len(t) for t in result.values())}
+377
View File
@@ -0,0 +1,377 @@
"""FlowDeck — Workspace, Comments, Favorites, History, Templates (v2.0.0)."""
from __future__ import annotations
import csv
import io
import json
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["workspace"], prefix="/workspace")
ROLES = ["admin", "editor", "commenter", "viewer"]
def _current_user(request: Request) -> dict:
s = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
# ── Workspaces ──
@router.get("")
async def list_workspaces(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
return {"workspaces": [dict(r) for r in rows]}
@router.post("")
async def create_workspace(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "Default Workspace")
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
uid_ensured = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
if not uid_ensured:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, uid))
ws_id = cur.lastrowid
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
(ws_id, uid, "admin"))
conn.commit()
return {"id": ws_id, "name": name, "status": "created"}
# ── Members ──
@router.get("/{ws_id}/members")
async def list_members(request: Request, ws_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
(ws_id,),
).fetchall()
return {"members": [dict(r) for r in rows]}
@router.post("/{ws_id}/members")
async def add_member(request: Request, ws_id: int):
body = await request.json() if request.headers.get("content-type") else {}
user_id = body.get("user_id")
role = body.get("role", "editor")
if role not in ROLES:
raise HTTPException(400, f"Invalid role: {role}")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(user_id, f"user_{user_id}", f"User {user_id}"))
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
(ws_id, user_id, role))
conn.commit()
return {"status": "added"}
@router.put("/{ws_id}/members/{user_id}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
body = await request.json() if request.headers.get("content-type") else {}
role = body.get("role", "editor")
if role not in ROLES:
raise HTTPException(400, f"Invalid role: {role}")
with get_conn() as conn:
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
(role, ws_id, user_id))
conn.commit()
return {"status": "updated"}
@router.delete("/{ws_id}/members/{user_id}")
async def remove_member(request: Request, ws_id: int, user_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
conn.commit()
return {"status": "removed"}
# ── Comments ──
@router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
(page_id,),
).fetchall()
return {"comments": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/comments")
async def add_comment(request: Request, page_id: int):
body = await request.json() if request.headers.get("content-type") else {}
b = body.get("body", "").strip()
if not b:
raise HTTPException(400, "body required")
user = _current_user(request)
uid = user.get("id", 1)
parent_id = body.get("parent_id")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)",
(page_id, uid, b, parent_id))
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@router.put("/comments/{comment_id}")
async def update_comment(request: Request, comment_id: int):
body = await request.json() if request.headers.get("content-type") else {}
b = body.get("body")
resolved = body.get("resolved")
with get_conn() as conn:
if b is not None:
conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id))
if resolved is not None:
conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id))
conn.commit()
return {"status": "updated"}
# ── Page History ──
@router.get("/pages/{page_id}/history")
async def page_history(request: Request, page_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
(page_id,),
).fetchall()
return {"history": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/history")
async def record_history(request: Request, page_id: int):
body = await request.json() if request.headers.get("content-type") else {}
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
conn.execute(
"INSERT INTO page_history (page_id, user_id, change_type, snapshot_json) VALUES (?,?,?,?)",
(page_id, uid, body.get("change_type", "updated"), json.dumps(body.get("snapshot", {}))),
)
conn.commit()
return {"status": "recorded"}
# ── Favorites ──
@router.get("/favorites")
async def list_favorites(request: Request):
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
rows = conn.execute(
"""SELECT f.*, cp.title as page_title, c.name as collection_name
FROM favorites f
LEFT JOIN collection_pages cp ON f.page_id=cp.id
LEFT JOIN collections c ON f.collection_id=c.id
WHERE f.user_id=? ORDER BY f.position""",
(uid,),
).fetchall()
return {"favorites": [dict(r) for r in rows]}
@router.post("/favorites")
async def add_favorite(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
user = _current_user(request)
uid = user.get("id", 1)
page_id = body.get("page_id")
collection_id = body.get("collection_id")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
conn.execute(
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
(uid, page_id, collection_id),
)
conn.commit()
return {"status": "favorited"}
@router.delete("/favorites/{fav_id}")
async def remove_favorite(request: Request, fav_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
conn.commit()
return {"status": "removed"}
# ── Templates ──
@router.get("/templates/database")
async def list_db_templates(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
return {"templates": [dict(r) for r in rows]}
@router.post("/templates/database")
async def create_db_template(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
cur = None
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@router.post("/templates/database/{tid}/apply")
async def apply_db_template(request: Request, tid: int):
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "New Database")
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
(name, tmpl["description"], "📋", tmpl["schema_json"]),
)
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
(cur.lastrowid, "Default View", "table", "{}"),
)
conn.commit()
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
@router.get("/collections/{collection_id}/templates/page")
async def list_page_templates(request: Request, collection_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
).fetchall()
return {"templates": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/templates/page")
async def create_page_template(request: Request, collection_id: int):
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
(collection_id, body.get("name", "Default"), json.dumps(body.get("properties", {}))),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
async def apply_page_template(request: Request, collection_id: int, tid: int):
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (collection_id,)
).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
# ── CSV Import/Export ──
@router.post("/collections/{collection_id}/import/csv")
async def import_csv(request: Request, collection_id: int):
body = await request.json() if request.headers.get("content-type") else {}
csv_data = body.get("csv", "")
if not csv_data:
raise HTTPException(400, "csv field required")
reader = csv.DictReader(io.StringIO(csv_data))
rows_imported = 0
with get_conn() as conn:
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?", (collection_id,)
).fetchone()[0]
for row in reader:
title = row.get("title", row.get("Title", row.get("Name", "Imported")))
props = {k: v for k, v in row.items() if k.lower() != "title"}
max_pos += 1
conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(collection_id, title, max_pos, json.dumps(props)),
)
rows_imported += 1
conn.commit()
return {"imported": rows_imported}
@router.get("/collections/{collection_id}/export/csv")
async def export_csv(request: Request, collection_id: int):
with get_conn() as conn:
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
).fetchall()
# Collect all property keys
all_keys = set()
rows = []
for p in pages:
props = json.loads(p["property_values_json"])
all_keys.update(props.keys())
rows.append({"title": p["title"], **props})
output = io.StringIO()
fieldnames = ["title"] + sorted(all_keys)
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=export.csv"},
)
# ── Public Sharing ──
@router.get("/public/{collection_id}")
async def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required."""
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
pages = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
items = "".join(
f"<li>{p['icon']} <b>{p['title']}</b></li>"
for p in pages
)
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
+223
View File
@@ -0,0 +1,223 @@
"""FlowDeck — Formula Engine (v1.5.0)."""
from __future__ import annotations
from datetime import datetime, date, timedelta
from typing import Any, Callable, Optional
class FormulaEngine:
"""Moteur d'expressions JavaScript-like pour propriétés calculées.
Usage:
engine = FormulaEngine()
result = engine.evaluate("prop('Price') * prop('Qty')", {"Price": 10, "Qty": 5})
# → 50
"""
def __init__(self):
self._functions: dict[str, Callable] = {
"prop": self._prop,
"now": self._now,
"today": self._today,
"if": self._if,
"concat": self._concat,
"round": self._round,
"contains": self._contains,
"length": self._length,
"toNumber": self._to_number,
"formatDate": self._format_date,
"dateAdd": self._date_add,
"dateSubtract": self._date_subtract,
"replace": self._replace,
"replaceAll": self._replace_all,
"join": self._join,
"empty": self._empty,
"and": self._and,
"or": self._or,
"not": self._not,
"start": self._start,
"end": self._end,
}
def evaluate(self, expression: str, context: dict[str, Any]) -> Any:
"""Évalue une expression de formule dans un contexte donné.
Le contexte contient les valeurs des propriétés accessibles via prop('name').
"""
if not expression or not expression.strip():
return None
try:
return self._eval_expr(expression.strip(), context)
except Exception:
return None
def _eval_expr(self, expr: str, ctx: dict[str, Any]) -> Any:
"""Parse et évalue l'expression."""
expr = expr.strip()
# Function call
if "(" in expr and expr.endswith(")"):
paren = expr.index("(")
func_name = expr[:paren].strip()
args_str = expr[paren + 1:-1]
args = self._parse_args(args_str)
evaluated = [self._eval_expr(a.strip(), ctx) for a in args]
if func_name in self._functions:
return self._functions[func_name](ctx, *evaluated)
return None
# String literal
if (expr.startswith("'") and expr.endswith("'")) or \
(expr.startswith('"') and expr.endswith('"')):
return expr[1:-1]
# Number
try:
return float(expr) if "." in expr else int(expr)
except ValueError:
pass
# Boolean
if expr.lower() == "true":
return True
if expr.lower() == "false":
return False
if expr.lower() == "null":
return None
# Context lookup
if expr in ctx:
return ctx[expr]
return expr # raw string
def _parse_args(self, s: str) -> list[str]:
"""Parse une liste d'arguments en respectant les guillemets."""
args = []
current = ""
depth = 0
in_quote = False
quote_char = ""
for ch in s:
if ch in ("'", '"') and not in_quote:
in_quote = True
quote_char = ch
current += ch
elif ch == quote_char and in_quote:
in_quote = False
current += ch
elif ch == "(":
depth += 1
current += ch
elif ch == ")":
depth -= 1
current += ch
elif ch == "," and depth == 0 and not in_quote:
args.append(current.strip())
current = ""
else:
current += ch
if current.strip():
args.append(current.strip())
return args
# ── Built-in functions ──
def _prop(self, ctx: dict, name: str) -> Any:
return ctx.get(name)
def _now(self, ctx: dict) -> str:
return datetime.now().isoformat()
def _today(self, ctx: dict) -> str:
return date.today().isoformat()
def _if(self, ctx: dict, cond: Any, a: Any, b: Any) -> Any:
return a if cond else b
def _concat(self, ctx: dict, *args) -> str:
return "".join(str(a) for a in args)
def _round(self, ctx: dict, n: Any, d: int = 0) -> float:
return round(float(n), int(d))
def _contains(self, ctx: dict, s: Any, sub: str) -> bool:
return str(sub) in str(s)
def _length(self, ctx: dict, s: Any) -> int:
return len(str(s))
def _to_number(self, ctx: dict, s: Any) -> float:
try:
return float(s)
except (ValueError, TypeError):
return 0.0
def _format_date(self, ctx: dict, d: str, fmt: str = "%Y-%m-%d") -> str:
try:
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
return dt.strftime(fmt)
except Exception:
return str(d)
def _date_add(self, ctx: dict, d: str, n: int, unit: str = "days") -> str:
try:
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
if unit == "days":
dt += timedelta(days=int(n))
elif unit == "hours":
dt += timedelta(hours=int(n))
elif unit == "minutes":
dt += timedelta(minutes=int(n))
return dt.isoformat()
except Exception:
return d
def _date_subtract(self, ctx: dict, d: str, n: int, unit: str = "days") -> str:
try:
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
if unit == "days":
dt -= timedelta(days=int(n))
elif unit == "hours":
dt -= timedelta(hours=int(n))
elif unit == "minutes":
dt -= timedelta(minutes=int(n))
return dt.isoformat()
except Exception:
return d
def _replace(self, ctx: dict, s: Any, old: str, new: str) -> str:
return str(s).replace(old, new)
def _replace_all(self, ctx: dict, s: Any, old: str, new: str) -> str:
return str(s).replace(old, new)
def _join(self, ctx: dict, sep: str, *args) -> str:
return sep.join(str(a) for a in args)
def _empty(self, ctx: dict, v: Any) -> bool:
return v is None or v == "" or v == [] or v == 0
def _and(self, ctx: dict, *args) -> bool:
return all(args)
def _or(self, ctx: dict, *args) -> bool:
return any(args)
def _not(self, ctx: dict, v: Any) -> bool:
return not v
def _start(self, ctx: dict, s: Any) -> str:
"""Extract start date from a date range (iso1...iso2 or just iso1)."""
val = str(s)
if "..." in val:
return val.split("...")[0]
return val
def _end(self, ctx: dict, s: Any) -> Optional[str]:
"""Extract end date from a date range."""
val = str(s)
if "..." in val:
parts = val.split("...")
return parts[1] if len(parts) > 1 else None
return None
+187
View File
@@ -0,0 +1,187 @@
"""FlowDeck — Property types & validation (v1.4.0)."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Optional
# ── Property type definitions ──
PROPERTY_TYPES: dict[str, dict] = {
"title": {
"storage": "string",
"validation": "max 2000 chars",
"default": "",
},
"text": {
"storage": "string",
"default": "",
},
"number": {
"storage": "float",
"validation": "numeric",
"format_options": ["number", "percent", "dollar", "euro", "pound", "yen"],
"default": None,
},
"select": {
"storage": "string (from options_json)",
"default": None,
},
"multi_select": {
"storage": "json array of strings",
"default": [],
},
"status": {
"storage": "string (from options_json) + color",
"default": None,
"colors": ["gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red"],
},
"date": {
"storage": "ISO 8601 string",
"default": None,
},
"person": {
"storage": "json array of {id, login, avatar_url}",
"default": [],
},
"checkbox": {
"storage": "boolean",
"default": False,
},
"url": {
"storage": "string",
"default": "",
},
"email": {
"storage": "string",
"default": "",
},
"phone": {
"storage": "string",
"default": "",
},
"files": {
"storage": "json array of {url, name}",
"default": [],
},
"unique_id": {
"storage": "integer (auto-increment per collection)",
"default": None,
},
"relation": {
"storage": "json array of page IDs",
"default": [],
},
"rollup": {
"storage": "computed — not stored",
"default": None,
},
"formula": {
"storage": "computed — not stored",
"default": None,
},
"created_time": {
"storage": "auto — ISO 8601",
"default": None,
},
"created_by": {
"storage": "auto — {id, login}",
"default": None,
},
"last_edited_time": {
"storage": "auto — ISO 8601",
"default": None,
},
"last_edited_by": {
"storage": "auto — {id, login}",
"default": None,
},
}
# CSV-friendly subset (no relation/rollup/formula)
SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
"date", "person", "checkbox", "url", "email", "phone", "files"]
# Auto-property types (computed server-side)
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
def validate_property_value(prop_type: str, value: Any, options: Optional[list] = None) -> tuple[bool, str]:
"""Validate a property value against its type. Returns (ok, error_message)."""
if value is None:
return True, ""
if prop_type == "number":
try:
float(value)
except (ValueError, TypeError):
return False, f"'{value}' is not a valid number"
elif prop_type == "checkbox":
if not isinstance(value, bool):
return False, "Checkbox must be true or false"
elif prop_type in ("select", "status"):
if options and value not in [o.get("name", o) if isinstance(o, dict) else o for o in options]:
return False, f"'{value}' is not a valid option"
elif prop_type == "multi_select":
if not isinstance(value, list):
return False, "Multi-select must be a list"
if options:
valid_names = {o.get("name", o) if isinstance(o, dict) else o for o in options}
for v in value:
if v not in valid_names:
return False, f"'{v}' is not a valid option"
elif prop_type == "date":
if isinstance(value, str) and value:
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except (ValueError, TypeError):
return False, f"'{value}' is not a valid ISO 8601 date"
elif prop_type == "url":
if not isinstance(value, str):
return False, "URL must be a string"
elif prop_type == "email":
if not isinstance(value, str) or ("@" not in value if value else False):
return False, "Invalid email format"
elif prop_type == "phone":
if not isinstance(value, str):
return False, "Phone must be a string"
return True, ""
def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(timezone.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 None
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
FROM collection_pages WHERE collection_id=?""",
(collection_id,),
).fetchone()
return row[0] if row else 1
def format_number(value: float, fmt: str = "number") -> str:
"""Format a number value for display."""
if value is None:
return ""
if fmt == "percent":
return f"{value}%"
elif fmt == "dollar":
return f"${value:,.2f}"
elif fmt == "euro":
return f"€{value:,.2f}"
elif fmt == "pound":
return f"£{value:,.2f}"
elif fmt == "yen":
return f"¥{value:,.0f}"
return str(value)
+150
View File
@@ -0,0 +1,150 @@
"""FlowDeck — Rollup Engine (v1.5.0)."""
from __future__ import annotations
import json
import statistics
from typing import Any, Optional
from app.db import get_conn
ROLLUP_FUNCTIONS = {
"count": lambda values: len(values),
"count_values": lambda values: len([v for v in values if v is not None and v != ""]),
"empty": lambda values: len([v for v in values if v is None or v == ""]),
"not_empty": lambda values: len([v for v in values if v is not None and v != ""]),
"sum": lambda values: sum(float(v) for v in values if v is not None),
"average": lambda values: _safe_avg(values),
"median": lambda values: _safe_stat(values, statistics.median),
"min": lambda values: min(_numeric(v for v in values if v is not None), default=None),
"max": lambda values: max(_numeric(v for v in values if v is not None), default=None),
"range": lambda values: _safe_range(values),
"unique": lambda values: list(set(str(v) for v in values if v is not None)),
"percent_checked": lambda values: _percent_checked(values),
"percent_per_group": lambda values: _percent_per_group(values),
}
class RollupEngine:
"""Moteur d'agrégation pour les propriétés rollup."""
def compute(
self,
collection_id: int,
relation_property_id: int,
target_property_id: int,
page_id: int,
rollup_function: str,
) -> Any:
"""Calcule l'agrégation d'une propriété via une relation.
Args:
collection_id: la collection de la page source
relation_property_id: la propriété relation (dans cette collection)
target_property_id: la propriété à agréger (dans la collection liée)
page_id: la page qui porte la relation
rollup_function: nom de la fonction (count, sum, avg, etc.)
"""
if rollup_function not in ROLLUP_FUNCTIONS:
return None
with get_conn() as conn:
# Get the relation property to find the related collection
rel_prop = conn.execute(
"SELECT * FROM collection_properties WHERE id=?",
(relation_property_id,),
).fetchone()
if not rel_prop:
return None
related_collection_id = rel_prop["related_collection_id"]
if not related_collection_id:
return None
# Get the target property name
target_prop = conn.execute(
"SELECT * FROM collection_properties WHERE id=?",
(target_property_id,),
).fetchone()
if not target_prop:
return None
target_name = target_prop["name"]
# Get the related page IDs from the source page's property_values_json
source_page = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if not source_page:
return None
props = json.loads(source_page["property_values_json"])
related_ids = props.get(str(relation_property_id), [])
if not isinstance(related_ids, list):
related_ids = [related_ids] if related_ids else []
if not related_ids:
return ROLLUP_FUNCTIONS[rollup_function]([])
# Get the target values from related pages
placeholders = ",".join("?" for _ in related_ids)
rows = conn.execute(
f"SELECT property_values_json FROM collection_pages WHERE id IN ({placeholders})",
related_ids,
).fetchall()
values = []
for row in rows:
page_props = json.loads(row["property_values_json"])
val = page_props.get(str(target_property_id))
if val is None:
# Try by name
val = page_props.get(target_name)
values.append(val)
return ROLLUP_FUNCTIONS[rollup_function](values)
def _safe_avg(values: list) -> Optional[float]:
nums = [float(v) for v in values if v is not None]
return sum(nums) / len(nums) if nums else None
def _safe_stat(values: list, fn) -> Optional[float]:
nums = [float(v) for v in values if v is not None]
return fn(nums) if nums else None
def _numeric(gen):
for v in gen:
try:
yield float(v)
except (ValueError, TypeError):
pass
def _safe_range(values: list) -> Optional[float]:
nums = list(_numeric(v for v in values if v is not None))
return max(nums) - min(nums) if len(nums) >= 2 else None
def _percent_checked(values: list) -> Optional[float]:
"""Percentage of true values (for checkbox properties)."""
if not values:
return 0.0
booleans = [v for v in values if isinstance(v, bool)]
if not booleans:
return 0.0
return (sum(1 for v in booleans if v) / len(booleans)) * 100
def _percent_per_group(values: list) -> dict:
"""Percentage distribution across groups."""
if not values:
return {}
counts = {}
for v in values:
key = str(v) if v is not None else "Empty"
counts[key] = counts.get(key, 0) + 1
total = sum(counts.values())
return {k: round(v / total * 100, 1) for k, v in counts.items()}
+583 -3
View File
@@ -34,7 +34,7 @@ def test_health(client):
data = resp.json()
assert "status" in data
assert data["db"] is True
assert data["version"] == "1.3.0"
assert data["version"] == "2.0.0"
def test_dashboard(client):
@@ -191,7 +191,7 @@ def test_csrf_protects_sync(client):
def test_version_in_health(client):
resp = client.get("/api/health")
assert resp.json()["version"] == "1.3.0"
assert resp.json()["version"] == "2.0.0"
def test_db_tables_exist(client):
@@ -204,7 +204,10 @@ def test_db_tables_exist(client):
required = {"boards", "cards", "notes", "checklists", "checklist_items",
"col_mapping", "users", "user_tokens",
"project_properties", "property_values", "ai_keywords",
"collections", "collection_pages", "collection_views"}
"collections", "collection_pages", "collection_views",
"collection_properties", "workspaces", "workspace_members",
"comments", "page_history", "favorites",
"database_templates", "page_templates"}
assert required <= names
@@ -356,3 +359,580 @@ def test_boards_as_collections(client):
data = resp.json()
assert "boards" in data
assert isinstance(data["boards"], list)
# ── v2.0.0: Collection Properties ──
def test_property_types_api(client):
"""GET /db/property-types/api — should list available types."""
resp = client.get("/db/property-types/api")
assert resp.status_code == 200
data = resp.json()
assert "types" in data
assert "text" in data["types"]
assert "number" in data["types"]
assert "checkbox" in data["types"]
assert "status" in data["types"]
def test_collection_properties_crud(client):
"""Full CRUD on collection properties."""
# Create collection
resp = client.post("/db/api", json={"name": "Props Test DB"})
coll_id = resp.json()["id"]
# List properties (empty)
resp = client.get(f"/db/{coll_id}/properties/api")
assert resp.status_code == 200
assert resp.json()["properties"] == []
# Create a text property
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "Description",
"prop_type": "text",
})
assert resp.status_code == 200
assert resp.json()["status"] == "created"
prop_id = resp.json()["id"]
# Create a number property
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "Estimation",
"prop_type": "number",
"number_format": "number",
})
assert resp.status_code == 200
# Create a status property with options
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "State",
"prop_type": "status",
"options": [
{"name": "Todo", "color": "gray"},
{"name": "Done", "color": "green"},
],
})
assert resp.status_code == 200
# Create a checkbox property
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "Verified",
"prop_type": "checkbox",
})
assert resp.status_code == 200
# List — should have 4
resp = client.get(f"/db/{coll_id}/properties/api")
assert len(resp.json()["properties"]) == 4
# Update a property
resp = client.put(f"/db/properties/{prop_id}/api", json={
"name": "Description Longue",
})
assert resp.status_code == 200
# Verify update
resp = client.get(f"/db/{coll_id}/properties/api")
names = [p["name"] for p in resp.json()["properties"]]
assert "Description Longue" in names
# Delete a property
resp = client.delete(f"/db/properties/{prop_id}/api")
assert resp.status_code == 200
# Verify deletion
resp = client.get(f"/db/{coll_id}/properties/api")
assert len(resp.json()["properties"]) == 3
# Cleanup
client.delete(f"/db/api/{coll_id}")
def test_collection_properties_duplicate(client):
"""Creating duplicate property name should return 409."""
resp = client.post("/db/api", json={"name": "Dup Test"})
coll_id = resp.json()["id"]
client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"})
resp = client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"})
assert resp.status_code == 409
client.delete(f"/db/api/{coll_id}")
def test_collection_properties_validation(client):
"""Validation: missing name should return 400."""
resp = client.post("/db/api", json={"name": "Val Test"})
coll_id = resp.json()["id"]
resp = client.post(f"/db/{coll_id}/properties/api", json={})
assert resp.status_code == 400
client.delete(f"/db/api/{coll_id}")
# ── v2.0.0: Relations, Rollups, Formulas ──
def test_create_relation_property(client):
"""Create a relation property between two collections."""
r1 = client.post("/db/api", json={"name": "Projects"})
r2 = client.post("/db/api", json={"name": "Tasks"})
c1, c2 = r1.json()["id"], r2.json()["id"]
resp = client.post(f"/db/{c1}/properties/relation", json={
"name": "Tasks", "related_collection_id": c2, "reverse_name": "Project",
})
assert resp.status_code == 200
assert resp.json()["prop_type"] == "relation"
resp2 = client.get(f"/db/{c2}/properties/api")
names = [p["name"] for p in resp2.json()["properties"]]
assert "Project" in names
client.delete(f"/db/api/{c2}") # c2 first (has reverse FK → c1)
client.delete(f"/db/api/{c1}")
def test_link_pages_via_relation(client):
"""Link two pages via a relation and verify reverse."""
r1 = client.post("/db/api", json={"name": "A"})
r2 = client.post("/db/api", json={"name": "B"})
c1, c2 = r1.json()["id"], r2.json()["id"]
rel = client.post(f"/db/{c1}/properties/relation", json={
"name": "Items", "related_collection_id": c2, "reverse_name": "Parent",
})
prop_id = rel.json()["id"]
p1 = client.post(f"/db/{c1}/pages/api", json={"title": "Page A"})
p2 = client.post(f"/db/{c2}/pages/api", json={"title": "Page B"})
pid1, pid2 = p1.json()["id"], p2.json()["id"]
resp = client.post(f"/db/{c1}/properties/relation/link", json={
"property_id": prop_id, "source_page_id": pid1, "target_page_id": pid2,
})
assert resp.status_code == 200
src = client.get(f"/db/pages/{pid1}/api").json()
props = json.loads(src["property_values_json"])
assert pid2 in props.get(str(prop_id), [])
tgt = client.get(f"/db/pages/{pid2}/api").json()
tprops = json.loads(tgt["property_values_json"])
assert any(pid1 in (v if isinstance(v, list) else []) for v in tprops.values())
client.delete(f"/db/api/{c2}") # c2 first
client.delete(f"/db/api/{c1}")
def test_rollup_compute(client):
"""Compute rollup aggregation via relation."""
r1 = client.post("/db/api", json={"name": "Proj"})
r2 = client.post("/db/api", json={"name": "Task"})
c1, c2 = r1.json()["id"], r2.json()["id"]
rel = client.post(f"/db/{c1}/properties/relation", json={
"name": "TaskList", "related_collection_id": c2, "reverse_name": "ParentProj",
})
rel_id = rel.json()["id"]
num = client.post(f"/db/{c2}/properties/api", json={"name": "Hours", "prop_type": "number"})
num_id = num.json()["id"]
proj = client.post(f"/db/{c1}/pages/api", json={"title": "Proj1"})
pid = proj.json()["id"]
t1 = client.post(f"/db/{c2}/pages/api", json={"title": "Task 1", "properties": {str(num_id): 5}})
t2 = client.post(f"/db/{c2}/pages/api", json={"title": "Task 2", "properties": {str(num_id): 10}})
client.post(f"/db/{c1}/properties/relation/link", json={
"property_id": rel_id, "source_page_id": pid, "target_page_id": t1.json()["id"],
})
client.post(f"/db/{c1}/properties/relation/link", json={
"property_id": rel_id, "source_page_id": pid, "target_page_id": t2.json()["id"],
})
resp = client.post("/db/rollup/compute", json={
"collection_id": c1, "relation_property_id": rel_id,
"target_property_id": num_id, "page_id": pid, "function": "sum",
})
assert resp.status_code == 200
assert resp.json()["result"] == 15.0
resp2 = client.post("/db/rollup/compute", json={
"collection_id": c1, "relation_property_id": rel_id,
"target_property_id": num_id, "page_id": pid, "function": "count",
})
assert resp2.json()["result"] == 2
client.delete(f"/db/api/{c2}") # c2 first
client.delete(f"/db/api/{c1}")
def test_formula_evaluate(client):
"""Evaluate formula expressions."""
# Function-based expressions work
resp = client.post("/db/formula/evaluate", json={
"expression": "round(3.14159, 2)", "context": {},
})
assert resp.status_code == 200
assert resp.json()["result"] == 3.14
resp = client.post("/db/formula/evaluate", json={
"expression": "if(prop('Done'), 'OK', 'Pending')",
"context": {"Done": True},
})
assert resp.json()["result"] == "OK"
resp = client.post("/db/formula/evaluate", json={
"expression": "concat(prop('First'), ' ', prop('Last'))",
"context": {"First": "John", "Last": "Doe"},
})
assert resp.json()["result"] == "John Doe"
resp = client.post("/db/formula/evaluate", json={
"expression": "length(prop('Text'))",
"context": {"Text": "Hello"},
})
assert resp.json()["result"] == 5
resp = client.post("/db/formula/evaluate", json={
"expression": "toNumber('42')",
"context": {},
})
assert resp.json()["result"] == 42.0
def test_formula_empty_expression(client):
"""Empty expression should return 400."""
resp = client.post("/db/formula/evaluate", json={"expression": "", "context": {}})
assert resp.status_code == 400
# ── v2.0.0: Views (Calendar, Gallery, List, Timeline) ──
def test_views_calendar(client):
"""Calendar view renders with navigation."""
r = client.post("/db/api", json={"name": "Cal DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Event 1", "properties": {"date": "2026-07-15"}})
resp = client.get(f"/db/{cid}/view/calendar")
assert resp.status_code == 200
assert "July" in resp.text or "juillet" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_gallery(client):
"""Gallery view renders card grid."""
r = client.post("/db/api", json={"name": "Gal DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Card 1"})
resp = client.get(f"/db/{cid}/view/gallery")
assert resp.status_code == 200
assert "gallery" in resp.text.lower() or "gal-card" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_list(client):
"""List view renders compact items."""
r = client.post("/db/api", json={"name": "List DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Item 1"})
resp = client.get(f"/db/{cid}/view/list")
assert resp.status_code == 200
assert "list-item" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_timeline(client):
"""Timeline view renders date bars."""
r = client.post("/db/api", json={"name": "TL DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"date": "2026-07-01...2026-07-15"}})
resp = client.get(f"/db/{cid}/view/timeline")
assert resp.status_code == 200
assert "tl-bar" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_default_table(client):
"""Default view renders as table."""
r = client.post("/db/api", json={"name": "Tab DB"})
cid = r.json()["id"]
resp = client.get(f"/db/{cid}")
assert resp.status_code == 200
assert "<table>" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_calendar_navigation(client):
"""Calendar supports month navigation via query params."""
r = client.post("/db/api", json={"name": "Nav DB"})
cid = r.json()["id"]
resp = client.get(f"/db/{cid}/view/calendar?year=2026&month=8")
assert resp.status_code == 200
assert "August" in resp.text or "août" in resp.text
client.delete(f"/db/api/{cid}")
# ── v1.7.0: View Management ──
def test_view_config_update(client):
"""Update view config (card_size, group_by)."""
r = client.post("/db/api", json={"name": "V Config"})
cid = r.json()["id"]
views = client.get(f"/db/{cid}/views/api").json()["views"]
vid = views[0]["id"]
resp = client.put(f"/db/views/{vid}/config", json={
"card_size": "large", "group_by": "Status",
})
assert resp.status_code == 200
assert resp.json()["config"]["card_size"] == "large"
client.delete(f"/db/api/{cid}")
def test_save_view_as(client):
"""Save current state as new view."""
r = client.post("/db/api", json={"name": "Save As"})
cid = r.json()["id"]
resp = client.post(f"/db/{cid}/views/save-as", json={
"name": "My Kanban", "view_type": "board",
"config": {"group_by": "Priority"},
})
assert resp.status_code == 200
assert resp.json()["name"] == "My Kanban"
views = client.get(f"/db/{cid}/views/api").json()["views"]
assert len(views) == 2
client.delete(f"/db/api/{cid}")
def test_list_views(client):
"""List views for a collection."""
r = client.post("/db/api", json={"name": "V List"})
cid = r.json()["id"]
resp = client.get(f"/db/{cid}/views/api")
assert resp.status_code == 200
assert len(resp.json()["views"]) == 1 # default view
client.delete(f"/db/api/{cid}")
# ── v2.0.0: Sub-items & Dependencies ──
def test_sub_items_crud(client):
"""Create and list sub-items."""
r = client.post("/db/api", json={"name": "Sub DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent Task"})
pid = p.json()["id"]
# Create sub-item
resp = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 1"})
assert resp.status_code == 200
assert resp.json()["parent_id"] == pid
resp2 = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 2"})
assert resp2.status_code == 200
# List
items = client.get(f"/db/{cid}/pages/{pid}/sub-items").json()["sub_items"]
assert len(items) == 2
client.delete(f"/db/api/{cid}")
def test_status_aggregate(client):
"""Aggregate child statuses."""
r = client.post("/db/api", json={"name": "Agg DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent", "properties": {"Status": "In Progress"}})
pid = p.json()["id"]
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C1", "properties": {"Status": "Done"}})
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C2", "properties": {"Status": "In Progress"}})
resp = client.get(f"/db/{cid}/pages/{pid}/status-aggregate")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert data["done"] == 1
assert data["all_done"] is False
client.delete(f"/db/api/{cid}")
def test_dependencies_check(client):
"""Dependency constraint check."""
r = client.post("/db/api", json={"name": "Dep DB"})
cid = r.json()["id"]
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Done"}})
aid, bid = a.json()["id"], b.json()["id"]
# A blocks B
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
# Check if A can go to Done (should not, B is Done but blocks is on A, wait...)
# B is Done, so A CAN transition
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
assert resp.status_code == 200
assert resp.json()["can_transition"] is True
assert resp.json()["blocked_by"] == []
client.delete(f"/db/api/{cid}")
def test_dependencies_blocked(client):
"""Dependency blocks transition when blocker is not done."""
r = client.post("/db/api", json={"name": "Block DB"})
cid = r.json()["id"]
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Todo"}})
aid, bid = a.json()["id"], b.json()["id"]
# A blocks B — A depends on B being done
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
# B is Todo, so A CANNOT transition to Done
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
assert resp.json()["can_transition"] is False
assert len(resp.json()["blocked_by"]) == 1
client.delete(f"/db/api/{cid}")
# ── v2.0.0: My Tasks ──
def test_my_tasks_page(client):
"""My Tasks dashboard renders."""
resp = client.get("/my-tasks")
assert resp.status_code == 200
assert "My Tasks" in resp.text
def test_my_tasks_api(client):
"""My Tasks API returns JSON."""
r = client.post("/db/api", json={"name": "My Project"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Task 1", "properties": {"Status": "Todo"}})
client.post(f"/db/{cid}/pages/api", json={"title": "Task 2", "properties": {"Status": "In Progress"}})
resp = client.get("/my-tasks/api")
assert resp.status_code == 200
data = resp.json()
assert "tasks" in data
assert data["total"] >= 2
client.delete(f"/db/api/{cid}")
def test_my_tasks_view_today(client):
resp = client.get("/my-tasks?view=today")
assert resp.status_code == 200
def test_my_tasks_view_overdue(client):
resp = client.get("/my-tasks?view=overdue")
assert resp.status_code == 200
# ── v2.0.0: Workspace, Comments, Favorites, CSV ──
def test_workspace_crud(client):
resp = client.post("/workspace", json={"name": "Team WS"})
assert resp.status_code == 200
ws_id = resp.json()["id"]
members = client.get(f"/workspace/{ws_id}/members")
assert len(members.json()["members"]) >= 1
# cleanup
with client as _:
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
conn.commit()
def test_comments_crud(client):
r = client.post("/db/api", json={"name": "Comment DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Discuss"})
pid = p.json()["id"]
resp = client.post(f"/workspace/pages/{pid}/comments", json={"body": "Nice work!"})
assert resp.status_code == 200
comments = client.get(f"/workspace/pages/{pid}/comments").json()["comments"]
assert len(comments) == 1
assert comments[0]["body"] == "Nice work!"
client.delete(f"/db/api/{cid}")
def test_favorites_crud(client):
r = client.post("/db/api", json={"name": "Fav DB"})
cid = r.json()["id"]
resp = client.post("/workspace/favorites", json={"collection_id": cid})
assert resp.status_code == 200
favs = client.get("/workspace/favorites").json()["favorites"]
assert len(favs) >= 1
fav_id = favs[0]["id"]
client.delete(f"/workspace/favorites/{fav_id}")
assert len(client.get("/workspace/favorites").json()["favorites"]) == 0
client.delete(f"/db/api/{cid}")
def test_csv_import_export(client):
r = client.post("/db/api", json={"name": "CSV DB"})
cid = r.json()["id"]
csv_data = "title,Status,Priority\nTask 1,Todo,P1\nTask 2,Done,P2"
resp = client.post(f"/workspace/collections/{cid}/import/csv", json={"csv": csv_data})
assert resp.status_code == 200
assert resp.json()["imported"] == 2
export = client.get(f"/workspace/collections/{cid}/export/csv")
assert export.status_code == 200
assert "Task 1" in export.text
client.delete(f"/db/api/{cid}")
def test_public_view(client):
r = client.post("/db/api", json={"name": "Public DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Public Page"})
resp = client.get(f"/workspace/public/{cid}")
assert resp.status_code == 200
assert "Public Page" in resp.text
client.delete(f"/db/api/{cid}")
def test_db_templates(client):
resp = client.post("/workspace/templates/database", json={
"name": "Bug Tracker", "schema": [{"name": "Severity", "type": "select"}],
})
assert resp.status_code == 200
tid = resp.json()["id"]
templates = client.get("/workspace/templates/database").json()["templates"]
assert len(templates) >= 1
apply = client.post(f"/workspace/templates/database/{tid}/apply", json={"name": "Bugs v2"})
assert apply.status_code == 200
client.delete(f"/db/api/{apply.json()['collection_id']}")
def test_page_history(client):
r = client.post("/db/api", json={"name": "Hist DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "History Page"})
pid = p.json()["id"]
client.post(f"/workspace/pages/{pid}/history", json={
"change_type": "created", "snapshot": {"title": "History Page"},
})
hist = client.get(f"/workspace/pages/{pid}/history").json()["history"]
assert len(hist) == 1
client.delete(f"/db/api/{cid}")