Semaine 15, Jour 4
This commit is contained in:
Binary file not shown.
@@ -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&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&Co</a>
|
<a class="logo" href="{{ url_for('home') }}">Bloc&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&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 %}
|
||||||
+4
-4
@@ -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 %}
|
||||||
+6
-6
@@ -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"<script>" in reponse.data
|
assert b"<script>" 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.")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user