feat(seed): add I4 forms publics + space etudiant + I5 healthcheck etendu
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

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__/.
This commit is contained in:
Corentin JOGUET 2026-05-07 18:49:00 +02:00
parent d5558caf9a
commit 1d71364c6e
6 changed files with 504 additions and 14 deletions

4
.gitignore vendored
View file

@ -12,6 +12,10 @@ backups/
*.sql.gz
*.tar.gz
# Python venv
.venv/
__pycache__/
# Node
node_modules/
dist/

View file

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

220
baserow/seed/seed_forms.py Normal file
View file

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

View file

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

37
scripts/cron-install.sh Executable file
View file

@ -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" <<EOF
# formation-hub — backup quotidien
# Genere par scripts/cron-install.sh
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
# Backup quotidien Docmost + Baserow + uploads (03:00 local time)
0 3 * * * $CRON_USER cd $REPO_DIR && ./scripts/backup.sh >> /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"

View file

@ -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}"
@ -9,8 +9,9 @@ TIMEOUT="${HEALTHCHECK_TIMEOUT:-10}"
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
# 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