feat: dynamic.alpinux.org — quiz interactifs Flask + AlpID OIDC
App Flask complète pour https://dynamic.alpinux.org : - 10 quiz Linux, 5 niveaux (Découverte → Expert), 50+ questions - Public : Découverte, Débutant, Intermédiaire (6 quiz) - Membres AlpID : Avancé, Expert (4 quiz — Git, Admin, Sécurité, Bash) - Navigation question par question avec avance automatique après choix - Score calculé côté serveur, enregistré en SQLite si connecté - Page profil : meilleurs scores par quiz + historique des tentatives Authentification : - OIDC via authlib + AlpID (Keycloak), SSO partagé avec Gitea/Nextcloud - Décorateur @login_required, redirection post-login sur l'URL d'origine - /auth/login, /auth/callback, /auth/logout Structure : - dynamic/app.py, db.py, quiz.py, auth_utils.py - dynamic/routes/ (public.py, auth.py, protected.py) - dynamic/templates/ (base, index, quiz/*, profil/) - dynamic/static/ (style.css thème Alpinux, quiz.js vanilla) - dynamic/data/quizzes.json (source de vérité des questions) - dynamic/.env.example Infrastructure : - scripts/dynamic.alpinux.org.vhost.conf (Apache reverse proxy) - scripts/dynamic.alpinux.org.service (systemd Gunicorn) - docs/technique/deploiement-dynamic.md (procédure complète) - mkdocs.yml : page de déploiement ajoutée à la nav Technique - .gitignore : exclut venv/ et .env Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
557e363480
commit
27847dfad3
25 changed files with 2040 additions and 0 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1,5 +1,9 @@
|
|||
site/
|
||||
__pycache__/
|
||||
dynamic/__pycache__/
|
||||
dynamic/routes/__pycache__/
|
||||
dynamic/venv/
|
||||
dynamic/.env
|
||||
|
||||
# Assets binaires — générés par scripts/build-assets.py
|
||||
# Hébergés sur https://static.alpinux.org/logo/
|
||||
|
|
|
|||
234
docs/technique/deploiement-dynamic.md
Normal file
234
docs/technique/deploiement-dynamic.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
---
|
||||
description: Déploiement de dynamic.alpinux.org — app Flask, Gunicorn, Apache reverse proxy, AlpID OIDC.
|
||||
---
|
||||
|
||||
# Déploiement de dynamic.alpinux.org
|
||||
|
||||
Application Flask de quiz interactifs, partiellement publique et partiellement réservée aux membres AlpID.
|
||||
|
||||
!!! note "Pour qui ?"
|
||||
Procédure pour les mainteneurs avec accès SSH au serveur.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Navigateur
|
||||
│ HTTPS
|
||||
▼
|
||||
Apache (reverse proxy, SSL)
|
||||
│ HTTP 127.0.0.1:5001
|
||||
▼
|
||||
Gunicorn (2 workers)
|
||||
│
|
||||
▼
|
||||
Flask app (dynamic/)
|
||||
│ │
|
||||
▼ ▼
|
||||
SQLite AlpID OIDC
|
||||
(scores.db) (alpid.alpinux.org)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation (première fois)
|
||||
|
||||
### 1. Cloner le dépôt sur le serveur
|
||||
|
||||
```bash
|
||||
ssh alpinux@alpinux.org
|
||||
git clone https://gitea.alpinux.org/alpinux.cedrica5l/alpinux.site.2026.git \
|
||||
/home/alpinux/site
|
||||
```
|
||||
|
||||
### 2. Créer l'environnement Python
|
||||
|
||||
```bash
|
||||
cd /home/alpinux/site/dynamic
|
||||
python3 -m venv venv
|
||||
venv/bin/pip install -r requirements.txt gunicorn
|
||||
```
|
||||
|
||||
### 3. Configurer les variables d'environnement
|
||||
|
||||
```bash
|
||||
sudo mkdir /etc/dynamic-alpinux
|
||||
sudo cp /home/alpinux/site/dynamic/.env.example /etc/dynamic-alpinux/config.env
|
||||
sudo nano /etc/dynamic-alpinux/config.env
|
||||
```
|
||||
|
||||
Remplissez les valeurs :
|
||||
|
||||
```bash
|
||||
SECRET_KEY=<générer avec : python3 -c "import secrets; print(secrets.token_hex(32))">
|
||||
ALPID_CLIENT_ID=dynamic-alpinux
|
||||
ALPID_CLIENT_SECRET=<obtenir depuis la console Keycloak AlpID>
|
||||
ALPID_DISCOVERY_URL=https://alpid.alpinux.org/realms/alpinux/.well-known/openid-configuration
|
||||
DATABASE=/var/lib/dynamic-alpinux/scores.db
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo chmod 600 /etc/dynamic-alpinux/config.env
|
||||
```
|
||||
|
||||
### 4. Créer les répertoires de données et de logs
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/lib/dynamic-alpinux
|
||||
sudo mkdir -p /var/log/dynamic-alpinux
|
||||
sudo chown alpinux:alpinux /var/lib/dynamic-alpinux /var/log/dynamic-alpinux
|
||||
```
|
||||
|
||||
### 5. Configurer AlpID (Keycloak)
|
||||
|
||||
Dans la console d'administration Keycloak (`https://alpid.alpinux.org`) :
|
||||
|
||||
1. **Clients** → **Créer un client**
|
||||
2. **Client ID** : `dynamic-alpinux`
|
||||
3. **Client authentication** : activé (pour obtenir un `client_secret`)
|
||||
4. **Valid redirect URIs** : `https://dynamic.alpinux.org/auth/callback`
|
||||
5. **Web origins** : `https://dynamic.alpinux.org`
|
||||
6. Notez le **Client secret** dans l'onglet *Credentials*
|
||||
|
||||
### 6. Installer le service systemd
|
||||
|
||||
```bash
|
||||
sudo cp /home/alpinux/site/scripts/dynamic.alpinux.org.service \
|
||||
/etc/systemd/system/dynamic-alpinux.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now dynamic-alpinux
|
||||
sudo systemctl status dynamic-alpinux
|
||||
```
|
||||
|
||||
### 7. Configurer Apache
|
||||
|
||||
```bash
|
||||
# Activer les modules nécessaires
|
||||
sudo a2enmod proxy proxy_http headers ssl
|
||||
|
||||
# Copier le vhost
|
||||
sudo cp /home/alpinux/site/scripts/dynamic.alpinux.org.vhost.conf \
|
||||
/etc/apache2/sites-available/dynamic.alpinux.org.conf
|
||||
sudo a2ensite dynamic.alpinux.org
|
||||
sudo apachectl configtest
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
### 8. Obtenir le certificat SSL
|
||||
|
||||
```bash
|
||||
sudo certbot --apache -d dynamic.alpinux.org
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mise à jour
|
||||
|
||||
```bash
|
||||
ssh alpinux@alpinux.org
|
||||
cd /home/alpinux/site
|
||||
git pull
|
||||
cd dynamic
|
||||
venv/bin/pip install -r requirements.txt # si requirements.txt a changé
|
||||
sudo systemctl restart dynamic-alpinux
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gestion du service
|
||||
|
||||
| Action | Commande |
|
||||
|---|---|
|
||||
| Démarrer | `sudo systemctl start dynamic-alpinux` |
|
||||
| Arrêter | `sudo systemctl stop dynamic-alpinux` |
|
||||
| Redémarrer | `sudo systemctl restart dynamic-alpinux` |
|
||||
| État | `sudo systemctl status dynamic-alpinux` |
|
||||
| Logs en direct | `sudo journalctl -u dynamic-alpinux -f` |
|
||||
| Logs accès | `tail -f /var/log/dynamic-alpinux/access.log` |
|
||||
| Logs erreurs | `tail -f /var/log/dynamic-alpinux/error.log` |
|
||||
|
||||
---
|
||||
|
||||
## Structure de l'application
|
||||
|
||||
```
|
||||
dynamic/
|
||||
├── app.py ← point d'entrée Flask
|
||||
├── auth_utils.py ← décorateur @login_required
|
||||
├── db.py ← SQLite (scores)
|
||||
├── quiz.py ← chargement du JSON
|
||||
├── requirements.txt
|
||||
├── .env.example
|
||||
├── data/
|
||||
│ └── quizzes.json ← toutes les questions (source de vérité)
|
||||
├── routes/
|
||||
│ ├── public.py ← accueil, liste, jeu, résultat
|
||||
│ ├── auth.py ← /auth/login, /auth/callback, /auth/logout
|
||||
│ └── protected.py ← /profil/
|
||||
├── static/
|
||||
│ ├── style.css
|
||||
│ └── quiz.js
|
||||
└── templates/
|
||||
├── base.html
|
||||
├── index.html
|
||||
├── quiz/
|
||||
│ ├── intro.html
|
||||
│ ├── play.html
|
||||
│ └── result.html
|
||||
└── profil/
|
||||
└── index.html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Accès public vs membres
|
||||
|
||||
| URL | Accès |
|
||||
|---|---|
|
||||
| `/` | Public |
|
||||
| `/quiz/` | Public (aperçu de tous les quiz) |
|
||||
| `/quiz/<id>/` | Public (page intro) |
|
||||
| `/quiz/<id>/jouer` | Public si `members_only: false`, sinon AlpID requis |
|
||||
| `/profil/` | AlpID requis |
|
||||
| `/auth/login` | Redirect → AlpID |
|
||||
| `/auth/callback` | Retour OIDC (interne) |
|
||||
|
||||
Les quiz avancés (niveau 4) et experts (niveau 5) ont `"members_only": true` dans `data/quizzes.json`.
|
||||
|
||||
---
|
||||
|
||||
## Ajouter un quiz
|
||||
|
||||
Éditez `dynamic/data/quizzes.json` et ajoutez un objet au tableau :
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "mon-quiz",
|
||||
"title": "Titre du quiz",
|
||||
"description": "Description courte.",
|
||||
"level": "Intermédiaire",
|
||||
"level_id": 3,
|
||||
"members_only": false,
|
||||
"duration_min": 5,
|
||||
"icon": "🐧",
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"text": "Question ?",
|
||||
"choices": ["Réponse A", "Réponse B", "Réponse C", "Réponse D"],
|
||||
"answer": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `level_id` : 1=Découverte, 2=Débutant, 3=Intermédiaire, 4=Avancé, 5=Expert
|
||||
- `answer` : index 0-based de la bonne réponse dans `choices`
|
||||
- `members_only` : `true` pour restreindre aux membres AlpID
|
||||
|
||||
Après modification, redémarrez le service pour recharger le JSON :
|
||||
|
||||
```bash
|
||||
sudo systemctl restart dynamic-alpinux
|
||||
```
|
||||
15
dynamic/.env.example
Normal file
15
dynamic/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# 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=dynamic-alpinux
|
||||
ALPID_CLIENT_SECRET=
|
||||
ALPID_DISCOVERY_URL=https://alpid.alpinux.org/realms/alpinux/.well-known/openid-configuration
|
||||
|
||||
# Base de données SQLite
|
||||
DATABASE=/var/lib/dynamic-alpinux/scores.db
|
||||
|
||||
# Mode debug (mettre à 0 en production)
|
||||
FLASK_DEBUG=0
|
||||
46
dynamic/app.py
Normal file
46
dynamic/app.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
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')
|
||||
16
dynamic/auth_utils.py
Normal file
16
dynamic/auth_utils.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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
|
||||
679
dynamic/data/quizzes.json
Normal file
679
dynamic/data/quizzes.json
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
[
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
61
dynamic/db.py
Normal file
61
dynamic/db.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import sqlite3
|
||||
import os
|
||||
from flask import g
|
||||
|
||||
|
||||
def get_db():
|
||||
if 'db' not in g:
|
||||
db_path = os.environ.get('DATABASE', '/var/lib/dynamic-alpinux/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
|
||||
31
dynamic/quiz.py
Normal file
31
dynamic/quiz.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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"]]
|
||||
3
dynamic/requirements.txt
Normal file
3
dynamic/requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
flask>=3.0
|
||||
authlib>=1.3
|
||||
requests>=2.31
|
||||
0
dynamic/routes/__init__.py
Normal file
0
dynamic/routes/__init__.py
Normal file
34
dynamic/routes/auth.py
Normal file
34
dynamic/routes/auth.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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'))
|
||||
37
dynamic/routes/protected.py
Normal file
37
dynamic/routes/protected.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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)
|
||||
85
dynamic/routes/public.py
Normal file
85
dynamic/routes/public.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
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())
|
||||
49
dynamic/static/quiz.js
Normal file
49
dynamic/static/quiz.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/* 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);
|
||||
})();
|
||||
219
dynamic/static/style.css
Normal file
219
dynamic/static/style.css
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
/* ── 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; }
|
||||
}
|
||||
59
dynamic/templates/base.html
Normal file
59
dynamic/templates/base.html
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
<!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>
|
||||
<a href="https://mamot.fr/@alpinux">Mastodon</a>
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
83
dynamic/templates/index.html
Normal file
83
dynamic/templates/index.html
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
{% 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 %}
|
||||
83
dynamic/templates/profil/index.html
Normal file
83
dynamic/templates/profil/index.html
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
{% 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 %}
|
||||
55
dynamic/templates/quiz/intro.html
Normal file
55
dynamic/templates/quiz/intro.html
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{% 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 %}
|
||||
64
dynamic/templates/quiz/list.html
Normal file
64
dynamic/templates/quiz/list.html
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{% 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 %}
|
||||
52
dynamic/templates/quiz/play.html
Normal file
52
dynamic/templates/quiz/play.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{% 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 %}
|
||||
73
dynamic/templates/quiz/result.html
Normal file
73
dynamic/templates/quiz/result.html
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
{% 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 %}
|
||||
|
|
@ -87,6 +87,7 @@ nav:
|
|||
- guides/ventoy.md
|
||||
- Technique:
|
||||
- technique/deploiement-wiki.md
|
||||
- technique/deploiement-dynamic.md
|
||||
- technique/git.md
|
||||
- technique/nextcloud.md
|
||||
- technique/matrix.md
|
||||
|
|
|
|||
24
scripts/dynamic.alpinux.org.service
Normal file
24
scripts/dynamic.alpinux.org.service
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Systemd unit pour l'app Flask dynamic.alpinux.org
|
||||
# Copier dans /etc/systemd/system/dynamic-alpinux.service
|
||||
# puis : sudo systemctl enable --now dynamic-alpinux
|
||||
|
||||
[Unit]
|
||||
Description=Alpinux Dynamic — Quiz interactifs (Flask + Gunicorn)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=alpinux
|
||||
Group=alpinux
|
||||
WorkingDirectory=/home/alpinux/dynamic
|
||||
EnvironmentFile=/etc/dynamic-alpinux/config.env
|
||||
ExecStart=/home/alpinux/dynamic/venv/bin/gunicorn \
|
||||
--workers 2 \
|
||||
--bind 127.0.0.1:5001 \
|
||||
--access-logfile /var/log/dynamic-alpinux/access.log \
|
||||
--error-logfile /var/log/dynamic-alpinux/error.log \
|
||||
app:app
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
33
scripts/dynamic.alpinux.org.vhost.conf
Normal file
33
scripts/dynamic.alpinux.org.vhost.conf
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Apache vhost pour dynamic.alpinux.org
|
||||
# L'app Flask tourne derrière Gunicorn sur 127.0.0.1:5001
|
||||
|
||||
<VirtualHost *:80>
|
||||
ServerName dynamic.alpinux.org
|
||||
Redirect permanent / https://dynamic.alpinux.org/
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ServerName dynamic.alpinux.org
|
||||
|
||||
# ── Proxy vers Gunicorn ──────────────────────────────────────
|
||||
ProxyPreserveHost On
|
||||
ProxyPass / http://127.0.0.1:5001/
|
||||
ProxyPassReverse / http://127.0.0.1:5001/
|
||||
|
||||
# En-têtes transmis à Flask
|
||||
RequestHeader set X-Forwarded-Proto "https"
|
||||
RequestHeader set X-Forwarded-For "%{REMOTE_ADDR}s"
|
||||
|
||||
# ── Sécurité ─────────────────────────────────────────────────
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
|
||||
# ── Logs ─────────────────────────────────────────────────────
|
||||
ErrorLog /var/log/apache2/dynamic.alpinux.org-error.log
|
||||
CustomLog /var/log/apache2/dynamic.alpinux.org-access.log combined
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/dynamic.alpinux.org/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/dynamic.alpinux.org/privkey.pem
|
||||
</VirtualHost>
|
||||
Loading…
Reference in a new issue