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:
Cédrix 2026-09-20 09:52:02 +02:00
parent e6b2c71ec0
commit 61a3a453be
34 changed files with 30 additions and 3709 deletions

118
README.md
View file

@ -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). Ce dépôt a porté tous les sous-domaines d'**Alpinux** (LUG de Savoie) jusqu'à la
Gitea : https://gitea.alpinux.org/alpinux.cedrica5l/alpinux.site.2026 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 **Il ne publie plus rien.** Un push ici ne met aucun site en ligne.
> 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/`.
--- ---
## 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` |
``` Chaque dépôt porte son propre README : comment le lancer en local, comment il se déploie,
org.alpinux.owni/ et ses pièges. Le déploiement, lui, est partout le même principe — un push sur `main`,
├── wiki/ → déménagé dans le dépôt alpinux-wiki (voir wiki/README.md) un webhook Gitea, un script sur le serveur.
├── 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)
```
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 : `scripts/owni-certs-sync.sh` — la synchronisation des certificats Let's Encrypt vers les
- utilise **AlpID** (SSO Keycloak) pour l'authentification quand nécessaire copies qu'ISPConfig ne rafraîchit pas. Outil du serveur, rattaché à aucun domaine.
- a son propre environnement Python (`venv/`) et son fichier `.env` (non versionés)
Les projets sont liés par le SSO partagé, le serveur commun, et l'identité visuelle définie dans https://wiki.alpinux.org/communication/. 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é.
## 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.

View file

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

View file

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

View file

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

View file

@ -1,4 +0,0 @@
flask>=3.0
authlib>=1.3
requests>=2.31
gunicorn>=21.0

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
flask>=3.0
authlib>=1.3
requests>=2.31

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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é &amp; 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 %}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://alpinux.org/sitemap.xml

View file

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

View file

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

View file

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

View file

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