From 1d71364c6eb2a5158c3a8d98987149657e9e4073 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 7 May 2026 18:49:00 +0200 Subject: [PATCH] feat(seed): add I4 forms publics + space etudiant + I5 healthcheck etendu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I4 — Forms publics + space etudiant : - baserow/seed/seed_forms.py : cree forms publics sur attribution + intervention (saisie heures via lien sans compte). Form cree OK, field-options endpoint retourne 404 sur Baserow 1.30 — a investiguer (URL different selon version). - docmost/setup/create-space-etudiant.py : cree space prive + invite etudiant + page Welcome template. Slug strict lettres+chiffres only (fix Docmost). I5 — Ops : - scripts/healthcheck.sh : check UI + API health (Docmost+Baserow), affiche status containers Docker. 4/4 OK en local. - scripts/cron-install.sh : installe cron quotidien backup + healthcheck */5min. Makefile : targets seed-baserow-forms, create-space-etudiant. Tous les targets utilisent maintenant .venv/ local pour eviter pip systeme. .gitignore : exclut .venv/ + __pycache__/. --- .gitignore | 4 + Makefile | 29 +++- baserow/seed/seed_forms.py | 220 +++++++++++++++++++++++++ docmost/setup/create-space-etudiant.py | 182 ++++++++++++++++++++ scripts/cron-install.sh | 37 +++++ scripts/healthcheck.sh | 46 +++++- 6 files changed, 504 insertions(+), 14 deletions(-) create mode 100644 baserow/seed/seed_forms.py create mode 100644 docmost/setup/create-space-etudiant.py create mode 100755 scripts/cron-install.sh diff --git a/.gitignore b/.gitignore index 81a5ca2..8d8bd18 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ backups/ *.sql.gz *.tar.gz +# Python venv +.venv/ +__pycache__/ + # Node node_modules/ dist/ diff --git a/Makefile b/Makefile index 54a7774..1759603 100644 --- a/Makefile +++ b/Makefile @@ -75,12 +75,12 @@ backup-baserow: @echo " -> $(BACKUP_DIR)/baserow-$(DATE).tar.gz" seed-baserow: - @command -v python3 >/dev/null || (echo "ERREUR: python3 requis" && exit 1) + @test -d .venv || python3 -m venv .venv + @.venv/bin/pip install -q -r baserow/seed/requirements.txt @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 + .venv/bin/python baserow/seed/seed.py setup-docmost-guide: @echo "Docmost API publique = feature Enterprise payante." @@ -89,12 +89,29 @@ setup-docmost-guide: @echo " - Seed automatise via endpoints internes — cf seed-docmost" seed-docmost: - @command -v python3 >/dev/null || (echo "ERREUR: python3 requis" && exit 1) + @test -d .venv || python3 -m venv .venv + @.venv/bin/pip install -q -r docmost/setup/requirements.txt @test -n "$$DOCMOST_ADMIN_EMAIL" -a -n "$$DOCMOST_ADMIN_PASSWORD" || \ (echo "ERREUR: exporter DOCMOST_ADMIN_EMAIL et DOCMOST_ADMIN_PASSWORD" && exit 1) - @cd docmost/setup && pip install -q -r requirements.txt DOCMOST_URL=$${DOCMOST_URL:-http://localhost:3000} \ - python3 docmost/setup/seed.py + .venv/bin/python docmost/setup/seed.py + +seed-baserow-forms: + @test -d .venv || python3 -m venv .venv + @.venv/bin/pip install -q -r baserow/seed/requirements.txt + @test -n "$$BASEROW_EMAIL" -a -n "$$BASEROW_PASSWORD" || \ + (echo "ERREUR: exporter BASEROW_EMAIL et BASEROW_PASSWORD" && exit 1) + BASEROW_URL=$${BASEROW_URL:-http://localhost:8080} \ + .venv/bin/python baserow/seed/seed_forms.py + +create-space-etudiant: + @test -d .venv || python3 -m venv .venv + @.venv/bin/pip install -q -r docmost/setup/requirements.txt + @test -n "$$NOM" -a -n "$$PRENOM" -a -n "$$EMAIL" || \ + (echo "ERREUR: exporter NOM, PRENOM, EMAIL (etudiant)" && exit 1) + DOCMOST_URL=$${DOCMOST_URL:-http://localhost:3000} \ + .venv/bin/python docmost/setup/create-space-etudiant.py \ + --nom "$$NOM" --prenom "$$PRENOM" --email "$$EMAIL" clean: @echo "ATTENTION: cette commande supprime TOUS les volumes (donnees perdues)." diff --git a/baserow/seed/seed_forms.py b/baserow/seed/seed_forms.py new file mode 100644 index 0000000..39d049d --- /dev/null +++ b/baserow/seed/seed_forms.py @@ -0,0 +1,220 @@ +#!/usr/bin/env python3 +"""Baserow forms publics — Iteration 4 BUILD. + +Cree des form views publiques sur les tables attribution et intervention. +Permet la saisie rapide via lien public sans compte Baserow (UX mobile). + +Usage : + BASEROW_URL=http://localhost:8080 \\ + BASEROW_EMAIL=admin@acadenice.fr \\ + BASEROW_PASSWORD=... \\ + python baserow/seed/seed_forms.py + +Idempotent : skip si form existe deja. + +ATTENTION Phase 1 : pas de validation d'identite cote form. N'importe qui +avec le lien peut soumettre. Phase 2 bridge service ajoutera l'auth SSO ++ filtre row-level (formateur saisit que ses propres attributions). +""" + +import os +import sys +from typing import Any + +import requests + + +FORMS_TO_CREATE: list[dict[str, Any]] = [ + { + "table_name": "attribution", + "form": { + "name": "Saisir heures realisees (formateur)", + "type": "form", + "title": "Heures realisees - Acadenice formation", + "description": "Formateur : enregistre tes heures realisees apres une session.\n\nMerci de selectionner le module concerne, ton attribution, et le nombre d'heures effectuees.", + "submit_action": "MESSAGE", + "submit_action_message": "Merci ! Tes heures sont enregistrees. La capacite restante est mise a jour automatiquement.", + "public": True, + }, + "fields_to_show": [ + "attribution_personne", + "attribution_module", + "attribution_heures_realisees", + "attribution_date_fin", + "attribution_statut", + ], + }, + { + "table_name": "intervention", + "form": { + "name": "Saisir intervention (developpeur)", + "type": "form", + "title": "Intervention - Acadenice agence", + "description": "Developpeur : log ton intervention sur une tache projet client.\n\nIndique la tache, tes heures, la date, et notes optionnelles (commit, lien PR).", + "submit_action": "MESSAGE", + "submit_action_message": "Merci ! L'intervention est enregistree. Capacite agence mise a jour.", + "public": True, + }, + "fields_to_show": [ + "intervention_personne", + "intervention_tache", + "intervention_heures", + "intervention_date", + "intervention_notes", + ], + }, +] + + +class BaserowForms: + 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]: + return {"Authorization": f"JWT {self.token}", "Content-Type": "application/json"} + + def login(self) -> None: + r = self.session.post( + f"{self.base_url}/api/user/token-auth/", + 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 list_workspaces(self) -> list[dict[str, Any]]: + r = self.session.get(f"{self.base_url}/api/workspaces/", headers=self._headers()) + r.raise_for_status() + return r.json() + + def list_databases(self, workspace_id: int) -> list[dict[str, Any]]: + r = self.session.get( + f"{self.base_url}/api/applications/workspace/{workspace_id}/", headers=self._headers() + ) + r.raise_for_status() + return [a for a in r.json() if a["type"] == "database"] + + def list_tables(self, database_id: int) -> dict[str, dict[str, Any]]: + 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 for t in r.json()} + + def list_views(self, table_id: int) -> list[dict[str, Any]]: + r = self.session.get( + f"{self.base_url}/api/database/views/table/{table_id}/", headers=self._headers() + ) + r.raise_for_status() + return r.json() + + def list_fields(self, table_id: int) -> dict[str, 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 {f["name"]: f for f in r.json()} + + def create_form(self, table_id: int, form_def: dict[str, Any]) -> dict[str, Any]: + r = self.session.post( + f"{self.base_url}/api/database/views/table/{table_id}/", + headers=self._headers(), + json=form_def, + ) + if r.status_code >= 300: + print(f" [ERROR] create form payload={form_def} resp={r.text[:300]}") + r.raise_for_status() + return r.json() + + def update_field_options( + self, view_id: int, field_options: dict[int, dict[str, Any]] + ) -> None: + r = self.session.patch( + f"{self.base_url}/api/database/views/form/{view_id}/field-options/", + headers=self._headers(), + json={"field_options": field_options}, + ) + if r.status_code >= 300: + print(f" [ERROR] update field options resp={r.text[:300]}") + r.raise_for_status() + + def run(self) -> None: + print(f"\n[1/4] Login") + self.login() + + print(f"\n[2/4] Find workspace 'Acadenice' + database 'formation-hub'") + ws = next((w for w in self.list_workspaces() if w["name"] == "Acadenice"), None) + if not ws: + raise RuntimeError("Workspace 'Acadenice' not found") + db = next((d for d in self.list_databases(ws["id"]) if d["name"] == "formation-hub"), None) + if not db: + raise RuntimeError("Database 'formation-hub' not found") + print(f" Workspace id={ws['id']}, Database id={db['id']}") + + print(f"\n[3/4] Resolve tables") + tables = self.list_tables(db["id"]) + + print(f"\n[4/4] Create forms") + for spec in FORMS_TO_CREATE: + tname = spec["table_name"] + if tname not in tables: + print(f" [skip] table '{tname}' absent") + continue + tid = tables[tname]["id"] + existing_forms = [v for v in self.list_views(tid) if v["type"] == "form"] + existing_names = {v["name"] for v in existing_forms} + if spec["form"]["name"] in existing_names: + form = next(v for v in existing_forms if v["name"] == spec["form"]["name"]) + print(f" [form] Reuse '{spec['form']['name']}' on {tname} id={form['id']}") + else: + form = self.create_form(tid, spec["form"]) + print(f" [form] Created '{form['name']}' on {tname} id={form['id']}") + + # Configure visible fields + fields = self.list_fields(tid) + field_options: dict[int, dict[str, Any]] = {} + for fname in spec["fields_to_show"]: + if fname in fields: + field_options[fields[fname]["id"]] = { + "enabled": True, + "required": fname in ("attribution_personne", "attribution_module", "attribution_heures_realisees", "intervention_personne", "intervention_tache", "intervention_heures"), + } + if field_options: + self.update_field_options(form["id"], field_options) + print(f" [form] {len(field_options)} fields visible/required configures") + + slug = form.get("slug") + if slug: + print(f" [public URL] {self.base_url}/form/{slug}") + else: + print(f" [note] slug pas trouve dans la response (cf UI Baserow pour le lien public)") + + print("\n=== Forms OK ===") + + +def main() -> int: + 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 + try: + BaserowForms(base_url, email, password).run() + except requests.HTTPError as e: + print(f"\nHTTP error: {e}", file=sys.stderr) + if e.response is not None: + print(f"Response: {e.response.text[:500]}", file=sys.stderr) + return 2 + except Exception as e: + print(f"\nError: {e}", file=sys.stderr) + return 3 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docmost/setup/create-space-etudiant.py b/docmost/setup/create-space-etudiant.py new file mode 100644 index 0000000..fa8d459 --- /dev/null +++ b/docmost/setup/create-space-etudiant.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Cree un space etudiant prive dans Docmost — pattern story S-08. + +Usage : + DOCMOST_URL=http://localhost:3000 \\ + DOCMOST_ADMIN_EMAIL=corentin@acadenice.fr \\ + DOCMOST_ADMIN_PASSWORD=... \\ + python docmost/setup/create-space-etudiant.py \\ + --nom "Dupont" --prenom "Marie" --email "marie.dupont@acadenice.fr" + +Cree : +- Un space "Etudiant - Marie Dupont" en visibility=private +- Y invite l'etudiant en role 'writer' +- Cree une page template "Bienvenue" avec instructions + +Idempotent : skip si space existe deja. +""" + +import argparse +import os +import sys +from typing import Any + +import requests + + +class DocmostSpaceCreator: + def __init__(self, base_url: str) -> None: + self.base_url = base_url.rstrip("/") + self.session = requests.Session() + self.session.headers.update({"Content-Type": "application/json"}) + + def _post(self, path: str, body: dict[str, Any] | None = None) -> dict[str, Any]: + r = self.session.post(f"{self.base_url}{path}", json=body or {}) + if r.status_code >= 300: + print(f" [ERROR] POST {path} → {r.status_code} resp={r.text[:300]}") + r.raise_for_status() + if not r.text: + return {} + payload = r.json() + if isinstance(payload, dict) and "data" in payload and isinstance(payload["data"], (dict, list)): + return payload["data"] + return payload + + def login(self, email: str, password: str) -> None: + self._post("/api/auth/login", {"email": email, "password": password}) + print(f" [auth] Logged in as {email}") + + def list_spaces(self) -> list[dict[str, Any]]: + try: + data = self._post("/api/spaces/", {"page": 1, "limit": 100}) + if isinstance(data, dict): + return data.get("items") or [] + if isinstance(data, list): + return data + except requests.HTTPError: + pass + return [] + + def create_space(self, name: str, description: str, slug: str, visibility: str = "private") -> str: + result = self._post( + "/api/spaces/create", + {"name": name, "description": description, "slug": slug, "visibility": visibility}, + ) + return result.get("id") + + def add_member_by_email(self, space_id: str, email: str, role: str = "writer") -> dict[str, Any] | None: + try: + return self._post( + "/api/spaces/members/add", + {"spaceId": space_id, "userEmails": [email], "role": role}, + ) + except requests.HTTPError as e: + print(f" [member] Echec ajout {email} : {e}") + print(f" (l'etudiant doit deja exister comme user Docmost. Sinon, l'inviter d'abord via UI ou API user/create.)") + return None + + def create_page(self, space_id: str, title: str, content_md: str) -> str: + result = self._post( + "/api/pages/create", + {"spaceId": space_id, "title": title, "format": "markdown", "content": content_md}, + ) + return result.get("id") + + +def welcome_template(prenom: str, nom: str) -> str: + return f"""# Bienvenue {prenom} ! + +Voici ton **espace personnel** sur le wiki Acadenice. Il est **prive** : seul toi et l'admin peuvent le voir. + +## Tu peux faire ce que tu veux ici + +- Prendre des notes pendant les cours +- Garder tes ressources perso (liens, references, brouillons) +- Tester les fonctionnalites du wiki : + - `/mermaid` pour faire un diagramme + - `/excalidraw` pour dessiner + - `/drawio` pour des schemas techniques + - Liste, tableaux, code blocks, embeds video, etc. + +## Quelques idees pour commencer + +- Cree une page "Mes objectifs formation" +- Une page par module avec tes notes +- Un journal d'apprentissage hebdomadaire + +## Acces aux supports formation + +Les supports officiels sont dans le space **CFA** (lecture seule pour toi). + +Bonne formation ! + +— L'equipe Acadenice +""" + + +def slugify(s: str) -> str: + """Docmost exige slug = lettres + chiffres uniquement.""" + import re + return re.sub(r"[^a-z0-9]", "", s.lower()) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Cree un space etudiant Docmost") + parser.add_argument("--nom", required=True) + parser.add_argument("--prenom", required=True) + parser.add_argument("--email", required=True, help="Email de l'etudiant (doit exister comme user Docmost)") + parser.add_argument("--no-template", action="store_true", help="Ne pas creer la page Bienvenue") + args = parser.parse_args() + + base_url = os.environ.get("DOCMOST_URL", "http://localhost:3000") + admin_email = os.environ.get("DOCMOST_ADMIN_EMAIL") + admin_password = os.environ.get("DOCMOST_ADMIN_PASSWORD") + if not admin_email or not admin_password: + print("ERROR: set DOCMOST_ADMIN_EMAIL and DOCMOST_ADMIN_PASSWORD", file=sys.stderr) + return 1 + + creator = DocmostSpaceCreator(base_url) + space_name = f"Etudiant - {args.prenom} {args.nom}" + slug = slugify(f"etudiant-{args.prenom}-{args.nom}") + + try: + print(f"\n[1/4] Login admin") + creator.login(admin_email, admin_password) + + print(f"\n[2/4] Check existing space '{space_name}'") + existing = next((s for s in creator.list_spaces() if s.get("slug") == slug or s.get("name") == space_name), None) + if existing: + sid = existing["id"] + print(f" [space] Reuse id={sid}") + else: + sid = creator.create_space( + name=space_name, + description=f"Space personnel de {args.prenom} {args.nom}. Libre usage.", + slug=slug, + visibility="private", + ) + print(f" [space] Created id={sid}") + + print(f"\n[3/4] Add etudiant {args.email} as writer") + creator.add_member_by_email(sid, args.email, role="writer") + + if not args.no_template: + print(f"\n[4/4] Welcome page template") + try: + creator.create_page(sid, "Bienvenue", welcome_template(args.prenom, args.nom)) + print(f" [page] Created Bienvenue") + except requests.HTTPError as e: + print(f" [page] Skip (peut-etre deja existante) : {e}") + + print(f"\n=== Space etudiant OK ===") + print(f" Name : {space_name}") + print(f" Slug : {slug}") + print(f" URL : {base_url} (login {args.email})") + except requests.HTTPError as e: + print(f"\nHTTP error: {e}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/cron-install.sh b/scripts/cron-install.sh new file mode 100755 index 0000000..ecd6541 --- /dev/null +++ b/scripts/cron-install.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# scripts/cron-install.sh — installe le cron quotidien backup +# Usage : sudo ./scripts/cron-install.sh +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CRON_USER="${CRON_USER:-$(whoami)}" +CRON_FILE="/etc/cron.d/formation-hub" + +if [ "$(id -u)" -ne 0 ]; then + echo "Run as root (sudo) pour ecrire dans /etc/cron.d/" + exit 1 +fi + +cat > "$CRON_FILE" <> /var/log/formation-hub-backup.log 2>&1 + +# Healthcheck toutes les 5 minutes (logue uniquement les fails) +*/5 * * * * $CRON_USER cd $REPO_DIR && ./scripts/healthcheck.sh >/dev/null 2>&1 || logger -t formation-hub "healthcheck failed at \$(date -Iseconds)" +EOF + +chmod 644 "$CRON_FILE" + +echo "Cron installe : $CRON_FILE" +echo "" +echo "Verification :" +cat "$CRON_FILE" +echo "" +echo "Pour test manuel : sudo -u $CRON_USER cd $REPO_DIR && ./scripts/backup.sh" +echo "Logs backup : tail -f /var/log/formation-hub-backup.log" diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh index 0d63bde..589a06e 100755 --- a/scripts/healthcheck.sh +++ b/scripts/healthcheck.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# scripts/healthcheck.sh — verifie que la stack repond +# scripts/healthcheck.sh — health check stack + APIs internes set -euo pipefail DOCMOST_URL="${DOCMOST_URL:-http://localhost:3000}" @@ -7,10 +7,11 @@ BASEROW_URL="${BASEROW_URL:-http://localhost:8080}" BRIDGE_URL="${BRIDGE_URL:-}" TIMEOUT="${HEALTHCHECK_TIMEOUT:-10}" -red() { printf '\033[31m%s\033[0m\n' "$1"; } +red() { printf '\033[31m%s\033[0m\n' "$1"; } green() { printf '\033[32m%s\033[0m\n' "$1"; } +yellow(){ printf '\033[33m%s\033[0m\n' "$1"; } -check() { +check_http() { local name="$1" local url="$2" if curl -sf --max-time "$TIMEOUT" -o /dev/null "$url"; then @@ -22,23 +23,52 @@ check() { fi } -echo "Healthcheck (timeout ${TIMEOUT}s)..." +check_health_endpoint() { + local name="$1" + local url="$2" + local expected="$3" + local resp + resp=$(curl -sf --max-time "$TIMEOUT" "$url" || echo "") + if echo "$resp" | grep -q "$expected"; then + green " OK $name (API health endpoint)" + return 0 + else + red " KO $name (API health endpoint) — got: ${resp:0:80}" + return 1 + fi +} + +echo "Healthcheck stack (timeout ${TIMEOUT}s)" +echo "----------------------------------------" ok=0 total=0 ((++total)) || true -check "Docmost " "$DOCMOST_URL" && ((++ok)) || true +check_http "Docmost UI " "$DOCMOST_URL" && ((++ok)) || true ((++total)) || true -check "Baserow " "$BASEROW_URL" && ((++ok)) || true +check_health_endpoint "Docmost API" "$DOCMOST_URL/api/health" "ok" && ((++ok)) || true + +((++total)) || true +check_http "Baserow UI " "$BASEROW_URL" && ((++ok)) || true + +((++total)) || true +check_http "Baserow API" "$BASEROW_URL/api/_health/" && ((++ok)) || true if [ -n "$BRIDGE_URL" ]; then ((++total)) || true - check "Bridge " "$BRIDGE_URL/api/health" && ((++ok)) || true + check_health_endpoint "Bridge " "$BRIDGE_URL/api/health" "ok" && ((++ok)) || true fi -echo "" +# Container status (si Docker dispo) +if command -v docker >/dev/null 2>&1; then + echo "" + echo "Containers :" + docker compose ps --format " {{.Name}}: {{.Status}}" 2>&1 | head -10 || yellow " (compose not running here)" +fi + +echo "----------------------------------------" if [ "$ok" -eq "$total" ]; then green "Healthcheck : $ok/$total OK" exit 0