Vider le monorepo : chaque domaine a son dépôt
Dernier acte de la migration commencée le 2026-05-03. home/ part dans alpinux-www, le dernier à être détaché ; dynamic/ et admin/ n'étaient plus que des copies périmées de alpinux-dynamic et alpinux-admin, où le code vit et se déploie ; wiki/, static/, feedback/ et portail/ ne contenaient plus qu'un écriteau. Le README devient ce qu'il reste d'utile : la table des domaines et de leurs dépôts. Seul scripts/owni-certs-sync.sh reste ici — outil du serveur, rattaché à aucun domaine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ae3qPEPRrNkEY5LwcTTKc1
This commit is contained in:
parent
e6b2c71ec0
commit
61a3a453be
34 changed files with 30 additions and 3709 deletions
118
README.md
118
README.md
|
|
@ -1,101 +1,43 @@
|
|||
# org.alpinux.owni — Monorepo Alpinux
|
||||
# alpinux.site.2026 — l'ancien monorepo
|
||||
|
||||
Dépôt unique de l'association **Alpinux** (LUG de Savoie).
|
||||
Gitea : https://gitea.alpinux.org/alpinux.cedrica5l/alpinux.site.2026
|
||||
Ce dépôt a porté tous les sous-domaines d'**Alpinux** (LUG de Savoie) jusqu'à la
|
||||
migration « un domaine = un dépôt », commencée le 2026-05-03 et terminée le 2026-09-20.
|
||||
|
||||
> **Le wiki n'est plus ici.** Depuis le 2026-09-19, `wiki.alpinux.org` est publié depuis
|
||||
> le dépôt [alpinux-wiki](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-wiki) —
|
||||
> voir [`wiki/README.md`](wiki/README.md). Un push dans ce dépôt-ci ne publie plus rien.
|
||||
|
||||
> **Le CDN non plus.** Depuis le 2026-09-20, `static.alpinux.org` vit dans le dépôt
|
||||
> [alpinux-static](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-static) —
|
||||
> voir [`static/README.md`](static/README.md), localement `~/Projects/alpinux.static/`.
|
||||
**Il ne publie plus rien.** Un push ici ne met aucun site en ligne.
|
||||
|
||||
---
|
||||
|
||||
## Structure
|
||||
## Où est passé quoi
|
||||
|
||||
Chaque sous-dossier correspond à un sous-domaine indépendant de `alpinux.org`.
|
||||
| Domaine | Dépôt | Local |
|
||||
|---|---|---|
|
||||
| `alpinux.org` | [alpinux-www](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-www) | `~/Projects/alpinux.www` |
|
||||
| `wiki.alpinux.org` | [alpinux-wiki](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-wiki) | `~/Projects/alpinux.wiki` |
|
||||
| `dynamic.alpinux.org` | [alpinux-dynamic](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-dynamic) | `~/Projects/alpinux.dynamic` |
|
||||
| `admin.alpinux.org` | [alpinux-admin](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-admin) | `~/Projects/alpinux.admin` |
|
||||
| `static.alpinux.org` | [alpinux-static](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-static) | — |
|
||||
| `portail.alpinux.org` | [alpinux-portail](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-portail) | `~/Projects/alpinux-portail` |
|
||||
| `feedback.alpinux.org` | [alpinux-feedback](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-feedback) | — |
|
||||
| `installparty.alpinux.org` | [alpinux-installparty](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-installparty) | `~/Projects/alpinux.installparty` |
|
||||
|
||||
```
|
||||
org.alpinux.owni/
|
||||
├── wiki/ → déménagé dans le dépôt alpinux-wiki (voir wiki/README.md)
|
||||
├── home/ → alpinux.org Page d'accueil statique
|
||||
├── dynamic/ → dynamic.alpinux.org Flask — quiz et jeux (public + membres)
|
||||
├── admin/ → admin.alpinux.org Flask — interface d'administration
|
||||
├── static/ → déménagé dans le dépôt alpinux-static (voir static/README.md)
|
||||
├── portail/ → portail.alpinux.org Portail membres (ISPConfig, Nextcloud…)
|
||||
└── feedback/ → feedback.alpinux.org Formulaire de retours (à construire)
|
||||
```
|
||||
Chaque dépôt porte son propre README : comment le lancer en local, comment il se déploie,
|
||||
et ses pièges. Le déploiement, lui, est partout le même principe — un push sur `main`,
|
||||
un webhook Gitea, un script sur le serveur.
|
||||
|
||||
Les configurations serveur (Apache, systemd) sont gérées, par ISPConfig à l'adresse https://owni.alpinux.org:8080, séparément et ne sont **pas versionnées**.
|
||||
## Pourquoi séparer
|
||||
|
||||
Un dépôt par domaine permet d'ouvrir l'écriture là où c'est utile sans donner le reste :
|
||||
quelqu'un qui corrige une faute dans le wiki n'a pas besoin des scripts de déploiement du
|
||||
site, ni du code de l'interface d'administration.
|
||||
|
||||
---
|
||||
|
||||
## Projets indépendants, liés par l'infrastructure
|
||||
## Ce qui reste ici
|
||||
|
||||
Chaque projet :
|
||||
- utilise **AlpID** (SSO Keycloak) pour l'authentification quand nécessaire
|
||||
- a son propre environnement Python (`venv/`) et son fichier `.env` (non versionés)
|
||||
`scripts/owni-certs-sync.sh` — la synchronisation des certificats Let's Encrypt vers les
|
||||
copies qu'ISPConfig ne rafraîchit pas. Outil du serveur, rattaché à aucun domaine.
|
||||
|
||||
Les projets sont liés par le SSO partagé, le serveur commun, et l'identité visuelle définie dans https://wiki.alpinux.org/communication/.
|
||||
|
||||
---
|
||||
|
||||
## Convention git et Claude
|
||||
|
||||
> **Travailler dans le sous-dossier du projet concerné**, pas à la racine du dépôt.
|
||||
|
||||
```bash
|
||||
# le wiki se modifie dans ~/Projects/alpinux.wiki (dépôt alpinux-wiki)
|
||||
# le CDN se modifie dans ~/Projects/alpinux.static (dépôt alpinux-static)
|
||||
cd dynamic/ # pour modifier l'appli quiz
|
||||
cd admin/ # pour modifier l'interface admin
|
||||
cd portail/ # pour modifier le portail membres
|
||||
cd feedback/ # pour modifier le formulaire de retour
|
||||
```
|
||||
|
||||
De même, lorsqu'on utilise **Claude Code** (ou tout autre outil IA) :
|
||||
lancer Claude depuis le sous-dossier du projet pour que le contexte soit limité à ce projet.
|
||||
|
||||
```bash
|
||||
cd ~/Projects/alpinux.wiki && claude
|
||||
cd ~/Projects/org.alpinux.owni/admin && claude
|
||||
cd ~/Projects/org.alpinux.owni/dynamic && claude
|
||||
cd ~/Projects/alpinux.static && claude
|
||||
cd ~/Projects/org.alpinux.owni/portail && claude
|
||||
cd ~/Projects/org.alpinux.owni/feedback && claude
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Démarrage rapide par projet
|
||||
|
||||
### wiki (wiki.alpinux.org)
|
||||
Déménagé : voir le dépôt [alpinux-wiki](https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-wiki)
|
||||
et son README.
|
||||
|
||||
### dynamic (dynamic.alpinux.org)
|
||||
```bash
|
||||
cd dynamic
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env && nano .env # renseigner les clés AlpID
|
||||
flask run
|
||||
```
|
||||
|
||||
### admin (admin.alpinux.org)
|
||||
```bash
|
||||
cd admin
|
||||
python3 -m venv venv && source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env && nano .env # renseigner les clés AlpID
|
||||
flask run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Déploiement serveur
|
||||
|
||||
Voir https://wiki.alpinux.org/technique/ pour la documentation.
|
||||
Les fichiers de configuration serveur sont conservés localement hors dépôt.
|
||||
Les configurations serveur (Apache, systemd) restent gérées par
|
||||
[ISPConfig](https://owni.alpinux.org:8080) et ne sont pas versionnées — à l'exception de
|
||||
la chaîne de déploiement de chaque site, qui vit maintenant dans le dépôt du site
|
||||
concerné.
|
||||
|
|
|
|||
|
|
@ -1,13 +0,0 @@
|
|||
SECRET_KEY=changez-moi-avec-une-valeur-aleatoire-longue
|
||||
|
||||
ALPID_CLIENT_ID=<client-id-configuré-dans-keycloak>
|
||||
ALPID_CLIENT_SECRET=
|
||||
ALPID_DISCOVERY_URL=https://alpid.alpinux.org/realms/alpinux/.well-known/openid-configuration
|
||||
|
||||
# Noms des groupes Keycloak autorisés (séparés par virgule)
|
||||
ADMIN_GROUPS=admins
|
||||
|
||||
# Chemins de déploiement (valeurs par défaut dans /tmp — à définir en production)
|
||||
# DEPLOY_SCRIPT=<chemin absolu vers le script de déploiement>
|
||||
# STATE_FILE=<chemin absolu vers builds.json>
|
||||
# LOG_FILE=<chemin absolu vers current.log>
|
||||
120
admin/app.py
120
admin/app.py
|
|
@ -1,120 +0,0 @@
|
|||
import os
|
||||
from flask import Flask, redirect, url_for, session, request, jsonify, render_template, abort
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
|
||||
import builds
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.environ["SECRET_KEY"]
|
||||
|
||||
# Gère X-Forwarded-Proto envoyé par Apache
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1)
|
||||
|
||||
# ── OIDC AlpID ────────────────────────────────────────────────────
|
||||
oauth = OAuth(app)
|
||||
oauth.register(
|
||||
name="alpid",
|
||||
server_metadata_url=os.environ["ALPID_DISCOVERY_URL"],
|
||||
client_id=os.environ["ALPID_CLIENT_ID"],
|
||||
client_secret=os.environ["ALPID_CLIENT_SECRET"],
|
||||
client_kwargs={"scope": "openid profile email groups"},
|
||||
)
|
||||
|
||||
ADMIN_GROUPS = set(os.environ.get("ADMIN_GROUPS", "admins").split(","))
|
||||
|
||||
|
||||
def _user():
|
||||
return session.get("user")
|
||||
|
||||
|
||||
def _require_admin():
|
||||
user = _user()
|
||||
if not user:
|
||||
session["next_url"] = request.url
|
||||
return redirect(url_for("login"))
|
||||
if not user.get("is_admin"):
|
||||
abort(403)
|
||||
return None
|
||||
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────
|
||||
@app.route("/auth/login")
|
||||
def login():
|
||||
return oauth.alpid.authorize_redirect(url_for("callback", _external=True))
|
||||
|
||||
|
||||
@app.route("/auth/callback")
|
||||
def callback():
|
||||
token = oauth.alpid.authorize_access_token()
|
||||
info = token.get("userinfo") or oauth.alpid.userinfo(token=token)
|
||||
|
||||
# Keycloak expose les groupes dans le claim "groups" (à activer dans le mapper)
|
||||
groups = set(info.get("groups", []))
|
||||
is_admin = bool(groups & ADMIN_GROUPS)
|
||||
|
||||
session["user"] = {
|
||||
"sub": info["sub"],
|
||||
"name": info.get("name") or info.get("preferred_username", "Admin"),
|
||||
"email": info.get("email", ""),
|
||||
"is_admin": is_admin,
|
||||
}
|
||||
return redirect(session.pop("next_url", url_for("index")))
|
||||
|
||||
|
||||
@app.route("/auth/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
# ── Pages ─────────────────────────────────────────────────────────
|
||||
@app.route("/")
|
||||
def index():
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return redir
|
||||
state = builds.get_state()
|
||||
log = builds.get_log()
|
||||
return render_template("index.html", user=_user(), state=state, log=log)
|
||||
|
||||
|
||||
# ── API JSON ──────────────────────────────────────────────────────
|
||||
@app.route("/api/build", methods=["POST"])
|
||||
def api_build():
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return jsonify({"error": "non authentifié"}), 401
|
||||
user = _user()
|
||||
ok = builds.start_build(triggered_by=user["name"])
|
||||
if ok:
|
||||
return jsonify({"started": True})
|
||||
return jsonify({"started": False, "reason": "Un build est déjà en cours"}), 409
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
def api_status():
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return jsonify({"error": "non authentifié"}), 401
|
||||
state = builds.get_state()
|
||||
return jsonify({
|
||||
"running": state.get("running", False),
|
||||
"last_success": state.get("last_success"),
|
||||
"last_duration": state.get("last_duration_s"),
|
||||
"started_at": state.get("started_at"),
|
||||
"triggered_by": state.get("triggered_by"),
|
||||
"history": state.get("history", [])[:5],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/log")
|
||||
def api_log():
|
||||
redir = _require_admin()
|
||||
if redir:
|
||||
return jsonify({"error": "non authentifié"}), 401
|
||||
return jsonify({"log": builds.get_log()})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=False, port=5002)
|
||||
103
admin/builds.py
103
admin/builds.py
|
|
@ -1,103 +0,0 @@
|
|||
"""
|
||||
Gestion des builds : exécution, état, historique.
|
||||
Un seul build peut tourner à la fois.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
STATE_FILE = Path(os.environ.get("STATE_FILE", "/tmp/admin-state.json"))
|
||||
LOG_FILE = Path(os.environ.get("LOG_FILE", "/tmp/admin-build.log"))
|
||||
DEPLOY_SCRIPT = Path(os.environ.get("DEPLOY_SCRIPT", "deploy-wiki.sh"))
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
pass
|
||||
return {"running": False, "history": []}
|
||||
|
||||
|
||||
def _save_state(state: dict):
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
def get_state() -> dict:
|
||||
return _load_state()
|
||||
|
||||
|
||||
def get_log(lines: int = 200) -> str:
|
||||
if LOG_FILE.exists():
|
||||
all_lines = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
return "\n".join(all_lines[-lines:])
|
||||
return ""
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
return _load_state().get("running", False)
|
||||
|
||||
|
||||
def start_build(triggered_by: str) -> bool:
|
||||
"""Lance le build dans un thread. Retourne False si déjà en cours."""
|
||||
with _lock:
|
||||
state = _load_state()
|
||||
if state.get("running"):
|
||||
return False
|
||||
state["running"] = True
|
||||
state["started_at"] = datetime.now().isoformat(timespec="seconds")
|
||||
state["triggered_by"] = triggered_by
|
||||
_save_state(state)
|
||||
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
LOG_FILE.write_text("")
|
||||
|
||||
threading.Thread(target=_run_build, args=(triggered_by,), daemon=True).start()
|
||||
return True
|
||||
|
||||
|
||||
def _run_build(triggered_by: str):
|
||||
started = time.time()
|
||||
success = False
|
||||
try:
|
||||
with open(LOG_FILE, "w", buffering=1) as log:
|
||||
log.write(f"=== Build démarré le {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} par {triggered_by} ===\n\n")
|
||||
proc = subprocess.Popen(
|
||||
["bash", str(DEPLOY_SCRIPT)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
for line in proc.stdout:
|
||||
log.write(line)
|
||||
log.flush()
|
||||
proc.wait()
|
||||
success = proc.returncode == 0
|
||||
log.write(f"\n=== Terminé (code {proc.returncode}) en {int(time.time()-started)}s ===\n")
|
||||
except Exception as exc:
|
||||
with open(LOG_FILE, "a") as log:
|
||||
log.write(f"\n=== ERREUR : {exc} ===\n")
|
||||
|
||||
with _lock:
|
||||
state = _load_state()
|
||||
state["running"] = False
|
||||
state["last_success"] = success
|
||||
state["last_duration_s"] = int(time.time() - started)
|
||||
entry = {
|
||||
"at": datetime.now().isoformat(timespec="seconds"),
|
||||
"triggered_by": triggered_by,
|
||||
"success": success,
|
||||
"duration_s": int(time.time() - started),
|
||||
}
|
||||
state.setdefault("history", []).insert(0, entry)
|
||||
state["history"] = state["history"][:20]
|
||||
_save_state(state)
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
flask>=3.0
|
||||
authlib>=1.3
|
||||
requests>=2.31
|
||||
gunicorn>=21.0
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
:root {
|
||||
--blue: #1a6bbf;
|
||||
--blue-dark: #0f4e8f;
|
||||
--blue-light:#e8f1fb;
|
||||
--bg: #f3f6fb;
|
||||
--text: #1a1a2e;
|
||||
--muted: #666;
|
||||
--radius: 10px;
|
||||
--shadow: 0 2px 12px rgba(26,107,191,.1);
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
|
||||
code { background: #e8eef7; padding: .1rem .4rem; border-radius: 4px; font-size: .88em; }
|
||||
a { color: var(--blue); }
|
||||
|
||||
/* ── Header ── */
|
||||
header { background: var(--blue-dark); color: #fff; }
|
||||
.header-inner { max-width: 900px; margin: 0 auto; padding: .8rem 1.5rem; display: flex; align-items: center; gap: 1rem; }
|
||||
.brand { display: flex; align-items: center; gap: .6rem; color: #fff; font-size: 1.1rem; text-decoration: none; }
|
||||
.brand strong { font-weight: 800; }
|
||||
.brand-sub { opacity: .7; font-weight: 300; }
|
||||
.brand img { border-radius: 5px; }
|
||||
.header-user { margin-left: auto; display: flex; align-items: center; gap: 1rem; color: rgba(255,255,255,.85); font-size: .9rem; }
|
||||
.btn-logout { color: rgba(255,255,255,.75); font-size: .85rem; border: 1px solid rgba(255,255,255,.4); border-radius: 4px; padding: .3rem .7rem; text-decoration: none; }
|
||||
.btn-logout:hover { background: rgba(255,255,255,.1); color: #fff; }
|
||||
|
||||
/* ── Main ── */
|
||||
main { max-width: 900px; margin: 2rem auto; padding: 0 1.5rem 3rem; display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
|
||||
/* ── Build card ── */
|
||||
.build-card { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.8rem 2rem; }
|
||||
.build-card-header { display: flex; align-items: flex-start; gap: 1rem; justify-content: space-between; flex-wrap: wrap; margin-bottom: .8rem; }
|
||||
.build-card-header h2 { font-size: 1.1rem; margin-bottom: .2rem; }
|
||||
.subtitle { font-size: .85rem; color: var(--muted); }
|
||||
.build-meta { display: flex; gap: 1.5rem; font-size: .85rem; color: var(--muted); margin-bottom: 1.2rem; flex-wrap: wrap; }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; gap: .5rem; padding: .65rem 1.6rem; border-radius: 6px; font-size: .95rem; font-weight: 600; cursor: pointer; border: none; transition: all .15s; }
|
||||
.btn-build { background: var(--blue); color: #fff; font-size: 1rem; }
|
||||
.btn-build:hover:not(:disabled) { background: var(--blue-dark); }
|
||||
.btn-build:disabled { opacity: .55; cursor: not-allowed; }
|
||||
|
||||
/* ── Status badges ── */
|
||||
.status-badge { display: inline-block; padding: .25rem .7rem; border-radius: 20px; font-size: .82rem; font-weight: 700; white-space: nowrap; }
|
||||
.status-badge.running { background: #fff3d4; color: #8a5c00; }
|
||||
.status-badge.success { background: #d4f0dc; color: #1a6b35; }
|
||||
.status-badge.failure { background: #fde8e8; color: #8a0000; }
|
||||
.status-badge.idle { background: var(--blue-light); color: var(--blue-dark); }
|
||||
|
||||
/* ── Log ── */
|
||||
.log-section { background: #111827; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); }
|
||||
.log-header { display: flex; align-items: center; justify-content: space-between; padding: .7rem 1.2rem; background: #1f2937; color: #d1d5db; }
|
||||
.log-header h3 { font-size: .9rem; font-weight: 600; }
|
||||
.scroll-toggle { display: flex; align-items: center; gap: .4rem; font-size: .82rem; color: #9ca3af; cursor: pointer; }
|
||||
.scroll-toggle input { cursor: pointer; accent-color: var(--blue); }
|
||||
.log-output { color: #a3e635; font-family: 'Courier New', monospace; font-size: .82rem; line-height: 1.55; padding: 1rem 1.2rem; max-height: 400px; overflow-y: auto; white-space: pre-wrap; word-break: break-word; }
|
||||
|
||||
/* ── History ── */
|
||||
.history-section { background: #fff; border-radius: var(--radius); box-shadow: var(--shadow); padding: 1.5rem 1.8rem; }
|
||||
.history-section h3 { font-size: 1rem; margin-bottom: 1rem; color: var(--blue-dark); }
|
||||
.history-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.history-table th { text-align: left; padding: .5rem .8rem; background: var(--blue-light); color: var(--blue-dark); font-weight: 600; }
|
||||
.history-table td { padding: .5rem .8rem; border-bottom: 1px solid var(--blue-light); }
|
||||
.history-table tr:last-child td { border-bottom: none; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 1.2rem; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.build-card-header { flex-direction: column; }
|
||||
.header-inner { flex-wrap: wrap; }
|
||||
.history-table th:nth-child(4), .history-table td:nth-child(4) { display: none; }
|
||||
}
|
||||
|
|
@ -1,215 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin — Alpinux</title>
|
||||
<link rel="icon" type="image/x-icon" href="https://static.alpinux.org/logo/favicon.ico">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='admin.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<a href="/" class="brand">
|
||||
<img src="https://static.alpinux.org/logo/alpinux-logo.png" alt="Alpinux" width="36" height="36">
|
||||
<span>A<strong>l</strong>p<strong>inux</strong> <span class="brand-sub">Admin</span></span>
|
||||
</a>
|
||||
<div class="header-user">
|
||||
<span>{{ user.name }}</span>
|
||||
<a href="{{ url_for('logout') }}" class="btn-logout">Déconnexion</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<!-- ── Carte de build ─────────────────────────────────────────── -->
|
||||
<section class="build-card" id="build-card">
|
||||
<div class="build-card-header">
|
||||
<div>
|
||||
<h2>📖 Wiki — <code>wiki.alpinux.org</code></h2>
|
||||
<p class="subtitle">Lance <code>git pull</code> + <code>mkdocs build --strict</code> sur le serveur</p>
|
||||
</div>
|
||||
<div class="build-status" id="build-status">
|
||||
{% if state.running %}
|
||||
<span class="status-badge running">⏳ En cours…</span>
|
||||
{% elif state.last_success is none %}
|
||||
<span class="status-badge idle">En attente</span>
|
||||
{% elif state.last_success %}
|
||||
<span class="status-badge success">✓ Succès</span>
|
||||
{% else %}
|
||||
<span class="status-badge failure">✗ Échec</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="build-meta" id="build-meta">
|
||||
{% if state.triggered_by %}
|
||||
<span>Dernier déclencheur : <strong>{{ state.triggered_by }}</strong></span>
|
||||
{% endif %}
|
||||
{% if state.last_duration_s %}
|
||||
<span>Durée : <strong>{{ state.last_duration_s }}s</strong></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-build" id="btn-build"
|
||||
{% if state.running %}disabled{% endif %}
|
||||
onclick="triggerBuild()">
|
||||
{% if state.running %}
|
||||
⏳ Build en cours…
|
||||
{% else %}
|
||||
🚀 Lancer mkdocs build
|
||||
{% endif %}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- ── Log ───────────────────────────────────────────────────── -->
|
||||
<section class="log-section">
|
||||
<div class="log-header">
|
||||
<h3>Journal du dernier build</h3>
|
||||
<label class="scroll-toggle">
|
||||
<input type="checkbox" id="auto-scroll" checked>
|
||||
Suivi auto
|
||||
</label>
|
||||
</div>
|
||||
<pre class="log-output" id="log-output">{{ log }}</pre>
|
||||
</section>
|
||||
|
||||
<!-- ── Historique ────────────────────────────────────────────── -->
|
||||
<section class="history-section">
|
||||
<h3>Historique des builds</h3>
|
||||
<table class="history-table" id="history-table">
|
||||
<thead>
|
||||
<tr><th>Date</th><th>Déclenché par</th><th>Résultat</th><th>Durée</th></tr>
|
||||
</thead>
|
||||
<tbody id="history-body">
|
||||
{% for entry in state.history %}
|
||||
<tr>
|
||||
<td>{{ entry.at }}</td>
|
||||
<td>{{ entry.triggered_by }}</td>
|
||||
<td>
|
||||
{% if entry.success %}
|
||||
<span class="status-badge success">✓ Succès</span>
|
||||
{% else %}
|
||||
<span class="status-badge failure">✗ Échec</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.duration_s }}s</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="4" class="empty">Aucun build enregistré</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const POLL_MS = 2000;
|
||||
let polling = false;
|
||||
|
||||
function triggerBuild() {
|
||||
if (!confirm('Lancer mkdocs build sur le serveur ?')) return;
|
||||
document.getElementById('btn-build').disabled = true;
|
||||
document.getElementById('btn-build').textContent = '⏳ Démarrage…';
|
||||
|
||||
fetch('/api/build', { method: 'POST' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.started) {
|
||||
startPolling();
|
||||
} else {
|
||||
alert(data.reason || 'Impossible de démarrer le build.');
|
||||
document.getElementById('btn-build').disabled = false;
|
||||
document.getElementById('btn-build').textContent = '🚀 Lancer mkdocs build';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
document.getElementById('btn-build').disabled = false;
|
||||
document.getElementById('btn-build').textContent = '🚀 Lancer mkdocs build';
|
||||
});
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
poll();
|
||||
}
|
||||
|
||||
function poll() {
|
||||
Promise.all([
|
||||
fetch('/api/status').then(r => r.json()),
|
||||
fetch('/api/log').then(r => r.json()),
|
||||
]).then(([status, logData]) => {
|
||||
updateStatus(status);
|
||||
updateLog(logData.log || '');
|
||||
|
||||
if (status.running) {
|
||||
setTimeout(poll, POLL_MS);
|
||||
} else {
|
||||
polling = false;
|
||||
updateHistory(status.history || []);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (polling) setTimeout(poll, POLL_MS * 2);
|
||||
});
|
||||
}
|
||||
|
||||
function updateStatus(s) {
|
||||
const badge = document.getElementById('build-status');
|
||||
const btn = document.getElementById('btn-build');
|
||||
const meta = document.getElementById('build-meta');
|
||||
|
||||
if (s.running) {
|
||||
badge.innerHTML = '<span class="status-badge running">⏳ En cours…</span>';
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ Build en cours…';
|
||||
} else if (s.last_success === true) {
|
||||
badge.innerHTML = '<span class="status-badge success">✓ Succès</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 Lancer mkdocs build';
|
||||
} else if (s.last_success === false) {
|
||||
badge.innerHTML = '<span class="status-badge failure">✗ Échec</span>';
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🚀 Lancer mkdocs build';
|
||||
}
|
||||
|
||||
let metaHtml = '';
|
||||
if (s.triggered_by) metaHtml += `<span>Dernier déclencheur : <strong>${s.triggered_by}</strong></span>`;
|
||||
if (s.last_duration) metaHtml += `<span>Durée : <strong>${s.last_duration}s</strong></span>`;
|
||||
meta.innerHTML = metaHtml;
|
||||
}
|
||||
|
||||
function updateLog(text) {
|
||||
const el = document.getElementById('log-output');
|
||||
el.textContent = text || '(aucun log)';
|
||||
if (document.getElementById('auto-scroll').checked) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function updateHistory(entries) {
|
||||
const tbody = document.getElementById('history-body');
|
||||
if (!entries.length) return;
|
||||
tbody.innerHTML = entries.map(e => `
|
||||
<tr>
|
||||
<td>${e.at}</td>
|
||||
<td>${e.triggered_by}</td>
|
||||
<td><span class="status-badge ${e.success ? 'success' : 'failure'}">${e.success ? '✓ Succès' : '✗ Échec'}</span></td>
|
||||
<td>${e.duration_s}s</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
/* Démarre le polling si un build tourne déjà au chargement de la page */
|
||||
{% if state.running %}startPolling();{% endif %}
|
||||
|
||||
/* Scroll initial vers la fin du log */
|
||||
window.addEventListener('load', function () {
|
||||
const log = document.getElementById('log-output');
|
||||
log.scrollTop = log.scrollHeight;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
# Copier ce fichier en .env et remplir les valeurs
|
||||
# Ne jamais commiter le fichier .env
|
||||
|
||||
SECRET_KEY=changez-moi-avec-une-valeur-aleatoire-longue
|
||||
|
||||
# AlpID / OIDC (obtenir les valeurs dans la console Keycloak)
|
||||
ALPID_CLIENT_ID=<client-id-configuré-dans-keycloak>
|
||||
ALPID_CLIENT_SECRET=
|
||||
ALPID_DISCOVERY_URL=https://alpid.alpinux.org/realms/alpinux/.well-known/openid-configuration
|
||||
|
||||
# Base de données SQLite
|
||||
DATABASE=<chemin absolu vers scores.db>
|
||||
|
||||
# Mode debug (mettre à 0 en production)
|
||||
FLASK_DEBUG=0
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import os
|
||||
from flask import Flask
|
||||
from authlib.integrations.flask_client import OAuth
|
||||
|
||||
from db import close_db, init_db
|
||||
from routes.public import public_bp
|
||||
from routes.auth import auth_bp
|
||||
from routes.protected import protected_bp
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask(__name__)
|
||||
|
||||
app.secret_key = os.environ['SECRET_KEY']
|
||||
|
||||
# OIDC AlpID
|
||||
oauth = OAuth(app)
|
||||
oauth.register(
|
||||
name='alpid',
|
||||
server_metadata_url=os.environ['ALPID_DISCOVERY_URL'],
|
||||
client_id=os.environ['ALPID_CLIENT_ID'],
|
||||
client_secret=os.environ['ALPID_CLIENT_SECRET'],
|
||||
client_kwargs={'scope': 'openid profile email'},
|
||||
)
|
||||
app.extensions['oauth'] = oauth
|
||||
|
||||
# Filtre Jinja2 utilitaire
|
||||
app.jinja_env.filters['enumerate'] = enumerate
|
||||
|
||||
# Blueprints
|
||||
app.register_blueprint(public_bp)
|
||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||
app.register_blueprint(protected_bp)
|
||||
|
||||
# Base de données
|
||||
app.teardown_appcontext(close_db)
|
||||
with app.app_context():
|
||||
init_db()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=os.environ.get('FLASK_DEBUG', '0') == '1')
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
from functools import wraps
|
||||
from flask import session, redirect, url_for, request
|
||||
|
||||
|
||||
def current_user():
|
||||
return session.get('user')
|
||||
|
||||
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if not current_user():
|
||||
session['next_url'] = request.url
|
||||
return redirect(url_for('auth.login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
|
|
@ -1,679 +0,0 @@
|
|||
[
|
||||
{
|
||||
"id": "bureau-decouverte",
|
||||
"title": "Le bureau Linux",
|
||||
"description": "Maîtrisez les bases de l'interface graphique de Linux Mint.",
|
||||
"level": "Découverte",
|
||||
"level_id": 1,
|
||||
"members_only": false,
|
||||
"duration_min": 3,
|
||||
"icon": "🖥️",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Sur Linux Mint, comment ouvre-t-on une application depuis le menu ?",
|
||||
"choices": [
|
||||
"On double-clique sur l'icône dans la barre des tâches",
|
||||
"On clique sur le menu principal puis on cherche l'application",
|
||||
"On appuie sur F5",
|
||||
"On tape le nom dans la barre d'adresse"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Que se passe-t-il quand on clique sur le bouton ✕ d'une fenêtre ?",
|
||||
"choices": [
|
||||
"Le programme se met en veille",
|
||||
"La fenêtre se réduit dans la barre des tâches",
|
||||
"Le programme se ferme",
|
||||
"L'ordinateur s'éteint"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Qu'est-ce que la barre des tâches sous Linux Mint (bureau Cinnamon) ?",
|
||||
"choices": [
|
||||
"Une barre de progression pour les téléchargements",
|
||||
"La barre en bas de l'écran qui affiche les fenêtres ouvertes",
|
||||
"Un outil de traduction de texte",
|
||||
"La barre de menu d'une application"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "Comment déplacer une fenêtre sur le bureau ?",
|
||||
"choices": [
|
||||
"En cliquant droit dessus et en choisissant « Déplacer »",
|
||||
"En faisant glisser la barre de titre de la fenêtre",
|
||||
"En maintenant Ctrl et en cliquant",
|
||||
"Ce n'est pas possible sous Linux"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "Où vont les fichiers quand on les supprime dans le gestionnaire de fichiers ?",
|
||||
"choices": [
|
||||
"Ils sont définitivement effacés immédiatement",
|
||||
"Ils vont dans la Corbeille",
|
||||
"Ils sont déplacés vers /tmp",
|
||||
"Ils sont compressés automatiquement"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"text": "Comment prendre une capture d'écran sous Linux Mint ?",
|
||||
"choices": [
|
||||
"Ctrl+P",
|
||||
"Alt+Impr écran (pour la fenêtre active)",
|
||||
"La touche Impr écran (Print Screen) pour tout l'écran",
|
||||
"Ctrl+C"
|
||||
],
|
||||
"answer": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "internet-decouverte",
|
||||
"title": "Internet et le navigateur web",
|
||||
"description": "Naviguez sur le web en toute confiance avec Firefox.",
|
||||
"level": "Découverte",
|
||||
"level_id": 1,
|
||||
"members_only": false,
|
||||
"duration_min": 3,
|
||||
"icon": "🌐",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Qu'est-ce qu'une URL ?",
|
||||
"choices": [
|
||||
"Un type de virus informatique",
|
||||
"L'adresse d'une page web",
|
||||
"Un logiciel de navigation",
|
||||
"Un protocole de messagerie"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Que signifie HTTPS par rapport à HTTP ?",
|
||||
"choices": [
|
||||
"HTTPS est plus rapide que HTTP",
|
||||
"HTTPS chiffre la communication entre votre navigateur et le serveur",
|
||||
"HTTPS est réservé aux sites professionnels",
|
||||
"Il n'y a aucune différence pratique"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Comment ouvrir un nouvel onglet dans Firefox ?",
|
||||
"choices": [
|
||||
"Ctrl+N",
|
||||
"Ctrl+T",
|
||||
"Ctrl+O",
|
||||
"F5"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "Qu'est-ce qu'un moteur de recherche ?",
|
||||
"choices": [
|
||||
"Un logiciel qui accélère votre ordinateur",
|
||||
"Un service qui référence des pages web et permet de les trouver",
|
||||
"Un programme qui traduit les pages web automatiquement",
|
||||
"Un type de navigateur spécialisé"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "Comment recharger une page web dans Firefox ?",
|
||||
"choices": [
|
||||
"Ctrl+R ou F5",
|
||||
"Ctrl+L",
|
||||
"Alt+F4",
|
||||
"Ctrl+Z"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"text": "Qu'est-ce qu'un favori (bookmark) dans un navigateur ?",
|
||||
"choices": [
|
||||
"Une page web enregistrée pour y accéder rapidement",
|
||||
"Le site web le plus visité de la semaine",
|
||||
"L'historique complet de navigation",
|
||||
"Un mot de passe enregistré"
|
||||
],
|
||||
"answer": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "fichiers-debutant",
|
||||
"title": "Fichiers et dossiers",
|
||||
"description": "Comprenez l'organisation des fichiers sous Linux.",
|
||||
"level": "Débutant",
|
||||
"level_id": 2,
|
||||
"members_only": false,
|
||||
"duration_min": 4,
|
||||
"icon": "📁",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Quel est le dossier personnel de l'utilisateur « alice » sous Linux ?",
|
||||
"choices": [
|
||||
"/home/alice",
|
||||
"C:\\Users\\alice",
|
||||
"/usr/alice",
|
||||
"/root/alice"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Qu'est-ce qu'une extension de fichier ?",
|
||||
"choices": [
|
||||
"La taille d'un fichier en octets",
|
||||
"Les lettres après le point dans le nom d'un fichier (.pdf, .mp3…)",
|
||||
"Le dossier où se trouve un fichier",
|
||||
"La date de création d'un fichier"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Que représente le dossier / (barre oblique seule) sous Linux ?",
|
||||
"choices": [
|
||||
"Le dossier personnel de l'utilisateur",
|
||||
"Un dossier temporaire vide",
|
||||
"La racine du système de fichiers (le point de départ de tout)",
|
||||
"Le dossier des fichiers de configuration"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "Comment afficher les fichiers cachés dans Nemo (le gestionnaire de fichiers de Linux Mint) ?",
|
||||
"choices": [
|
||||
"Clic droit → « Afficher les fichiers cachés »",
|
||||
"Menu Affichage → « Afficher les fichiers cachés » ou raccourci Ctrl+H",
|
||||
"Il n'est pas possible d'afficher les fichiers cachés",
|
||||
"En tapant « hidden » dans la barre d'adresse"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "Sous Linux, par quel caractère commence le nom d'un fichier caché ?",
|
||||
"choices": [
|
||||
"Un underscore _",
|
||||
"Un point . (exemple : .bashrc)",
|
||||
"Un tiret -",
|
||||
"Un dièse #"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"text": "Que fait le raccourci Ctrl+Z dans la plupart des applications ?",
|
||||
"choices": [
|
||||
"Fermer l'application",
|
||||
"Annuler la dernière action",
|
||||
"Enregistrer le fichier",
|
||||
"Couper la sélection"
|
||||
],
|
||||
"answer": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "logiciels-debutant",
|
||||
"title": "Installer des logiciels",
|
||||
"description": "Gérez vos logiciels sous Linux Mint sans ligne de commande.",
|
||||
"level": "Débutant",
|
||||
"level_id": 2,
|
||||
"members_only": false,
|
||||
"duration_min": 4,
|
||||
"icon": "📦",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Quel est l'outil graphique pour installer des logiciels sous Linux Mint ?",
|
||||
"choices": [
|
||||
"Le Gestionnaire de logiciels",
|
||||
"L'App Store",
|
||||
"Le Centre de téléchargement Windows",
|
||||
"Le Panneau de configuration"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Qu'est-ce qu'un dépôt de logiciels (repository) sous Linux ?",
|
||||
"choices": [
|
||||
"Un disque dur externe pour stocker des sauvegardes",
|
||||
"Un serveur qui héberge des paquets logiciels prêts à installer",
|
||||
"Un logiciel de gestion de fichiers",
|
||||
"Une partition dédiée aux programmes"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Pourquoi faut-il régulièrement mettre à jour son système Linux ?",
|
||||
"choices": [
|
||||
"Pour améliorer les performances uniquement",
|
||||
"Pour corriger les bugs et les failles de sécurité",
|
||||
"Pour changer l'apparence du bureau",
|
||||
"Les mises à jour ne sont pas nécessaires sous Linux"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "Qu'est-ce que LibreOffice ?",
|
||||
"choices": [
|
||||
"Un système d'exploitation",
|
||||
"Un navigateur web libre",
|
||||
"Une suite bureautique libre, alternative à Microsoft Office",
|
||||
"Un antivirus"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "Comment installe-t-on un logiciel en ligne de commande sous Linux Mint ?",
|
||||
"choices": [
|
||||
"install <logiciel>",
|
||||
"sudo apt install <logiciel>",
|
||||
"get-app <logiciel>",
|
||||
"download <logiciel>"
|
||||
],
|
||||
"answer": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "terminal-intermediaire",
|
||||
"title": "Le terminal — premiers pas",
|
||||
"description": "Découvrez la puissance de la ligne de commande Linux.",
|
||||
"level": "Intermédiaire",
|
||||
"level_id": 3,
|
||||
"members_only": false,
|
||||
"duration_min": 5,
|
||||
"icon": "⌨️",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Quelle commande affiche le répertoire courant (là où vous êtes) ?",
|
||||
"choices": ["ls", "cd", "pwd", "dir"],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Quelle commande liste les fichiers et dossiers du répertoire courant ?",
|
||||
"choices": ["list", "ls", "dir", "files"],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Que fait la commande `cd ..` ?",
|
||||
"choices": [
|
||||
"Revient au dossier personnel (~)",
|
||||
"Monte d'un niveau dans l'arborescence (dossier parent)",
|
||||
"Change le nom du dossier courant",
|
||||
"Crée un nouveau dossier"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "Quelle commande crée un nouveau dossier nommé « projets » ?",
|
||||
"choices": ["newdir projets", "create projets", "mkdir projets", "touch projets"],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "Que fait la commande `man ls` ?",
|
||||
"choices": [
|
||||
"Affiche la version de la commande ls",
|
||||
"Exécute ls avec toutes les options disponibles",
|
||||
"Affiche le manuel de la commande ls",
|
||||
"Liste les fichiers en mode manuel"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"text": "Que signifie le ~ (tilde) dans le prompt du terminal ?",
|
||||
"choices": [
|
||||
"Le dossier racine /",
|
||||
"Le dossier /tmp",
|
||||
"Le dossier personnel de l'utilisateur (/home/utilisateur)",
|
||||
"Le dernier dossier visité"
|
||||
],
|
||||
"answer": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "permissions-intermediaire",
|
||||
"title": "Les permissions Linux",
|
||||
"description": "Comprenez qui peut lire, écrire et exécuter quoi.",
|
||||
"level": "Intermédiaire",
|
||||
"level_id": 3,
|
||||
"members_only": false,
|
||||
"duration_min": 5,
|
||||
"icon": "🔐",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Que signifient les permissions `rwxr-xr--` sur un fichier ?",
|
||||
"choices": [
|
||||
"Lecture seule pour tout le monde",
|
||||
"Propriétaire : lire/écrire/exécuter | Groupe : lire/exécuter | Autres : lire seulement",
|
||||
"Propriétaire : tout | Groupe : tout | Autres : rien",
|
||||
"Ce format de permissions n'existe pas sous Linux"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Quelle commande modifie les permissions d'un fichier ?",
|
||||
"choices": ["perm", "chperm", "chmod", "access"],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Que fait `chmod 755 script.sh` ?",
|
||||
"choices": [
|
||||
"Supprime tous les droits sur le fichier",
|
||||
"Donne tous les droits au propriétaire, lecture+exécution au groupe et aux autres",
|
||||
"Rend le fichier lisible uniquement par le propriétaire",
|
||||
"Change le propriétaire du fichier"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "Quelle commande change le propriétaire d'un fichier ?",
|
||||
"choices": ["owner", "chown", "chmod", "usermod"],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "Que signifie la lettre `x` dans les permissions Linux ?",
|
||||
"choices": [
|
||||
"Exclure (exclude)",
|
||||
"eXtended (étendu)",
|
||||
"Exécuter (execute)",
|
||||
"eXport"
|
||||
],
|
||||
"answer": 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "git-avance",
|
||||
"title": "Git — les bases",
|
||||
"description": "Maîtrisez la gestion de versions avec Git.",
|
||||
"level": "Avancé",
|
||||
"level_id": 4,
|
||||
"members_only": true,
|
||||
"duration_min": 6,
|
||||
"icon": "🌿",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Que fait `git init` dans un dossier ?",
|
||||
"choices": [
|
||||
"Télécharge un dépôt distant",
|
||||
"Initialise un nouveau dépôt Git local dans le dossier",
|
||||
"Initialise la connexion avec GitHub/Gitea",
|
||||
"Réinitialise toutes les modifications en cours"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Quelle commande affiche l'état actuel du dépôt (fichiers modifiés, staged, etc.) ?",
|
||||
"choices": ["git log", "git diff", "git status", "git show"],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Comment ajouter tous les fichiers modifiés à l'index (staging area) ?",
|
||||
"choices": ["git commit -a", "git add .", "git stage all", "git push all"],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "Que fait `git commit -m \"message\"` ?",
|
||||
"choices": [
|
||||
"Envoie les modifications vers le dépôt distant",
|
||||
"Crée un point de sauvegarde (commit) avec le message donné",
|
||||
"Ajoute les fichiers à l'index",
|
||||
"Crée une nouvelle branche nommée « message »"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "Quelle commande envoie les commits locaux vers le dépôt distant ?",
|
||||
"choices": ["git send", "git upload", "git push", "git commit --remote"],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"text": "Que fait `git pull` ?",
|
||||
"choices": [
|
||||
"Supprime les modifications locales non committées",
|
||||
"Récupère et fusionne les modifications du dépôt distant",
|
||||
"Crée une nouvelle branche à partir du distant",
|
||||
"Affiche les derniers commits du dépôt distant"
|
||||
],
|
||||
"answer": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-avance",
|
||||
"title": "Administration système",
|
||||
"description": "Gérez votre système Linux comme un pro.",
|
||||
"level": "Avancé",
|
||||
"level_id": 4,
|
||||
"members_only": true,
|
||||
"duration_min": 6,
|
||||
"icon": "⚙️",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Quelle commande met à jour la liste des paquets disponibles sous Debian/Ubuntu/Mint ?",
|
||||
"choices": ["sudo apt upgrade", "sudo apt update", "sudo apt refresh", "sudo dpkg update"],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Quelle commande affiche les processus en cours d'exécution de façon interactive ?",
|
||||
"choices": ["ps aux", "top ou htop", "process", "list-proc"],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Comment arrêter le service `nginx` avec systemd ?",
|
||||
"choices": [
|
||||
"service nginx kill",
|
||||
"nginx --stop",
|
||||
"sudo systemctl stop nginx",
|
||||
"sudo kill nginx"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "Que fait la commande `sudo` ?",
|
||||
"choices": [
|
||||
"Exécute une commande avec les droits d'un autre utilisateur (par défaut root)",
|
||||
"Sécurise une commande contre les erreurs",
|
||||
"Vérifie si une commande existe sur le système",
|
||||
"Exécute une commande en arrière-plan"
|
||||
],
|
||||
"answer": 0
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "Quelle commande affiche l'espace disque disponible sur les partitions montées ?",
|
||||
"choices": ["diskspace", "du -h", "df -h", "free -h"],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"text": "Comment afficher les dernières lignes d'un fichier de log en temps réel ?",
|
||||
"choices": [
|
||||
"cat /var/log/syslog",
|
||||
"tail -f /var/log/syslog",
|
||||
"watch /var/log/syslog",
|
||||
"log -f /var/log/syslog"
|
||||
],
|
||||
"answer": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "securite-expert",
|
||||
"title": "Sécurité Linux",
|
||||
"description": "Bonnes pratiques et outils de sécurité sous Linux.",
|
||||
"level": "Expert",
|
||||
"level_id": 5,
|
||||
"members_only": true,
|
||||
"duration_min": 7,
|
||||
"icon": "🛡️",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Pourquoi ne faut-il pas utiliser le compte root au quotidien ?",
|
||||
"choices": [
|
||||
"Root est plus lent que les autres utilisateurs",
|
||||
"Une erreur ou un programme malveillant peut endommager tout le système sans restriction",
|
||||
"Root ne peut pas accéder aux fichiers personnels",
|
||||
"Il n'y a aucun risque particulier à utiliser root"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Qu'est-ce qu'une clé SSH ?",
|
||||
"choices": [
|
||||
"Un mot de passe chiffré pour les connexions web HTTPS",
|
||||
"Une paire cryptographique (clé publique/privée) pour s'authentifier sans mot de passe",
|
||||
"Un certificat SSL pour serveurs web",
|
||||
"Un outil de chiffrement de disque comme LUKS"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Quelle commande génère une paire de clés SSH de type Ed25519 ?",
|
||||
"choices": [
|
||||
"ssh-create -t ed25519",
|
||||
"openssl genkey ed25519",
|
||||
"ssh-keygen -t ed25519",
|
||||
"gpg --gen-key --type ed25519"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "Quel est le principe du moindre privilège (least privilege) ?",
|
||||
"choices": [
|
||||
"Toujours utiliser le compte root pour les tâches système",
|
||||
"Accorder à chaque processus et utilisateur uniquement les droits strictement nécessaires",
|
||||
"Réduire les performances du système pour plus de sécurité",
|
||||
"Interdire l'accès internet aux utilisateurs non-root"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "Quelle commande affiche les connexions réseau actives et les ports en écoute ?",
|
||||
"choices": ["ifconfig -a", "ss -tulpn (ou netstat -tulpn)", "ping -l", "route -n"],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"text": "Qu'est-ce qu'un pare-feu (firewall) ?",
|
||||
"choices": [
|
||||
"Un antivirus spécialisé pour les applications web",
|
||||
"Un système qui filtre le trafic réseau entrant et sortant selon des règles",
|
||||
"Un logiciel qui chiffre les applications installées",
|
||||
"Un outil de sauvegarde automatique"
|
||||
],
|
||||
"answer": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "bash-expert",
|
||||
"title": "Bash scripting",
|
||||
"description": "Automatisez vos tâches avec des scripts shell.",
|
||||
"level": "Expert",
|
||||
"level_id": 5,
|
||||
"members_only": true,
|
||||
"duration_min": 7,
|
||||
"icon": "📜",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Quelle doit être la première ligne d'un script bash ?",
|
||||
"choices": ["#!/usr/bin/python3", "#!/bin/bash", "#bash", "// bash script"],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"text": "Comment rendre un script bash exécutable ?",
|
||||
"choices": ["bash +x script.sh", "chmod +x script.sh", "exec script.sh", "run script.sh"],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"text": "Que représente `$1` dans un script bash ?",
|
||||
"choices": [
|
||||
"Le nom du script lui-même",
|
||||
"La valeur de la variable nommée « 1 »",
|
||||
"Le premier argument passé au script lors de son appel",
|
||||
"Le code de retour de la dernière commande exécutée"
|
||||
],
|
||||
"answer": 2
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"text": "Quelle est la différence entre `>` et `>>` en redirection ?",
|
||||
"choices": [
|
||||
">> est plus rapide que >",
|
||||
">> ajoute à la fin du fichier existant, > écrase le fichier",
|
||||
">> redirige les erreurs, > redirige la sortie standard",
|
||||
"Aucune différence pratique entre les deux"
|
||||
],
|
||||
"answer": 1
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"text": "Comment tester si le fichier `/etc/hosts` existe dans un script bash ?",
|
||||
"choices": [
|
||||
"if exists \"/etc/hosts\"; then",
|
||||
"if [ -f \"/etc/hosts\" ]; then",
|
||||
"if file \"/etc/hosts\"; then",
|
||||
"if check -f \"/etc/hosts\"; then"
|
||||
],
|
||||
"answer": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
import sqlite3
|
||||
import os
|
||||
from flask import g
|
||||
|
||||
|
||||
def get_db():
|
||||
if 'db' not in g:
|
||||
db_path = os.environ.get('DATABASE', 'scores.db')
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
g.db = sqlite3.connect(db_path)
|
||||
g.db.row_factory = sqlite3.Row
|
||||
return g.db
|
||||
|
||||
|
||||
def close_db(e=None):
|
||||
db = g.pop('db', None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
db = get_db()
|
||||
db.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS quiz_results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_sub TEXT NOT NULL,
|
||||
user_name TEXT NOT NULL DEFAULT '',
|
||||
quiz_id TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
total INTEGER NOT NULL,
|
||||
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_user ON quiz_results(user_sub);
|
||||
''')
|
||||
db.commit()
|
||||
|
||||
|
||||
def save_result(user_sub, user_name, quiz_id, score, total):
|
||||
db = get_db()
|
||||
db.execute(
|
||||
'INSERT INTO quiz_results (user_sub, user_name, quiz_id, score, total) VALUES (?,?,?,?,?)',
|
||||
(user_sub, user_name, quiz_id, score, total),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_user_results(user_sub):
|
||||
db = get_db()
|
||||
return db.execute(
|
||||
'SELECT * FROM quiz_results WHERE user_sub=? ORDER BY completed_at DESC',
|
||||
(user_sub,),
|
||||
).fetchall()
|
||||
|
||||
|
||||
def get_best_score(user_sub, quiz_id):
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
'SELECT MAX(score) as best, total FROM quiz_results WHERE user_sub=? AND quiz_id=?',
|
||||
(user_sub, quiz_id),
|
||||
).fetchone()
|
||||
return row if row and row['best'] is not None else None
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
_QUIZZES = None
|
||||
|
||||
LEVEL_ORDER = {1: "Découverte", 2: "Débutant", 3: "Intermédiaire", 4: "Avancé", 5: "Expert"}
|
||||
LEVEL_COLOR = {1: "green", 2: "teal", 3: "orange", 4: "red", 5: "purple"}
|
||||
|
||||
|
||||
def load_quizzes():
|
||||
global _QUIZZES
|
||||
if _QUIZZES is None:
|
||||
path = Path(__file__).parent / "data" / "quizzes.json"
|
||||
_QUIZZES = json.loads(path.read_text(encoding="utf-8"))
|
||||
return _QUIZZES
|
||||
|
||||
|
||||
def get_all():
|
||||
return load_quizzes()
|
||||
|
||||
|
||||
def get_by_id(quiz_id):
|
||||
return next((q for q in load_quizzes() if q["id"] == quiz_id), None)
|
||||
|
||||
|
||||
def get_public():
|
||||
return [q for q in load_quizzes() if not q["members_only"]]
|
||||
|
||||
|
||||
def get_members():
|
||||
return [q for q in load_quizzes() if q["members_only"]]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
flask>=3.0
|
||||
authlib>=1.3
|
||||
requests>=2.31
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
from flask import Blueprint, redirect, url_for, session, current_app, request
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
@auth_bp.route('/login')
|
||||
def login():
|
||||
redirect_uri = url_for('auth.callback', _external=True)
|
||||
return current_app.extensions['oauth'].alpid.authorize_redirect(redirect_uri)
|
||||
|
||||
|
||||
@auth_bp.route('/callback')
|
||||
def callback():
|
||||
token = current_app.extensions['oauth'].alpid.authorize_access_token()
|
||||
userinfo = token.get('userinfo') or \
|
||||
current_app.extensions['oauth'].alpid.userinfo(token=token)
|
||||
session['user'] = {
|
||||
'sub': userinfo['sub'],
|
||||
'name': userinfo.get('name') or userinfo.get('preferred_username', 'Membre'),
|
||||
'username': userinfo.get('preferred_username', ''),
|
||||
'email': userinfo.get('email', ''),
|
||||
}
|
||||
return redirect(session.pop('next_url', url_for('public.index')))
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
# Déconnexion côté AlpID si end_session_endpoint disponible
|
||||
end_session = current_app.config.get('ALPID_END_SESSION_URL')
|
||||
if end_session:
|
||||
post_logout = url_for('public.index', _external=True)
|
||||
return redirect(f"{end_session}?post_logout_redirect_uri={post_logout}")
|
||||
return redirect(url_for('public.index'))
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
from flask import Blueprint, render_template
|
||||
|
||||
from auth_utils import current_user, login_required
|
||||
from quiz import get_all
|
||||
from db import get_user_results, get_best_score
|
||||
|
||||
protected_bp = Blueprint('protected', __name__)
|
||||
|
||||
|
||||
@protected_bp.route('/profil/')
|
||||
@login_required
|
||||
def profil():
|
||||
user = current_user()
|
||||
results = get_user_results(user['sub'])
|
||||
quizzes = get_all()
|
||||
|
||||
# Calcule le meilleur score par quiz
|
||||
best_scores = {}
|
||||
for q in quizzes:
|
||||
row = get_best_score(user['sub'], q['id'])
|
||||
if row:
|
||||
best_scores[q['id']] = {'score': row['best'], 'total': row['total']}
|
||||
|
||||
# Statistiques globales
|
||||
completed_ids = {r['quiz_id'] for r in results}
|
||||
stats = {
|
||||
'quizzes_done': len(completed_ids),
|
||||
'quizzes_total': len(quizzes),
|
||||
'attempts': len(results),
|
||||
}
|
||||
|
||||
return render_template('profil/index.html',
|
||||
user=user,
|
||||
results=results,
|
||||
quizzes={q['id']: q for q in quizzes},
|
||||
best_scores=best_scores,
|
||||
stats=stats)
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import json
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, session, abort
|
||||
|
||||
from auth_utils import current_user, login_required
|
||||
from quiz import get_all, get_by_id
|
||||
from db import save_result
|
||||
|
||||
public_bp = Blueprint('public', __name__)
|
||||
|
||||
|
||||
@public_bp.route('/')
|
||||
def index():
|
||||
quizzes = get_all()
|
||||
return render_template('index.html', quizzes=quizzes, user=current_user())
|
||||
|
||||
|
||||
@public_bp.route('/quiz/')
|
||||
def quiz_list():
|
||||
quizzes = get_all()
|
||||
return render_template('quiz/list.html', quizzes=quizzes, user=current_user())
|
||||
|
||||
|
||||
@public_bp.route('/quiz/<quiz_id>/')
|
||||
def quiz_intro(quiz_id):
|
||||
quiz = get_by_id(quiz_id)
|
||||
if not quiz:
|
||||
abort(404)
|
||||
return render_template('quiz/intro.html', quiz=quiz, user=current_user())
|
||||
|
||||
|
||||
@public_bp.route('/quiz/<quiz_id>/jouer', methods=['GET', 'POST'])
|
||||
def quiz_play(quiz_id):
|
||||
quiz = get_by_id(quiz_id)
|
||||
if not quiz:
|
||||
abort(404)
|
||||
|
||||
if quiz['members_only'] and not current_user():
|
||||
session['next_url'] = url_for('public.quiz_play', quiz_id=quiz_id)
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
if request.method == 'POST':
|
||||
answers = {}
|
||||
for i in range(len(quiz['questions'])):
|
||||
val = request.form.get(f'q{i}')
|
||||
answers[i] = int(val) if val is not None else -1
|
||||
|
||||
score = sum(
|
||||
1 for i, q in enumerate(quiz['questions'])
|
||||
if answers.get(i) == q['answer']
|
||||
)
|
||||
total = len(quiz['questions'])
|
||||
|
||||
if current_user():
|
||||
save_result(
|
||||
current_user()['sub'],
|
||||
current_user()['name'],
|
||||
quiz_id, score, total,
|
||||
)
|
||||
|
||||
# Stocke résultat et détail en session pour la page résultat
|
||||
session['last_result'] = {
|
||||
'quiz_id': quiz_id,
|
||||
'score': score,
|
||||
'total': total,
|
||||
'answers': answers,
|
||||
}
|
||||
return redirect(url_for('public.quiz_result', quiz_id=quiz_id))
|
||||
|
||||
return render_template('quiz/play.html',
|
||||
quiz=quiz,
|
||||
quiz_json=json.dumps(quiz),
|
||||
user=current_user())
|
||||
|
||||
|
||||
@public_bp.route('/quiz/<quiz_id>/resultat')
|
||||
def quiz_result(quiz_id):
|
||||
quiz = get_by_id(quiz_id)
|
||||
result = session.pop('last_result', None)
|
||||
if not quiz or not result or result['quiz_id'] != quiz_id:
|
||||
return redirect(url_for('public.quiz_intro', quiz_id=quiz_id))
|
||||
|
||||
return render_template('quiz/result.html',
|
||||
quiz=quiz,
|
||||
result=result,
|
||||
user=current_user())
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/* Quiz navigation — vanilla JS, aucune dépendance */
|
||||
(function () {
|
||||
const quiz = JSON.parse(document.getElementById('quiz-data').textContent);
|
||||
const total = quiz.questions.length;
|
||||
let current = 0;
|
||||
|
||||
function showQuestion(index) {
|
||||
document.querySelectorAll('.question-block').forEach(function (el) {
|
||||
el.hidden = parseInt(el.dataset.index) !== index;
|
||||
});
|
||||
|
||||
document.getElementById('progress-text').textContent =
|
||||
(index + 1) + ' / ' + total;
|
||||
document.getElementById('progress-bar').style.width =
|
||||
Math.round((index / total) * 100) + '%';
|
||||
|
||||
document.getElementById('btn-prev').hidden = index === 0;
|
||||
document.getElementById('btn-next').hidden = index === total - 1;
|
||||
document.getElementById('btn-submit').hidden = index !== total - 1;
|
||||
}
|
||||
|
||||
window.navigate = function (delta) {
|
||||
var next = current + delta;
|
||||
if (next < 0 || next >= total) return;
|
||||
current = next;
|
||||
showQuestion(current);
|
||||
};
|
||||
|
||||
window.onChoice = function (questionIndex, choiceIndex) {
|
||||
/* Mise à jour visuelle */
|
||||
document.querySelectorAll('.question-block[data-index="' + questionIndex + '"] .choice-label')
|
||||
.forEach(function (lbl, j) {
|
||||
lbl.classList.toggle('selected', j === choiceIndex);
|
||||
});
|
||||
|
||||
/* Le radio button natif sert de valeur soumise avec le formulaire.
|
||||
On synchronise aussi un hidden input de secours. */
|
||||
var hidden = document.getElementById('ans-' + questionIndex);
|
||||
if (hidden) hidden.value = choiceIndex;
|
||||
|
||||
/* Avancer automatiquement sauf sur la dernière question */
|
||||
if (questionIndex < total - 1) {
|
||||
setTimeout(function () { navigate(1); }, 350);
|
||||
}
|
||||
};
|
||||
|
||||
/* Init */
|
||||
showQuestion(0);
|
||||
})();
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
/* ── Variables ────────────────────────────────────────────────── */
|
||||
:root {
|
||||
--blue: #1a6bbf;
|
||||
--blue-dark: #0f4e8f;
|
||||
--blue-light: #e8f1fb;
|
||||
--orange: #e8820c;
|
||||
--bg: #f3f6fb;
|
||||
--text: #1a1a2e;
|
||||
--text-muted: #666;
|
||||
--card-shadow: 0 2px 12px rgba(26,107,191,.1);
|
||||
--radius: 10px;
|
||||
}
|
||||
|
||||
/* ── Reset / base ─────────────────────────────────────────────── */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||||
a { color: var(--blue); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
main { flex: 1; max-width: 1000px; margin: 0 auto; padding: 2rem 1.5rem; width: 100%; }
|
||||
h1, h2, h3 { line-height: 1.2; }
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────── */
|
||||
header { background: var(--blue-dark); color: #fff; }
|
||||
.header-inner { max-width: 1000px; margin: 0 auto; padding: .8rem 1.5rem; display: flex; align-items: center; gap: 1.5rem; }
|
||||
.brand { display: flex; align-items: center; gap: .6rem; color: #fff; font-size: 1.15rem; font-weight: 400; flex-shrink: 0; }
|
||||
.brand:hover { text-decoration: none; opacity: .9; }
|
||||
.brand strong { font-weight: 800; }
|
||||
.brand-sub { font-weight: 300; font-size: .9em; opacity: .8; }
|
||||
.brand-logo { border-radius: 6px; }
|
||||
.main-nav { display: flex; align-items: center; gap: .3rem; margin-left: auto; flex-wrap: wrap; }
|
||||
.main-nav a { color: rgba(255,255,255,.85); font-size: .88rem; padding: .4rem .8rem; border-radius: 5px; transition: background .15s; }
|
||||
.main-nav a:hover { background: rgba(255,255,255,.15); color: #fff; text-decoration: none; }
|
||||
.btn-nav { background: var(--blue); color: #fff !important; border-radius: 5px; padding: .4rem .9rem !important; }
|
||||
.btn-nav-outline { border: 1px solid rgba(255,255,255,.5); border-radius: 5px; padding: .35rem .8rem !important; }
|
||||
.nav-toggle { display: none; background: none; border: none; color: #fff; font-size: 1.4rem; cursor: pointer; margin-left: auto; }
|
||||
|
||||
/* ── Footer ───────────────────────────────────────────────────── */
|
||||
footer { background: var(--blue-dark); color: rgba(255,255,255,.7); padding: 1rem 1.5rem; font-size: .85rem; }
|
||||
.footer-inner { max-width: 1000px; margin: 0 auto; display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.footer-links { display: flex; gap: 1rem; }
|
||||
.footer-links a { color: rgba(255,255,255,.75); }
|
||||
.footer-links a:hover { color: #fff; }
|
||||
|
||||
/* ── Buttons ──────────────────────────────────────────────────── */
|
||||
.btn { display: inline-block; padding: .6rem 1.4rem; border-radius: 6px; font-size: .95rem; font-weight: 600; cursor: pointer; border: none; transition: all .15s; text-decoration: none !important; }
|
||||
.btn-primary { background: var(--blue); color: #fff; }
|
||||
.btn-primary:hover { background: var(--blue-dark); }
|
||||
.btn-outline { background: transparent; color: var(--blue); border: 2px solid var(--blue); }
|
||||
.btn-outline:hover { background: var(--blue-light); }
|
||||
.btn-success { background: #1e8a3e; color: #fff; }
|
||||
.btn-success:hover { background: #166b30; }
|
||||
.btn-lg { padding: .8rem 2rem; font-size: 1.05rem; }
|
||||
.btn-sm { padding: .35rem .8rem; font-size: .84rem; }
|
||||
|
||||
/* ── Badges ───────────────────────────────────────────────────── */
|
||||
.badge { display: inline-block; padding: .2rem .6rem; border-radius: 20px; font-size: .78rem; font-weight: 700; }
|
||||
.badge.public { background: #d4f0dc; color: #1a6b35; }
|
||||
.badge.members { background: #fde8cc; color: #a35800; }
|
||||
.lock-badge { font-size: .8rem; color: #a35800; }
|
||||
|
||||
.level-badge { display: inline-block; padding: .15rem .55rem; border-radius: 20px; font-size: .78rem; font-weight: 700; }
|
||||
.level-badge-1 { background: #d4f0dc; color: #1a6b35; }
|
||||
.level-badge-2 { background: #d0edf5; color: #0d6779; }
|
||||
.level-badge-3 { background: #fff3d4; color: #8a5c00; }
|
||||
.level-badge-4 { background: #ffe0d0; color: #8a2800; }
|
||||
.level-badge-5 { background: #ead4f5; color: #5e0a8a; }
|
||||
|
||||
/* ── Flash messages ───────────────────────────────────────────── */
|
||||
.flash { padding: .8rem 1.2rem; border-radius: 6px; margin-bottom: 1rem; }
|
||||
.flash-error { background: #fde8e8; color: #8a0000; border-left: 4px solid #c0392b; }
|
||||
.flash-success { background: #d4f0dc; color: #1a6b35; border-left: 4px solid #27ae60; }
|
||||
|
||||
/* ── Section titles ───────────────────────────────────────────── */
|
||||
.section-title { font-size: 1.1rem; font-weight: 700; color: var(--blue-dark); margin-bottom: 1.2rem; display: flex; align-items: center; gap: .6rem; }
|
||||
.section-title::after { content: ''; flex: 1; height: 2px; background: var(--blue-light); }
|
||||
|
||||
/* ── Hero ─────────────────────────────────────────────────────── */
|
||||
.hero { background: linear-gradient(135deg, var(--blue-dark) 0%, var(--blue) 100%); color: #fff; border-radius: var(--radius); padding: 3rem 2rem; text-align: center; margin-bottom: 2.5rem; }
|
||||
.hero h1 { font-size: 2.2rem; font-weight: 800; }
|
||||
.hero-sub { font-weight: 300; opacity: .8; }
|
||||
.hero-desc { margin: 1rem auto; max-width: 560px; opacity: .88; line-height: 1.6; font-size: 1.05rem; }
|
||||
.hero-actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; margin-top: 1.5rem; }
|
||||
|
||||
/* ── Levels grid ──────────────────────────────────────────────── */
|
||||
.levels-section { margin-bottom: 2.5rem; }
|
||||
.levels-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 1rem; }
|
||||
.level-card { background: #fff; border-radius: var(--radius); padding: 1.2rem 1rem; text-align: center; box-shadow: var(--card-shadow); }
|
||||
.level-card p { font-size: .82rem; color: var(--text-muted); margin: .3rem 0 .5rem; }
|
||||
.level-icon { font-size: 1.8rem; display: block; margin-bottom: .4rem; }
|
||||
|
||||
/* ── Quiz grid / cards ────────────────────────────────────────── */
|
||||
.quiz-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); gap: 1rem; }
|
||||
.quiz-card { display: flex; gap: 1rem; background: #fff; border-radius: var(--radius); padding: 1.2rem; box-shadow: var(--card-shadow); color: var(--text); transition: transform .15s, box-shadow .15s; text-decoration: none !important; border: 2px solid transparent; }
|
||||
.quiz-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(26,107,191,.15); border-color: var(--blue-light); }
|
||||
.quiz-card--members { border-left: 3px solid var(--orange); }
|
||||
.quiz-icon { font-size: 2rem; flex-shrink: 0; align-self: flex-start; }
|
||||
.quiz-card-body h3 { font-size: 1rem; margin-bottom: .3rem; }
|
||||
.quiz-card-body p { font-size: .84rem; color: var(--text-muted); line-height: 1.4; margin-bottom: .5rem; }
|
||||
.quiz-meta { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
.quiz-duration, .quiz-count { font-size: .78rem; color: var(--text-muted); }
|
||||
|
||||
/* ── Members CTA ──────────────────────────────────────────────── */
|
||||
.members-cta { display: flex; align-items: center; gap: 1.5rem; background: #fff8f0; border: 2px solid #f5d0a0; border-radius: var(--radius); padding: 1.4rem 1.8rem; margin-top: 2rem; flex-wrap: wrap; }
|
||||
.cta-icon { font-size: 2rem; flex-shrink: 0; }
|
||||
.members-cta strong { display: block; margin-bottom: .2rem; }
|
||||
.members-cta p { font-size: .9rem; color: var(--text-muted); margin: 0; }
|
||||
.members-cta .btn { margin-left: auto; }
|
||||
|
||||
/* ── Level sections in list ───────────────────────────────────── */
|
||||
.level-section { margin-bottom: 2.5rem; }
|
||||
.level-heading { font-size: 1.2rem; font-weight: 700; padding-bottom: .6rem; border-bottom: 2px solid var(--blue-light); margin-bottom: 1.2rem; display: flex; align-items: center; gap: .7rem; }
|
||||
.level-heading-4, .level-heading-5 { border-bottom-color: #f5d0a0; }
|
||||
|
||||
/* ── Quiz intro ───────────────────────────────────────────────── */
|
||||
.quiz-intro-wrap { display: flex; justify-content: center; padding: 2rem 0; }
|
||||
.quiz-intro-card { background: #fff; border-radius: var(--radius); box-shadow: var(--card-shadow); padding: 2.5rem 2rem; max-width: 560px; width: 100%; text-align: center; }
|
||||
.quiz-intro-icon { font-size: 3.5rem; display: block; margin-bottom: .8rem; }
|
||||
.quiz-intro-card h1 { font-size: 1.6rem; margin-bottom: .6rem; }
|
||||
.quiz-intro-desc { color: var(--text-muted); line-height: 1.6; margin-bottom: 1.5rem; }
|
||||
.quiz-intro-meta { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; background: var(--bg); border-radius: 8px; padding: 1.2rem; margin-bottom: 1.8rem; text-align: left; }
|
||||
.meta-item { display: flex; flex-direction: column; gap: .2rem; }
|
||||
.meta-label { font-size: .78rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.intro-lock-notice { background: #fff8f0; border: 1px solid #f5d0a0; border-radius: 8px; padding: 1.2rem; margin-bottom: 1.5rem; }
|
||||
.intro-lock-notice p { color: var(--text-muted); margin-bottom: 1rem; }
|
||||
.intro-note { font-size: .85rem; color: var(--text-muted); margin-top: .8rem; }
|
||||
|
||||
/* ── Quiz play ────────────────────────────────────────────────── */
|
||||
.quiz-play-wrap { max-width: 680px; margin: 0 auto; }
|
||||
.quiz-play-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.8rem; flex-wrap: wrap; }
|
||||
.quiz-play-title { font-weight: 700; font-size: 1.05rem; flex: 1; }
|
||||
.progress-bar-wrap { flex: 1; height: 8px; background: var(--blue-light); border-radius: 4px; overflow: hidden; min-width: 100px; }
|
||||
.progress-bar { height: 100%; background: var(--blue); transition: width .3s; border-radius: 4px; }
|
||||
.progress-text { font-size: .85rem; color: var(--text-muted); white-space: nowrap; }
|
||||
|
||||
.question-block { background: #fff; border-radius: var(--radius); box-shadow: var(--card-shadow); padding: 2rem 1.8rem; }
|
||||
.question-num { font-size: .82rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: .06em; margin-bottom: .6rem; }
|
||||
.question-text { font-size: 1.2rem; line-height: 1.5; margin-bottom: 1.5rem; }
|
||||
.choices { display: flex; flex-direction: column; gap: .7rem; }
|
||||
.choice-label { display: flex; align-items: center; gap: .9rem; padding: .9rem 1.1rem; border: 2px solid var(--blue-light); border-radius: 8px; cursor: pointer; transition: all .15s; }
|
||||
.choice-label:hover { border-color: var(--blue); background: var(--blue-light); }
|
||||
.choice-label.selected { border-color: var(--blue); background: var(--blue-light); }
|
||||
.choice-label input[type=radio] { position: absolute; opacity: 0; width: 0; }
|
||||
.choice-letter { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 50%; background: var(--blue-light); color: var(--blue); font-weight: 700; font-size: .85rem; flex-shrink: 0; transition: all .15s; }
|
||||
.choice-label.selected .choice-letter { background: var(--blue); color: #fff; }
|
||||
.choice-text { font-size: .95rem; }
|
||||
|
||||
.quiz-nav { display: flex; gap: 1rem; justify-content: space-between; margin-top: 1.5rem; flex-wrap: wrap; }
|
||||
|
||||
/* ── Quiz result ──────────────────────────────────────────────── */
|
||||
.result-wrap { display: flex; justify-content: center; }
|
||||
.result-card { background: #fff; border-radius: var(--radius); box-shadow: var(--card-shadow); padding: 2.5rem 2rem; max-width: 680px; width: 100%; }
|
||||
.result-icon { font-size: 3.5rem; display: block; text-align: center; margin-bottom: .8rem; }
|
||||
.result-card h1 { text-align: center; font-size: 1.5rem; margin-bottom: 1.2rem; }
|
||||
.score-display { display: flex; align-items: baseline; justify-content: center; gap: .5rem; margin-bottom: .8rem; }
|
||||
.score-num { font-size: 4rem; font-weight: 800; color: var(--blue); line-height: 1; }
|
||||
.score-sep { font-size: 2rem; color: var(--text-muted); }
|
||||
.score-total { font-size: 2rem; color: var(--text-muted); }
|
||||
.score-bar-wrap { height: 12px; background: var(--blue-light); border-radius: 6px; overflow: hidden; margin-bottom: .8rem; }
|
||||
.score-bar { height: 100%; border-radius: 6px; transition: width .5s ease; }
|
||||
.score-bar-great { background: #27ae60; }
|
||||
.score-bar-good { background: var(--blue); }
|
||||
.score-bar-ok { background: var(--orange); }
|
||||
.score-bar-low { background: #e74c3c; }
|
||||
.score-comment { text-align: center; font-size: 1.05rem; margin-bottom: .6rem; font-weight: 600; }
|
||||
.result-saved { text-align: center; font-size: .9rem; color: var(--text-muted); margin-bottom: 1.8rem; }
|
||||
|
||||
.detail-title { font-size: 1.05rem; margin-bottom: 1rem; padding-top: 1rem; border-top: 2px solid var(--blue-light); }
|
||||
.answer-review { display: flex; flex-direction: column; gap: .7rem; margin-bottom: 1.8rem; }
|
||||
.answer-item { padding: .9rem 1.1rem; border-radius: 8px; border-left: 4px solid; }
|
||||
.answer-correct { background: #f0fdf4; border-color: #27ae60; }
|
||||
.answer-wrong { background: #fff5f5; border-color: #e74c3c; }
|
||||
.answer-q { font-weight: 600; margin-bottom: .3rem; font-size: .93rem; }
|
||||
.answer-given { font-size: .9rem; }
|
||||
.answer-correct-text { font-size: .88rem; color: #1a6b35; margin-top: .2rem; }
|
||||
.tag-correct { background: #d4f0dc; color: #1a6b35; padding: .1rem .5rem; border-radius: 4px; font-size: .8rem; font-weight: 700; margin-left: .4rem; }
|
||||
.tag-wrong { background: #fde8e8; color: #8a0000; padding: .1rem .5rem; border-radius: 4px; font-size: .8rem; font-weight: 700; margin-left: .4rem; }
|
||||
.result-actions { display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap; }
|
||||
|
||||
/* ── Profile ──────────────────────────────────────────────────── */
|
||||
.page-header { margin-bottom: 2rem; }
|
||||
.page-header h1 { font-size: 1.8rem; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
.profil-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 2.5rem; }
|
||||
.stat-card { background: #fff; border-radius: var(--radius); padding: 1.4rem 1rem; text-align: center; box-shadow: var(--card-shadow); }
|
||||
.stat-num { display: block; font-size: 2.2rem; font-weight: 800; color: var(--blue); }
|
||||
.stat-label { font-size: .85rem; color: var(--text-muted); }
|
||||
.scores-grid { display: flex; flex-direction: column; gap: .8rem; }
|
||||
.score-item { display: flex; align-items: center; gap: 1rem; background: #fff; border-radius: var(--radius); padding: 1rem 1.2rem; box-shadow: var(--card-shadow); }
|
||||
.score-item-icon { font-size: 1.8rem; flex-shrink: 0; }
|
||||
.score-item-body { flex: 1; }
|
||||
.score-item-body strong { display: block; margin-bottom: .2rem; }
|
||||
.mini-score-bar-wrap { height: 6px; background: var(--blue-light); border-radius: 3px; overflow: hidden; margin: .3rem 0; }
|
||||
.mini-score-bar { height: 100%; border-radius: 3px; }
|
||||
.mini-score-bar.great { background: #27ae60; }
|
||||
.mini-score-bar.good { background: var(--blue); }
|
||||
.mini-score-bar.ok { background: var(--orange); }
|
||||
.mini-score-text { font-size: .82rem; color: var(--text-muted); }
|
||||
.history-table-wrap { overflow-x: auto; }
|
||||
.history-table { width: 100%; border-collapse: collapse; background: #fff; border-radius: var(--radius); overflow: hidden; box-shadow: var(--card-shadow); }
|
||||
.history-table th { background: var(--blue); color: #fff; padding: .7rem 1rem; text-align: left; font-size: .9rem; }
|
||||
.history-table td { padding: .6rem 1rem; border-bottom: 1px solid var(--blue-light); font-size: .9rem; }
|
||||
.history-table tr:last-child td { border-bottom: none; }
|
||||
.score-great { color: #1a6b35; font-weight: 700; }
|
||||
.score-good { color: var(--blue); font-weight: 700; }
|
||||
.score-ok { color: #a35800; font-weight: 700; }
|
||||
|
||||
/* ── Responsive ───────────────────────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
.hero h1 { font-size: 1.6rem; }
|
||||
.hero-actions { flex-direction: column; }
|
||||
.levels-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.quiz-grid { grid-template-columns: 1fr; }
|
||||
.profil-stats { grid-template-columns: 1fr; }
|
||||
.nav-toggle { display: block; }
|
||||
.main-nav { display: none; flex-direction: column; width: 100%; background: var(--blue-dark); padding: .5rem 1.5rem 1rem; }
|
||||
header.nav-open .main-nav { display: flex; }
|
||||
.main-nav a { padding: .6rem .8rem; }
|
||||
.members-cta .btn { margin-left: 0; width: 100%; text-align: center; }
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Quiz Linux{% endblock %} — Alpinux</title>
|
||||
<meta name="description" content="{% block description %}Quiz interactifs sur Linux, du niveau découverte à expert — par l'association Alpinux, LUG de Savoie.{% endblock %}">
|
||||
<link rel="icon" type="image/x-icon" href="https://static.alpinux.org/logo/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="https://static.alpinux.org/logo/favicon-32.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="https://static.alpinux.org/logo/favicon-192.png">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="header-inner">
|
||||
<a href="{{ url_for('public.index') }}" class="brand">
|
||||
<img src="https://static.alpinux.org/logo/alpinux-logo.png"
|
||||
alt="Alpinux" width="44" height="44" class="brand-logo">
|
||||
<span>A<strong>l</strong>p<strong>inux</strong> <span class="brand-sub">Quiz</span></span>
|
||||
</a>
|
||||
<nav class="main-nav">
|
||||
<a href="{{ url_for('public.index') }}">Accueil</a>
|
||||
<a href="{{ url_for('public.quiz_list') }}">Tous les quiz</a>
|
||||
{% if user %}
|
||||
<a href="{{ url_for('protected.profil') }}">Mon profil</a>
|
||||
<a href="{{ url_for('auth.logout') }}" class="btn-nav-outline">Déconnexion</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}" class="btn-nav">Se connecter</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
<button class="nav-toggle" aria-label="Menu" onclick="this.closest('header').classList.toggle('nav-open')">☰</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="footer-inner">
|
||||
<span>© Alpinux — LUG de Savoie</span>
|
||||
<span class="footer-links">
|
||||
<a href="https://alpinux.org">alpinux.org</a>
|
||||
<a href="https://wiki.alpinux.org">Wiki</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Accueil{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="hero">
|
||||
<h1>Quiz Linux <span class="hero-sub">par Alpinux</span></h1>
|
||||
<p class="hero-desc">
|
||||
Testez vos connaissances sur Linux — du premier clic sur le bureau
|
||||
jusqu'aux scripts Bash et la sécurité système.
|
||||
</p>
|
||||
<div class="hero-actions">
|
||||
<a href="{{ url_for('public.quiz_list') }}" class="btn btn-primary">Voir tous les quiz</a>
|
||||
{% if not user %}
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-outline">
|
||||
Se connecter pour accéder aux quiz Avancé & Expert
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="levels-section">
|
||||
<h2 class="section-title">Cinq niveaux progressifs</h2>
|
||||
<div class="levels-grid">
|
||||
<div class="level-card level-1">
|
||||
<span class="level-icon">🌱</span>
|
||||
<strong>Découverte</strong>
|
||||
<p>Bureau, internet, navigateur web</p>
|
||||
<span class="badge public">Public</span>
|
||||
</div>
|
||||
<div class="level-card level-2">
|
||||
<span class="level-icon">📘</span>
|
||||
<strong>Débutant</strong>
|
||||
<p>Fichiers, logiciels, Linux Mint</p>
|
||||
<span class="badge public">Public</span>
|
||||
</div>
|
||||
<div class="level-card level-3">
|
||||
<span class="level-icon">⚡</span>
|
||||
<strong>Intermédiaire</strong>
|
||||
<p>Terminal, permissions</p>
|
||||
<span class="badge public">Public</span>
|
||||
</div>
|
||||
<div class="level-card level-4">
|
||||
<span class="level-icon">🔧</span>
|
||||
<strong>Avancé</strong>
|
||||
<p>Git, administration système</p>
|
||||
<span class="badge members">Membres</span>
|
||||
</div>
|
||||
<div class="level-card level-5">
|
||||
<span class="level-icon">🏆</span>
|
||||
<strong>Expert</strong>
|
||||
<p>Sécurité, Bash scripting</p>
|
||||
<span class="badge members">Membres</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="quizzes-preview">
|
||||
<h2 class="section-title">Quiz disponibles</h2>
|
||||
<div class="quiz-grid">
|
||||
{% for quiz in quizzes[:6] %}
|
||||
<a href="{{ url_for('public.quiz_intro', quiz_id=quiz.id) }}" class="quiz-card {% if quiz.members_only %}quiz-card--members{% endif %}">
|
||||
<span class="quiz-icon">{{ quiz.icon }}</span>
|
||||
<div class="quiz-card-body">
|
||||
<h3>{{ quiz.title }}</h3>
|
||||
<p>{{ quiz.description }}</p>
|
||||
<div class="quiz-meta">
|
||||
<span class="level-badge level-badge-{{ quiz.level_id }}">{{ quiz.level }}</span>
|
||||
<span class="quiz-duration">⏱ {{ quiz.duration_min }} min</span>
|
||||
{% if quiz.members_only %}
|
||||
<span class="lock-badge">🔒 Membres</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if quizzes|length > 6 %}
|
||||
<div style="text-align:center;margin-top:2rem">
|
||||
<a href="{{ url_for('public.quiz_list') }}" class="btn btn-outline">Voir les {{ quizzes|length }} quiz</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Mon profil{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>👤 {{ user.name }}</h1>
|
||||
<p class="text-muted">{{ user.email }}</p>
|
||||
</div>
|
||||
|
||||
<div class="profil-stats">
|
||||
<div class="stat-card">
|
||||
<span class="stat-num">{{ stats.quizzes_done }}</span>
|
||||
<span class="stat-label">quiz terminés</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-num">{{ stats.quizzes_total }}</span>
|
||||
<span class="stat-label">quiz disponibles</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-num">{{ stats.attempts }}</span>
|
||||
<span class="stat-label">tentatives au total</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-title">Mes meilleurs scores</h2>
|
||||
{% if best_scores %}
|
||||
<div class="scores-grid">
|
||||
{% for quiz_id, q in quizzes.items() %}
|
||||
{% if quiz_id in best_scores %}
|
||||
{% set bs = best_scores[quiz_id] %}
|
||||
{% set pct = (bs.score / bs.total * 100) | int %}
|
||||
<div class="score-item">
|
||||
<span class="score-item-icon">{{ q.icon }}</span>
|
||||
<div class="score-item-body">
|
||||
<strong>{{ q.title }}</strong>
|
||||
<span class="level-badge level-badge-{{ q.level_id }}">{{ q.level }}</span>
|
||||
<div class="mini-score-bar-wrap">
|
||||
<div class="mini-score-bar {% if pct >= 80 %}great{% elif pct >= 60 %}good{% else %}ok{% endif %}"
|
||||
style="width:{{ pct }}%"></div>
|
||||
</div>
|
||||
<span class="mini-score-text">{{ bs.score }} / {{ bs.total }} ({{ pct }}%)</span>
|
||||
</div>
|
||||
<a href="{{ url_for('public.quiz_play', quiz_id=quiz_id) }}" class="btn btn-sm btn-outline">Rejouer</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Vous n'avez pas encore complété de quiz. <a href="{{ url_for('public.quiz_list') }}">Commencez maintenant !</a></p>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="section-title" style="margin-top:2.5rem">Historique des tentatives</h2>
|
||||
{% if results %}
|
||||
<div class="history-table-wrap">
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr><th>Quiz</th><th>Score</th><th>Date</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in results %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if r['quiz_id'] in quizzes %}
|
||||
{{ quizzes[r['quiz_id']].icon }} {{ quizzes[r['quiz_id']].title }}
|
||||
{% else %}
|
||||
{{ r['quiz_id'] }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="{% if (r['score'] / r['total']) >= 0.8 %}score-great{% elif (r['score'] / r['total']) >= 0.6 %}score-good{% else %}score-ok{% endif %}">
|
||||
{{ r['score'] }} / {{ r['total'] }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted">{{ r['completed_at'][:16] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Aucune tentative enregistrée.</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ quiz.title }}{% endblock %}
|
||||
{% block description %}{{ quiz.description }} Niveau {{ quiz.level }} — {{ quiz.questions|length }} questions.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="quiz-intro-wrap">
|
||||
<div class="quiz-intro-card">
|
||||
<span class="quiz-intro-icon">{{ quiz.icon }}</span>
|
||||
<h1>{{ quiz.title }}</h1>
|
||||
<p class="quiz-intro-desc">{{ quiz.description }}</p>
|
||||
|
||||
<div class="quiz-intro-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Niveau</span>
|
||||
<span class="level-badge level-badge-{{ quiz.level_id }}">{{ quiz.level }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Questions</span>
|
||||
<strong>{{ quiz.questions | length }}</strong>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Durée estimée</span>
|
||||
<strong>{{ quiz.duration_min }} min</strong>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Accès</span>
|
||||
{% if quiz.members_only %}
|
||||
<span class="badge members">🔒 Membres</span>
|
||||
{% else %}
|
||||
<span class="badge public">Public</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if quiz.members_only and not user %}
|
||||
<div class="intro-lock-notice">
|
||||
<p>Ce quiz est réservé aux membres Alpinux.<br>
|
||||
Connectez-vous avec votre compte AlpID pour y accéder.</p>
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-primary">Se connecter avec AlpID</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="{{ url_for('public.quiz_play', quiz_id=quiz.id) }}" class="btn btn-primary btn-lg">
|
||||
Commencer le quiz →
|
||||
</a>
|
||||
{% if user %}
|
||||
<p class="intro-note">Votre score sera enregistré dans votre profil.</p>
|
||||
{% else %}
|
||||
<p class="intro-note">
|
||||
<a href="{{ url_for('auth.login') }}">Connectez-vous</a> pour enregistrer votre score.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Tous les quiz{% endblock %}
|
||||
{% block description %}10 quiz Linux de tous niveaux — découverte, débutant, intermédiaire, avancé, expert.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Tous les quiz</h1>
|
||||
<p>{{ quizzes|length }} quiz — du niveau Découverte à Expert</p>
|
||||
</div>
|
||||
|
||||
{% set levels = [
|
||||
(1, "🌱", "Découverte", "public"),
|
||||
(2, "📘", "Débutant", "public"),
|
||||
(3, "⚡", "Intermédiaire", "public"),
|
||||
(4, "🔧", "Avancé", "members"),
|
||||
(5, "🏆", "Expert", "members"),
|
||||
] %}
|
||||
|
||||
{% for level_id, icon, label, access in levels %}
|
||||
{% set level_quizzes = quizzes | selectattr("level_id", "equalto", level_id) | list %}
|
||||
{% if level_quizzes %}
|
||||
<section class="level-section">
|
||||
<h2 class="level-heading level-heading-{{ level_id }}">
|
||||
{{ icon }} Niveau {{ label }}
|
||||
{% if access == "members" %}
|
||||
<span class="badge members">Membres AlpID</span>
|
||||
{% else %}
|
||||
<span class="badge public">Public</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<div class="quiz-grid">
|
||||
{% for quiz in level_quizzes %}
|
||||
<a href="{{ url_for('public.quiz_intro', quiz_id=quiz.id) }}"
|
||||
class="quiz-card {% if quiz.members_only %}quiz-card--members{% endif %}">
|
||||
<span class="quiz-icon">{{ quiz.icon }}</span>
|
||||
<div class="quiz-card-body">
|
||||
<h3>{{ quiz.title }}</h3>
|
||||
<p>{{ quiz.description }}</p>
|
||||
<div class="quiz-meta">
|
||||
<span class="quiz-duration">⏱ {{ quiz.duration_min }} min</span>
|
||||
<span class="quiz-count">{{ quiz.questions | length }} questions</span>
|
||||
{% if quiz.members_only and not user %}
|
||||
<span class="lock-badge">🔒 Connexion requise</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not user %}
|
||||
<div class="members-cta">
|
||||
<span class="cta-icon">🔒</span>
|
||||
<div>
|
||||
<strong>Quiz Avancé et Expert réservés aux membres</strong>
|
||||
<p>Créez un compte AlpID pour accéder aux 4 quiz avancés.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-primary">Se connecter</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}{{ quiz.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="quiz-play-wrap">
|
||||
<div class="quiz-play-header">
|
||||
<span class="quiz-play-title">{{ quiz.icon }} {{ quiz.title }}</span>
|
||||
<div class="progress-bar-wrap">
|
||||
<div class="progress-bar" id="progress-bar" style="width:0%"></div>
|
||||
</div>
|
||||
<span class="progress-text" id="progress-text">1 / {{ quiz.questions|length }}</span>
|
||||
</div>
|
||||
|
||||
<form method="post" id="quiz-form" action="{{ url_for('public.quiz_play', quiz_id=quiz.id) }}">
|
||||
{% for i, q in quiz.questions | enumerate %}
|
||||
<div class="question-block" data-index="{{ i }}" {% if i > 0 %}hidden{% endif %}>
|
||||
<p class="question-num">Question {{ i + 1 }} / {{ quiz.questions|length }}</p>
|
||||
<h2 class="question-text">{{ q.text }}</h2>
|
||||
<div class="choices">
|
||||
{% for j, choice in q.choices | enumerate %}
|
||||
<label class="choice-label" id="label-{{ i }}-{{ j }}">
|
||||
<input type="radio" name="q{{ i }}" value="{{ j }}"
|
||||
onchange="onChoice({{ i }}, {{ j }})">
|
||||
<span class="choice-letter">{{ ['A','B','C','D'][j] }}</span>
|
||||
<span class="choice-text">{{ choice }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="hidden" name="q{{ i }}" id="ans-{{ i }}" value="">
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="quiz-nav">
|
||||
<button type="button" id="btn-prev" class="btn btn-outline" onclick="navigate(-1)" hidden>
|
||||
← Précédente
|
||||
</button>
|
||||
<button type="button" id="btn-next" class="btn btn-primary" onclick="navigate(1)">
|
||||
Suivante →
|
||||
</button>
|
||||
<button type="submit" id="btn-submit" class="btn btn-success" hidden>
|
||||
Terminer le quiz ✓
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script id="quiz-data" type="application/json">{{ quiz_json | safe }}</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='quiz.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% block title %}Résultat — {{ quiz.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set pct = (result.score / result.total * 100) | int %}
|
||||
<div class="result-wrap">
|
||||
<div class="result-card">
|
||||
<span class="result-icon">
|
||||
{% if pct >= 80 %}🏆
|
||||
{% elif pct >= 60 %}👍
|
||||
{% elif pct >= 40 %}📚
|
||||
{% else %}💪{% endif %}
|
||||
</span>
|
||||
|
||||
<h1>{{ quiz.title }}</h1>
|
||||
|
||||
<div class="score-display">
|
||||
<span class="score-num">{{ result.score }}</span>
|
||||
<span class="score-sep">/</span>
|
||||
<span class="score-total">{{ result.total }}</span>
|
||||
</div>
|
||||
|
||||
<div class="score-bar-wrap">
|
||||
<div class="score-bar score-bar-{% if pct >= 80 %}great{% elif pct >= 60 %}good{% elif pct >= 40 %}ok{% else %}low{% endif %}"
|
||||
style="width: {{ pct }}%"></div>
|
||||
</div>
|
||||
|
||||
<p class="score-comment">
|
||||
{% if pct == 100 %}Score parfait ! 🎉
|
||||
{% elif pct >= 80 %}Excellent résultat !
|
||||
{% elif pct >= 60 %}Bon travail, continuez !
|
||||
{% elif pct >= 40 %}C'est un début, réessayez !
|
||||
{% else %}Pas de souci, la pratique fait le maître !
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if user %}
|
||||
<p class="result-saved">✓ Résultat enregistré dans <a href="{{ url_for('protected.profil') }}">votre profil</a>.</p>
|
||||
{% else %}
|
||||
<p class="result-saved">
|
||||
<a href="{{ url_for('auth.login') }}">Connectez-vous</a> pour enregistrer vos scores.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="detail-title">Détail des réponses</h2>
|
||||
<div class="answer-review">
|
||||
{% for i, q in quiz.questions | enumerate %}
|
||||
{% set user_ans = result.answers.get(i | string, result.answers.get(i, -1)) %}
|
||||
{% set correct = (user_ans == q.answer) %}
|
||||
<div class="answer-item {% if correct %}answer-correct{% else %}answer-wrong{% endif %}">
|
||||
<p class="answer-q">{{ i + 1 }}. {{ q.text }}</p>
|
||||
<p class="answer-given">
|
||||
Votre réponse :
|
||||
<strong>{% if user_ans >= 0 %}{{ q.choices[user_ans] }}{% else %}(sans réponse){% endif %}</strong>
|
||||
{% if correct %}<span class="tag-correct">✓ Correct</span>
|
||||
{% else %}<span class="tag-wrong">✗</span>{% endif %}
|
||||
</p>
|
||||
{% if not correct %}
|
||||
<p class="answer-correct-text">
|
||||
Bonne réponse : <strong>{{ q.choices[q.answer] }}</strong>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="result-actions">
|
||||
<a href="{{ url_for('public.quiz_play', quiz_id=quiz.id) }}" class="btn btn-outline">Recommencer</a>
|
||||
<a href="{{ url_for('public.quiz_list') }}" class="btn btn-primary">Autres quiz</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
# feedback.alpinux.org
|
||||
|
||||
Formulaire de retours et suggestions pour l'association Alpinux.
|
||||
|
||||
## Statut
|
||||
|
||||
À construire. Ce sous-domaine n'est pas encore développé.
|
||||
|
||||
## Idées
|
||||
|
||||
- Formulaire simple (Flask ou statique) pour collecter les retours des participants aux événements
|
||||
- Intégration AlpID optionnelle (retours anonymes possibles)
|
||||
- Stockage SQLite ou envoi par e-mail vers une liste de l'association
|
||||
|
||||
## Structure prévue
|
||||
|
||||
```
|
||||
feedback/
|
||||
├── app.py # Flask app (à créer)
|
||||
├── templates/
|
||||
├── static/
|
||||
├── requirements.txt
|
||||
├── .env.example
|
||||
├── feedback.alpinux.org.vhost.conf
|
||||
└── feedback.alpinux.org.service
|
||||
```
|
||||
1229
home/index.html
1229
home/index.html
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +0,0 @@
|
|||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://alpinux.org/sitemap.xml
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://alpinux.org/</loc>
|
||||
<lastmod>2026-05-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# portail.alpinux.org
|
||||
|
||||
Portail membres de l'association Alpinux.
|
||||
|
||||
## Statut
|
||||
|
||||
Géré directement via **ISPConfig** sur le serveur. Le contenu de ce sous-domaine (Nextcloud, Dolibarr ou autre outil collaboratif) est installé et configuré côté serveur et n'est pas versioné dans ce dépôt.
|
||||
|
||||
## Accès
|
||||
|
||||
- URL : https://portail.alpinux.org
|
||||
- Authentification : AlpID (SSO Keycloak — https://alpid.alpinux.org)
|
||||
|
||||
## Notes
|
||||
|
||||
Ce dossier est un espace réservé. Si un développement spécifique au portail est nécessaire (thème, plugin, scripts de déploiement), il sera ajouté ici.
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# static.alpinux.org a déménagé
|
||||
|
||||
Le CDN et son tableau de bord Flask ne sont plus ici. Ils vivent dans leur propre
|
||||
dépôt, avec l'arborescence remontée à la racine (`logo/`, `app/`, `scripts/` et non
|
||||
plus `static/logo/`…) :
|
||||
|
||||
**https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-static**
|
||||
|
||||
```bash
|
||||
git clone git@gitea.alpinux.org:alpinux.cedrica5l/alpinux-static.git
|
||||
```
|
||||
|
||||
Localement : `~/Projects/alpinux.static/`.
|
||||
|
||||
## Pourquoi
|
||||
|
||||
Un domaine, un dépôt — la même bascule que pour le wiki, `admin` et `dynamic`.
|
||||
Le contenu présent ici n'était de toute façon qu'un README : les assets et l'app
|
||||
étaient déjà suivis ailleurs, et ce dossier décrivait un déploiement (`a2ensite`,
|
||||
copie manuelle du vhost) qui ne correspond plus à ce qui tourne.
|
||||
|
||||
## Ce qui change concrètement
|
||||
|
||||
Le déploiement se fait depuis le dépôt `alpinux-static`, par rsync :
|
||||
|
||||
```bash
|
||||
cd ~/Projects/alpinux.static
|
||||
scripts/deploy-app.sh # app Flask → /opt/static-cdn + restart du service static-cdn
|
||||
scripts/push-assets.sh # logo/, wiki/, stats/, error/ → web root ISPConfig
|
||||
```
|
||||
|
||||
**Aucun push dans ce dépôt-ci ne publie plus quoi que ce soit sur static.alpinux.org.**
|
||||
Les URLs `https://static.alpinux.org/logo/…` référencées dans `home/` et `dynamic/`
|
||||
restent valides, elles pointent vers ce CDN.
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
# Le wiki a déménagé
|
||||
|
||||
Le contenu de **wiki.alpinux.org** n'est plus ici. Il vit maintenant dans son propre
|
||||
dépôt, avec le reste de l'arborescence remontée à la racine (`docs/` et non plus
|
||||
`wiki/docs/`) :
|
||||
|
||||
**https://gitea.alpinux.org/alpinux.cedrica5l/alpinux-wiki**
|
||||
|
||||
```bash
|
||||
git clone git@gitea.alpinux.org:alpinux.cedrica5l/alpinux-wiki.git
|
||||
```
|
||||
|
||||
Localement : `~/Projects/alpinux.wiki/` — c'est aussi le coffre Obsidian à ouvrir
|
||||
pour rédiger.
|
||||
|
||||
## Pourquoi
|
||||
|
||||
La migration du monorepo vers un dépôt par domaine, faite le 2026-05-03, n'avait pas été
|
||||
suivie d'effet pour le wiki : le `repo_url` de `mkdocs.yml` — le bouton « Contribuer sur
|
||||
Gitea » de chaque page — pointait encore ici, et y ramenait les contributeurs. Le wiki a
|
||||
donc continué d'être modifié dans ce dépôt jusqu'au 2026-09-19, date à laquelle la bascule
|
||||
a été terminée.
|
||||
|
||||
Un dépôt séparé permet surtout d'ouvrir l'écriture sur la documentation sans donner accès
|
||||
au reste : quelqu'un qui corrige une faute de frappe n'a pas besoin des scripts de
|
||||
déploiement ni des configurations serveur.
|
||||
|
||||
## Ce qui change concrètement
|
||||
|
||||
Le déploiement est automatique : un `git push` sur `main` du dépôt `alpinux-wiki` suffit,
|
||||
un webhook construit et met le site en ligne. **Aucun push dans ce dépôt-ci ne publie plus
|
||||
quoi que ce soit.**
|
||||
|
||||
La procédure complète : https://wiki.alpinux.org/technique/deploiement-wiki/
|
||||
Loading…
Reference in a new issue