feat(baserow): add seed script + Fast-App iteration 1 artifacts
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions

- 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.
This commit is contained in:
Corentin JOGUET 2026-05-07 17:37:55 +02:00
parent ecb7a44c3c
commit 6724be6c85
11 changed files with 813 additions and 0 deletions

View file

@ -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

View file

@ -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"
}

View file

@ -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'."
}

View file

@ -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"
}

View file

@ -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
}

View file

@ -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"
}

View file

@ -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
}

86
baserow/seed/README.md Normal file
View file

@ -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`

View file

@ -0,0 +1 @@
requests>=2.32.0

202
baserow/seed/schema.json Normal file
View file

@ -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."
}

249
baserow/seed/seed.py Normal file
View file

@ -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())