Semaine 15, Jour 4

This commit is contained in:
gauvainboiche
2026-10-09 09:37:39 +02:00
parent 3b304bcaa4
commit 82b039bfbb
15 changed files with 407 additions and 354 deletions
Binary file not shown.
+173 -120
View File
@@ -1,7 +1,6 @@
"""Bloc&Co — gestion des adhérents d'une salle d'escalade. """Bloc&Co — climbing gym member management.
Squelette de l'atelier. Complétez tout ce qui est marqué « À FAIRE ». Run: python app.py then open http://127.0.0.1:5000
Lancement : python app.py puis http://127.0.0.1:5000
""" """
import sqlite3 import sqlite3
@@ -18,15 +17,21 @@ DB = BASE / "blocandco.db"
app = Flask(__name__) app = Flask(__name__)
app.secret_key = "cle-de-developpement-a-changer" app.secret_key = "cle-de-developpement-a-changer"
levelX = ["debutant", "intermediaire", "confirme"] LEVELS = ["debutant", "intermediaire", "confirme"]
MIN_PASSWORD_LENGTH = 12
# Compared against when the e-mail is unknown, so that both login failures
# take about the same time (limits account enumeration by timing).
DUMMY_HASH = generate_password_hash("mot-de-passe-factice")
# ========================================================================== # ==========================================================================
# FOURNI — plomberie de la base de données. Ne rien modifier ici. # Database plumbing
# ========================================================================== # ==========================================================================
def get_db(): def get_db():
"""Une connexion par requête, rangée dans g et fermée automatiquement.""" """One connection per request, stored in g and closed automatically."""
if "db" not in g: if "db" not in g:
g.db = sqlite3.connect(DB) g.db = sqlite3.connect(DB)
g.db.row_factory = sqlite3.Row g.db.row_factory = sqlite3.Row
@@ -41,13 +46,13 @@ def close_db(exception):
def init_db(): def init_db():
"""Crée la base au premier lancement et sème un compte administrateur.""" """Create the database on first launch and seed an administrator account."""
if DB.exists(): if DB.exists():
return return
con = sqlite3.connect(DB) con = sqlite3.connect(DB)
con.executescript((BASE / "schema.sql").read_text(encoding="utf-8")) con.executescript((BASE / "schema.sql").read_text(encoding="utf-8"))
con.execute( con.execute(
"INSERT INTO adherents (lastname, firstname, email, password, level, role," "INSERT INTO members (lastname, firstname, email, password, level, role,"
" sub_date) VALUES (?, ?, ?, ?, ?, ?, ?)", " sub_date) VALUES (?, ?, ?, ?, ?, ?, ?)",
("Martin", "Claire", "admin@blocandco.fr", ("Martin", "Claire", "admin@blocandco.fr",
generate_password_hash("Admin2026!"), "confirme", "admin", generate_password_hash("Admin2026!"), "confirme", "admin",
@@ -59,160 +64,208 @@ def init_db():
@app.context_processor @app.context_processor
def injecter_utilisateur(): def inject_user():
"""Rend la variable `utilisateur` disponible dans tous les templates.""" """Make the `user` variable available in every template."""
return {"utilisateur": utilisateur_courant()} return {"user": current_user()}
# ========================================================================== # ==========================================================================
# EXERCICE 6 — qui est connecté ? # Who is logged in, and access control
# ========================================================================== # ==========================================================================
def utilisateur_courant(): def current_user():
"""Renvoie la ligne de l'adhérent connecté, ou None si personne. """Return the logged-in member's row, or None if nobody is logged in."""
user_id = session.get("user_id")
À FAIRE : if user_id is None:
- si "user_id" n'est pas dans `session`, renvoyer None
- sinon, aller chercher la ligne correspondante dans la base
"""
return None return None
return get_db().execute(
"SELECT * FROM members WHERE id = ?", (user_id,)
).fetchone()
# ========================================================================== def login_required(view):
# EXERCICE 7 — protéger une page """Block the view if nobody is logged in."""
# ========================================================================== @wraps(view)
def login_requis(vue):
"""Interdit l'accès à la vue si personne n'est connecté.
À FAIRE : si `utilisateur_courant()` vaut None, afficher un message
et rediriger vers la page de connexion. Sinon, appeler la vue.
"""
@wraps(vue)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
return vue(*args, **kwargs) if current_user() is None:
flash("Connectez-vous pour accéder à cette page.", "error")
return redirect(url_for("login"))
return view(*args, **kwargs)
return wrapper
def admin_required(view):
"""Block the view unless the user is an administrator."""
@wraps(view)
def wrapper(*args, **kwargs):
user = current_user()
if user is None:
flash("Connectez-vous pour accéder à cette page.", "error")
return redirect(url_for("login"))
if user["role"] != "admin":
flash("Accès réservé aux administrateurs.", "error")
return redirect(url_for("home"))
return view(*args, **kwargs)
return wrapper return wrapper
# ========================================================================== # ==========================================================================
# EXERCICE 9 — réserver une page aux administrateurs # Registration form validation
# ========================================================================== # ==========================================================================
def admin_requis(vue): def validate_registration(lastname, firstname, email, password, confirmation):
"""Interdit l'accès à la vue si l'utilisateur n'est pas administrateur. """Return the list of error messages. An empty list means the form is valid."""
errors = []
À FAIRE : refuser les visiteurs non connectés, PUIS refuser ceux dont if not lastname:
le rôle n'est pas "admin". Deux messages différents. errors.append("Le nom est obligatoire.")
""" if not firstname:
@wraps(vue) errors.append("Le prénom est obligatoire.")
def wrapper(*args, **kwargs): if "@" not in email or "." not in email.split("@", 1)[1]:
return vue(*args, **kwargs) errors.append("L'adresse e-mail n'est pas valide.")
return wrapper if len(password) < MIN_PASSWORD_LENGTH:
errors.append(
f"Le mot de passe doit faire au moins {MIN_PASSWORD_LENGTH} caractères."
)
if not any(c.isdigit() for c in password):
errors.append("Le mot de passe doit contenir au moins un chiffre.")
if not any(c.isupper() for c in password):
errors.append("Le mot de passe doit contenir au moins une majuscule.")
if password != confirmation:
errors.append("Les mots de passe ne correspondent pas.")
return errors
# ========================================================================== # ==========================================================================
# EXERCICE 5 — valider le formulaire d'inscription # Routes (URLs and messages stay in French: they are the user interface)
# ==========================================================================
def valider_inscription(lastname, firstname, email, mdp, confirmation):
"""Renvoie la liste des messages d'erreur. Liste vide = tout est bon.
À FAIRE, dans cet ordre :
- lastname et prélastname non vides
- email contenant "@" et un point après le "@"
- mot de passe d'au moins 12 caractères
- au moins un chiffre, au moins une majuscule
- les deux mots de passe identiques
"""
erreurs = []
return erreurs
# ==========================================================================
# ROUTES
# ========================================================================== # ==========================================================================
@app.route("/") @app.route("/")
def accueil(): def home():
"""EXERCICE 10 — afficher le lastnamebre d'adhérents inscrits. member_count = get_db().execute("SELECT COUNT(*) FROM members").fetchone()[0]
return render_template("home.html", member_count=member_count)
À FAIRE : remplacer le 0 par le vrai total, lu dans la base
avec un SELECT COUNT(*).
"""
nb = 0
return render_template("accueil.html", nb_adherents=nb)
@app.route("/inscription", methods=["GET", "POST"]) @app.route("/inscription", methods=["GET", "POST"])
def inscription(): def register():
"""EXERCICE 5 — créer un compte.
À FAIRE :
- en POST, récupérer les champs du formulaire
- les valider avec valider_inscription()
- refuser une adresse e-mail déjà présente dans la base
- hacher le mot de passe avec generate_password_hash()
- insérer l'adhérent avec le rôle "adherent"
- rediriger vers la page de connexion
"""
if request.method == "POST": if request.method == "POST":
pass lastname = request.form.get("lastname", "").strip()
return render_template("inscription.html") firstname = request.form.get("firstname", "").strip()
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
confirmation = request.form.get("confirmation", "")
errors = validate_registration(
lastname, firstname, email, password, confirmation
)
db = get_db()
if not errors and db.execute(
"SELECT 1 FROM members WHERE email = ?", (email,)
).fetchone():
errors.append("Cette adresse e-mail est déjà utilisée.")
if not errors:
try:
db.execute(
"INSERT INTO members (lastname, firstname, email, password,"
" level, role, sub_date) VALUES (?, ?, ?, ?, ?, ?, ?)",
(lastname, firstname, email, generate_password_hash(password),
LEVELS[0], "adherent", date.today().isoformat()),
)
db.commit()
except sqlite3.IntegrityError:
# Two simultaneous sign-ups with the same e-mail.
errors.append("Cette adresse e-mail est déjà utilisée.")
if not errors:
flash("Compte créé. Vous pouvez vous connecter.", "success")
return redirect(url_for("login"))
for error in errors:
flash(error, "error")
# The password is deliberately not sent back to the form.
return render_template(
"register.html",
lastname=lastname, firstname=firstname, email=email,
)
return render_template("register.html")
@app.route("/connexion", methods=["GET", "POST"]) @app.route("/connexion", methods=["GET", "POST"])
def connexion(): def login():
"""EXERCICE 6 — se connecter.
À FAIRE :
- chercher l'adhérent par son e-mail
- vérifier le mot de passe avec check_password_hash()
- en cas d'échec, afficher LE MÊME message que l'adresse existe ou non
- en cas de succès, vider la session puis y stocker l'identifiant
"""
if request.method == "POST": if request.method == "POST":
pass email = request.form.get("email", "").strip().lower()
return render_template("connexion.html") password = request.form.get("password", "")
member = get_db().execute(
"SELECT * FROM members WHERE email = ?", (email,)
).fetchone()
stored_hash = member["password"] if member else DUMMY_HASH
password_ok = check_password_hash(stored_hash, password)
if member is None or not password_ok:
# Same message whether the address exists or not.
flash("Identifiants incorrects.", "error")
return render_template("login.html", email=email)
session.clear()
session["user_id"] = member["id"]
return redirect(url_for("profile"))
return render_template("login.html")
@app.route("/deconnexion") @app.route("/deconnexion")
def deconnexion(): def logout():
"""EXERCICE 7 — se déconnecter. session.clear()
return redirect(url_for("home"))
À FAIRE : vider la session et rediriger vers l'accueil.
"""
return redirect(url_for("accueil"))
@app.route("/profil", methods=["GET", "POST"]) @app.route("/profil", methods=["GET", "POST"])
@login_requis @login_required
def profil(): def profile():
"""EXERCICE 8 — consulter et modifier son profil.
À FAIRE :
- en POST, mettre à jour lastname, prélastname et level de l'utilisateur CONNECTÉ
- refuser un level qui n'est pas dans levelX
"""
if request.method == "POST": if request.method == "POST":
pass lastname = request.form.get("lastname", "").strip()
return render_template("profil.html", levelx=levelX) firstname = request.form.get("firstname", "").strip()
level = request.form.get("level", "")
if not lastname or not firstname:
flash("Le nom et le prénom sont obligatoires.", "error")
elif level not in LEVELS:
flash("Niveau invalide.", "error")
else:
db = get_db()
# The row to update comes from the session, never from the form.
db.execute(
"UPDATE members SET lastname = ?, firstname = ?, level = ?"
" WHERE id = ?",
(lastname, firstname, level, session["user_id"]),
)
db.commit()
flash("Profil mis à jour.", "success")
return redirect(url_for("profile"))
return render_template("profile.html", levels=LEVELS)
@app.route("/admin") @app.route("/admin")
@admin_requis @admin_required
def admin(): def admin():
"""EXERCICE 9 — lister les adhérents. members = get_db().execute(
"SELECT * FROM members ORDER BY sub_date DESC, id DESC"
À FAIRE : lire tous les adhérents, les plus récents d'abord. ).fetchall()
""" return render_template("admin.html", members=members)
adherents = []
return render_template("admin.html", adherents=adherents)
@app.route("/admin/promouvoir/<int:adherent_id>", methods=["POST"]) @app.route("/admin/promouvoir/<int:member_id>", methods=["POST"])
@admin_requis @admin_required
def promouvoir(adherent_id): def promote(member_id):
"""EXERCICE 9 (suite) — promouvoir un adhérent administrateur.""" db = get_db()
cursor = db.execute(
"UPDATE members SET role = 'admin' WHERE id = ?", (member_id,)
)
db.commit()
if cursor.rowcount:
flash("Adhérent promu administrateur.", "success")
else:
flash("Adhérent introuvable.", "error")
return redirect(url_for("admin")) return redirect(url_for("admin"))
Binary file not shown.
@@ -1,9 +1,9 @@
-- Schéma de la base « Bloc&Co » -- Schéma de la base « Bloc&Co »
-- Exécuté une seule fois, au premier démarrage de l'application. -- Exécuté une seule fois, au premier démarrage de l'application.
DROP TABLE IF EXISTS adherents; DROP TABLE IF EXISTS members;
CREATE TABLE adherents ( CREATE TABLE members (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
lastname TEXT NOT NULL, lastname TEXT NOT NULL,
firstname TEXT NOT NULL, firstname TEXT NOT NULL,
@@ -1,74 +1,74 @@
:root { :root {
--bleu: #0b6e9e; --blue: #0b6e9e;
--bleu-fonce: #084f71; --blue-dark: #084f71;
--gris: #5c6672; --grey: #5c6672;
--bord: #dde3e9; --border: #dde3e9;
--fond: #f6f8fa; --background: #f6f8fa;
--rouge: #a8341f; --red: #a8341f;
--vert: #1f6b45; --green: #1f6b45;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
font-family: "Segoe UI", system-ui, sans-serif; font-family: "Segoe UI", system-ui, sans-serif;
color: #1a1f26; color: #1a1f26;
background: var(--fond); background: var(--background);
line-height: 1.55; line-height: 1.55;
} }
header { header {
display: flex; align-items: center; gap: 1.5rem; display: flex; align-items: center; gap: 1.5rem;
padding: 0.9rem 1.5rem; background: #fff; padding: 0.9rem 1.5rem; background: #fff;
border-bottom: 1px solid var(--bord); border-bottom: 1px solid var(--border);
} }
.logo { font-weight: 700; font-size: 1.15rem; color: var(--bleu); text-decoration: none; } .logo { font-weight: 700; font-size: 1.15rem; color: var(--blue); text-decoration: none; }
nav { margin-left: auto; display: flex; gap: 1.1rem; align-items: center; } nav { margin-left: auto; display: flex; gap: 1.1rem; align-items: center; }
nav a { color: var(--gris); text-decoration: none; font-size: 0.92rem; } nav a { color: var(--grey); text-decoration: none; font-size: 0.92rem; }
nav a:hover { color: var(--bleu); } nav a:hover { color: var(--blue); }
main { max-width: 780px; margin: 0 auto; padding: 2rem 1.5rem 4rem; } main { max-width: 780px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
h1 { font-size: 1.7rem; margin: 0 0 0.4rem; } h1 { font-size: 1.7rem; margin: 0 0 0.4rem; }
h2 { font-size: 1.15rem; margin: 2rem 0 0.6rem; } h2 { font-size: 1.15rem; margin: 2rem 0 0.6rem; }
.chapeau { color: var(--gris); margin-top: 0; } .lead { color: var(--grey); margin-top: 0; }
.carte { .card {
background: #fff; border: 1px solid var(--bord); border-radius: 8px; background: #fff; border: 1px solid var(--border); border-radius: 8px;
padding: 1.25rem; display: flex; flex-direction: column; gap: 0.85rem; padding: 1.25rem; display: flex; flex-direction: column; gap: 0.85rem;
max-width: 420px; max-width: 420px;
} }
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.88rem; color: var(--gris); } label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.88rem; color: var(--grey); }
input, select { input, select {
padding: 0.5rem 0.65rem; border: 1px solid var(--bord); border-radius: 5px; padding: 0.5rem 0.65rem; border: 1px solid var(--border); border-radius: 5px;
font-size: 0.95rem; font-family: inherit; color: #1a1f26; background: #fff; font-size: 0.95rem; font-family: inherit; color: #1a1f26; background: #fff;
} }
input:focus, select:focus { outline: 2px solid var(--bleu); outline-offset: 1px; border-color: var(--bleu); } input:focus, select:focus { outline: 2px solid var(--blue); outline-offset: 1px; border-color: var(--blue); }
.aide { font-size: 0.8rem; color: var(--gris); margin: 0; } .hint { font-size: 0.8rem; color: var(--grey); margin: 0; }
button, .bouton { button, .button {
background: var(--bleu); color: #fff; border: none; border-radius: 5px; background: var(--blue); color: #fff; border: none; border-radius: 5px;
padding: 0.55rem 1rem; font-size: 0.95rem; font-family: inherit; padding: 0.55rem 1rem; font-size: 0.95rem; font-family: inherit;
cursor: pointer; text-decoration: none; display: inline-block; text-align: center; cursor: pointer; text-decoration: none; display: inline-block; text-align: center;
} }
button:hover, .bouton:hover { background: var(--bleu-fonce); } button:hover, .button:hover { background: var(--blue-dark); }
button.lien { background: none; color: var(--bleu); padding: 0; text-decoration: underline; } button.link { background: none; color: var(--blue); padding: 0; text-decoration: underline; }
button.lien:hover { background: none; color: var(--bleu-fonce); } button.link:hover { background: none; color: var(--blue-dark); }
.messages { list-style: none; padding: 0; margin: 0 0 1.25rem; display: flex; flex-direction: column; gap: 0.4rem; } .messages { list-style: none; padding: 0; margin: 0 0 1.25rem; display: flex; flex-direction: column; gap: 0.4rem; }
.messages li { padding: 0.6rem 0.85rem; border-radius: 5px; font-size: 0.9rem; border-left: 3px solid; } .messages li { padding: 0.6rem 0.85rem; border-radius: 5px; font-size: 0.9rem; border-left: 3px solid; }
.messages .erreur { background: #fbecea; border-color: var(--rouge); color: var(--rouge); } .messages .error { background: #fbecea; border-color: var(--red); color: var(--red); }
.messages .succes { background: #e9f3ed; border-color: var(--vert); color: var(--vert); } .messages .success { background: #e9f3ed; border-color: var(--green); color: var(--green); }
.stat { .stat {
display: flex; align-items: baseline; gap: 0.6rem; display: flex; align-items: baseline; gap: 0.6rem;
background: #fff; border: 1px solid var(--bord); border-radius: 8px; background: #fff; border: 1px solid var(--border); border-radius: 8px;
padding: 1.1rem 1.4rem; margin: 1.5rem 0; max-width: 420px; padding: 1.1rem 1.4rem; margin: 1.5rem 0; max-width: 420px;
} }
.chiffre { font-size: 2.4rem; font-weight: 700; color: var(--bleu); line-height: 1; } .figure { font-size: 2.4rem; font-weight: 700; color: var(--blue); line-height: 1; }
.libelle { color: var(--gris); } .caption { color: var(--grey); }
.infos { display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 1.5rem; } .details { display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 1.5rem; }
.infos dt { color: var(--gris); font-size: 0.88rem; } .details dt { color: var(--grey); font-size: 0.88rem; }
.infos dd { margin: 0; } .details dd { margin: 0; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--bord); border-radius: 8px; overflow: hidden; font-size: 0.9rem; } table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; font-size: 0.9rem; }
th, td { text-align: left; padding: 0.6rem 0.8rem; border-bottom: 1px solid var(--bord); } th, td { text-align: left; padding: 0.6rem 0.8rem; border-bottom: 1px solid var(--border); }
th { background: #eef2f6; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gris); } th { background: #eef2f6; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--grey); }
tr:last-child td { border-bottom: none; } tr:last-child td { border-bottom: none; }
@@ -1,14 +0,0 @@
{% extends "base.html" %}
{% block contenu %}
<h1>Salle d'escalade Bloc&amp;Co</h1>
<p class="chapeau">Blocs, voies et cours collectifs, sept jours sur sept.</p>
<div class="stat">
<span class="chiffre">{{ nb_adherents }}</span>
<span class="libelle">adhérent{{ 's' if nb_adherents > 1 else '' }} inscrit{{ 's' if nb_adherents > 1 else '' }}</span>
</div>
{% if not utilisateur %}
<p><a class="bouton" href="{{ url_for('inscription') }}">Créer mon compte</a></p>
{% endif %}
{% endblock %}
@@ -1,25 +1,25 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titre %}Administration — Bloc&Co{% endblock %} {% block title %}Administration — Bloc&Co{% endblock %}
{% block contenu %} {% block content %}
<h1>Administration</h1> <h1>Administration</h1>
<p>{{ adherents|length }} adhérent{{ 's' if adherents|length > 1 else '' }} enregistré{{ 's' if adherents|length > 1 else '' }}.</p> <p>{{ members|length }} adhérent{{ 's' if members|length > 1 else '' }} enregistré{{ 's' if members|length > 1 else '' }}.</p>
<table> <table>
<thead> <thead>
<tr><th>lastname</th><th>E-mail</th><th>level</th><th>Rôle</th><th>Inscription</th><th></th></tr> <tr><th>Nom</th><th>E-mail</th><th>Niveau</th><th>Rôle</th><th>Inscription</th><th></th></tr>
</thead> </thead>
<tbody> <tbody>
{% for a in adherents %} {% for m in members %}
<tr> <tr>
<td>{{ a.firstname }} {{ a.lastname }}</td> <td>{{ m.firstname }} {{ m.lastname }}</td>
<td>{{ a.email }}</td> <td>{{ m.email }}</td>
<td>{{ a.level }}</td> <td>{{ m.level }}</td>
<td>{{ a.role }}</td> <td>{{ m.role }}</td>
<td>{{ a.sub_date }}</td> <td>{{ m.sub_date }}</td>
<td> <td>
{% if a.role != 'admin' %} {% if m.role != 'admin' %}
<form method="post" action="{{ url_for('promouvoir', adherent_id=a.id) }}"> <form method="post" action="{{ url_for('promote', member_id=m.id) }}">
<button type="submit" class="lien">Promouvoir</button> <button type="submit" class="link">Promouvoir</button>
</form> </form>
{% endif %} {% endif %}
</td> </td>
@@ -3,22 +3,22 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titre %}Bloc&Co{% endblock %}</title> <title>{% block title %}Bloc&Co{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
<header> <header>
<a class="logo" href="{{ url_for('accueil') }}">Bloc&amp;Co</a> <a class="logo" href="{{ url_for('home') }}">Bloc&amp;Co</a>
<nav> <nav>
{% if utilisateur %} {% if user %}
<a href="{{ url_for('profil') }}">Mon profil</a> <a href="{{ url_for('profile') }}">Mon profil</a>
{% if utilisateur.role == 'admin' %} {% if user.role == 'admin' %}
<a href="{{ url_for('admin') }}">Administration</a> <a href="{{ url_for('admin') }}">Administration</a>
{% endif %} {% endif %}
<a href="{{ url_for('deconnexion') }}">Déconnexion</a> <a href="{{ url_for('logout') }}">Déconnexion</a>
{% else %} {% else %}
<a href="{{ url_for('connexion') }}">Connexion</a> <a href="{{ url_for('login') }}">Connexion</a>
<a class="bouton" href="{{ url_for('inscription') }}">Inscription</a> <a class="button" href="{{ url_for('register') }}">Inscription</a>
{% endif %} {% endif %}
</nav> </nav>
</header> </header>
@@ -27,14 +27,14 @@
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<ul class="messages"> <ul class="messages">
{% for categorie, texte in messages %} {% for category, text in messages %}
<li class="{{ categorie }}">{{ texte }}</li> <li class="{{ category }}">{{ text }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% block contenu %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
</body> </body>
</html> </html>
@@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<h1>Salle d'escalade Bloc&amp;Co</h1>
<p class="lead">Blocs, voies et cours collectifs, sept jours sur sept.</p>
<div class="stat">
<span class="figure">{{ member_count }}</span>
<span class="caption">adhérent{{ 's' if member_count > 1 else '' }} inscrit{{ 's' if member_count > 1 else '' }}</span>
</div>
{% if not user %}
<p><a class="button" href="{{ url_for('register') }}">Créer mon compte</a></p>
{% endif %}
{% endblock %}
@@ -1,8 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titre %}Connexion — Bloc&Co{% endblock %} {% block title %}Connexion — Bloc&Co{% endblock %}
{% block contenu %} {% block content %}
<h1>Connexion</h1> <h1>Connexion</h1>
<form method="post" class="carte"> <form method="post" class="card">
<label>Adresse e-mail <label>Adresse e-mail
<input type="email" name="email" value="{{ email or '' }}" required> <input type="email" name="email" value="{{ email or '' }}" required>
</label> </label>
@@ -11,5 +11,5 @@
</label> </label>
<button type="submit">Se connecter</button> <button type="submit">Se connecter</button>
</form> </form>
<p>Pas encore de compte ? <a href="{{ url_for('inscription') }}">Inscrivez-vous</a>.</p> <p>Pas encore de compte ? <a href="{{ url_for('register') }}">Inscrivez-vous</a>.</p>
{% endblock %} {% endblock %}
@@ -1,29 +0,0 @@
{% extends "base.html" %}
{% block titre %}Mon profil — Bloc&Co{% endblock %}
{% block contenu %}
<h1>Mon profil</h1>
<dl class="carte infos">
<dt>Adresse e-mail</dt><dd>{{ utilisateur.email }}</dd>
<dt>Rôle</dt><dd>{{ utilisateur.role }}</dd>
<dt>Inscrit depuis le</dt><dd>{{ utilisateur.sub_date }}</dd>
</dl>
<h2>Modifier mes informations</h2>
<form method="post" class="carte">
<label>Prélastname
<input type="text" name="firstname" value="{{ utilisateur.firstname }}" required>
</label>
<label>lastname
<input type="text" name="lastname" value="{{ utilisateur.lastname }}" required>
</label>
<label>level
<select name="level">
{% for n in levelx %}
<option value="{{ n }}" {{ 'selected' if n == utilisateur.level }}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Enregistrer</button>
</form>
{% endblock %}
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Mon profil — Bloc&Co{% endblock %}
{% block content %}
<h1>Mon profil</h1>
<dl class="card details">
<dt>Adresse e-mail</dt><dd>{{ user.email }}</dd>
<dt>Rôle</dt><dd>{{ user.role }}</dd>
<dt>Inscrit depuis le</dt><dd>{{ user.sub_date }}</dd>
</dl>
<h2>Modifier mes informations</h2>
<form method="post" class="card">
<label>Prénom
<input type="text" name="firstname" value="{{ user.firstname }}" required>
</label>
<label>Nom
<input type="text" name="lastname" value="{{ user.lastname }}" required>
</label>
<label>Niveau
<select name="level">
{% for n in levels %}
<option value="{{ n }}" {{ 'selected' if n == user.level }}>{{ n }}</option>
{% endfor %}
</select>
</label>
<button type="submit">Enregistrer</button>
</form>
{% endblock %}
@@ -1,12 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block titre %}Inscription — Bloc&Co{% endblock %} {% block title %}Inscription — Bloc&Co{% endblock %}
{% block contenu %} {% block content %}
<h1>Créer un compte</h1> <h1>Créer un compte</h1>
<form method="post" class="carte"> <form method="post" class="card">
<label>Prélastname <label>Prénom
<input type="text" name="firstname" value="{{ firstname or '' }}" required> <input type="text" name="firstname" value="{{ firstname or '' }}" required>
</label> </label>
<label>lastname <label>Nom
<input type="text" name="lastname" value="{{ lastname or '' }}" required> <input type="text" name="lastname" value="{{ lastname or '' }}" required>
</label> </label>
<label>Adresse e-mail <label>Adresse e-mail
@@ -18,7 +18,7 @@
<label>Confirmer le mot de passe <label>Confirmer le mot de passe
<input type="password" name="confirmation" required> <input type="password" name="confirmation" required>
</label> </label>
<p class="aide">12 caractères minimum, au moins une majuscule et un chiffre.</p> <p class="hint">12 caractères minimum, au moins une majuscule et un chiffre.</p>
<button type="submit">S'inscrire</button> <button type="submit">S'inscrire</button>
</form> </form>
{% endblock %} {% endblock %}
@@ -1,6 +1,6 @@
"""Tests de l'application Bloc&Co. """Bloc&Co application tests.
Lancement : pytest -v Run: pytest -v
Ces tests valident le comportement attendu à la fin de l'atelier. Ces tests valident le comportement attendu à la fin de l'atelier.
Donnez-les aux étudiants : un exercice est réussi quand ses tests passent. Donnez-les aux étudiants : un exercice est réussi quand ses tests passent.
@@ -13,160 +13,160 @@ import pytest
@pytest.fixture() @pytest.fixture()
def client(tmp_path, monkeypatch): def client(tmp_path, monkeypatch):
"""Une application neuve, avec une base jetable, pour chaque test.""" """A fresh application with a throwaway database for each test."""
base = tmp_path / "test.db" db_path = tmp_path / "test.db"
monkeypatch.setattr(application, "DB", base) monkeypatch.setattr(application, "DB", db_path)
application.init_db() application.init_db()
application.app.config["TESTING"] = True application.app.config["TESTING"] = True
with application.app.test_client() as c: with application.app.test_client() as test_client:
yield c yield test_client
def subscribe(client, email="lea@test.fr", mdp="Grimpe2026ABC"): def subscribe(client, email="lea@test.fr", password="Grimpe2026ABC"):
return client.post("/inscription", data={ return client.post("/inscription", data={
"lastname": "Dupont", "firstname": "Lea", "email": email, "lastname": "Dupont", "firstname": "Lea", "email": email,
"password": mdp, "confirmation": mdp, "password": password, "confirmation": password,
}, follow_redirects=True) }, follow_redirects=True)
def connect(client, email="lea@test.fr", mdp="Grimpe2026ABC"): def log_in(client, email="lea@test.fr", password="Grimpe2026ABC"):
return client.post("/connexion", data={ return client.post("/connexion", data={
"email": email, "password": mdp, "email": email, "password": password,
}, follow_redirects=True) }, follow_redirects=True)
# --- Exercice 10 : la statistique ---------------------------------------- # --- Exercise 10: home page statistic ----------------------------------------
def test_accueil_affiche_le_lastnamebre_d_adherents(client): def test_home_shows_member_count(client):
reponse = client.get("/") response = client.get("/")
assert reponse.status_code == 200 assert response.status_code == 200
assert b">1<" in reponse.data # le compte admin seul assert b">1<" in response.data # the admin account alone
subscribe(client) subscribe(client)
assert b">2<" in client.get("/").data assert b">2<" in client.get("/").data
# --- Exercice 5 : l'inscription ------------------------------------------ # --- Exercise 5: registration ------------------------------------------
def test_inscription_password_trop_court_refuse(client): def test_registration_rejects_short_password(client):
reponse = subscribe(client, mdp="Court1A") response = subscribe(client, password="Court1A")
assert b"12 caract" in reponse.data assert b"12 caract" in response.data
def test_inscription_password_sans_chiffre_refuse(client): def test_registration_rejects_password_without_digit(client):
reponse = subscribe(client, mdp="MotDePasseSansChiffre") response = subscribe(client, password="MotDePasseSansChiffre")
assert b"chiffre" in reponse.data assert b"chiffre" in response.data
def test_inscription_email_en_double_refuse(client): def test_registration_rejects_duplicate_email(client):
subscribe(client) subscribe(client)
reponse = subscribe(client) response = subscribe(client)
assert "déjà utilis".encode() in reponse.data assert "déjà utilis".encode() in response.data
def test_inscription_email_insensible_a_la_casse(client): def test_registration_email_is_case_insensitive(client):
subscribe(client, email="Lea@Test.fr") subscribe(client, email="Lea@Test.fr")
reponse = subscribe(client, email="lea@test.fr") response = subscribe(client, email="lea@test.fr")
assert "déjà utilis".encode() in reponse.data assert "déjà utilis".encode() in response.data
def test_inscription_password_jamais_en_clair(client): def test_registration_never_stores_plain_password(client):
subscribe(client) subscribe(client)
stocke = sqlite3.connect(application.DB).execute( stored = sqlite3.connect(application.DB).execute(
"SELECT password FROM adherents WHERE email = 'lea@test.fr'" "SELECT password FROM members WHERE email = 'lea@test.fr'"
).fetchone()[0] ).fetchone()[0]
assert stocke != "Grimpe2026ABC" assert stored != "Grimpe2026ABC"
assert len(stocke) > 40 assert len(stored) > 40
# --- Exercice 6 : la connexion ------------------------------------------- # --- Exercise 6: login -------------------------------------------
def test_mauvais_password_refuse(client): def test_login_rejects_wrong_password(client):
subscribe(client) subscribe(client)
reponse = connect(client, mdp="MauvaisMotDePasse") response = log_in(client, password="MauvaisMotDePasse")
assert b"Identifiants incorrects" in reponse.data assert b"Identifiants incorrects" in response.data
def test_compte_inexistant_donne_le_meme_message(client): def test_login_unknown_account_gives_same_message(client):
"""Le message ne doit pas révéler si l'adresse existe dans la base.""" """The message must not reveal whether the address exists in the database."""
subscribe(client) subscribe(client)
inconnu = connect(client, email="personne@test.fr", mdp="Grimpe2026ABC") unknown = log_in(client, email="personne@test.fr", password="Grimpe2026ABC")
mauvais = connect(client, mdp="MauvaisMotDePasse") wrong = log_in(client, password="MauvaisMotDePasse")
assert b"Identifiants incorrects" in inconnu.data assert b"Identifiants incorrects" in unknown.data
assert b"Identifiants incorrects" in mauvais.data assert b"Identifiants incorrects" in wrong.data
def test_connexion_reussie(client): def test_login_succeeds(client):
subscribe(client) subscribe(client)
reponse = connect(client) response = log_in(client)
assert b"Mon profil" in reponse.data assert b"Mon profil" in response.data
# --- Exercice 7 : les pages protégées ------------------------------------ # --- Exercise 7: protected pages ------------------------------------
def test_profil_protege_sans_connexion(client): def test_profile_protected_when_anonymous(client):
reponse = client.get("/profil", follow_redirects=True) response = client.get("/profil", follow_redirects=True)
assert b"Connectez-vous" in reponse.data assert b"Connectez-vous" in response.data
def test_profil_protege_apres_deconnexion(client): def test_profile_protected_after_logout(client):
subscribe(client) subscribe(client)
connect(client) log_in(client)
client.get("/deconnexion") client.get("/deconnexion")
reponse = client.get("/profil", follow_redirects=True) response = client.get("/profil", follow_redirects=True)
assert b"Connectez-vous" in reponse.data assert b"Connectez-vous" in response.data
# --- Exercice 8 : le profil ---------------------------------------------- # --- Exercise 8: profile ----------------------------------------------
def test_profil_modifiable(client): def test_profile_can_be_updated(client):
subscribe(client) subscribe(client)
connect(client) log_in(client)
reponse = client.post("/profil", data={ response = client.post("/profil", data={
"lastname": "Dupont", "firstname": "Léa", "level": "confirme", "lastname": "Dupont", "firstname": "Léa", "level": "confirme",
}, follow_redirects=True) }, follow_redirects=True)
assert "mis à jour".encode() in reponse.data assert "mis à jour".encode() in response.data
def test_level_invalide_rejete(client): def test_invalid_level_rejected(client):
"""Le formulaire HTML est modifiable : le serveur doit revalider.""" """The HTML form can be tampered with: the server must revalidate."""
subscribe(client) subscribe(client)
connect(client) log_in(client)
reponse = client.post("/profil", data={ response = client.post("/profil", data={
"lastname": "Dupont", "firstname": "Lea", "level": "super_admin", "lastname": "Dupont", "firstname": "Lea", "level": "super_admin",
}, follow_redirects=True) }, follow_redirects=True)
assert b"level invalide" in reponse.data assert b"Niveau invalide" in response.data
# --- Exercice 9 : les rôles ---------------------------------------------- # --- Exercise 9: roles ----------------------------------------------
def test_admin_interdit_a_un_adherent(client): def test_admin_forbidden_to_regular_member(client):
subscribe(client) subscribe(client)
connect(client) log_in(client)
reponse = client.get("/admin", follow_redirects=True) response = client.get("/admin", follow_redirects=True)
assert "réservé aux administrateurs".encode() in reponse.data assert "réservé aux administrateurs".encode() in response.data
def test_admin_accessible_a_l_administrateur(client): def test_admin_accessible_to_administrator(client):
subscribe(client) subscribe(client)
connect(client, email="admin@blocandco.fr", mdp="Admin2026!") log_in(client, email="admin@blocandco.fr", password="Admin2026!")
reponse = client.get("/admin") response = client.get("/admin")
assert reponse.status_code == 200 assert response.status_code == 200
assert b"lea@test.fr" in reponse.data assert b"lea@test.fr" in response.data
def test_promotion_interdite_a_un_adherent(client): def test_promotion_forbidden_to_regular_member(client):
subscribe(client) subscribe(client)
connect(client) log_in(client)
client.post("/admin/promouvoir/1", follow_redirects=True) client.post("/admin/promouvoir/1", follow_redirects=True)
role = sqlite3.connect(application.DB).execute( role = sqlite3.connect(application.DB).execute(
"SELECT role FROM adherents WHERE email = 'lea@test.fr'" "SELECT role FROM members WHERE email = 'lea@test.fr'"
).fetchone()[0] ).fetchone()[0]
assert role == "adherent" assert role == "adherent"
# --- Exercice 11 : injection SQL ----------------------------------------- # --- Exercise 11: SQL injection -----------------------------------------
def test_injection_sql_sur_la_connexion(client): def test_sql_injection_on_login(client):
"""Avec des requêtes paramétrées, cette charge ne doit rien ouvrir.""" """With parameterized queries, this payload must not open anything."""
subscribe(client) subscribe(client)
reponse = client.post("/connexion", data={ response = client.post("/connexion", data={
"email": "lea@test.fr' OR '1'='1", "email": "lea@test.fr' OR '1'='1",
"password": "peu importe", "password": "peu importe",
}, follow_redirects=True) }, follow_redirects=True)
assert b"Identifiants incorrects" in reponse.data assert b"Identifiants incorrects" in response.data
assert b"Mon profil" not in reponse.data assert b"Mon profil" not in response.data
# --- Exercice 12 : XSS --------------------------------------------------- # --- Exercise 12: XSS ---------------------------------------------------
def test_xss_dans_le_firstname_est_echappe(client): def test_xss_in_firstname_is_escaped(client):
subscribe(client) subscribe(client)
connect(client) log_in(client)
client.post("/profil", data={ client.post("/profil", data={
"lastname": "Dupont", "firstname": "<script>alert(1)</script>", "lastname": "Dupont", "firstname": "<script>alert(1)</script>",
"level": "debutant", "level": "debutant",
}, follow_redirects=True) }, follow_redirects=True)
reponse = client.get("/profil") response = client.get("/profil")
assert b"<script>alert(1)</script>" not in reponse.data assert b"<script>alert(1)</script>" not in response.data
assert b"&lt;script&gt;" in reponse.data assert b"&lt;script&gt;" in response.data
@@ -1,41 +1,41 @@
"""Vérification du poste avant l'atelier. """Workstation check before the workshop.
Lancez simplement : python verif_poste.py Simply run: python verif_poste.py
Puis copiez-collez TOUT le résultat au formateur. Then copy-paste ALL of the output to the instructor.
Ce script ne modifie rien et n'installe rien. This script changes nothing and installs nothing.
""" """
import platform import platform
import sys import sys
MINIMUM = (3, 9) MINIMUM_VERSION = (3, 9)
def version_python(): def python_version():
v = sys.version_info v = sys.version_info
actuelle = f"{v.major}.{v.minor}.{v.micro}" current = f"{v.major}.{v.minor}.{v.micro}"
if (v.major, v.minor) >= MINIMUM: if (v.major, v.minor) >= MINIMUM_VERSION:
return True, f"Python {actuelle}" return True, f"Python {current}"
return False, (f"Python {actuelle} — TROP ANCIEN, il faut au moins " return False, (f"Python {current} — TROP ANCIEN, il faut au moins "
f"{MINIMUM[0]}.{MINIMUM[1]}") f"{MINIMUM_VERSION[0]}.{MINIMUM_VERSION[1]}")
def module_present(lastname): def module_status(name):
try: try:
__import__(lastname) __import__(name)
except ImportError: except ImportError:
return False, f"{lastname} : ABSENT" return False, f"{name} : ABSENT"
try: try:
from importlib.metadata import version from importlib.metadata import version
return True, f"{lastname} : {version(lastname)}" return True, f"{name} : {version(name)}"
except Exception: except Exception:
return True, f"{lastname} : présent" return True, f"{name} : présent"
def dans_un_venv(): def in_virtualenv():
actif = sys.prefix != getattr(sys, "base_prefix", sys.prefix) active = sys.prefix != getattr(sys, "base_prefix", sys.prefix)
if actif: if active:
return True, "environnement virtuel : actif" return True, "environnement virtuel : actif"
return True, "environnement virtuel : aucun (ce n'est pas bloquant)" return True, "environnement virtuel : aucun (ce n'est pas bloquant)"
@@ -48,27 +48,27 @@ def main():
print(f" Executable : {sys.executable}") print(f" Executable : {sys.executable}")
print("-" * 56) print("-" * 56)
resultats = [version_python(), dans_un_venv()] results = [python_version(), in_virtualenv()]
for lastname in ("flask", "pytest"): for name in ("flask", "pytest"):
resultats.append(module_present(lastname)) results.append(module_status(name))
for ok, message in resultats: for ok, message in results:
print((" [ OK ] " if ok else " [ KO ] ") + message) print((" [ OK ] " if ok else " [ KO ] ") + message)
print("-" * 56) print("-" * 56)
bloquant = not resultats[0][0] blocking = not results[0][0]
modules = zip(("flask", "pytest"), resultats[2:], strict=False) modules = zip(("flask", "pytest"), results[2:], strict=False)
manquants = [lastname for lastname, (ok, _) in modules if not ok] missing = [name for name, (ok, _) in modules if not ok]
if bloquant: if blocking:
print(" ACTION : installez une version recente de Python depuis") print(" ACTION : installez une version recente de Python depuis")
print(" python.org, en cochant 'Add python.exe to PATH'.") print(" python.org, en cochant 'Add python.exe to PATH'.")
print(" Prevenez le formateur des ce soir.") print(" Prevenez le formateur des ce soir.")
elif manquants: elif missing:
print(" ACTION : lancez cette commande, puis relancez ce script :") print(" ACTION : lancez cette commande, puis relancez ce script :")
print() print()
print(" python -m pip install " + " ".join(manquants)) print(" python -m pip install " + " ".join(missing))
else: else:
print(" Tout est pret. Rien a faire.") print(" Tout est pret. Rien a faire.")