Compare commits

..
3 Commits
Author SHA1 Message Date
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
12 changed files with 1299 additions and 234 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
+56 -47
View File
@@ -54,60 +54,69 @@ 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)
@@ -202,13 +211,13 @@ 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 1: Database Concept (v1.3) — 6 items ✅
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅ (complété)
├─ 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
+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)
+21
View File
@@ -184,6 +184,27 @@ 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)
);
""")
# Migration: add parent_id if missing (v1.0.0+)
try:
+2 -2
View File
@@ -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 v1.5.0 started on port %d", settings.app_port)
yield
app = FastAPI(
title="FlowDeck",
version="1.3.0",
version="1.5.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+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": "1.5.0",
"db": db_ok,
"gitea": gitea_ok,
}
+290
View File
@@ -247,6 +247,296 @@ 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}
# ── {collection_id} wildcards (LAST — catches everything else) ──
+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()}
+252 -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"] == "1.5.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"] == "1.5.0"
def test_db_tables_exist(client):
@@ -204,7 +204,8 @@ 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"}
assert required <= names
@@ -356,3 +357,251 @@ def test_boards_as_collections(client):
data = resp.json()
assert "boards" in data
assert isinstance(data["boards"], list)
# ── v1.5.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}")
# ── v1.5.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