Semaine 15, Jour 4 - Début
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,509 @@
|
|||||||
|
# Atelier — Développer une application web sécurisée en Python
|
||||||
|
|
||||||
|
**Fil rouge : Bloc&Co, salle d'escalade.**
|
||||||
|
La salle veut gérer ses adhérents en ligne : inscription, connexion, espace
|
||||||
|
personnel, et un accès réservé au personnel.
|
||||||
|
|
||||||
|
À la fin de l'atelier, vous saurez construire **seul** un module de gestion
|
||||||
|
d'utilisateurs sécurisé : c'est la brique de base de presque toutes les
|
||||||
|
applications web, et elle se transpose à n'importe quel domaine métier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce qui est fourni
|
||||||
|
|
||||||
|
| Fichier | Contenu |
|
||||||
|
|---|---|
|
||||||
|
| `starter/app.py` | La plomberie de la base, et les fonctions à compléter |
|
||||||
|
| `starter/schema.sql` | La table `adherents`. Ne pas la modifier. |
|
||||||
|
| `starter/templates/` | Tout le HTML, déjà écrit |
|
||||||
|
| `starter/static/style.css` | La feuille de style |
|
||||||
|
| `test_app.py` | Les tests. **Un exercice est réussi quand ses tests passent.** |
|
||||||
|
|
||||||
|
## Installation (10 minutes, à faire en premier)
|
||||||
|
|
||||||
|
```
|
||||||
|
py -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install flask pytest
|
||||||
|
python app.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur macOS ou Linux, remplacez les deux premières lignes par :
|
||||||
|
`python3 -m venv .venv` puis `source .venv/bin/activate`
|
||||||
|
|
||||||
|
Ouvrez http://127.0.0.1:5000 — la page d'accueil doit s'afficher.
|
||||||
|
Un compte administrateur existe déjà : `admin@blocandco.fr` / `Admin2026!`
|
||||||
|
|
||||||
|
Pour lancer les tests : `pytest -v`
|
||||||
|
Au départ, la plupart échouent. C'est normal : c'est votre feuille de route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Partie A — Les bases du web en Python
|
||||||
|
|
||||||
|
## Exercice 0 — Monter un projet de zéro
|
||||||
|
|
||||||
|
**Notions** : environnement virtuel, dépendances, arborescence d'un projet Flask.
|
||||||
|
|
||||||
|
> Le jour de l'épreuve, vous ne saurez qu'en lisant le sujet si un dépôt vous
|
||||||
|
> est fourni ou si vous partez de rien. **Entraînez-vous aux deux.**
|
||||||
|
|
||||||
|
**A. Page blanche.** Sans rien copier, dans un dossier neuf, créez un projet
|
||||||
|
Flask qui démarre : environnement virtuel, installation, `app.py`, une route
|
||||||
|
qui affiche un texte. Chronométrez-vous.
|
||||||
|
|
||||||
|
```
|
||||||
|
Objectif : moins de 10 minutes, sans consulter de documentation.
|
||||||
|
Recommencez demain dans un autre dossier, sans regarder vos notes.
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Projet existant.** Ouvrez le dossier `starter/` et, **avant de coder
|
||||||
|
quoi que ce soit**, remplissez ce tableau :
|
||||||
|
|
||||||
|
| Question | Votre réponse |
|
||||||
|
|---|---|
|
||||||
|
| Combien de routes sont déclarées ? | |
|
||||||
|
| Où le mot de passe est-il haché ? | |
|
||||||
|
| Quelles fonctions sont vides ? | |
|
||||||
|
| Que fait `@app.teardown_appcontext` ? | |
|
||||||
|
| L'application démarre-t-elle en l'état ? | |
|
||||||
|
|
||||||
|
**Pourquoi cet exercice** : reprendre du code écrit par quelqu'un d'autre est
|
||||||
|
la situation normale en entreprise, et la moitié des candidats perdent une
|
||||||
|
heure parce qu'ils ont commencé à taper avant d'avoir lu.
|
||||||
|
|
||||||
|
La fiche `MEMO-JOUR-J.md` récapitule les deux démarches. Gardez-la ouverte
|
||||||
|
pendant l'épreuve.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 1 — Votre première page
|
||||||
|
|
||||||
|
**Notions** : route, fonction de vue, serveur de développement.
|
||||||
|
|
||||||
|
Créez un fichier `essai.py` séparé contenant une application Flask minimale
|
||||||
|
avec une route `/bonjour` qui renvoie le texte `Bonjour Bloc&Co`.
|
||||||
|
Lancez-la, ouvrez-la dans le navigateur.
|
||||||
|
|
||||||
|
Ajoutez ensuite une route `/bonjour/<prenom>` qui affiche `Bonjour <prenom>`.
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:5000/bonjour → Bonjour Bloc&Co
|
||||||
|
http://127.0.0.1:5000/bonjour/Lea → Bonjour Lea
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erreurs attendues** : oublier `app.run()` · deux routes avec le même nom de
|
||||||
|
fonction → `AssertionError` · modifier le code sans recharger (pensez à
|
||||||
|
`debug=True`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 2 — Séparer le HTML du Python
|
||||||
|
|
||||||
|
**Notions** : templates Jinja, `render_template`, héritage de gabarit.
|
||||||
|
|
||||||
|
Ouvrez `templates/base.html` et `templates/accueil.html`. Répondez par écrit,
|
||||||
|
en une phrase chacune :
|
||||||
|
|
||||||
|
1. Que fait `{% extends "base.html" %}` ?
|
||||||
|
2. Où atterrit le contenu de `{% block contenu %}` ?
|
||||||
|
3. Pourquoi écrit-on `{{ url_for('profil') }}` plutôt que `/profil` ?
|
||||||
|
4. Dans `base.html`, pourquoi le lien « Administration » n'apparaît-il pas
|
||||||
|
pour tout le monde ?
|
||||||
|
|
||||||
|
**Pourquoi c'est important** : le jury vous demandera de justifier votre
|
||||||
|
architecture. « J'ai séparé la présentation de la logique » est une réponse,
|
||||||
|
encore faut-il pouvoir la montrer dans le code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 3 — Un formulaire qui parle au serveur
|
||||||
|
|
||||||
|
**Notions** : méthodes GET et POST, `request.form`.
|
||||||
|
|
||||||
|
Dans `essai.py`, créez une route `/echo` qui accepte GET et POST :
|
||||||
|
|
||||||
|
- en GET, elle affiche un formulaire avec un champ `message`
|
||||||
|
- en POST, elle réaffiche la page avec le message reçu
|
||||||
|
|
||||||
|
```
|
||||||
|
Saisie : "salut"
|
||||||
|
Affiché : Vous avez écrit : salut
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis essayez de saisir `<b>salut</b>`. Que s'affiche-t-il à l'écran ?
|
||||||
|
Gardez la réponse de côté : on y revient à l'exercice 12.
|
||||||
|
|
||||||
|
**Erreurs attendues** : oublier `methods=["GET", "POST"]` → erreur 405 ·
|
||||||
|
`request.form["message"]` sur un champ absent → `KeyError` (préférez
|
||||||
|
`request.form.get("message", "")`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Partie B — Enregistrer les données
|
||||||
|
|
||||||
|
## Exercice 4 — Parler à la base
|
||||||
|
|
||||||
|
**Notions** : SQLite, `SELECT`, `INSERT`, requêtes paramétrées.
|
||||||
|
|
||||||
|
Dans un fichier d'essai, ouvrez `blocandco.db` et :
|
||||||
|
|
||||||
|
1. affichez tous les adhérents
|
||||||
|
2. affichez uniquement ceux dont le rôle est `admin`
|
||||||
|
3. comptez le nombre total d'adhérents
|
||||||
|
4. insérez un adhérent de test, puis supprimez-le
|
||||||
|
|
||||||
|
**Règle non négociable, à appliquer dès maintenant :** une valeur variable ne
|
||||||
|
s'écrit **jamais** dans le texte de la requête.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# NON — faille d'injection SQL
|
||||||
|
db.execute("SELECT * FROM adherents WHERE email = '" + email + "'")
|
||||||
|
|
||||||
|
# OUI — requête paramétrée
|
||||||
|
db.execute("SELECT * FROM adherents WHERE email = ?", (email,))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Erreurs attendues** : oublier la virgule dans `(email,)` → le tuple n'en est
|
||||||
|
pas un · oublier `commit()` après un `INSERT` → rien n'est enregistré ·
|
||||||
|
`fetchone()` renvoie `None` quand il n'y a pas de résultat, et le code plante
|
||||||
|
trois lignes plus loin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 5 — L'inscription
|
||||||
|
|
||||||
|
**Notions** : validation des entrées, hachage de mot de passe, contrainte
|
||||||
|
d'unicité.
|
||||||
|
|
||||||
|
Complétez `valider_inscription()` puis la route `/inscription`.
|
||||||
|
|
||||||
|
**Règles de validation** :
|
||||||
|
|
||||||
|
| Champ | Règle |
|
||||||
|
|---|---|
|
||||||
|
| nom, prénom | non vides |
|
||||||
|
| email | contient `@` et un point après le `@` |
|
||||||
|
| mot de passe | 12 caractères minimum, une majuscule, un chiffre |
|
||||||
|
| confirmation | identique au mot de passe |
|
||||||
|
| email | pas déjà présent en base |
|
||||||
|
|
||||||
|
**Le mot de passe ne doit jamais arriver en clair dans la base.** Utilisez
|
||||||
|
`generate_password_hash()` de Werkzeug.
|
||||||
|
|
||||||
|
```
|
||||||
|
Entrée : Léa / Dupont / lea@test.fr / "Court1A"
|
||||||
|
Sortie : « Le mot de passe doit faire au moins 12 caractères. »
|
||||||
|
|
||||||
|
Entrée : Léa / Dupont / lea@test.fr / "Grimpe2026ABC"
|
||||||
|
Sortie : « Compte créé. Vous pouvez vous connecter. »
|
||||||
|
En base : $scrypt$... (et non "Grimpe2026ABC")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vérification** : `pytest -k inscription` et `pytest -k mot_de_passe`
|
||||||
|
|
||||||
|
**Erreurs attendues** : enregistrer le mot de passe en clair · hacher la
|
||||||
|
confirmation au lieu du mot de passe · renvoyer le mot de passe dans le
|
||||||
|
formulaire réaffiché · comparer les e-mails sans normaliser la casse, si bien
|
||||||
|
que `Lea@Test.fr` et `lea@test.fr` créent deux comptes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Partie C — Authentification et autorisations
|
||||||
|
|
||||||
|
## Exercice 6 — La connexion
|
||||||
|
|
||||||
|
**Notions** : session, vérification d'empreinte.
|
||||||
|
|
||||||
|
Complétez `utilisateur_courant()` puis la route `/connexion`.
|
||||||
|
|
||||||
|
Le principe : on ne déchiffre **jamais** un mot de passe. On rehache celui qui
|
||||||
|
est saisi et on compare les deux empreintes, avec `check_password_hash()`.
|
||||||
|
|
||||||
|
**Point de sécurité, et il compte** : que l'adresse existe ou non, que le mot
|
||||||
|
de passe soit faux ou non, affichez **exactement le même message**. Sinon un
|
||||||
|
attaquant découvre quelles adresses sont inscrites chez vous — c'est une
|
||||||
|
énumération de comptes.
|
||||||
|
|
||||||
|
```
|
||||||
|
Entrée : lea@test.fr / mauvais → « Identifiants incorrects. »
|
||||||
|
Entrée : inconnu@test.fr / n'importe → « Identifiants incorrects. »
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vérification** : `pytest -k connexion`
|
||||||
|
|
||||||
|
**Erreurs attendues** : messages différents selon le cas · stocker tout
|
||||||
|
l'utilisateur dans la session au lieu de son seul identifiant · oublier
|
||||||
|
`session.clear()` avant de connecter quelqu'un (fixation de session) · oublier
|
||||||
|
`app.secret_key` → Flask refuse d'écrire dans la session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 7 — Déconnexion et pages protégées
|
||||||
|
|
||||||
|
**Notions** : décorateur, redirection.
|
||||||
|
|
||||||
|
1. Complétez `/deconnexion` : elle vide la session.
|
||||||
|
2. Complétez le décorateur `login_requis`.
|
||||||
|
|
||||||
|
Un décorateur est une fonction qui en enveloppe une autre pour ajouter un
|
||||||
|
contrôle avant son exécution. Ici : « si personne n'est connecté, redirige
|
||||||
|
vers la connexion ; sinon, laisse passer ».
|
||||||
|
|
||||||
|
```
|
||||||
|
Visiteur anonyme sur /profil → redirigé vers /connexion
|
||||||
|
« Connectez-vous pour accéder à cette page. »
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vérification** : `pytest -k protege` et `pytest -k deconnexion`
|
||||||
|
|
||||||
|
**Erreurs attendues** : oublier `@wraps(vue)` → Flask voit deux routes nommées
|
||||||
|
`wrapper` et refuse de démarrer · placer `@login_requis` **au-dessus** de
|
||||||
|
`@app.route` (l'ordre compte : la route en premier) · oublier de renvoyer le
|
||||||
|
résultat de `vue(...)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 8 — L'espace personnel
|
||||||
|
|
||||||
|
**Notions** : `UPDATE`, revalidation côté serveur.
|
||||||
|
|
||||||
|
Complétez `/profil` : afficher les informations, et permettre de modifier nom,
|
||||||
|
prénom et niveau.
|
||||||
|
|
||||||
|
**Le piège, et c'est le plus important de l'atelier** : le `<select>` du
|
||||||
|
niveau propose trois valeurs, mais n'importe qui peut modifier le HTML dans
|
||||||
|
son navigateur et envoyer ce qu'il veut. **Tout ce qui vient du client est
|
||||||
|
hostile jusqu'à preuve du contraire.** Revalidez contre `NIVEAUX` côté serveur.
|
||||||
|
|
||||||
|
```
|
||||||
|
Entrée : niveau = "confirme" → « Profil mis à jour. »
|
||||||
|
Entrée : niveau = "super_admin" → « Niveau invalide. »
|
||||||
|
```
|
||||||
|
|
||||||
|
Vérifiez aussi que l'`UPDATE` porte bien sur l'utilisateur **connecté**, et
|
||||||
|
pas sur un identifiant envoyé par le formulaire.
|
||||||
|
|
||||||
|
**Vérification** : `pytest -k profil` et `pytest -k niveau`
|
||||||
|
|
||||||
|
**Erreurs attendues** : faire confiance au `<select>` · oublier le `WHERE` dans
|
||||||
|
l'`UPDATE` → tous les adhérents sont modifiés d'un coup · laisser l'utilisateur
|
||||||
|
changer son propre rôle par ce formulaire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 9 — Les rôles
|
||||||
|
|
||||||
|
**Notions** : contrôle d'autorisation, séparation authentification /
|
||||||
|
autorisation.
|
||||||
|
|
||||||
|
Complétez le décorateur `admin_requis`, la route `/admin` et `/admin/promouvoir`.
|
||||||
|
|
||||||
|
Retenez la distinction, le jury la pose souvent :
|
||||||
|
|
||||||
|
- **authentification** : qui êtes-vous ? (connexion)
|
||||||
|
- **autorisation** : avez-vous le droit de faire ceci ? (rôle)
|
||||||
|
|
||||||
|
Un utilisateur connecté n'est pas un utilisateur autorisé.
|
||||||
|
|
||||||
|
```
|
||||||
|
Visiteur anonyme sur /admin → « Connectez-vous… »
|
||||||
|
Adhérent connecté sur /admin → « Accès réservé aux administrateurs. »
|
||||||
|
Administrateur sur /admin → la liste des adhérents
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vérification** : `pytest -k admin` et `pytest -k promotion`
|
||||||
|
|
||||||
|
**Erreurs attendues** : masquer le lien dans le menu **sans** protéger la route
|
||||||
|
(l'URL reste accessible en la tapant) · vérifier le rôle avec `=` au lieu de
|
||||||
|
`==` · protéger l'affichage mais pas l'action `/admin/promouvoir` — c'est la
|
||||||
|
faille la plus fréquente et la plus grave.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 10 — La statistique d'accueil
|
||||||
|
|
||||||
|
**Notions** : `COUNT`, passage de variable au template.
|
||||||
|
|
||||||
|
Affichez sur la page d'accueil le nombre réel d'adhérents inscrits.
|
||||||
|
|
||||||
|
```
|
||||||
|
Base vide + compte admin → 1 adhérent inscrit
|
||||||
|
Après une inscription → 2 adhérents inscrits
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vérification** : `pytest -k nombre`
|
||||||
|
|
||||||
|
**Erreurs attendues** : compter en Python avec `len(fetchall())` au lieu d'un
|
||||||
|
`COUNT(*)` en SQL — correct ici, inacceptable sur un million de lignes ·
|
||||||
|
oublier d'accorder le pluriel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Partie D — La sécurité, ce que le jury regarde vraiment
|
||||||
|
|
||||||
|
> Les quatre exercices suivants se font **sur votre propre application**.
|
||||||
|
> C'est légal, c'est le but, et c'est exactement ce qu'on vous demandera de
|
||||||
|
> démontrer.
|
||||||
|
|
||||||
|
## Exercice 11 — Injection SQL
|
||||||
|
|
||||||
|
**Notions** : OWASP A03, requêtes paramétrées.
|
||||||
|
|
||||||
|
1. Dans une copie de votre code, remplacez volontairement la requête de
|
||||||
|
connexion par une concaténation :
|
||||||
|
|
||||||
|
```python
|
||||||
|
db.execute("SELECT * FROM adherents WHERE email = '" + email + "'")
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Connectez-vous avec l'adresse `lea@test.fr' --` et n'importe quel mot de
|
||||||
|
passe. Observez.
|
||||||
|
3. Essayez `' OR '1'='1`. Observez.
|
||||||
|
4. Revenez à la version paramétrée. Rejouez les deux charges.
|
||||||
|
5. **Expliquez par écrit, en trois phrases, pourquoi le `?` règle le problème.**
|
||||||
|
|
||||||
|
**Vérification** : `pytest -k injection`
|
||||||
|
|
||||||
|
La réponse attendue : avec `?`, la requête et les données voyagent séparément.
|
||||||
|
Le moteur a déjà analysé la structure de la requête quand il reçoit la valeur ;
|
||||||
|
celle-ci ne peut donc plus être lue comme du code SQL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 12 — Injection de script (XSS)
|
||||||
|
|
||||||
|
**Notions** : OWASP A03, échappement automatique.
|
||||||
|
|
||||||
|
1. Modifiez votre prénom en `<script>alert('XSS')</script>` puis affichez
|
||||||
|
votre profil. Que se passe-t-il ? Regardez le code source de la page.
|
||||||
|
2. Dans `profil.html`, remplacez `{{ utilisateur.prenom }}` par
|
||||||
|
`{{ utilisateur.prenom|safe }}`. Rechargez.
|
||||||
|
3. Expliquez ce que fait Jinja par défaut, et ce que `|safe` désactive.
|
||||||
|
4. **Remettez le code dans son état initial.**
|
||||||
|
|
||||||
|
**Vérification** : `pytest -k xss`
|
||||||
|
|
||||||
|
**À retenir** : Jinja échappe automatiquement. Ce filtre `|safe`, vous le
|
||||||
|
croiserez dans du vrai code — c'est souvent là qu'est la faille.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 13 — Contrôle d'accès défaillant
|
||||||
|
|
||||||
|
**Notions** : OWASP A01, le risque numéro 1 du classement.
|
||||||
|
|
||||||
|
Trois attaques à mener contre votre propre application :
|
||||||
|
|
||||||
|
1. **Accès direct par l'URL** — déconnectez-vous, tapez `/admin` à la main.
|
||||||
|
Êtes-vous bloqué ?
|
||||||
|
2. **Élévation de privilège** — connectez-vous comme simple adhérent, puis
|
||||||
|
envoyez une requête POST vers `/admin/promouvoir/1`. Votre rôle change-t-il ?
|
||||||
|
(Le test `test_promotion_interdite_a_un_adherent` fait exactement cela.)
|
||||||
|
3. **Accès aux données d'autrui** — si vous aviez écrit `/profil/<id>` au lieu
|
||||||
|
de `/profil`, que se passerait-il en changeant le numéro dans l'URL ?
|
||||||
|
Expliquez pourquoi la version sans identifiant est plus sûre.
|
||||||
|
|
||||||
|
**Règle** : cacher un bouton n'est pas une mesure de sécurité. Seul le contrôle
|
||||||
|
exécuté sur le serveur en est une.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 14 — Les quatre critères, appliqués à votre code
|
||||||
|
|
||||||
|
**Notions** : disponibilité, intégrité, confidentialité, traçabilité.
|
||||||
|
|
||||||
|
Pour chacun des quatre critères, citez **une ligne précise de votre code** qui
|
||||||
|
y contribue, ou constatez qu'il manque et dites ce que vous ajouteriez.
|
||||||
|
|
||||||
|
| Critère | Dans votre application |
|
||||||
|
|---|---|
|
||||||
|
| Confidentialité | ? |
|
||||||
|
| Intégrité | ? |
|
||||||
|
| Disponibilité | ? |
|
||||||
|
| Traçabilité | ? |
|
||||||
|
|
||||||
|
Indice pour le dernier : votre application n'enregistre aujourd'hui **aucune**
|
||||||
|
trace des connexions. Proposez une table `journal` et dites ce que vous y
|
||||||
|
mettriez — et surtout ce que vous n'y mettriez **jamais**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Partie E — L'épreuve
|
||||||
|
|
||||||
|
## Exercice 15 — Projet de synthèse, en autonomie (4 heures)
|
||||||
|
|
||||||
|
**Nouveau cahier des charges, à traiter seul.**
|
||||||
|
|
||||||
|
> **Rouletabosse**, association de covoiturage domicile-travail, veut une
|
||||||
|
> application web. Elle doit permettre à un salarié de créer un compte
|
||||||
|
> (nom, prénom, e-mail, mot de passe, entreprise), de se connecter, de
|
||||||
|
> consulter et modifier son profil. Deux rôles : `membre` et `referent`.
|
||||||
|
> Seul un référent peut voir la liste des membres et valider un compte.
|
||||||
|
> La page d'accueil affiche le nombre de membres inscrits.
|
||||||
|
> Front : HTML / CSS. Back : Python. Mots de passe hachés, sessions
|
||||||
|
> sécurisées.
|
||||||
|
|
||||||
|
Vous partez d'une page blanche. Vous avez quatre heures.
|
||||||
|
|
||||||
|
**Livrable** : l'application qui tourne en local, plus la présentation de
|
||||||
|
l'exercice 16.
|
||||||
|
|
||||||
|
**Conseil de méthode** : passez les 20 premières minutes à écrire le schéma de
|
||||||
|
la base et la liste de vos routes, **avant** la première ligne de code. Les
|
||||||
|
candidats qui codent immédiatement sont ceux qui ne finissent pas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 16 — La présentation au jury
|
||||||
|
|
||||||
|
Une application qui marche mais qu'on ne sait pas présenter est une
|
||||||
|
application à moitié rendue. Préparez un support de 12 à 15 diapositives :
|
||||||
|
|
||||||
|
1. **Introduction** — le besoin métier, en deux phrases
|
||||||
|
2. **Schéma de la base de données** — les tables, leurs champs, leurs liens
|
||||||
|
3. **Modélisation de l'application** — les routes, et le parcours d'un
|
||||||
|
utilisateur de l'inscription au profil
|
||||||
|
4. **Choix technologiques** — et leur **justification**. « Flask parce que
|
||||||
|
c'est léger et que le besoin ne demande pas d'ORM » est une justification.
|
||||||
|
« Flask parce qu'on l'a vu en cours » n'en est pas une.
|
||||||
|
5. **Sécurité** — hachage, sessions, requêtes paramétrées, contrôle des rôles,
|
||||||
|
validation des entrées. Une diapositive par mesure, avec l'extrait de code.
|
||||||
|
6. **Tests** — votre stratégie, le nombre de tests, une capture de `pytest`
|
||||||
|
7. **Démonstration** — captures d'écran du parcours complet
|
||||||
|
8. **Conclusion** — ce qui manque, et ce que vous feriez avec deux jours de plus
|
||||||
|
|
||||||
|
**Préparez une réponse à ces trois questions**, elles tombent presque toujours :
|
||||||
|
|
||||||
|
- Pourquoi hacher plutôt que chiffrer un mot de passe ?
|
||||||
|
- Que se passe-t-il si je tape l'URL d'administration sans être connecté ?
|
||||||
|
- Comment savez-vous que votre application fonctionne ?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Grille d'évaluation
|
||||||
|
|
||||||
|
| Compétence du référentiel | Vérifiée par |
|
||||||
|
|---|---|
|
||||||
|
| **C15** — procédures d'authentification et d'autorisation intégrées | Exercices 6, 7, 9 |
|
||||||
|
| **C15** — activités autorisées et interdites définies | Exercices 9, 13 |
|
||||||
|
| **C15** — gestion sécurisée des données sur tout leur cycle de vie | Exercices 5, 14 |
|
||||||
|
| **C16** — langages web et logiciel mobilisés (Python, JS…) | Exercices 1 à 10 |
|
||||||
|
| **C16** — standards de développement respectés | `ruff check .` sur le rendu |
|
||||||
|
| **C16** — les 4 critères de sécurité respectés | Exercice 14 |
|
||||||
|
| **C17** — risques identifiés, robustesse aux attaques | Exercices 11, 12, 13 |
|
||||||
|
| **C17** — composants externes connus et éprouvés | Exercice 16, diapositive 4 |
|
||||||
|
|
||||||
|
| Barème indicatif | Points |
|
||||||
|
|---|---|
|
||||||
|
| Partie A — bases web (ex. 1-3) | 2 |
|
||||||
|
| Partie B — données et inscription (ex. 4-5) | 4 |
|
||||||
|
| Partie C — authentification et rôles (ex. 6-10) | 6 |
|
||||||
|
| Partie D — sécurité (ex. 11-14) | 4 |
|
||||||
|
| Partie E — présentation (ex. 16) | 4 |
|
||||||
|
|
||||||
|
**Retraits transversaux** : mot de passe en clair en base (**−5, éliminatoire**)
|
||||||
|
· requête SQL construite par concaténation (−3) · route protégée uniquement par
|
||||||
|
le masquage d'un lien (−3) · `except:` nu (−1) · `app.secret_key` codée en dur
|
||||||
|
dans un rendu présenté comme « prêt pour la production » (−1).
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# Fiche — installer Python et Flask
|
||||||
|
|
||||||
|
À garder de côté. Vous n'en avez besoin que si `verif_poste.py` affiche une
|
||||||
|
ligne `[ KO ]`, ou si votre terminal ne reconnaît pas la commande `python`.
|
||||||
|
|
||||||
|
**Durée : 10 minutes.** Suivez les étapes dans l'ordre, sans en sauter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
|
||||||
|
## Étape 1 — Vérifier ce que vous avez déjà
|
||||||
|
|
||||||
|
Ouvrez un terminal : touche **Windows**, tapez `cmd`, **Entrée**.
|
||||||
|
|
||||||
|
Tapez :
|
||||||
|
|
||||||
|
```
|
||||||
|
py --list
|
||||||
|
```
|
||||||
|
|
||||||
|
| Ce que vous voyez | Ce que ça veut dire |
|
||||||
|
|---|---|
|
||||||
|
| Une liste avec `-V:3.9` ou mieux | **Python est déjà installé.** Passez directement à l'étape 4. |
|
||||||
|
| `'py' n'est pas reconnu…` | Python n'est pas installé, ou mal installé. Étape 2. |
|
||||||
|
| Uniquement des versions `3.8` ou moins | Trop ancien pour Flask 3. Étape 2. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 2 — Télécharger
|
||||||
|
|
||||||
|
Allez sur **https://www.python.org/downloads/**
|
||||||
|
|
||||||
|
Le gros bouton jaune propose la dernière version pour votre système.
|
||||||
|
Cliquez, puis lancez le fichier téléchargé.
|
||||||
|
|
||||||
|
> N'installez pas Python depuis le Microsoft Store. Il fonctionne, mais il
|
||||||
|
> isole les fichiers d'une façon qui pose des problèmes avec les
|
||||||
|
> environnements virtuels — exactement ce qu'on va utiliser.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 3 — Installer : l'écran qui compte
|
||||||
|
|
||||||
|
Le premier écran de l'installateur contient **une case à cocher en bas** :
|
||||||
|
|
||||||
|
```
|
||||||
|
☑ Add python.exe to PATH ← COCHEZ-LA
|
||||||
|
```
|
||||||
|
|
||||||
|
**C'est l'étape la plus importante de toute la fiche.** Si vous l'oubliez,
|
||||||
|
Windows ne saura pas où trouver Python et toutes les commandes échoueront.
|
||||||
|
|
||||||
|
Cochez la case, puis cliquez sur **Install Now**.
|
||||||
|
|
||||||
|
Si vous avez déjà cliqué sans cocher : relancez l'installateur, choisissez
|
||||||
|
**Modify**, puis **Repair** — ou désinstallez et recommencez.
|
||||||
|
|
||||||
|
À la fin, s'il propose **« Disable path length limit »**, acceptez.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 4 — Vérifier
|
||||||
|
|
||||||
|
**Fermez complètement votre terminal et rouvrez-en un neuf.** Le PATH n'est lu
|
||||||
|
qu'à l'ouverture : sans ça, vous testerez l'ancienne configuration.
|
||||||
|
|
||||||
|
```
|
||||||
|
py --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Vous devez voir un numéro de version, par exemple `Python 3.13.2`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 5 — Installer Flask
|
||||||
|
|
||||||
|
```
|
||||||
|
py -m pip install flask pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Puis relancez la vérification :
|
||||||
|
|
||||||
|
```
|
||||||
|
py verif_poste.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Vous devez obtenir « Tout est prêt. Rien à faire. »
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
|
||||||
|
## Étape 1 — Vérifier
|
||||||
|
|
||||||
|
Ouvrez **Terminal** (Spotlight : `Cmd + Espace`, tapez `terminal`).
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 --version
|
||||||
|
```
|
||||||
|
|
||||||
|
Si vous obtenez 3.9 ou mieux, passez à l'étape 3.
|
||||||
|
|
||||||
|
## Étape 2 — Installer
|
||||||
|
|
||||||
|
Téléchargez l'installateur macOS sur **https://www.python.org/downloads/**
|
||||||
|
et suivez-le. Aucune case particulière à cocher : sur macOS le PATH est
|
||||||
|
configuré automatiquement.
|
||||||
|
|
||||||
|
## Étape 3 — Installer Flask
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m pip install flask pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur macOS et Linux, remplacez partout `py` par `python3` dans les commandes
|
||||||
|
de l'atelier.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Dépannage
|
||||||
|
|
||||||
|
## « 'python' n'est pas reconnu en tant que commande interne »
|
||||||
|
|
||||||
|
La case **Add python.exe to PATH** n'a pas été cochée.
|
||||||
|
|
||||||
|
Solution immédiate : utilisez `py` à la place de `python`. Le lanceur `py`
|
||||||
|
est installé séparément et fonctionne presque toujours.
|
||||||
|
|
||||||
|
Solution durable : relancez l'installateur → **Modify** → **Repair**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Taper `python` ouvre le Microsoft Store
|
||||||
|
|
||||||
|
Windows intercepte la commande avec un raccourci publicitaire.
|
||||||
|
|
||||||
|
**Paramètres** → **Applications** → **Paramètres avancés des applications**
|
||||||
|
→ **Alias d'exécution d'application** → désactivez les deux lignes
|
||||||
|
`python.exe` et `python3.exe`.
|
||||||
|
|
||||||
|
En attendant, utilisez `py` : il n'est pas concerné.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## J'ai plusieurs versions de Python et je ne sais pas laquelle tourne
|
||||||
|
|
||||||
|
```
|
||||||
|
py --list
|
||||||
|
```
|
||||||
|
|
||||||
|
Vous choisissez explicitement laquelle utiliser :
|
||||||
|
|
||||||
|
```
|
||||||
|
py -3.13 verif_poste.py # remplacez 3.13 par VOTRE version
|
||||||
|
py -3.13 -m pip install flask
|
||||||
|
```
|
||||||
|
|
||||||
|
Le numéro après `-` est la version. Utilisez **toujours le même** pendant
|
||||||
|
tout l'atelier, sinon vous installerez Flask dans une version et lancerez le
|
||||||
|
code avec une autre — c'est la cause numéro un des `ModuleNotFoundError`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `pip` échoue avec une erreur de certificat ou de proxy
|
||||||
|
|
||||||
|
Vous êtes probablement sur un réseau d'entreprise ou d'école qui filtre.
|
||||||
|
|
||||||
|
Essayez d'abord :
|
||||||
|
|
||||||
|
```
|
||||||
|
py -m pip install --user flask pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Si cela échoue encore, utilisez votre connexion personnelle (partage de
|
||||||
|
connexion du téléphone) le temps de l'installation. Une fois Flask installé,
|
||||||
|
le réseau n'a plus d'importance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `ModuleNotFoundError: No module named 'flask'`
|
||||||
|
|
||||||
|
Flask est installé, mais pas pour l'interpréteur qui exécute votre code.
|
||||||
|
|
||||||
|
Vérifiez avec quel Python vous travaillez :
|
||||||
|
|
||||||
|
```
|
||||||
|
py verif_poste.py
|
||||||
|
```
|
||||||
|
|
||||||
|
La ligne **Executable** vous donne le chemin exact. Installez Flask pour
|
||||||
|
celui-là précisément :
|
||||||
|
|
||||||
|
```
|
||||||
|
py -m pip install flask
|
||||||
|
```
|
||||||
|
|
||||||
|
Si vous utilisez un environnement virtuel, vérifiez qu'il est **activé** :
|
||||||
|
votre ligne de commande doit commencer par `(.venv)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rien ne fonctionne et le cours a commencé
|
||||||
|
|
||||||
|
**Ne restez pas bloqué seul.** Dans le chat du cours, envoyez :
|
||||||
|
|
||||||
|
1. Le résultat complet de `py verif_poste.py`
|
||||||
|
2. La commande que vous avez tapée
|
||||||
|
3. Le message d'erreur **en entier**
|
||||||
|
|
||||||
|
En attendant la réponse, suivez sur l'écran partagé et binômez avec un
|
||||||
|
camarade. Vous rattraperez le code à la pause : ce qui compte maintenant,
|
||||||
|
c'est de ne pas perdre le fil du raisonnement.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Mémo — les 30 premières minutes de l'épreuve
|
||||||
|
|
||||||
|
> À garder sous les yeux pendant l'examen.
|
||||||
|
> Vous avez 4 heures. Les 30 premières décident des 3 h 30 suivantes.
|
||||||
|
|
||||||
|
Deux situations possibles. Lisez le sujet : on vous donne un dépôt, ou pas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Situation A — on vous fournit un dépôt
|
||||||
|
|
||||||
|
**Ne codez rien avant d'avoir fait ces six étapes.**
|
||||||
|
|
||||||
|
1. **Clonez**, puis **lisez le README en entier.** Cinq minutes de lecture
|
||||||
|
valent une heure de tâtonnement. Il dit comment lancer le projet.
|
||||||
|
|
||||||
|
2. **Cherchez le fichier de dépendances** : `requirements.txt`,
|
||||||
|
`pyproject.toml` ou `Pipfile`. Il vous dit quel framework est imposé.
|
||||||
|
|
||||||
|
3. **Installez et lancez l'application telle quelle**, avant toute
|
||||||
|
modification. Si elle ne démarre pas, c'est votre premier problème à
|
||||||
|
résoudre — et vous devez le savoir maintenant, pas à 15 h.
|
||||||
|
|
||||||
|
4. **Cartographiez le projet en cinq minutes**, par écrit :
|
||||||
|
|
||||||
|
| Question | Réponse |
|
||||||
|
|---|---|
|
||||||
|
| Où sont déclarées les routes ? | |
|
||||||
|
| Où sont les templates ? | |
|
||||||
|
| Y a-t-il déjà une base de données ? un schéma ? | |
|
||||||
|
| Qu'est-ce qui marche déjà ? | |
|
||||||
|
| Qu'est-ce qui est vide ou marqué « à faire » ? | |
|
||||||
|
|
||||||
|
5. **Repérez les conventions existantes** : langue des noms de variables,
|
||||||
|
structure des fonctions, façon d'accéder à la base. Votre code doit
|
||||||
|
ressembler à celui qui est déjà là. Un jury le remarque.
|
||||||
|
|
||||||
|
6. **Faites un premier commit** avant de commencer. Vous aurez un point de
|
||||||
|
retour si vous cassez tout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Situation B — page blanche
|
||||||
|
|
||||||
|
**L'arborescence à créer, dans cet ordre :**
|
||||||
|
|
||||||
|
```
|
||||||
|
monprojet/
|
||||||
|
app.py
|
||||||
|
schema.sql
|
||||||
|
requirements.txt
|
||||||
|
templates/
|
||||||
|
base.html
|
||||||
|
static/
|
||||||
|
style.css
|
||||||
|
```
|
||||||
|
|
||||||
|
**Les commandes (Windows)** :
|
||||||
|
|
||||||
|
```
|
||||||
|
mkdir monprojet
|
||||||
|
cd monprojet
|
||||||
|
py -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install flask
|
||||||
|
pip freeze > requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Sur macOS ou Linux, remplacez les deux premières lignes par :
|
||||||
|
`python3 -m venv .venv` puis `source .venv/bin/activate`
|
||||||
|
|
||||||
|
**Le `app.py` minimal, à taper de mémoire :**
|
||||||
|
|
||||||
|
```python
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = "a-changer"
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def accueil():
|
||||||
|
return "ça tourne"
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(debug=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
Lancez-le. **Vous devez voir « ça tourne » dans le navigateur avant d'écrire
|
||||||
|
la moindre fonctionnalité.** Tant que ce n'est pas le cas, rien d'autre
|
||||||
|
n'a d'importance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dans les deux cas : l'ordre de construction
|
||||||
|
|
||||||
|
Ne construisez jamais dans un autre ordre. Chaque étape s'appuie sur la
|
||||||
|
précédente, et chacune doit marcher avant de passer à la suivante.
|
||||||
|
|
||||||
|
| # | Étape | Fini quand… |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | L'application démarre | une page s'affiche |
|
||||||
|
| 2 | La base et la table existent | vous lisez une ligne en Python |
|
||||||
|
| 3 | L'inscription | un compte apparaît en base, mot de passe **haché** |
|
||||||
|
| 4 | La connexion | la session retient qui vous êtes |
|
||||||
|
| 5 | Le profil | vous voyez et modifiez vos infos |
|
||||||
|
| 6 | Les rôles | un non-admin est refusé sur la page admin |
|
||||||
|
| 7 | La statistique | le compteur s'affiche sur l'accueil |
|
||||||
|
| 8 | **Les captures d'écran** | vous avez de quoi faire la démo |
|
||||||
|
|
||||||
|
**À 14 h, arrêtez de coder.** La présentation vaut autant que le code. Une
|
||||||
|
application à 80 % bien présentée passe mieux qu'une application à 100 %
|
||||||
|
sans support.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Les pièges qui coûtent des heures
|
||||||
|
|
||||||
|
| Symptôme | Cause réelle |
|
||||||
|
|---|---|
|
||||||
|
| `ModuleNotFoundError: flask` | environnement virtuel non activé — regardez si `(.venv)` est au début de la ligne |
|
||||||
|
| `Address already in use` | une ancienne instance tourne encore : fermez-la, ou `app.run(port=5001)` |
|
||||||
|
| Les modifications n'apparaissent pas | `debug=True` oublié, ou page en cache (Ctrl+F5) |
|
||||||
|
| `jinja2.TemplateNotFound` | le dossier doit s'appeler exactement `templates` |
|
||||||
|
| `RuntimeError: The session is unavailable` | `app.secret_key` manquante |
|
||||||
|
| Les accents s'affichent en `?` | `encoding="utf-8"` oublié à la lecture du fichier |
|
||||||
|
| L'erreur 405 | la route n'accepte pas POST : `methods=["GET", "POST"]` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce qui est éliminatoire
|
||||||
|
|
||||||
|
Quoi qu'il arrive, même à court de temps :
|
||||||
|
|
||||||
|
- **Jamais** de mot de passe en clair dans la base
|
||||||
|
- **Jamais** de requête SQL construite avec `+` ou une f-string
|
||||||
|
- **Toujours** le contrôle de rôle sur le serveur, pas seulement un lien caché
|
||||||
|
|
||||||
|
Si vous devez sacrifier une fonctionnalité, sacrifiez-la. Ne sacrifiez
|
||||||
|
jamais une de ces trois règles.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
Flask>=3.0
|
||||||
|
pytest>=8.0
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Règles de style de l'atelier.
|
||||||
|
# Les étudiants lancent ruff check . avant de rendre.
|
||||||
|
# Ce fichier garantit que tout le monde obtient le même résultat.
|
||||||
|
|
||||||
|
line-length = 88
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
select = ["E", "F", "W", "I", "UP", "B", "RET"]
|
||||||
|
|
||||||
|
ignore = [
|
||||||
|
# On enseigne date.today(), les fuseaux horaires ne sont pas au programme.
|
||||||
|
"DTZ011",
|
||||||
|
]
|
||||||
|
|
||||||
|
[lint.per-file-ignores]
|
||||||
|
# Dans le squelette, ces imports sont fournis d'avance aux étudiants :
|
||||||
|
# ils deviennent utiles au fur et à mesure des exercices.
|
||||||
|
"starter/app.py" = ["F401", "RET504", "RET501"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Bloc&Co — squelette de l'atelier
|
||||||
|
|
||||||
|
## Démarrer
|
||||||
|
|
||||||
|
py -m venv .venv
|
||||||
|
.venv\Scripts\activate
|
||||||
|
pip install -r ..\requirements.txt
|
||||||
|
python app.py
|
||||||
|
|
||||||
|
Sur macOS ou Linux, remplacez les deux premières lignes par :
|
||||||
|
`python3 -m venv .venv` puis `source .venv/bin/activate`
|
||||||
|
|
||||||
|
Puis ouvrir http://127.0.0.1:5000
|
||||||
|
|
||||||
|
## Ce qui est fourni
|
||||||
|
|
||||||
|
- `schema.sql` — la table `adherents`, à ne pas modifier
|
||||||
|
- `templates/` — tout le HTML, déjà écrit
|
||||||
|
- `static/style.css` — la feuille de style
|
||||||
|
- `app.py` — la plomberie de la base de données, et des fonctions à compléter
|
||||||
|
- `verif_poste.py` — vérifie que votre poste est prêt (à lancer en premier)
|
||||||
|
|
||||||
|
## Ce que vous écrivez
|
||||||
|
|
||||||
|
Tout ce qui est marqué `# À FAIRE`. Suivez les exercices dans l'ordre :
|
||||||
|
l'application ne fonctionnera entièrement qu'à la fin de l'exercice 10.
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Bloc&Co — gestion des adhérents d'une salle d'escalade.
|
||||||
|
|
||||||
|
Squelette de l'atelier. Complétez tout ce qui est marqué « À FAIRE ».
|
||||||
|
Lancement : python app.py puis http://127.0.0.1:5000
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import date
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Flask, flash, g, redirect, render_template, request, session, url_for
|
||||||
|
from werkzeug.security import check_password_hash, generate_password_hash
|
||||||
|
|
||||||
|
BASE = Path(__file__).parent
|
||||||
|
DB = BASE / "blocandco.db"
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.secret_key = "cle-de-developpement-a-changer"
|
||||||
|
|
||||||
|
levelX = ["debutant", "intermediaire", "confirme"]
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# FOURNI — plomberie de la base de données. Ne rien modifier ici.
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Une connexion par requête, rangée dans g et fermée automatiquement."""
|
||||||
|
if "db" not in g:
|
||||||
|
g.db = sqlite3.connect(DB)
|
||||||
|
g.db.row_factory = sqlite3.Row
|
||||||
|
return g.db
|
||||||
|
|
||||||
|
|
||||||
|
@app.teardown_appcontext
|
||||||
|
def close_db(exception):
|
||||||
|
db = g.pop("db", None)
|
||||||
|
if db is not None:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Crée la base au premier lancement et sème un compte administrateur."""
|
||||||
|
if DB.exists():
|
||||||
|
return
|
||||||
|
con = sqlite3.connect(DB)
|
||||||
|
con.executescript((BASE / "schema.sql").read_text(encoding="utf-8"))
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO adherents (lastname, firstname, email, password, level, role,"
|
||||||
|
" sub_date) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("Martin", "Claire", "admin@blocandco.fr",
|
||||||
|
generate_password_hash("Admin2026!"), "confirme", "admin",
|
||||||
|
date.today().isoformat()),
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
print("Base créée. Compte admin : admin@blocandco.fr / Admin2026!")
|
||||||
|
|
||||||
|
|
||||||
|
@app.context_processor
|
||||||
|
def injecter_utilisateur():
|
||||||
|
"""Rend la variable `utilisateur` disponible dans tous les templates."""
|
||||||
|
return {"utilisateur": utilisateur_courant()}
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# EXERCICE 6 — qui est connecté ?
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def utilisateur_courant():
|
||||||
|
"""Renvoie la ligne de l'adhérent connecté, ou None si personne.
|
||||||
|
|
||||||
|
À FAIRE :
|
||||||
|
- si "user_id" n'est pas dans `session`, renvoyer None
|
||||||
|
- sinon, aller chercher la ligne correspondante dans la base
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# EXERCICE 7 — protéger une page
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
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):
|
||||||
|
return vue(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# EXERCICE 9 — réserver une page aux administrateurs
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
def admin_requis(vue):
|
||||||
|
"""Interdit l'accès à la vue si l'utilisateur n'est pas administrateur.
|
||||||
|
|
||||||
|
À FAIRE : refuser les visiteurs non connectés, PUIS refuser ceux dont
|
||||||
|
le rôle n'est pas "admin". Deux messages différents.
|
||||||
|
"""
|
||||||
|
@wraps(vue)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
return vue(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# EXERCICE 5 — valider le formulaire d'inscription
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
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("/")
|
||||||
|
def accueil():
|
||||||
|
"""EXERCICE 10 — afficher le lastnamebre d'adhérents inscrits.
|
||||||
|
|
||||||
|
À 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"])
|
||||||
|
def inscription():
|
||||||
|
"""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":
|
||||||
|
pass
|
||||||
|
return render_template("inscription.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/connexion", methods=["GET", "POST"])
|
||||||
|
def connexion():
|
||||||
|
"""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":
|
||||||
|
pass
|
||||||
|
return render_template("connexion.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/deconnexion")
|
||||||
|
def deconnexion():
|
||||||
|
"""EXERCICE 7 — se déconnecter.
|
||||||
|
|
||||||
|
À FAIRE : vider la session et rediriger vers l'accueil.
|
||||||
|
"""
|
||||||
|
return redirect(url_for("accueil"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/profil", methods=["GET", "POST"])
|
||||||
|
@login_requis
|
||||||
|
def profil():
|
||||||
|
"""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":
|
||||||
|
pass
|
||||||
|
return render_template("profil.html", levelx=levelX)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin")
|
||||||
|
@admin_requis
|
||||||
|
def admin():
|
||||||
|
"""EXERCICE 9 — lister les adhérents.
|
||||||
|
|
||||||
|
À FAIRE : lire tous les adhérents, les plus récents d'abord.
|
||||||
|
"""
|
||||||
|
adherents = []
|
||||||
|
return render_template("admin.html", adherents=adherents)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/admin/promouvoir/<int:adherent_id>", methods=["POST"])
|
||||||
|
@admin_requis
|
||||||
|
def promouvoir(adherent_id):
|
||||||
|
"""EXERCICE 9 (suite) — promouvoir un adhérent administrateur."""
|
||||||
|
return redirect(url_for("admin"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
app.run(debug=True)
|
||||||
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "starter"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=3.1.3",
|
||||||
|
"pytest>=9.1.1",
|
||||||
|
]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- Schéma de la base « Bloc&Co »
|
||||||
|
-- Exécuté une seule fois, au premier démarrage de l'application.
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS adherents;
|
||||||
|
|
||||||
|
CREATE TABLE adherents (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lastname TEXT NOT NULL,
|
||||||
|
firstname TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL, -- empreinte, jamais le mot de passe
|
||||||
|
level TEXT NOT NULL DEFAULT 'debutant',
|
||||||
|
role TEXT NOT NULL DEFAULT 'adherent',
|
||||||
|
sub_date TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
:root {
|
||||||
|
--bleu: #0b6e9e;
|
||||||
|
--bleu-fonce: #084f71;
|
||||||
|
--gris: #5c6672;
|
||||||
|
--bord: #dde3e9;
|
||||||
|
--fond: #f6f8fa;
|
||||||
|
--rouge: #a8341f;
|
||||||
|
--vert: #1f6b45;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
|
color: #1a1f26;
|
||||||
|
background: var(--fond);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex; align-items: center; gap: 1.5rem;
|
||||||
|
padding: 0.9rem 1.5rem; background: #fff;
|
||||||
|
border-bottom: 1px solid var(--bord);
|
||||||
|
}
|
||||||
|
.logo { font-weight: 700; font-size: 1.15rem; color: var(--bleu); text-decoration: none; }
|
||||||
|
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:hover { color: var(--bleu); }
|
||||||
|
main { max-width: 780px; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
|
||||||
|
h1 { font-size: 1.7rem; margin: 0 0 0.4rem; }
|
||||||
|
h2 { font-size: 1.15rem; margin: 2rem 0 0.6rem; }
|
||||||
|
.chapeau { color: var(--gris); margin-top: 0; }
|
||||||
|
|
||||||
|
.carte {
|
||||||
|
background: #fff; border: 1px solid var(--bord); border-radius: 8px;
|
||||||
|
padding: 1.25rem; display: flex; flex-direction: column; gap: 0.85rem;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.88rem; color: var(--gris); }
|
||||||
|
input, select {
|
||||||
|
padding: 0.5rem 0.65rem; border: 1px solid var(--bord); border-radius: 5px;
|
||||||
|
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); }
|
||||||
|
.aide { font-size: 0.8rem; color: var(--gris); margin: 0; }
|
||||||
|
|
||||||
|
button, .bouton {
|
||||||
|
background: var(--bleu); color: #fff; border: none; border-radius: 5px;
|
||||||
|
padding: 0.55rem 1rem; font-size: 0.95rem; font-family: inherit;
|
||||||
|
cursor: pointer; text-decoration: none; display: inline-block; text-align: center;
|
||||||
|
}
|
||||||
|
button:hover, .bouton:hover { background: var(--bleu-fonce); }
|
||||||
|
button.lien { background: none; color: var(--bleu); padding: 0; text-decoration: underline; }
|
||||||
|
button.lien:hover { background: none; color: var(--bleu-fonce); }
|
||||||
|
|
||||||
|
.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 .erreur { background: #fbecea; border-color: var(--rouge); color: var(--rouge); }
|
||||||
|
.messages .succes { background: #e9f3ed; border-color: var(--vert); color: var(--vert); }
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex; align-items: baseline; gap: 0.6rem;
|
||||||
|
background: #fff; border: 1px solid var(--bord); border-radius: 8px;
|
||||||
|
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; }
|
||||||
|
.libelle { color: var(--gris); }
|
||||||
|
|
||||||
|
.infos { display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 1.5rem; }
|
||||||
|
.infos dt { color: var(--gris); font-size: 0.88rem; }
|
||||||
|
.infos 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; }
|
||||||
|
th, td { text-align: left; padding: 0.6rem 0.8rem; border-bottom: 1px solid var(--bord); }
|
||||||
|
th { background: #eef2f6; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--gris); }
|
||||||
|
tr:last-child td { border-bottom: none; }
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{% 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 %}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block titre %}Administration — Bloc&Co{% endblock %}
|
||||||
|
{% block contenu %}
|
||||||
|
<h1>Administration</h1>
|
||||||
|
<p>{{ adherents|length }} adhérent{{ 's' if adherents|length > 1 else '' }} enregistré{{ 's' if adherents|length > 1 else '' }}.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>lastname</th><th>E-mail</th><th>level</th><th>Rôle</th><th>Inscription</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for a in adherents %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ a.firstname }} {{ a.lastname }}</td>
|
||||||
|
<td>{{ a.email }}</td>
|
||||||
|
<td>{{ a.level }}</td>
|
||||||
|
<td>{{ a.role }}</td>
|
||||||
|
<td>{{ a.sub_date }}</td>
|
||||||
|
<td>
|
||||||
|
{% if a.role != 'admin' %}
|
||||||
|
<form method="post" action="{{ url_for('promouvoir', adherent_id=a.id) }}">
|
||||||
|
<button type="submit" class="lien">Promouvoir</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{% block titre %}Bloc&Co{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<a class="logo" href="{{ url_for('accueil') }}">Bloc&Co</a>
|
||||||
|
<nav>
|
||||||
|
{% if utilisateur %}
|
||||||
|
<a href="{{ url_for('profil') }}">Mon profil</a>
|
||||||
|
{% if utilisateur.role == 'admin' %}
|
||||||
|
<a href="{{ url_for('admin') }}">Administration</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ url_for('deconnexion') }}">Déconnexion</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ url_for('connexion') }}">Connexion</a>
|
||||||
|
<a class="bouton" href="{{ url_for('inscription') }}">Inscription</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
<ul class="messages">
|
||||||
|
{% for categorie, texte in messages %}
|
||||||
|
<li class="{{ categorie }}">{{ texte }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block contenu %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block titre %}Connexion — Bloc&Co{% endblock %}
|
||||||
|
{% block contenu %}
|
||||||
|
<h1>Connexion</h1>
|
||||||
|
<form method="post" class="carte">
|
||||||
|
<label>Adresse e-mail
|
||||||
|
<input type="email" name="email" value="{{ email or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Mot de passe
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Se connecter</button>
|
||||||
|
</form>
|
||||||
|
<p>Pas encore de compte ? <a href="{{ url_for('inscription') }}">Inscrivez-vous</a>.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block titre %}Inscription — Bloc&Co{% endblock %}
|
||||||
|
{% block contenu %}
|
||||||
|
<h1>Créer un compte</h1>
|
||||||
|
<form method="post" class="carte">
|
||||||
|
<label>Prélastname
|
||||||
|
<input type="text" name="firstname" value="{{ firstname or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>lastname
|
||||||
|
<input type="text" name="lastname" value="{{ lastname or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Adresse e-mail
|
||||||
|
<input type="email" name="email" value="{{ email or '' }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Mot de passe
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</label>
|
||||||
|
<label>Confirmer le mot de passe
|
||||||
|
<input type="password" name="confirmation" required>
|
||||||
|
</label>
|
||||||
|
<p class="aide">12 caractères minimum, au moins une majuscule et un chiffre.</p>
|
||||||
|
<button type="submit">S'inscrire</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{% 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,172 @@
|
|||||||
|
"""Tests de l'application Bloc&Co.
|
||||||
|
|
||||||
|
Lancement : pytest -v
|
||||||
|
|
||||||
|
Ces tests valident le comportement attendu à la fin de l'atelier.
|
||||||
|
Donnez-les aux étudiants : un exercice est réussi quand ses tests passent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import app as application
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
"""Une application neuve, avec une base jetable, pour chaque test."""
|
||||||
|
base = tmp_path / "test.db"
|
||||||
|
monkeypatch.setattr(application, "DB", base)
|
||||||
|
application.init_db()
|
||||||
|
application.app.config["TESTING"] = True
|
||||||
|
with application.app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
def subscribe(client, email="lea@test.fr", mdp="Grimpe2026ABC"):
|
||||||
|
return client.post("/inscription", data={
|
||||||
|
"lastname": "Dupont", "firstname": "Lea", "email": email,
|
||||||
|
"password": mdp, "confirmation": mdp,
|
||||||
|
}, follow_redirects=True)
|
||||||
|
|
||||||
|
def connect(client, email="lea@test.fr", mdp="Grimpe2026ABC"):
|
||||||
|
return client.post("/connexion", data={
|
||||||
|
"email": email, "password": mdp,
|
||||||
|
}, follow_redirects=True)
|
||||||
|
|
||||||
|
# --- Exercice 10 : la statistique ----------------------------------------
|
||||||
|
|
||||||
|
def test_accueil_affiche_le_lastnamebre_d_adherents(client):
|
||||||
|
reponse = client.get("/")
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
assert b">1<" in reponse.data # le compte admin seul
|
||||||
|
|
||||||
|
subscribe(client)
|
||||||
|
assert b">2<" in client.get("/").data
|
||||||
|
|
||||||
|
# --- Exercice 5 : l'inscription ------------------------------------------
|
||||||
|
|
||||||
|
def test_inscription_password_trop_court_refuse(client):
|
||||||
|
reponse = subscribe(client, mdp="Court1A")
|
||||||
|
assert b"12 caract" in reponse.data
|
||||||
|
|
||||||
|
def test_inscription_password_sans_chiffre_refuse(client):
|
||||||
|
reponse = subscribe(client, mdp="MotDePasseSansChiffre")
|
||||||
|
assert b"chiffre" in reponse.data
|
||||||
|
|
||||||
|
def test_inscription_email_en_double_refuse(client):
|
||||||
|
subscribe(client)
|
||||||
|
reponse = subscribe(client)
|
||||||
|
assert "déjà utilis".encode() in reponse.data
|
||||||
|
|
||||||
|
def test_inscription_email_insensible_a_la_casse(client):
|
||||||
|
subscribe(client, email="Lea@Test.fr")
|
||||||
|
reponse = subscribe(client, email="lea@test.fr")
|
||||||
|
assert "déjà utilis".encode() in reponse.data
|
||||||
|
|
||||||
|
def test_inscription_password_jamais_en_clair(client):
|
||||||
|
subscribe(client)
|
||||||
|
stocke = sqlite3.connect(application.DB).execute(
|
||||||
|
"SELECT password FROM adherents WHERE email = 'lea@test.fr'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert stocke != "Grimpe2026ABC"
|
||||||
|
assert len(stocke) > 40
|
||||||
|
|
||||||
|
# --- Exercice 6 : la connexion -------------------------------------------
|
||||||
|
|
||||||
|
def test_mauvais_password_refuse(client):
|
||||||
|
subscribe(client)
|
||||||
|
reponse = connect(client, mdp="MauvaisMotDePasse")
|
||||||
|
assert b"Identifiants incorrects" in reponse.data
|
||||||
|
|
||||||
|
def test_compte_inexistant_donne_le_meme_message(client):
|
||||||
|
"""Le message ne doit pas révéler si l'adresse existe dans la base."""
|
||||||
|
subscribe(client)
|
||||||
|
inconnu = connect(client, email="personne@test.fr", mdp="Grimpe2026ABC")
|
||||||
|
mauvais = connect(client, mdp="MauvaisMotDePasse")
|
||||||
|
assert b"Identifiants incorrects" in inconnu.data
|
||||||
|
assert b"Identifiants incorrects" in mauvais.data
|
||||||
|
|
||||||
|
def test_connexion_reussie(client):
|
||||||
|
subscribe(client)
|
||||||
|
reponse = connect(client)
|
||||||
|
assert b"Mon profil" in reponse.data
|
||||||
|
|
||||||
|
# --- Exercice 7 : les pages protégées ------------------------------------
|
||||||
|
|
||||||
|
def test_profil_protege_sans_connexion(client):
|
||||||
|
reponse = client.get("/profil", follow_redirects=True)
|
||||||
|
assert b"Connectez-vous" in reponse.data
|
||||||
|
|
||||||
|
def test_profil_protege_apres_deconnexion(client):
|
||||||
|
subscribe(client)
|
||||||
|
connect(client)
|
||||||
|
client.get("/deconnexion")
|
||||||
|
reponse = client.get("/profil", follow_redirects=True)
|
||||||
|
assert b"Connectez-vous" in reponse.data
|
||||||
|
|
||||||
|
# --- Exercice 8 : le profil ----------------------------------------------
|
||||||
|
|
||||||
|
def test_profil_modifiable(client):
|
||||||
|
subscribe(client)
|
||||||
|
connect(client)
|
||||||
|
reponse = client.post("/profil", data={
|
||||||
|
"lastname": "Dupont", "firstname": "Léa", "level": "confirme",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert "mis à jour".encode() in reponse.data
|
||||||
|
|
||||||
|
def test_level_invalide_rejete(client):
|
||||||
|
"""Le formulaire HTML est modifiable : le serveur doit revalider."""
|
||||||
|
subscribe(client)
|
||||||
|
connect(client)
|
||||||
|
reponse = client.post("/profil", data={
|
||||||
|
"lastname": "Dupont", "firstname": "Lea", "level": "super_admin",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert b"level invalide" in reponse.data
|
||||||
|
|
||||||
|
# --- Exercice 9 : les rôles ----------------------------------------------
|
||||||
|
|
||||||
|
def test_admin_interdit_a_un_adherent(client):
|
||||||
|
subscribe(client)
|
||||||
|
connect(client)
|
||||||
|
reponse = client.get("/admin", follow_redirects=True)
|
||||||
|
assert "réservé aux administrateurs".encode() in reponse.data
|
||||||
|
|
||||||
|
def test_admin_accessible_a_l_administrateur(client):
|
||||||
|
subscribe(client)
|
||||||
|
connect(client, email="admin@blocandco.fr", mdp="Admin2026!")
|
||||||
|
reponse = client.get("/admin")
|
||||||
|
assert reponse.status_code == 200
|
||||||
|
assert b"lea@test.fr" in reponse.data
|
||||||
|
|
||||||
|
def test_promotion_interdite_a_un_adherent(client):
|
||||||
|
subscribe(client)
|
||||||
|
connect(client)
|
||||||
|
client.post("/admin/promouvoir/1", follow_redirects=True)
|
||||||
|
role = sqlite3.connect(application.DB).execute(
|
||||||
|
"SELECT role FROM adherents WHERE email = 'lea@test.fr'"
|
||||||
|
).fetchone()[0]
|
||||||
|
assert role == "adherent"
|
||||||
|
|
||||||
|
# --- Exercice 11 : injection SQL -----------------------------------------
|
||||||
|
|
||||||
|
def test_injection_sql_sur_la_connexion(client):
|
||||||
|
"""Avec des requêtes paramétrées, cette charge ne doit rien ouvrir."""
|
||||||
|
subscribe(client)
|
||||||
|
reponse = client.post("/connexion", data={
|
||||||
|
"email": "lea@test.fr' OR '1'='1",
|
||||||
|
"password": "peu importe",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
assert b"Identifiants incorrects" in reponse.data
|
||||||
|
assert b"Mon profil" not in reponse.data
|
||||||
|
|
||||||
|
# --- Exercice 12 : XSS ---------------------------------------------------
|
||||||
|
|
||||||
|
def test_xss_dans_le_firstname_est_echappe(client):
|
||||||
|
subscribe(client)
|
||||||
|
connect(client)
|
||||||
|
client.post("/profil", data={
|
||||||
|
"lastname": "Dupont", "firstname": "<script>alert(1)</script>",
|
||||||
|
"level": "debutant",
|
||||||
|
}, follow_redirects=True)
|
||||||
|
reponse = client.get("/profil")
|
||||||
|
assert b"<script>alert(1)</script>" not in reponse.data
|
||||||
|
assert b"<script>" in reponse.data
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blinker"
|
||||||
|
version = "1.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flask"
|
||||||
|
version = "3.1.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "blinker" },
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "itsdangerous" },
|
||||||
|
{ name = "jinja2" },
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
{ name = "werkzeug" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itsdangerous"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jinja2"
|
||||||
|
version = "3.1.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.21.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "starter"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "flask", specifier = ">=3.1.3" },
|
||||||
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "werkzeug"
|
||||||
|
version = "3.1.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Vérification du poste avant l'atelier.
|
||||||
|
|
||||||
|
Lancez simplement : python verif_poste.py
|
||||||
|
Puis copiez-collez TOUT le résultat au formateur.
|
||||||
|
|
||||||
|
Ce script ne modifie rien et n'installe rien.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import platform
|
||||||
|
import sys
|
||||||
|
|
||||||
|
MINIMUM = (3, 9)
|
||||||
|
|
||||||
|
|
||||||
|
def version_python():
|
||||||
|
v = sys.version_info
|
||||||
|
actuelle = f"{v.major}.{v.minor}.{v.micro}"
|
||||||
|
if (v.major, v.minor) >= MINIMUM:
|
||||||
|
return True, f"Python {actuelle}"
|
||||||
|
return False, (f"Python {actuelle} — TROP ANCIEN, il faut au moins "
|
||||||
|
f"{MINIMUM[0]}.{MINIMUM[1]}")
|
||||||
|
|
||||||
|
|
||||||
|
def module_present(lastname):
|
||||||
|
try:
|
||||||
|
__import__(lastname)
|
||||||
|
except ImportError:
|
||||||
|
return False, f"{lastname} : ABSENT"
|
||||||
|
try:
|
||||||
|
from importlib.metadata import version
|
||||||
|
return True, f"{lastname} : {version(lastname)}"
|
||||||
|
except Exception:
|
||||||
|
return True, f"{lastname} : présent"
|
||||||
|
|
||||||
|
|
||||||
|
def dans_un_venv():
|
||||||
|
actif = sys.prefix != getattr(sys, "base_prefix", sys.prefix)
|
||||||
|
if actif:
|
||||||
|
return True, "environnement virtuel : actif"
|
||||||
|
return True, "environnement virtuel : aucun (ce n'est pas bloquant)"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=" * 56)
|
||||||
|
print(" VERIFICATION DU POSTE - ATELIER PYTHON WEB")
|
||||||
|
print("=" * 56)
|
||||||
|
print(f" Systeme : {platform.system()} {platform.release()}")
|
||||||
|
print(f" Executable : {sys.executable}")
|
||||||
|
print("-" * 56)
|
||||||
|
|
||||||
|
resultats = [version_python(), dans_un_venv()]
|
||||||
|
for lastname in ("flask", "pytest"):
|
||||||
|
resultats.append(module_present(lastname))
|
||||||
|
|
||||||
|
for ok, message in resultats:
|
||||||
|
print((" [ OK ] " if ok else " [ KO ] ") + message)
|
||||||
|
|
||||||
|
print("-" * 56)
|
||||||
|
|
||||||
|
bloquant = not resultats[0][0]
|
||||||
|
modules = zip(("flask", "pytest"), resultats[2:], strict=False)
|
||||||
|
manquants = [lastname for lastname, (ok, _) in modules if not ok]
|
||||||
|
|
||||||
|
if bloquant:
|
||||||
|
print(" ACTION : installez une version recente de Python depuis")
|
||||||
|
print(" python.org, en cochant 'Add python.exe to PATH'.")
|
||||||
|
print(" Prevenez le formateur des ce soir.")
|
||||||
|
elif manquants:
|
||||||
|
print(" ACTION : lancez cette commande, puis relancez ce script :")
|
||||||
|
print()
|
||||||
|
print(" python -m pip install " + " ".join(manquants))
|
||||||
|
else:
|
||||||
|
print(" Tout est pret. Rien a faire.")
|
||||||
|
|
||||||
|
print("=" * 56)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user