From 6724be6c85f4c2423028ad17333df0cb800a599e Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 7 May 2026 17:37:55 +0200 Subject: [PATCH] feat(baserow): add seed script + Fast-App iteration 1 artifacts - baserow/seed/schema.json : 9 tables declaratif (personne + CFA + Agence) - baserow/seed/seed.py : Python script idempotent (login + workspace + db + tables + fields + links) - baserow/seed/requirements.txt : requests - baserow/seed/README.md : quickstart 4 etapes - Makefile target seed-baserow Fast-App workflow local : - _byan-output/fast-app/formation-hub/ : 6 artifacts (pitch, backlog, cdcf-stories, plan, dispatch, build-state) - Phase 0 mappee : phases 1-6 done depuis docs Merise/UML existants - Iteration 1 BUILD = setup tables Baserow vanilla (S-02 + S-03 + S-04) Stack locale up et healthy. Pret pour seed apres creation compte admin Baserow. --- Makefile | 8 + .../fast-app/formation-hub/backlog.json | 29 ++ .../fast-app/formation-hub/build-state.json | 15 ++ .../fast-app/formation-hub/cdcf-stories.json | 93 +++++++ .../fast-app/formation-hub/dispatch.json | 55 ++++ .../fast-app/formation-hub/pitch.json | 10 + _byan-output/fast-app/formation-hub/plan.json | 65 +++++ baserow/seed/README.md | 86 ++++++ baserow/seed/requirements.txt | 1 + baserow/seed/schema.json | 202 ++++++++++++++ baserow/seed/seed.py | 249 ++++++++++++++++++ 11 files changed, 813 insertions(+) create mode 100644 _byan-output/fast-app/formation-hub/backlog.json create mode 100644 _byan-output/fast-app/formation-hub/build-state.json create mode 100644 _byan-output/fast-app/formation-hub/cdcf-stories.json create mode 100644 _byan-output/fast-app/formation-hub/dispatch.json create mode 100644 _byan-output/fast-app/formation-hub/pitch.json create mode 100644 _byan-output/fast-app/formation-hub/plan.json create mode 100644 baserow/seed/README.md create mode 100644 baserow/seed/requirements.txt create mode 100644 baserow/seed/schema.json create mode 100644 baserow/seed/seed.py diff --git a/Makefile b/Makefile index f3aef9e..7d575fb 100644 --- a/Makefile +++ b/Makefile @@ -74,6 +74,14 @@ backup-baserow: docker compose exec -T baserow tar czf - /baserow/data > $(BACKUP_DIR)/baserow-$(DATE).tar.gz @echo " -> $(BACKUP_DIR)/baserow-$(DATE).tar.gz" +seed-baserow: + @command -v python3 >/dev/null || (echo "ERREUR: python3 requis" && exit 1) + @test -n "$$BASEROW_EMAIL" -a -n "$$BASEROW_PASSWORD" || \ + (echo "ERREUR: exporter BASEROW_EMAIL et BASEROW_PASSWORD" && exit 1) + @cd baserow/seed && pip install -q -r requirements.txt + BASEROW_URL=$${BASEROW_URL:-http://localhost:8080} \ + python3 baserow/seed/seed.py + clean: @echo "ATTENTION: cette commande supprime TOUS les volumes (donnees perdues)." @read -p "Tapez 'oui' pour confirmer: " confirm; [ "$$confirm" = "oui" ] || exit 1 diff --git a/_byan-output/fast-app/formation-hub/backlog.json b/_byan-output/fast-app/formation-hub/backlog.json new file mode 100644 index 0000000..394563a --- /dev/null +++ b/_byan-output/fast-app/formation-hub/backlog.json @@ -0,0 +1,29 @@ +{ + "scope_mode": "full", + "features": [ + {"id": "F-01", "title": "Wiki Docmost avec spaces multi-tenant", "priority": "MUST", "justification": "Coeur metier — centralisation doc"}, + {"id": "F-02", "title": "Diagrammes natifs Mermaid + Drawio + Excalidraw", "priority": "MUST", "justification": "Inclus Docmost v0.3+, zero dev"}, + {"id": "F-03", "title": "Permissions hierarchiques (workspace/space/page)", "priority": "MUST", "justification": "RGPD + workflow team"}, + {"id": "F-04", "title": "Share links externes (clients guests)", "priority": "MUST", "justification": "Acces partenaires/financeurs"}, + {"id": "F-05", "title": "9 tables Baserow (PERSONNE pivot + CFA + Agence)", "priority": "MUST", "justification": "Modele de donnees scope B approved"}, + {"id": "F-06", "title": "Rollups + formulas heures restantes", "priority": "MUST", "justification": "Calcul auto capacite formateurs/devs"}, + {"id": "F-07", "title": "Vues kanban/calendar/timeline par DB", "priority": "MUST", "justification": "UX metier admin"}, + {"id": "F-08", "title": "Spaces personnels etudiants Docmost", "priority": "MUST", "justification": "Promesse Vision Acadenice"}, + {"id": "F-09", "title": "Forms publics saisie heures (formateurs/devs)", "priority": "SHOULD", "justification": "UX mobile-friendly + permissions simples"}, + {"id": "F-10", "title": "Bridge service Tiptap node-views custom", "priority": "SHOULD", "justification": "UX unifie Phase 2"}, + {"id": "F-11", "title": "Sync bidirectionnel Docmost ↔ Baserow", "priority": "SHOULD", "justification": "Auto-creation pages depuis projets, etc."}, + {"id": "F-12", "title": "MCP server pour Claude/agents IA", "priority": "COULD", "justification": "Productivity boost pour admin Yan/Corentin"}, + {"id": "F-13", "title": "Bidirec backlinks Docmost (custom)", "priority": "COULD", "justification": "Nice-to-have, le bridge couvre 80% du besoin via DB relations"}, + {"id": "F-14", "title": "Dual-mode editor (WYSIWYG + raw markdown)", "priority": "COULD", "justification": "Power-users only"}, + {"id": "F-15", "title": "Rapports PDF (formation/personne/projet)", "priority": "COULD", "justification": "Export pour comptabilite, Phase 3"} + ], + "wont": [ + {"id": "W-01", "title": "Modeliser les etudiants en table Baserow", "reason": "Decision Corentin 2026-05-07 : etudiants restent users Docmost libres, pas de modelisation structuree (inscriptions/notes). Si besoin Phase 4."}, + {"id": "W-02", "title": "Generer factures clients automatiquement", "reason": "Hors scope outil de connaissance. Comptabilite via outil dedie."}, + {"id": "W-03", "title": "ATS / recrutement", "reason": "Pas le metier, hors scope."}, + {"id": "W-04", "title": "Application mobile native", "reason": "Responsive web suffit. Mobile-native couterait 6 mois. Ockham."}, + {"id": "W-05", "title": "Multi-tenant (plusieurs centres de formation sur instance)", "reason": "Acadenice mono-instance pour l'instant. A reevaluer si scale."} + ], + "validated_at": "2026-05-07", + "validated_by": "Corentin JOGUET" +} diff --git a/_byan-output/fast-app/formation-hub/build-state.json b/_byan-output/fast-app/formation-hub/build-state.json new file mode 100644 index 0000000..76939ba --- /dev/null +++ b/_byan-output/fast-app/formation-hub/build-state.json @@ -0,0 +1,15 @@ +{ + "current_iteration": 1, + "completed": [], + "pending": [1, 2, 3, 4, 5, 6, 7], + "next_iteration": 1, + "stack_local_status": { + "docmost": "up + healthy on http://localhost:3000", + "baserow": "up + healthy on http://localhost:8080", + "docmost-db": "up + healthy", + "docmost-redis": "up", + "verified_at": "2026-05-07T14:48:00Z" + }, + "ready_for_iteration_1": true, + "iteration_1_first_step": "Creer compte admin Baserow via http://localhost:8080 (1ere page invite a creer un compte). Apres : creer database 'formation-hub', puis table 'personne'." +} diff --git a/_byan-output/fast-app/formation-hub/cdcf-stories.json b/_byan-output/fast-app/formation-hub/cdcf-stories.json new file mode 100644 index 0000000..7da5a7c --- /dev/null +++ b/_byan-output/fast-app/formation-hub/cdcf-stories.json @@ -0,0 +1,93 @@ +{ + "scope": "Phase 1 vanilla setup metier — focalise BUILD iteration 1-3", + "stories": [ + { + "id": "S-01", + "feature_id": "F-01", + "connextra": "En tant qu'Admin Acadenice, je veux creer un workspace Docmost et 3 spaces (CFA, Agence, Interne), afin de centraliser la doc avec une structure miroir des collections Outline existantes.", + "ac": [ + {"given": "compte admin Docmost cree", "when": "je cree workspace 'Acadenice formation-hub'", "then": "workspace existe avec moi en owner"}, + {"given": "workspace cree", "when": "je cree spaces CFA/Agence/Interne avec permissions par defaut 'workspace members'", "then": "3 spaces visibles sidebar"} + ] + }, + { + "id": "S-02", + "feature_id": "F-05", + "connextra": "En tant qu'Admin, je veux creer la table PERSONNE dans Baserow avec tous ses champs et formulas selon doc 15 MPD section 2, afin d'avoir le pivot multi-roles operationnel.", + "ac": [ + {"given": "database 'formation-hub' creee", "when": "je cree la table PERSONNE avec 16 fields (incluant capacity_annuelle, split_pcts, roles multi-select, formulas heures_restantes)", "then": "la table est listee dans la database et les fields ont les bons types"}, + {"given": "table PERSONNE creee", "when": "je cree une row test (Yan, role formateur+developpeur, capacity 1500)", "then": "les formulas heures_restantes affichent 750/750/1500"} + ] + }, + { + "id": "S-03", + "feature_id": "F-05", + "connextra": "En tant qu'Admin, je veux creer les 4 tables CFA (FORMATION → BLOC → MODULE → ATTRIBUTION) avec leurs liens FK et rollups, afin que le suivi des heures formation soit operationnel.", + "ac": [ + {"given": "table PERSONNE existe", "when": "je cree FORMATION, BLOC, MODULE, ATTRIBUTION dans l'ordre avec liens vers PERSONNE pour ATTRIBUTION", "then": "les 4 tables existent avec les liens visibles bidirectionnellement"}, + {"given": "tables CFA crees", "when": "je cree formation test avec 1 bloc + 1 module + 1 attribution a Yan", "then": "les rollups formation_heures_attribuees, bloc_heures_attribuees, module_heures_attribuees sont calcules"} + ] + }, + { + "id": "S-04", + "feature_id": "F-05", + "connextra": "En tant qu'Admin, je veux creer les 4 tables Agence (CLIENT → PROJET → TACHE → INTERVENTION) avec liens FK et rollups, afin de tracer les projets clients.", + "ac": [ + {"given": "table PERSONNE existe", "when": "je cree CLIENT, PROJET, TACHE, INTERVENTION avec liens", "then": "tables existent + lien optionnel PROJET ↔ FORMATION pour projet pedagogique"}, + {"given": "tables creees", "when": "j'ajoute client Centralis Europe + projet test + tache + intervention", "then": "les rollups projet_heures_realisees + tache_heures_realisees calculent"} + ] + }, + { + "id": "S-05", + "feature_id": "F-07", + "connextra": "En tant qu'Admin, je veux creer les vues recommandees doc 15 par table (table, kanban, calendar) afin d'avoir l'UX metier prete pour onboarding.", + "ac": [ + {"given": "tables existent", "when": "je cree vue 'A attribuer' kanban sur MODULE group by module_statut", "then": "vue affiche kanban fonctionnel"}, + {"given": "vues creees", "when": "je verifie les vues principales par table (Tous, Kanban, Calendar selon doc 15)", "then": "minimum 9 vues fonctionnelles (~1 par table)"} + ] + }, + { + "id": "S-06", + "feature_id": "F-09", + "connextra": "En tant que Formateur, je veux saisir mes heures realisees via un form public Baserow sans compte, afin de logger rapidement depuis mobile.", + "ac": [ + {"given": "table ATTRIBUTION existe", "when": "Admin cree form view publique 'Saisir heures realisees' sur ATTRIBUTION (champs limites)", "then": "formateur peut acceder par lien et soumettre"}, + {"given": "form public actif", "when": "formateur saisit attribution + heures + date", "then": "row creee, rollups recalcules"} + ] + }, + { + "id": "S-07", + "feature_id": "F-04", + "connextra": "En tant qu'Admin, je veux generer un share link Docmost pour une page support de formation, afin qu'un client puisse consulter sans creer de compte.", + "ac": [ + {"given": "page Docmost existante", "when": "je clique 'Share' et configure expiration 7j + password", "then": "lien genere fonctionne en navigation privee sans login"} + ] + }, + { + "id": "S-08", + "feature_id": "F-08", + "connextra": "En tant qu'Admin, je veux pouvoir creer rapidement un space personnel pour un nouvel etudiant avec template, afin de l'onboarder en moins de 2 min.", + "ac": [ + {"given": "Docmost workspace 'Acadenice formation-hub'", "when": "je cree space 'Etudiant - Marie Dupont' visibility prive (Marie + admins)", "then": "space cree, Marie peut creer/editer ses pages, autres etudiants ne le voient pas"} + ] + }, + { + "id": "S-09", + "feature_id": "F-05", + "connextra": "En tant qu'Admin, je veux generer un API token Baserow scope read+write pour le bridge service Phase 2, afin que le code custom puisse interroger les tables.", + "ac": [ + {"given": "database formation-hub ok", "when": "je cree API token via Settings → API tokens", "then": "token genere fonctionne sur curl GET /api/database/rows/table/X/"} + ] + }, + { + "id": "S-10", + "feature_id": "F-01", + "connextra": "En tant qu'Admin, je veux backup quotidien automatique de Postgres docmost + data Baserow + uploads, afin d'avoir RPO 24h conforme CDC.", + "ac": [ + {"given": "stack up", "when": "le cron quotidien 03:00 execute scripts/backup.sh", "then": "fichiers .sql.gz et .tar.gz crees dans backups/local/, retention 30 jours"} + ] + } + ], + "validated_at": null, + "next_step": "PLAN d'iterations BUILD" +} diff --git a/_byan-output/fast-app/formation-hub/dispatch.json b/_byan-output/fast-app/formation-hub/dispatch.json new file mode 100644 index 0000000..b11d0f8 --- /dev/null +++ b/_byan-output/fast-app/formation-hub/dispatch.json @@ -0,0 +1,55 @@ +{ + "assignments": [ + { + "iteration_idx": 1, + "specialist": "Claude Code (Sonnet 4.6) + Corentin", + "model_tier": "haiku", + "rationale": "Setup Baserow tables = action UI repetitive. Claude guide etape par etape, Corentin clique. Pas besoin de gros raisonnement.", + "automation_possible": "Partiel — l'API Baserow permet de creer tables/fields programmatiquement, mais l'UI Baserow est plus rapide pour le 1er setup" + }, + { + "iteration_idx": 2, + "specialist": "Claude Code + Corentin", + "model_tier": "sonnet", + "rationale": "Formulas Baserow ont une syntaxe specifique (field('x'), lookup, count, sum). Sonnet pour bien syntaxer les formulas du doc 15.", + "automation_possible": "Partiel" + }, + { + "iteration_idx": 3, + "specialist": "Corentin solo", + "model_tier": "haiku", + "rationale": "Setup Docmost UI = action de config standard. Pas besoin Claude.", + "automation_possible": "Faible — Docmost API est limitee pour creation workspace/spaces" + }, + { + "iteration_idx": 4, + "specialist": "Corentin + (eventuellement Claude pour script create-space-etudiant)", + "model_tier": "haiku", + "rationale": "Pattern repetitif → automatisable via script bash + Docmost API", + "automation_possible": "Oui via script" + }, + { + "iteration_idx": 5, + "specialist": "Corentin (DevOps son metier)", + "model_tier": "haiku", + "rationale": "API token + cron + smoke = du DevOps pur. Corentin maitrise.", + "automation_possible": "100% script" + }, + { + "iteration_idx": 6, + "specialist": "Corentin + Yan + Sophie", + "model_tier": "n/a (humain)", + "rationale": "Migration data necessite knowledge metier (qui est qui dans les RH, quels clients, etc.). Pas Claude — humains internes.", + "automation_possible": "Partiel : Claude peut transformer CSV → format Baserow API" + }, + { + "iteration_idx": 7, + "specialist": "Equipe Acadenice (Yan, Ludo, Corentin) + 5-10 testeurs", + "model_tier": "n/a (humain)", + "rationale": "Test reel necessite humains. Claude peut compiler les retours en backlog priorise apres.", + "automation_possible": "Non" + } + ], + "halt": null, + "validated_at": null +} diff --git a/_byan-output/fast-app/formation-hub/pitch.json b/_byan-output/fast-app/formation-hub/pitch.json new file mode 100644 index 0000000..75a68b3 --- /dev/null +++ b/_byan-output/fast-app/formation-hub/pitch.json @@ -0,0 +1,10 @@ +{ + "name_app": "formation-hub", + "one_liner": "Notion-like self-host pour Acadenice (CFA + Agence dev) avec suivi heures formateurs/devs unifie", + "who": "Equipe Acadenice (~20 employes : direction Ludo, resp tech Yan, AdminSys/DevOps Corentin, formateurs, devs) + ~70 etudiants (spaces personnels libres) + clients guests (acces lien partage). Cible totale : 90-100 users, ~30 simultanes peak.", + "what": "Plateforme self-host composite : (1) Wiki collaboratif (Docmost AGPL) avec diagrammes natifs Mermaid/Drawio/Excalidraw + share links + spaces multi-tenant. (2) Bases de donnees structurees (Baserow MIT) pour le suivi heures formation/agence avec entite PERSONNE pivot multi-roles. (3) Bridge custom Node TS (Phase 2) qui synchronise Docmost et Baserow bidirectionnel et expose des Tiptap nodes custom pour UX unifie. (4) MCP server (Phase 3) pour interaction Claude/agents IA.", + "why": "Centraliser la doc + suivre les heures formation/agence dans un outil unifie self-host **illimite users**. Alternatives ecartees : Notion paye au seat, AFFiNE limite a 10 seats free, AppFlowy limite a 1 user free, Outline pas de bidirec backlinks. Acadenice a une double casquette CFA + Agence dev (formateurs = devs sur projets clients) = capacite annuelle splittee entre les deux activites. Aucun outil existant ne modelise ca correctement.", + "context": "Phase 0 conception complete (19 docs Merise Agile + UML + GitOps). Repo : github.com/AcadeNice/wiki + git.acadenice.com/AcadeNice/Wiki (selfhost source of truth). Stack Docker compose locale up et healthy au 2026-05-07.", + "validated_at": "2026-05-07", + "validated_by": "Corentin JOGUET" +} diff --git a/_byan-output/fast-app/formation-hub/plan.json b/_byan-output/fast-app/formation-hub/plan.json new file mode 100644 index 0000000..05577a9 --- /dev/null +++ b/_byan-output/fast-app/formation-hub/plan.json @@ -0,0 +1,65 @@ +{ + "iterations": [ + { + "idx": 1, + "name": "I1 — Setup Baserow vanilla (tables + liens)", + "stories": ["S-02", "S-03", "S-04"], + "expected_loops": 2, + "definition_of_done": "9 tables creees dans Baserow database 'formation-hub' avec tous les liens FK fonctionnels (testes manuellement avec rows-temoin). Pas encore de formulas/rollups complexes — juste structure.", + "deliverable": "Schema Baserow exporte JSON dans baserow/schemas/*.json + screenshots de chaque table" + }, + { + "idx": 2, + "name": "I2 — Formulas, rollups, vues", + "stories": ["S-02 (formulas part)", "S-05"], + "expected_loops": 2, + "definition_of_done": "Toutes les formulas du doc 15 sont actives + 9+ vues recommandees (kanban, calendar, table) crees. Rows test confirment les calculs.", + "deliverable": "Vues exportees + screenshots dashboards" + }, + { + "idx": 3, + "name": "I3 — Setup Docmost workspace + permissions + share", + "stories": ["S-01", "S-07"], + "expected_loops": 2, + "definition_of_done": "Workspace + 3 spaces + permissions par defaut + 1 page test partagee par lien public expire 7j", + "deliverable": "Captures workspace + URL share test" + }, + { + "idx": 4, + "name": "I4 — Spaces etudiants + form public saisie heures", + "stories": ["S-08", "S-06"], + "expected_loops": 2, + "definition_of_done": "Pattern create-space-etudiant valide (script ou checklist 2-min) + form public Baserow ATTRIBUTION accessible mobile", + "deliverable": "Doc onboarding etudiant + URL form public" + }, + { + "idx": 5, + "name": "I5 — API token + backup automatise + smoke test E2E", + "stories": ["S-09", "S-10"], + "expected_loops": 1, + "definition_of_done": "Token Baserow fonctionnel + cron backup setup + scripts/healthcheck.sh + scripts/smoke-test.sh passent", + "deliverable": "Token stocke (vault/.env), 1ere execution backup reussie, logs" + }, + { + "idx": 6, + "name": "I6 — Migration data initiale (formations + clients existants)", + "stories": ["data migration"], + "expected_loops": 3, + "definition_of_done": "Donnees reelles Acadenice importees depuis sources actuelles (Excel/Trello/autre) dans Baserow, integrite verifiee (rollups coherents avec realite metier)", + "deliverable": "Rapport migration : nb rows attendus vs imported, cas speciaux" + }, + { + "idx": 7, + "name": "I7 — Onboarding 5-10 power users + retours UX", + "stories": ["onboarding"], + "expected_loops": 2, + "definition_of_done": "5-10 personnes Acadenice (Yan, Ludo, Sophie, 2-3 formateurs, 2 devs) ont utilise pendant 1 semaine + retours collectes", + "deliverable": "Backlog UX issues priorise" + } + ], + "phases_apres_plan": [ + "Iterations Phase 2 (bridge custom) seront planifiees apres I7 selon douleurs reelles identifiees", + "MPD Baserow concret est dans doc 15-baserow-mpd.md" + ], + "validated_at": null +} diff --git a/baserow/seed/README.md b/baserow/seed/README.md new file mode 100644 index 0000000..203bb72 --- /dev/null +++ b/baserow/seed/README.md @@ -0,0 +1,86 @@ +# Baserow seed + +Cree les 9 tables `formation-hub` dans Baserow via l'API. Idempotent — skip ce qui existe. + +## Quickstart (4 etapes, ~5 min) + +### 1. Creer le compte admin Baserow (premier boot — manuel UI) + +Aller sur **http://localhost:8080**. La premiere page propose de creer un compte. Remplir : +- Workspace name : `Acadenice` +- Email : `admin@acadenice.fr` (ou autre admin) +- Password : password robuste (a sauvegarder dans pass/vault) + +### 2. Pre-requis Python + +```bash +cd baserow/seed +pip install -r requirements.txt +# OU si tu prefere : python -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt +``` + +### 3. Run le seed + +```bash +BASEROW_URL=http://localhost:8080 \ +BASEROW_EMAIL=admin@acadenice.fr \ +BASEROW_PASSWORD='ton-password' \ +python baserow/seed/seed.py +``` + +Output attendu : +``` +[1/5] Login http://localhost:8080 + [auth] Logged in as admin@acadenice.fr + +[2/5] Workspace 'Acadenice' + [workspace] Reuse 'Acadenice' id=1 + +[3/5] Database 'formation-hub' + [db] Created 'formation-hub' id=1 + +[4/5] Tables + primitive fields + [table] Created 'personne' id=1 + [field] Created 'personne_prenom' (text) + ... + +[5/5] Link fields (2nd pass) + [link] Created bloc.bloc_formation -> formation + ... + +=== Seed OK === + Workspace: Acadenice + Database : formation-hub + Tables : 9 + Links : 10 +``` + +### 4. Verifier dans l'UI + +Ouvrir http://localhost:8080, naviguer dans le workspace **Acadenice** → database **formation-hub** → 9 tables doivent etre presentes avec leurs champs et liens. + +## Re-runnable + +Le script est **idempotent**. Tu peux le relancer apres modifications du schema — il ne recree pas ce qui existe deja. + +Pour reset complet : drop la database via l'UI Baserow, puis relancer. + +## Schema + +`schema.json` decrit les 9 tables + 10 liens FK. Format JSON declaratif, modifiable. + +Les **formulas** (rollups, heures_restantes, etc.) ne sont **pas** crees par ce seed (Phase 1 = structure seule). Elles seront ajoutees en iteration 2 du plan Fast-App via un seed-formulas.py separe. + +## Troubleshooting + +| Symptome | Cause probable | Fix | +|----------|----------------|-----| +| `401 Unauthorized` | Mauvais email/password | Verifier BASEROW_EMAIL et BASEROW_PASSWORD | +| `400 Bad Request` sur field | Type non supporte version Baserow | Voir logs detail, ajuster `_field_to_payload` | +| Field deja existant | Idempotent OK | Aucune action | +| Tables creees mais vides | Normal — pas de seed data Phase 1 | Sera ajoute iteration 6 (migration data) | + +## References + +- Baserow API doc : https://baserow.io/api-docs +- Doc 15 MPD (mapping fields → Baserow types) : `docs/15-baserow-mpd.md` diff --git a/baserow/seed/requirements.txt b/baserow/seed/requirements.txt new file mode 100644 index 0000000..98d8768 --- /dev/null +++ b/baserow/seed/requirements.txt @@ -0,0 +1 @@ +requests>=2.32.0 diff --git a/baserow/seed/schema.json b/baserow/seed/schema.json new file mode 100644 index 0000000..b970d2d --- /dev/null +++ b/baserow/seed/schema.json @@ -0,0 +1,202 @@ +{ + "$schema": "Baserow schema declaratif — formation-hub Phase 1", + "version": "1.0.0", + "workspace_name": "Acadenice", + "database_name": "formation-hub", + "tables": [ + { + "name": "personne", + "primary_field": "personne_nom", + "fields": [ + {"name": "personne_nom", "type": "text", "primary": true}, + {"name": "personne_prenom", "type": "text"}, + {"name": "personne_email", "type": "email"}, + {"name": "personne_telephone", "type": "phone_number"}, + {"name": "personne_capacite_annuelle", "type": "number", "number_decimal_places": 2}, + {"name": "personne_split_formation_pct", "type": "number", "number_decimal_places": 1, "number_default": 50.0}, + {"name": "personne_split_agence_pct", "type": "number", "number_decimal_places": 1, "number_default": 50.0}, + {"name": "personne_roles", "type": "multiple_select", "select_options": [ + {"value": "formateur", "color": "blue"}, + {"value": "developpeur", "color": "green"}, + {"value": "admin", "color": "red"}, + {"value": "direction", "color": "purple"}, + {"value": "support", "color": "gray"} + ]}, + {"name": "personne_statut", "type": "single_select", "select_options": [ + {"value": "actif", "color": "green"}, + {"value": "inactif", "color": "gray"} + ]} + ] + }, + { + "name": "formation", + "primary_field": "formation_nom", + "fields": [ + {"name": "formation_nom", "type": "text", "primary": true}, + {"name": "formation_description", "type": "long_text"}, + {"name": "formation_filiere", "type": "single_select", "select_options": [ + {"value": "dev", "color": "blue"}, + {"value": "graphisme", "color": "pink"}, + {"value": "marketing", "color": "yellow"}, + {"value": "iot", "color": "orange"}, + {"value": "cybersec", "color": "red"} + ]}, + {"name": "formation_heures_totales", "type": "number", "number_decimal_places": 2}, + {"name": "formation_statut", "type": "single_select", "select_options": [ + {"value": "draft", "color": "gray"}, + {"value": "actif", "color": "green"}, + {"value": "termine", "color": "blue"}, + {"value": "archive", "color": "dark-gray"} + ]}, + {"name": "formation_date_debut", "type": "date"}, + {"name": "formation_date_fin", "type": "date"}, + {"name": "formation_created_at", "type": "created_on"}, + {"name": "formation_updated_at", "type": "last_modified"} + ] + }, + { + "name": "bloc", + "primary_field": "bloc_nom", + "fields": [ + {"name": "bloc_nom", "type": "text", "primary": true}, + {"name": "bloc_description", "type": "long_text"}, + {"name": "bloc_heures_prevues", "type": "number", "number_decimal_places": 2}, + {"name": "bloc_ordre", "type": "number", "number_decimal_places": 0} + ] + }, + { + "name": "module", + "primary_field": "module_nom", + "fields": [ + {"name": "module_nom", "type": "text", "primary": true}, + {"name": "module_description", "type": "long_text"}, + {"name": "module_heures_prevues", "type": "number", "number_decimal_places": 2}, + {"name": "module_statut", "type": "single_select", "select_options": [ + {"value": "a_attribuer", "color": "gray"}, + {"value": "attribue", "color": "blue"}, + {"value": "en_cours", "color": "yellow"}, + {"value": "realise", "color": "green"}, + {"value": "annule", "color": "red"} + ]} + ] + }, + { + "name": "attribution", + "primary_field": "attribution_titre", + "fields": [ + {"name": "attribution_titre", "type": "text", "primary": true, "comment": "Sera remplace par formula apres link"}, + {"name": "attribution_heures_attribuees", "type": "number", "number_decimal_places": 2}, + {"name": "attribution_heures_realisees", "type": "number", "number_decimal_places": 2, "number_default": 0}, + {"name": "attribution_date_debut", "type": "date"}, + {"name": "attribution_date_fin", "type": "date"}, + {"name": "attribution_statut", "type": "single_select", "select_options": [ + {"value": "planifie", "color": "gray"}, + {"value": "en_cours", "color": "yellow"}, + {"value": "realise", "color": "green"}, + {"value": "annule", "color": "red"} + ]} + ] + }, + { + "name": "client", + "primary_field": "client_nom", + "fields": [ + {"name": "client_nom", "type": "text", "primary": true}, + {"name": "client_contact_principal", "type": "text"}, + {"name": "client_contact_email", "type": "email"}, + {"name": "client_contact_telephone", "type": "phone_number"}, + {"name": "client_secteur", "type": "text"}, + {"name": "client_notes", "type": "long_text"}, + {"name": "client_statut", "type": "single_select", "select_options": [ + {"value": "prospect", "color": "yellow"}, + {"value": "actif", "color": "green"}, + {"value": "inactif", "color": "gray"}, + {"value": "archive", "color": "dark-gray"} + ]}, + {"name": "client_created_at", "type": "created_on"} + ] + }, + { + "name": "projet", + "primary_field": "projet_nom", + "fields": [ + {"name": "projet_nom", "type": "text", "primary": true}, + {"name": "projet_description", "type": "long_text"}, + {"name": "projet_type", "type": "single_select", "select_options": [ + {"value": "site_web", "color": "blue"}, + {"value": "app_mobile", "color": "green"}, + {"value": "api", "color": "yellow"}, + {"value": "infra", "color": "orange"}, + {"value": "audit", "color": "red"}, + {"value": "support", "color": "purple"}, + {"value": "autre", "color": "gray"} + ]}, + {"name": "projet_charge_heures", "type": "number", "number_decimal_places": 2}, + {"name": "projet_date_debut", "type": "date"}, + {"name": "projet_date_fin_prevue", "type": "date"}, + {"name": "projet_date_livraison", "type": "date"}, + {"name": "projet_statut", "type": "single_select", "select_options": [ + {"value": "devis", "color": "yellow"}, + {"value": "en_cours", "color": "blue"}, + {"value": "livre", "color": "green"}, + {"value": "cloture", "color": "dark-green"}, + {"value": "abandonne", "color": "red"} + ]}, + {"name": "projet_url", "type": "url"}, + {"name": "projet_repository", "type": "url"} + ] + }, + { + "name": "tache", + "primary_field": "tache_titre", + "fields": [ + {"name": "tache_titre", "type": "text", "primary": true}, + {"name": "tache_description", "type": "long_text"}, + {"name": "tache_charge_heures", "type": "number", "number_decimal_places": 2}, + {"name": "tache_priorite", "type": "single_select", "select_options": [ + {"value": "faible", "color": "gray"}, + {"value": "normale", "color": "blue"}, + {"value": "haute", "color": "orange"}, + {"value": "critique", "color": "red"} + ]}, + {"name": "tache_statut", "type": "single_select", "select_options": [ + {"value": "todo", "color": "gray"}, + {"value": "in_progress", "color": "blue"}, + {"value": "review", "color": "yellow"}, + {"value": "done", "color": "green"}, + {"value": "abandoned", "color": "red"} + ]}, + {"name": "tache_date_debut", "type": "date"}, + {"name": "tache_date_fin_prevue", "type": "date"} + ] + }, + { + "name": "intervention", + "primary_field": "intervention_titre", + "fields": [ + {"name": "intervention_titre", "type": "text", "primary": true, "comment": "Sera remplace par formula apres link"}, + {"name": "intervention_heures", "type": "number", "number_decimal_places": 2}, + {"name": "intervention_date", "type": "date"}, + {"name": "intervention_notes", "type": "long_text"}, + {"name": "intervention_statut", "type": "single_select", "select_options": [ + {"value": "planifie", "color": "gray"}, + {"value": "realise", "color": "green"}, + {"value": "annule", "color": "red"} + ]} + ] + } + ], + "links": [ + {"from_table": "bloc", "from_field": "bloc_formation", "to_table": "formation"}, + {"from_table": "module", "from_field": "module_bloc", "to_table": "bloc"}, + {"from_table": "attribution", "from_field": "attribution_module", "to_table": "module"}, + {"from_table": "attribution", "from_field": "attribution_personne", "to_table": "personne"}, + {"from_table": "projet", "from_field": "projet_client", "to_table": "client"}, + {"from_table": "projet", "from_field": "projet_formation_pedagogique", "to_table": "formation"}, + {"from_table": "tache", "from_field": "tache_projet", "to_table": "projet"}, + {"from_table": "tache", "from_field": "tache_assignee", "to_table": "personne"}, + {"from_table": "intervention", "from_field": "intervention_tache", "to_table": "tache"}, + {"from_table": "intervention", "from_field": "intervention_personne", "to_table": "personne"} + ], + "_note_phase_2": "Les formulas (rollups, heures_restantes) seront ajoutees en iteration 2 du plan Fast-App. Phase 1 = structure + liens seuls." +} diff --git a/baserow/seed/seed.py b/baserow/seed/seed.py new file mode 100644 index 0000000..c8daf63 --- /dev/null +++ b/baserow/seed/seed.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Baserow seed script — cree les 9 tables formation-hub via API. + +Usage : + BASEROW_URL=http://localhost:8080 \ + BASEROW_EMAIL=admin@acadenice.fr \ + BASEROW_PASSWORD=... \ + python baserow/seed/seed.py [--schema baserow/seed/schema.json] + +Process : + 1. Login (recupere JWT) + 2. Create or reuse workspace + 3. Create or reuse database + 4. Create tables (1 pass) + 5. Create primitive fields per table + 6. Create link_row fields (2nd pass — apres que les tables existent) + +Idempotent : skip ce qui existe deja. +""" + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any + +import requests + + +class BaserowSeed: + def __init__(self, base_url: str, email: str, password: str) -> None: + self.base_url = base_url.rstrip("/") + self.email = email + self.password = password + self.token: str | None = None + self.session = requests.Session() + + def _headers(self) -> dict[str, str]: + if self.token is None: + raise RuntimeError("Not logged in") + return {"Authorization": f"JWT {self.token}", "Content-Type": "application/json"} + + def login(self) -> None: + url = f"{self.base_url}/api/user/token-auth/" + r = self.session.post(url, json={"email": self.email, "password": self.password}) + r.raise_for_status() + self.token = r.json()["token"] + print(f" [auth] Logged in as {self.email}") + + def get_or_create_workspace(self, name: str) -> int: + r = self.session.get(f"{self.base_url}/api/workspaces/", headers=self._headers()) + r.raise_for_status() + for w in r.json(): + if w["name"] == name: + print(f" [workspace] Reuse '{name}' id={w['id']}") + return w["id"] + r = self.session.post( + f"{self.base_url}/api/workspaces/", headers=self._headers(), json={"name": name} + ) + r.raise_for_status() + wid = r.json()["id"] + print(f" [workspace] Created '{name}' id={wid}") + return wid + + def get_or_create_database(self, workspace_id: int, name: str) -> int: + r = self.session.get( + f"{self.base_url}/api/applications/workspace/{workspace_id}/", headers=self._headers() + ) + r.raise_for_status() + for app in r.json(): + if app["name"] == name and app["type"] == "database": + print(f" [db] Reuse '{name}' id={app['id']}") + return app["id"] + r = self.session.post( + f"{self.base_url}/api/applications/workspace/{workspace_id}/", + headers=self._headers(), + json={"name": name, "type": "database"}, + ) + r.raise_for_status() + did = r.json()["id"] + print(f" [db] Created '{name}' id={did}") + return did + + def list_tables(self, database_id: int) -> dict[str, int]: + r = self.session.get( + f"{self.base_url}/api/database/tables/database/{database_id}/", headers=self._headers() + ) + r.raise_for_status() + return {t["name"]: t["id"] for t in r.json()} + + def create_table_with_minimal(self, database_id: int, name: str) -> int: + r = self.session.post( + f"{self.base_url}/api/database/tables/database/{database_id}/", + headers=self._headers(), + json={"name": name}, + ) + r.raise_for_status() + tid = r.json()["id"] + print(f" [table] Created '{name}' id={tid}") + return tid + + def list_fields(self, table_id: int) -> list[dict[str, Any]]: + r = self.session.get( + f"{self.base_url}/api/database/fields/table/{table_id}/", headers=self._headers() + ) + r.raise_for_status() + return r.json() + + def create_field(self, table_id: int, field_def: dict[str, Any]) -> int: + payload = self._field_to_payload(field_def) + r = self.session.post( + f"{self.base_url}/api/database/fields/table/{table_id}/", + headers=self._headers(), + json=payload, + ) + if r.status_code >= 300: + print(f" [ERROR] field {field_def['name']} payload={payload} resp={r.text}") + r.raise_for_status() + return r.json()["id"] + + def update_field(self, field_id: int, field_def: dict[str, Any]) -> None: + payload = self._field_to_payload(field_def) + r = self.session.patch( + f"{self.base_url}/api/database/fields/{field_id}/", + headers=self._headers(), + json=payload, + ) + r.raise_for_status() + + def delete_field(self, field_id: int) -> None: + r = self.session.delete( + f"{self.base_url}/api/database/fields/{field_id}/", headers=self._headers() + ) + r.raise_for_status() + + @staticmethod + def _field_to_payload(field_def: dict[str, Any]) -> dict[str, Any]: + ftype = field_def["type"] + payload: dict[str, Any] = {"name": field_def["name"], "type": ftype} + if ftype == "number": + payload["number_decimal_places"] = field_def.get("number_decimal_places", 0) + payload["number_negative"] = True + elif ftype == "single_select" or ftype == "multiple_select": + payload["select_options"] = field_def.get("select_options", []) + elif ftype == "date": + payload["date_format"] = "ISO" + elif ftype == "long_text": + payload["long_text_enable_rich_text"] = True + return payload + + def create_link_field(self, from_table_id: int, name: str, to_table_id: int) -> int: + r = self.session.post( + f"{self.base_url}/api/database/fields/table/{from_table_id}/", + headers=self._headers(), + json={"name": name, "type": "link_row", "link_row_table_id": to_table_id}, + ) + if r.status_code >= 300: + print(f" [ERROR] link {name} resp={r.text}") + r.raise_for_status() + return r.json()["id"] + + def seed(self, schema: dict[str, Any]) -> None: + print(f"\n[1/5] Login {self.base_url}") + self.login() + + print(f"\n[2/5] Workspace '{schema['workspace_name']}'") + ws_id = self.get_or_create_workspace(schema["workspace_name"]) + + print(f"\n[3/5] Database '{schema['database_name']}'") + db_id = self.get_or_create_database(ws_id, schema["database_name"]) + + print(f"\n[4/5] Tables + primitive fields") + existing = self.list_tables(db_id) + table_ids: dict[str, int] = dict(existing) + for table in schema["tables"]: + tname = table["name"] + if tname in existing: + tid = existing[tname] + print(f" [table] Reuse '{tname}' id={tid}") + else: + tid = self.create_table_with_minimal(db_id, tname) + table_ids[tname] = tid + self._sync_fields(tid, table) + + print(f"\n[5/5] Link fields (2nd pass)") + for link in schema["links"]: + from_id = table_ids[link["from_table"]] + to_id = table_ids[link["to_table"]] + existing_fields = {f["name"]: f for f in self.list_fields(from_id)} + if link["from_field"] in existing_fields: + print(f" [link] Reuse {link['from_table']}.{link['from_field']} -> {link['to_table']}") + continue + self.create_link_field(from_id, link["from_field"], to_id) + print(f" [link] Created {link['from_table']}.{link['from_field']} -> {link['to_table']}") + + print("\n=== Seed OK ===") + print(f" Workspace: {schema['workspace_name']}") + print(f" Database : {schema['database_name']}") + print(f" Tables : {len(table_ids)}") + print(f" Links : {len(schema['links'])}") + + def _sync_fields(self, table_id: int, table_def: dict[str, Any]) -> None: + existing = {f["name"]: f for f in self.list_fields(table_id)} + # Default Baserow table comes with one primary field "Name". Rename it to our primary if needed. + primary_target = table_def["primary_field"] + if primary_target not in existing: + primary_existing = next((f for f in existing.values() if f.get("primary")), None) + if primary_existing is not None: + # Find the primary field def + primary_def = next(f for f in table_def["fields"] if f.get("primary")) + self.update_field(primary_existing["id"], primary_def) + print(f" [field] Renamed primary -> '{primary_target}'") + existing[primary_target] = {**primary_existing, "name": primary_target} + if primary_existing["name"] != primary_target: + del existing[primary_existing["name"]] + + for field_def in table_def["fields"]: + fname = field_def["name"] + if fname in existing: + continue + self.create_field(table_id, field_def) + print(f" [field] Created '{fname}' ({field_def['type']})") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Seed Baserow formation-hub") + parser.add_argument("--schema", default=str(Path(__file__).parent / "schema.json")) + args = parser.parse_args() + + base_url = os.environ.get("BASEROW_URL", "http://localhost:8080") + email = os.environ.get("BASEROW_EMAIL") + password = os.environ.get("BASEROW_PASSWORD") + if not email or not password: + print("ERROR: set BASEROW_EMAIL et BASEROW_PASSWORD env vars", file=sys.stderr) + return 1 + + schema = json.loads(Path(args.schema).read_text()) + seeder = BaserowSeed(base_url, email, password) + try: + seeder.seed(schema) + except requests.HTTPError as e: + print(f"\nHTTP error: {e}\nResponse: {e.response.text if e.response else ''}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main())