Semaine 11
@@ -0,0 +1 @@
|
|||||||
|
3.12
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Module 1 — Exercices : Python & Web (HTTP / REST, sans framework)
|
||||||
|
|
||||||
|
Fil rouge : la **gestion d'un parc d'équipements réseau** (serveurs, switchs, routeurs). Tous les exercices restent en **Python pur** (bibliothèque standard uniquement, aucun framework web).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consignes communes à tous les exercices
|
||||||
|
|
||||||
|
**Usage de l'IA — transparence obligatoire.**
|
||||||
|
L'utilisation d'une IA générative (assistant de code, chatbot, complétion automatique) **n'est pas interdite**, mais elle doit être **assumée et documentée**. Chaque rendu **doit obligatoirement contenir un fichier `USAGE-IA.md`**. Un rendu sans ce fichier est considéré comme **incomplet**.
|
||||||
|
|
||||||
|
Le fichier `USAGE-IA.md` doit répondre à ces points :
|
||||||
|
1. **Outils utilisés** : quel(s) outil(s) d'IA, et pour quelles parties de l'exercice.
|
||||||
|
2. **Aide obtenue** : ce que l'IA a proposé (idées, extraits de code, explications). Donnez au moins un exemple de prompt.
|
||||||
|
3. **Ce que vous avez accepté** : ce que vous avez repris, et surtout *pourquoi vous l'avez compris et validé*.
|
||||||
|
4. **Ce que vous avez refusé ou corrigé** : ce que vous avez écarté ou modifié, et *pourquoi* (erreur, hors-sujet, non compris, mauvaise pratique…).
|
||||||
|
5. **Ce que vous avez appris** et ce que vous feriez autrement.
|
||||||
|
|
||||||
|
> Si vous n'avez utilisé aucune IA, écrivez-le explicitement dans `USAGE-IA.md` et décrivez brièvement votre démarche.
|
||||||
|
|
||||||
|
**Règle d'or : vous devez pouvoir expliquer et justifier chaque ligne de votre rendu**, que vous l'ayez écrite vous-même ou non. Le gabarit de `USAGE-IA.md` est fourni en annexe (fin de fichier).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 1.1 — Rapport d'inventaire en Python
|
||||||
|
|
||||||
|
**Compétences visées :** variables, listes, dictionnaires, boucles, conditions, f-strings.
|
||||||
|
|
||||||
|
**Contexte :** on vous fournit l'inventaire du parc sous forme d'une liste de dictionnaires.
|
||||||
|
|
||||||
|
```python
|
||||||
|
equipements = [
|
||||||
|
{"hostname": "srv-web-01", "ip": "192.168.1.10", "type": "serveur", "actif": True, "latence_ms": 12.4},
|
||||||
|
{"hostname": "sw-access-02", "ip": "192.168.1.2", "type": "switch", "actif": True, "latence_ms": 1.2},
|
||||||
|
{"hostname": "rtr-core-01", "ip": "10.0.0.1", "type": "routeur", "actif": False, "latence_ms": 3.5},
|
||||||
|
{"hostname": "sw-access-03", "ip": "192.168.1.3", "type": "switch", "actif": True, "latence_ms": 1.8},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Calculez le **nombre total** d'équipements et le **nombre d'équipements actifs**.
|
||||||
|
2. Construisez la **liste des hostnames** dont le `type` est `"switch"`.
|
||||||
|
3. Calculez la **latence moyenne des équipements actifs**.
|
||||||
|
4. Affichez un rapport lisible (une ligne par indicateur), latence arrondie à **2 décimales**.
|
||||||
|
|
||||||
|
**Livrables :** `inventaire.py`, `USAGE-IA.md`.
|
||||||
|
|
||||||
|
**Critères d'évaluation :** exactitude des calculs, usage correct des boucles/compréhensions et des conditions, lisibilité de la sortie.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Annexe — Gabarit `USAGE-IA.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Usage de l'IA — Exercice X.Y
|
||||||
|
|
||||||
|
## 1. Outils utilisés
|
||||||
|
(Ex : assistant de code / chatbot ; pour quelles parties. Ou : « aucune IA utilisée ».)
|
||||||
|
|
||||||
|
## 2. Aide obtenue
|
||||||
|
(Ce que l'IA a proposé. Exemple de prompt utilisé : « … »)
|
||||||
|
|
||||||
|
## 3. Ce que j'ai accepté
|
||||||
|
(Ce que j'ai repris et pourquoi je l'ai compris et validé.)
|
||||||
|
|
||||||
|
## 4. Ce que j'ai refusé ou corrigé
|
||||||
|
(Ce que j'ai écarté / modifié, et pourquoi.)
|
||||||
|
|
||||||
|
## 5. Ce que j'ai appris
|
||||||
|
(Ce que je retiens, ce que je ferais autrement.)
|
||||||
|
```
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
## Exercice 1.2 — Modéliser un équipement (POO) & calculer un sous-réseau
|
||||||
|
|
||||||
|
**Compétences visées :** fonctions, POO (classe, `__init__`, `self`, méthode), fonction `lambda`, tri.
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Écrivez une classe `Equipement` avec :
|
||||||
|
- un constructeur `__init__(self, hostname, ip, latence_ms, actif=True)` ;
|
||||||
|
- une méthode `__str__` renvoyant une description lisible ;
|
||||||
|
- une méthode `est_operationnel(self, seuil_ms=100)` qui renvoie `True` si l'équipement est **actif ET** que sa latence est **sous le seuil**.
|
||||||
|
2. Écrivez une fonction `hotes_disponibles(bits_hote)` qui renvoie le nombre d'adresses **hôtes utilisables** d'un sous-réseau (indice : `2 ** bits - 2`).
|
||||||
|
3. Écrivez une fonction `trier_par_latence(equipements)` qui renvoie la liste triée par latence croissante (utilisez `sorted` + `lambda`).
|
||||||
|
4. Créez 3 équipements, affichez-les **triés par latence** avec leur état opérationnel, puis affichez le nombre d'hôtes d'un `/24` (soit 8 bits d'hôte).
|
||||||
|
|
||||||
|
**Livrables :** `equipement.py`, `USAGE-IA.md`.
|
||||||
|
|
||||||
|
**Critères d'évaluation :** POO correcte (`self`, méthodes), `lambda` pour le tri, exactitude du calcul réseau.
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## Exercice 1.3 — API REST d'inventaire **sans framework**
|
||||||
|
|
||||||
|
**Compétences visées :** comprendre HTTP/REST « de l'intérieur » — méthodes, codes de statut, JSON — en n'utilisant **que la bibliothèque standard**.
|
||||||
|
|
||||||
|
**Contexte :** vous exposez l'inventaire via une petite API REST bâtie avec le module `http.server`. **Flask, Django et toute bibliothèque tierce sont interdits.**
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Utilisez `http.server.BaseHTTPRequestHandler` et `HTTPServer`.
|
||||||
|
2. Stockez les équipements dans une **liste Python en mémoire** (`id`, `hostname`, `ip`).
|
||||||
|
3. `GET /equipements` → renvoie la liste en JSON, code **200**.
|
||||||
|
4. `GET /equipements/<id>` → renvoie l'équipement (**200**), une erreur JSON **404** s'il n'existe pas, **400** si l'`id` n'est pas un entier.
|
||||||
|
5. `POST /equipements` → crée un équipement à partir du JSON du corps ; renvoie **201** et l'objet créé ; renvoie **400** si `hostname` manque ou si le JSON est invalide.
|
||||||
|
6. Toutes les réponses portent l'en-tête `Content-Type: application/json`.
|
||||||
|
7. Testez votre API (serveur lancé sur le port 8000) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://127.0.0.1:8000/equipements
|
||||||
|
curl http://127.0.0.1:8000/equipements/1
|
||||||
|
curl -X POST http://127.0.0.1:8000/equipements \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hostname":"srv-dns-01","ip":"192.168.1.20"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Livrables :** `api_inventaire.py`, `TESTS.md` (vos commandes `curl` et les réponses obtenues), `USAGE-IA.md`.
|
||||||
|
|
||||||
|
**Critères d'évaluation :** bonnes méthodes et bons codes HTTP, JSON valide, gestion des erreurs (400/404), **aucun framework**.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Exercice 2.3 — Inventaire avec l'ORM SQLAlchemy *(Avancé — 45 min)*
|
||||||
|
|
||||||
|
**Compétences visées :** ORM SQLAlchemy 2.0 (`Mapped` / `mapped_column`), modèles liés (`relationship`, `ForeignKey`), `Session`.
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Définissez deux modèles `Equipement` et `Interface` avec une relation **1-N** (`relationship` + `ForeignKey` + `back_populates`).
|
||||||
|
2. Créez le moteur SQLite et générez les tables (`create_all`).
|
||||||
|
3. Dans une `Session`, créez **un équipement avec ses deux interfaces en une seule fois**, puis `commit`.
|
||||||
|
4. Récupérez les équipements avec `select()` et affichez, pour chacun, la liste de ses interfaces.
|
||||||
|
|
||||||
|
**Livrables :** `inventaire_orm.py`, `USAGE-IA.md`.
|
||||||
|
|
||||||
|
**Critères d'évaluation :** modèles + relation corrects, gestion de la session/commit, requête `select()`, affichage des objets liés.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Exercice 1.3 "API REST" - Gauvain BOICHÉ
|
||||||
|
|
||||||
|
### Modèle(s) utilisés
|
||||||
|
|
||||||
|
- [MiniMax M3](https://www.minimax.io/blog/minimax-m3) (version 2026-06-12) avec étendue "Entire Web" activée.
|
||||||
|
|
||||||
|
### Usage de l'IA
|
||||||
|
|
||||||
|
Je déclare avoir usé de l'IA pour :
|
||||||
|
|
||||||
|
- Redéfinir les demandes (pourquoi l'absence de PUT ou DELETE dans une API REST ?) et les contours
|
||||||
|
=> "Sans toutes les méthodes, l'API sera dite REST-incomplète"
|
||||||
|
- Etablir des co-dépendances non mentionnées (comment lire deux listes sans threading ?)
|
||||||
|
=> `import threading`
|
||||||
|
=> `import urlparse`
|
||||||
|
- Quels garde-fous mettre sans connaissance de base ?
|
||||||
|
=> Valider le format de l'adresse IP
|
||||||
|
=> Rejet des doublons
|
||||||
|
=> Gérer un ID non fourni lors d'un POST
|
||||||
|
- Quelle gestion des états ?
|
||||||
|
=> 405 "Method not allowed"
|
||||||
|
=> 415 "Unsupported media type"
|
||||||
|
=> 500 "Internal Server Error"
|
||||||
|
- Sécurité ?
|
||||||
|
=> Appels anonymes, pas de limites ?
|
||||||
|
=> `_next_id = 1`
|
||||||
|
=> `MAX_BODY = 1_048_576 # 1 Mo`
|
||||||
|
|
||||||
|
Alors, ce n'est pas pour autant que j'ai TOUT implémenté. J'ai corrigé un ou deux trucs, mais faire des méthodes PUT et DELETE aurait prit trop de temps. Rédiger ex nihilo des fonctions sur une stack que je ne connais pas, c'est déjà bien assez comme ça.
|
||||||
|
|
||||||
|
### Non-usage de l'IA
|
||||||
|
|
||||||
|
Je déclare n'avoir pas usé de l'IA au profit des documentations officielles pour établir les fonctions.
|
||||||
|
|
||||||
|
Les documentations utilisées :
|
||||||
|
|
||||||
|
- [Exemple de fonctions déjà rédigées](https://gist.github.com/scimad/ae0196afc0bade2ae39d604225084507)
|
||||||
|
- [Doc http.server pour Python 3.12](https://docs.python.org/3.12/library/http.server.html)
|
||||||
|
- [Doc urllib.parse pour Python 3.12](https://docs.python.org/3.12/library/urllib.parse.html)
|
||||||
|
- [Threading](https://docs.python.org/3/library/threading.html#threading.Lock)
|
||||||
|
- [Architecture REST](https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm)
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Exercice 1.3 "API REST" - Gauvain BOICHÉ
|
||||||
|
|
||||||
|
## Commandes lancées
|
||||||
|
|
||||||
|
### 1. Liste vide
|
||||||
|
curl -i http://127.0.0.1:8000/equipments
|
||||||
|
curl -i http://127.0.0.1:8000/equipments/1
|
||||||
|
|
||||||
|
### 2. Création
|
||||||
|
curl -i -X POST http://127.0.0.1:8000/equipments \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hostname":"srv-dns-01","ip":"192.168.1.20"}'
|
||||||
|
|
||||||
|
### 3. Lecture
|
||||||
|
curl -i http://127.0.0.1:8000/equipments/1
|
||||||
|
|
||||||
|
### 4. Erreurs
|
||||||
|
curl -i http://127.0.0.1:8000/equipments/abc
|
||||||
|
curl -i http://127.0.0.1:8000/equipments/99
|
||||||
|
curl -i -X POST http://127.0.0.1:8000/equipments \
|
||||||
|
-H "Content-Type: application/json" -d '{"ip":"1.1.1.1"}'
|
||||||
|
|
||||||
|
## Résultats
|
||||||
|
|
||||||
|
curl -i -X POST http://127.0.0.1:8000/equipments -H "Content-Type: application/json" -d '{"hostname":"srv-dns-01","ip":"192.168.1.20"}'
|
||||||
|
|
||||||
|
### 1. Liste vide
|
||||||
|
|
||||||
|
#### Côté client
|
||||||
|
```
|
||||||
|
> curl -i http://127.0.0.1:8000/equipments
|
||||||
|
HTTP/1.0 200 OK
|
||||||
|
Server: BaseHTTP/0.6 Python/3.12.12
|
||||||
|
Date: Mon, 06 Jul 2026 14:21:49 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 2
|
||||||
|
|
||||||
|
[]
|
||||||
|
|
||||||
|
> curl -i http://127.0.0.1:8000/equipments/1
|
||||||
|
HTTP/1.0 404 Not Found
|
||||||
|
Server: BaseHTTP/0.6 Python/3.12.12
|
||||||
|
Date: Mon, 06 Jul 2026 14:26:13 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 41
|
||||||
|
|
||||||
|
{"erreur": "\u00e9quipement introuvable"}
|
||||||
|
|
||||||
|
```
|
||||||
|
#### Côté serveur
|
||||||
|
```
|
||||||
|
127.0.0.1 - - [06/Jul/2026 16:21:49] "GET /equipments HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [06/Jul/2026 16:24:39] "GET /equipments/1 HTTP/1.1" 404 -
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Création
|
||||||
|
|
||||||
|
#### Côté client
|
||||||
|
```
|
||||||
|
> curl -i -X POST http://127.0.0.1:8000/equipments -H "Content-Type: application/json" -d '{"hostname":"srv-dns-01","ip":"192.168.1.20"}'
|
||||||
|
HTTP/1.0 201 Created
|
||||||
|
Server: BaseHTTP/0.6 Python/3.12.12
|
||||||
|
Date: Mon, 06 Jul 2026 14:26:31 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 57
|
||||||
|
|
||||||
|
{"id": 1, "hostname": "srv-dns-01", "ip": "192.168.1.20"}
|
||||||
|
|
||||||
|
```
|
||||||
|
#### Côté serveur
|
||||||
|
```
|
||||||
|
127.0.0.1 - - [06/Jul/2026 16:26:31] "POST /equipments HTTP/1.1" 201 -
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Lecture
|
||||||
|
|
||||||
|
#### Côté client
|
||||||
|
```
|
||||||
|
> curl -i http://127.0.0.1:8000/equipments/1
|
||||||
|
HTTP/1.0 200 OK
|
||||||
|
Server: BaseHTTP/0.6 Python/3.12.12
|
||||||
|
Date: Mon, 06 Jul 2026 14:26:45 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 57
|
||||||
|
|
||||||
|
{"id": 1, "hostname": "srv-dns-01", "ip": "192.168.1.20"}
|
||||||
|
|
||||||
|
```
|
||||||
|
#### Côté serveur
|
||||||
|
```
|
||||||
|
127.0.0.1 - - [06/Jul/2026 16:26:45] "GET /equipments/1 HTTP/1.1" 200 -
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Erreurs
|
||||||
|
|
||||||
|
#### Côté client
|
||||||
|
```
|
||||||
|
> curl -i http://127.0.0.1:8000/equipments/abc
|
||||||
|
HTTP/1.0 400 Bad Request
|
||||||
|
Server: BaseHTTP/0.6 Python/3.12.12
|
||||||
|
Date: Mon, 06 Jul 2026 14:27:32 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 27
|
||||||
|
|
||||||
|
{"erreur": "id non entier"}
|
||||||
|
|
||||||
|
> curl -i http://127.0.0.1:8000/equipments/99
|
||||||
|
HTTP/1.0 404 Not Found
|
||||||
|
Server: BaseHTTP/0.6 Python/3.12.12
|
||||||
|
Date: Mon, 06 Jul 2026 14:30:26 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 41
|
||||||
|
|
||||||
|
{"erreur": "\u00e9quipement introuvable"}
|
||||||
|
|
||||||
|
> curl -i -X POST http://127.0.0.1:8000/equipments -H "Content-Type: application/json" -d '{"ip":"1.1.1.1"}'
|
||||||
|
HTTP/1.0 400 Bad Request
|
||||||
|
Server: BaseHTTP/0.6 Python/3.12.12
|
||||||
|
Date: Mon, 06 Jul 2026 14:30:48 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 35
|
||||||
|
|
||||||
|
{"erreur": "hostname et ip requis"}
|
||||||
|
```
|
||||||
|
#### Côté serveur
|
||||||
|
```
|
||||||
|
127.0.0.1 - - [06/Jul/2026 16:27:32] "GET /equipments/abc HTTP/1.1" 400 -
|
||||||
|
127.0.0.1 - - [06/Jul/2026 16:30:26] "GET /equipments/99 HTTP/1.1" 404 -
|
||||||
|
127.0.0.1 - - [06/Jul/2026 16:30:48] "POST /equipments HTTP/1.1" 400 -
|
||||||
|
```
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_equipments = []
|
||||||
|
_next_id = 1
|
||||||
|
MAX_BODY = 1_048_576 # 1 Mo
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryHandler(BaseHTTPRequestHandler):
|
||||||
|
def _send_json(self, code, payload):
|
||||||
|
body = json.dumps(payload).encode("utf-8")
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _read_json(self):
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
|
||||||
|
if not length or length > MAX_BODY:
|
||||||
|
raise ValueError("Corps absent ou trop volumineux.")
|
||||||
|
|
||||||
|
if "application/json" not in self.headers.get("Content-Type", ""):
|
||||||
|
raise ValueError("Content-Type invalide.")
|
||||||
|
|
||||||
|
return json.loads(self.rfile.read(length))
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
parts = path.strip("/").split("/")
|
||||||
|
|
||||||
|
if parts[0] != "equipments":
|
||||||
|
return self._send_json(404, {"erreur": "route inconnue"})
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
if len(parts) == 1:
|
||||||
|
return self._send_json(200, _equipments)
|
||||||
|
if len(parts) == 2:
|
||||||
|
try:
|
||||||
|
eid = int(parts[1])
|
||||||
|
except ValueError:
|
||||||
|
return self._send_json(400, {"erreur": "id non entier"})
|
||||||
|
for e in _equipments:
|
||||||
|
if e["id"] == eid:
|
||||||
|
return self._send_json(200, e)
|
||||||
|
return self._send_json(404, {"erreur": "équipement introuvable"})
|
||||||
|
|
||||||
|
return self._send_json(405, {"erreur": "méthode non autorisée"})
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
global _next_id
|
||||||
|
|
||||||
|
if urlparse(self.path).path.rstrip("/") != "/equipments":
|
||||||
|
return self._send_json(404, {"erreur": "route inconnue"})
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = self._read_json()
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
return self._send_json(400, {"erreur": f"JSON invalide : {exc}"})
|
||||||
|
if not isinstance(data, dict) or "hostname" not in data or "ip" not in data:
|
||||||
|
return self._send_json(400, {"erreur": "hostname et ip requis"})
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
equipement = {"id": _next_id, "hostname": data["hostname"], "ip": data["ip"]}
|
||||||
|
_equipments.append(equipement)
|
||||||
|
_next_id += 1
|
||||||
|
|
||||||
|
self._send_json(201, equipement)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
HTTPServer(("127.0.0.1", 8000), InventoryHandler).serve_forever()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
**Consignes :**
|
||||||
|
1. Écrivez une classe `Equipement` avec :
|
||||||
|
- un constructeur `__init__(self, hostname, ip, latence_ms, actif=True)` ;
|
||||||
|
- une méthode `__str__` renvoyant une description lisible ;
|
||||||
|
- une méthode `est_operationnel(self, seuil_ms=100)` qui renvoie `True` si l'équipement est **actif ET** que sa latence est **sous le seuil**.
|
||||||
|
|
||||||
|
2. Écrivez une fonction `hotes_disponibles(bits_hote)` qui renvoie le nombre d'adresses **hôtes utilisables** d'un sous-réseau (indice : `2 ** bits - 2`).
|
||||||
|
|
||||||
|
3. Écrivez une fonction `trier_par_latence(equipements)` qui renvoie la liste triée par latence croissante (utilisez `sorted` + `lambda`).
|
||||||
|
|
||||||
|
4. Créez 3 équipements, affichez-les **triés par latence** avec leur état opérationnel, puis affichez le nombre d'hôtes d'un `/24` (soit 8 bits d'hôte).
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Equipment():
|
||||||
|
def __init__(self, hostname:str, up:str, latency:float, active:bool=True):
|
||||||
|
self.hostname = hostname
|
||||||
|
self.up = up
|
||||||
|
self.latency = latency
|
||||||
|
self.active = active
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.active:
|
||||||
|
return f"=====\n \
|
||||||
|
HOST : {self.hostname}\n \
|
||||||
|
IS_UP : {self.up}\n \
|
||||||
|
LATENCY : {self.latency}"
|
||||||
|
else:
|
||||||
|
return f"=====\n \
|
||||||
|
Host is currently inactive."
|
||||||
|
|
||||||
|
__repr__ = __str__
|
||||||
|
|
||||||
|
def is_operational(self, max_latency=100) -> bool:
|
||||||
|
return self.active and self.latency < max_latency
|
||||||
|
|
||||||
|
def available_host(host_bits):
|
||||||
|
return f"{2 ** (32 - host_bits) - 2} hôtes sont disponibles sur {host_bits} bits."
|
||||||
|
|
||||||
|
def sort_by_latency(equipments):
|
||||||
|
return sorted(equipments, key=lambda e: e.latency)
|
||||||
|
|
||||||
|
# Equipements
|
||||||
|
|
||||||
|
e1 = Equipment("Berillium", "Yes", 15, True)
|
||||||
|
e2 = Equipment("Thorium", "No", 7, False)
|
||||||
|
e3 = Equipment("Cadmium", "Yes", 12.4, True)
|
||||||
|
e4 = Equipment("Polonium", "Yes", 145, True)
|
||||||
|
e5 = Equipment("Actinium", "Yes", 2.7, True)
|
||||||
|
|
||||||
|
eX = [e1, e2, e3, e4, e5]
|
||||||
|
|
||||||
|
for e in sort_by_latency(eX):
|
||||||
|
if e.up == "Yes":
|
||||||
|
print(e)
|
||||||
|
|
||||||
|
print(available_host(24))
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
**Consignes :**
|
||||||
|
1. Calculez le **nombre total** d'équipements et le **nombre d'équipements actifs**.
|
||||||
|
2. Construisez la **liste des hostnames** dont le `type` est `"switch"`.
|
||||||
|
3. Calculez la **latence moyenne des équipements actifs**.
|
||||||
|
4. Affichez un rapport lisible (une ligne par indicateur), latence arrondie à **2 décimales**.
|
||||||
|
"""
|
||||||
|
|
||||||
|
equipements = [
|
||||||
|
{"hostname": "srv-web-01", "ip": "192.168.1.10", "type": "serveur", "actif": True, "latence_ms": 12.4},
|
||||||
|
{"hostname": "sw-access-02", "ip": "192.168.1.2", "type": "switch", "actif": True, "latence_ms": 1.2},
|
||||||
|
{"hostname": "rtr-core-01", "ip": "10.0.0.1", "type": "routeur", "actif": False, "latence_ms": 3.5},
|
||||||
|
{"hostname": "sw-access-03", "ip": "192.168.1.3", "type": "switch", "actif": True, "latence_ms": 1.8},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 1. Nombre total d'équipements
|
||||||
|
|
||||||
|
def total_equipments(equipments):
|
||||||
|
return f"{len(equipements)} équipements au total, {sum(e["actif"]for e in equipements)} actifs."
|
||||||
|
|
||||||
|
# 2. Liste des noms d'hôtes de type "Switch"
|
||||||
|
|
||||||
|
def name_equipments(equipments):
|
||||||
|
return (e["hostname"] for e in equipments if e["type"]=="switch")
|
||||||
|
|
||||||
|
# 3. Calcul de la latence moyenne des équipements actifs
|
||||||
|
|
||||||
|
def latency_equipments(equipments):
|
||||||
|
return f"{sum(e['latence_ms'] for e in equipments if e['actif']) / sum(e['actif'] for e in equipements):.2f}"
|
||||||
|
|
||||||
|
# 4. Rapport lisible avec une ligne par indicateur
|
||||||
|
|
||||||
|
print(total_equipments(equipements))
|
||||||
|
print(list(name_equipments(equipements)))
|
||||||
|
print(latency_equipments(equipements))
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
**Consignes :**
|
||||||
|
|
||||||
|
1. Définissez deux modèles `Equipement` et `Interface` avec une relation **1-N** (`relationship` + `ForeignKey` + `back_populates`).
|
||||||
|
|
||||||
|
2. Créez le moteur SQLite et générez les tables (`create_all`).
|
||||||
|
|
||||||
|
3. Dans une `Session`, créez **un équipement avec ses deux interfaces en une seule fois**, puis `commit`.
|
||||||
|
|
||||||
|
4. Récupérez les équipements avec `select()` et affichez, pour chacun, la liste de ses interfaces.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import DeclarativeMeta, declarative_base, sessionmaker
|
||||||
|
|
||||||
|
Base: DeclarativeMeta = declarative_base()
|
||||||
|
db_url = "sqlite:///exo_2_1.db"
|
||||||
|
|
||||||
|
def get_engine():
|
||||||
|
return create_engine(url= db_url)
|
||||||
|
|
||||||
|
def get_session():
|
||||||
|
engine = get_engine()
|
||||||
|
Session = sessionmaker(bind= engine)
|
||||||
|
return Session()
|
||||||
|
|
||||||
|
engine = get_engine()
|
||||||
|
|
||||||
|
from random import choice, randint
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Column, Integer, Float, String, Boolean
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
class Equipment(Base):
|
||||||
|
__tablename__ = "equipments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key= True, autoincrement= True)
|
||||||
|
hostname = Column(String, default= "[HOSTNAME]", nullable= False)
|
||||||
|
type = Column(String, default= "Server", nullable= False)
|
||||||
|
ip_address = Column(String, default= "255.255.255.0", nullable= False)
|
||||||
|
is_active = Column(Boolean, default= True, nullable= False)
|
||||||
|
latency = Column(Float, nullable= False)
|
||||||
|
|
||||||
|
interfaces = relationship("Interface", back_populates="equipment")
|
||||||
|
|
||||||
|
class Interface(Base):
|
||||||
|
__tablename__ = "interfaces"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key= True, autoincrement= True)
|
||||||
|
port = Column(Integer, nullable= False)
|
||||||
|
service = Column(String, default= "[SERVICE]", nullable= False)
|
||||||
|
|
||||||
|
equipment_id = Column(Integer, ForeignKey("equipments.id"), nullable= False)
|
||||||
|
|
||||||
|
equipment = relationship("Equipment", back_populates="interfaces")
|
||||||
|
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
session = get_session()
|
||||||
|
|
||||||
|
def random_ipv4() -> str:
|
||||||
|
return ".".join(str(randint(0, 255)) for _ in range(4))
|
||||||
|
|
||||||
|
def load_words(path: str = "wordlist.txt") -> list[str]:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return [line.strip() for line in f if line.strip()]
|
||||||
|
|
||||||
|
def random_hostname(words: list[str] | None = None) -> str:
|
||||||
|
if words is None:
|
||||||
|
words = load_words()
|
||||||
|
triplet = "-".join(choice(words) for _ in range(3))
|
||||||
|
number = f"{randint(1, 999):03d}"
|
||||||
|
return f"{triplet}-{number}"
|
||||||
|
|
||||||
|
equipment = Equipment(
|
||||||
|
hostname=random_hostname(),
|
||||||
|
type="Server",
|
||||||
|
ip_address=random_ipv4(),
|
||||||
|
is_active=True,
|
||||||
|
latency=12.5,
|
||||||
|
interfaces=[
|
||||||
|
Interface(port=22, service="SSH"),
|
||||||
|
Interface(port=443, service="HTTPS"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(equipment)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
stmt = select(Equipment)
|
||||||
|
for eq in session.execute(stmt).scalars():
|
||||||
|
print(f"{eq.hostname} ({eq.ip_address})")
|
||||||
|
for iface in eq.interfaces:
|
||||||
|
print(f" - port {iface.port} : {iface.service}")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from jour-01!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "jour-01"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"sqlalchemy>=2.0.51",
|
||||||
|
]
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.5.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/fd/6fea0e3d6600f785069481ee637e09378dd4118acdfd38ad88ae2db31c98/greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d", size = 238211, upload-time = "2026-06-26T18:22:37.671Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/7e/220a7f5824a64a60443fc03b39dfac4ea63a7fb6d481efa27eafa928e7f4/greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4", size = 238141, upload-time = "2026-06-26T18:22:48.507Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jour-01"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [{ name = "sqlalchemy", specifier = ">=2.0.51" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.0.51"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/70/e868bc5412acd101a8280f25c95f10eeae0771c4eb806b02491142810ee8/sqlalchemy-2.0.51-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d78702b26ba1c18b2d0fb2ea940ba7f17a9581b42e8361ff93920ebbee1235a", size = 2160291, upload-time = "2026-06-15T16:08:48.918Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/1c/71ee0f8a6b9d7316a1ccd30430b4c62b6c2e36adc96017a4e3a72dce49d6/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581921d849d6e6f994d560389192955e80e2950e18fcdfe2ccea863e01158e6e", size = 3343835, upload-time = "2026-06-15T16:19:42.613Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/7c/7ab9f9aadc5944fdd06612484ed7918fe376ad871a5f50404dc1536e0194/sqlalchemy-2.0.51-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d21ce524ab86c23046e992a5b81cb54c21079c6df6e78b8fc77d77cac70a6b9", size = 3358470, upload-time = "2026-06-15T16:26:38.011Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/7d/ff77169fee6186de145a7f2b87006c39638391130abbab2b1f63ac6ea583/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c5d98a2709840027f5a347c3af0a7c3d5f6c1ff93af2ca1c54494e23cba8f389", size = 3289874, upload-time = "2026-06-15T16:19:45.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/3b/6c505903710d781b55bc3141ee34a062bf9745a6b5bc7333305b9ed63b33/sqlalchemy-2.0.51-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1181256e0f16479691b5616d36375dc2620ad8332b25978763c3d206ad3f3f1d", size = 3321692, upload-time = "2026-06-15T16:26:39.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/b7/c5ffe50aa2f4d947c9250e1519d939260329a07fe6272edfccd784b3d007/sqlalchemy-2.0.51-cp312-cp312-win32.whl", hash = "sha256:9f380393be5abeb6815f68fd39271b95127173511b6706b0a630a9995d53f8f5", size = 2119674, upload-time = "2026-06-15T16:23:09.543Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/dc/46a65916af68a06ef6b972c6050ba4c8f97070fe3fb33097d34229d9bef6/sqlalchemy-2.0.51-cp312-cp312-win_amd64.whl", hash = "sha256:2cf39aabdf48e87c1c2c2ed6d20d33ffa0733b3071ce9c5f66357947dd009080", size = 2146670, upload-time = "2026-06-15T16:23:11.048Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/fe/a210d52fd1a90ecfae8a78e9d8b27e18d733d60818a8bf250ff690b75120/sqlalchemy-2.0.51-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c2056838b6685b72fdb36c99996cf862753461a62f2e84f4196371d3b2d6a07", size = 2157184, upload-time = "2026-06-15T16:08:50.374Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/6b/2dce8369b199cb855110e056032f94a9f66dacc2237d3d39c115a86eac56/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483b11bd46bf35fc14c52faf338b04300c9e6ce554bce9b11be85bfec3bc3195", size = 3284735, upload-time = "2026-06-15T16:19:46.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/53/ff/dbc495b8a14da840faffb353857a72d4190113cac33727906fb997047f0f/sqlalchemy-2.0.51-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1bed1ee8b01da6088210aa9412023326fb98a599ba502e6118308601dcbef77f", size = 3302756, upload-time = "2026-06-15T16:26:41.336Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cf/d5/fde8f4dddcf518ee15ab35a7c6a28acc32c8ba548d1d2aa451f96e6dbb0b/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:72ca54c952107ba5cd58854b67a5a6268631289d21651a1235396f3b98b47400", size = 3232055, upload-time = "2026-06-15T16:19:49.286Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/d1/43d3a0ac955a58601c24fa23038b1c55ee3a1ec02c0f96ebb1eae2bcf614/sqlalchemy-2.0.51-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b3e693d15533a45cd5906f0589f9c35090bef6ef45bf1e8195c424aa0ae06a8d", size = 3269850, upload-time = "2026-06-15T16:26:43.017Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/df/de669c7054cd47c4439ac34b1b2ee8b804a794791fbb10720e997a2c87c7/sqlalchemy-2.0.51-cp313-cp313-win32.whl", hash = "sha256:b93ab07b5292dbe7e6b8da89475275e7042744283921344b56105f3eeb0f828b", size = 2117721, upload-time = "2026-06-15T16:23:12.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/8a/403c51d064196bae20a0bc2476577f83a3f8dd299719a97417086b7f2ec5/sqlalchemy-2.0.51-cp313-cp313-win_amd64.whl", hash = "sha256:0f053118c30e53161857a953e4de667d90e274980dccbe5dd3829bbbeece72a5", size = 2143615, upload-time = "2026-06-15T16:23:13.906Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# Exercice 3.3 - Jinja2 multi-pages
|
||||||
|
|
||||||
|
## Navigation + templates dynamiques (sans base de données)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Objectifs pédagogiques
|
||||||
|
|
||||||
|
* structurer une application Flask avec plusieurs routes ;
|
||||||
|
* générer plusieurs pages HTML avec Jinja2 ;
|
||||||
|
* utiliser des templates réutilisables ;
|
||||||
|
* exploiter des données simulées côté serveur (Python) ;
|
||||||
|
* manipuler boucles et conditions dans Jinja2 ;
|
||||||
|
* construire une navigation entre pages ;
|
||||||
|
* séparer clairement logique serveur et affichage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Contexte
|
||||||
|
|
||||||
|
Vous développez une mini application de type **tableau de bord de gestion de parc informatique**.
|
||||||
|
|
||||||
|
Les données sont **simulées en mémoire (liste Python)**.
|
||||||
|
|
||||||
|
L’application doit permettre :
|
||||||
|
|
||||||
|
* d’afficher une liste globale des équipements ;
|
||||||
|
* de voir uniquement les équipements actifs ;
|
||||||
|
* de consulter une page de détail par équipement ;
|
||||||
|
* de naviguer entre les pages via des liens HTML.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Structure proposée
|
||||||
|
|
||||||
|
```
|
||||||
|
app.py
|
||||||
|
/templates
|
||||||
|
base.html
|
||||||
|
index.html
|
||||||
|
actifs.html
|
||||||
|
detail.html
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Travail demandé
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 1 - Initialiser l’application Flask
|
||||||
|
|
||||||
|
Créer une application Flask fonctionnelle.
|
||||||
|
L’application doit pouvoir afficher une page d’accueil.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 2 - Créer les données simulées
|
||||||
|
|
||||||
|
Définir côté serveur une liste Python représentant des équipements.
|
||||||
|
Chaque équipement contient :
|
||||||
|
|
||||||
|
* un identifiant ;
|
||||||
|
* un nom ;
|
||||||
|
* un statut (actif / inactif) ;
|
||||||
|
* une catégorie (serveur, poste, réseau, etc.).
|
||||||
|
|
||||||
|
Ces données sont **fixes et non modifiables**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 3 - Créer une structure de templates
|
||||||
|
|
||||||
|
Mettre en place :
|
||||||
|
|
||||||
|
* un template principal servant de base commune ;
|
||||||
|
* une structure avec bloc de contenu réutilisable ;
|
||||||
|
* un menu de navigation commun à toutes les pages.
|
||||||
|
|
||||||
|
Le menu doit permettre de naviguer entre les pages principales.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 4 - Page d’accueil (liste complète)
|
||||||
|
|
||||||
|
Créer une route et une page affichant :
|
||||||
|
|
||||||
|
* la liste complète des équipements ;
|
||||||
|
* toutes leurs informations.
|
||||||
|
|
||||||
|
Dans le template :
|
||||||
|
|
||||||
|
* utiliser une boucle Jinja2 ;
|
||||||
|
* afficher chaque équipement dans un bloc structuré ;
|
||||||
|
* distinguer visuellement les équipements actifs et inactifs avec une condition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 5 - Page “équipements actifs”
|
||||||
|
|
||||||
|
Créer une page dédiée affichant uniquement :
|
||||||
|
* les équipements actifs.
|
||||||
|
|
||||||
|
La logique de filtrage doit être réalisée côté serveur (Python), pas dans le template.
|
||||||
|
|
||||||
|
Dans le template :
|
||||||
|
* afficher uniquement les éléments fournis par la vue ;
|
||||||
|
* utiliser une boucle simple sans logique complexe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 6 - Page de détail d’un équipement
|
||||||
|
|
||||||
|
Créer une route dynamique permettant d’afficher :
|
||||||
|
* un équipement spécifique selon son identifiant.
|
||||||
|
|
||||||
|
La page doit afficher :
|
||||||
|
* toutes les informations de l’équipement ;
|
||||||
|
* un message d’erreur si l’identifiant n’existe pas.
|
||||||
|
|
||||||
|
Dans le template :
|
||||||
|
* utiliser des variables Jinja2 ;
|
||||||
|
* utiliser une condition pour gérer le cas “non trouvé”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 7 - Navigation entre pages
|
||||||
|
|
||||||
|
Dans le template principal :
|
||||||
|
* ajouter un menu de navigation commun ;
|
||||||
|
* permettre d’accéder à toutes les pages ;
|
||||||
|
* assurer une cohérence visuelle sur toutes les vues.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 8 - Mise en forme Jinja2 obligatoire
|
||||||
|
|
||||||
|
Dans les templates, vous devez utiliser :
|
||||||
|
* des boucles `{% for %}` ;
|
||||||
|
* des conditions `{% if %}` ;
|
||||||
|
* des variables `{{ }}` ;
|
||||||
|
* au moins un bloc réutilisable (`block`) ;
|
||||||
|
* une logique de filtrage côté Python (obligatoire).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 9
|
||||||
|
|
||||||
|
Ajouter :
|
||||||
|
* une page “statistiques” affichant :
|
||||||
|
|
||||||
|
* nombre total d’équipements ;
|
||||||
|
* nombre d’actifs ;
|
||||||
|
* nombre d’inactifs ;
|
||||||
|
* un message conditionnel si la liste est vide dans une page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Livrables attendus
|
||||||
|
|
||||||
|
* application Flask fonctionnelle ;
|
||||||
|
* plusieurs routes Python ;
|
||||||
|
* 3 à 4 templates Jinja2 ;
|
||||||
|
* aucune base de données ;
|
||||||
|
* aucune méthode POST ;
|
||||||
|
* aucune modification de données.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Compétences évaluées
|
||||||
|
|
||||||
|
| Compétence | Niveau attendu |
|
||||||
|
| ---------------------- | ------------------------------- |
|
||||||
|
| Flask routing | Plusieurs routes fonctionnelles |
|
||||||
|
| Jinja2 | Boucles + conditions maîtrisées |
|
||||||
|
| Architecture templates | base.html + héritage |
|
||||||
|
| Séparation logique/vue | filtrage côté Python |
|
||||||
|
| Navigation web | cohérence UX simple |
|
||||||
|
| Lecture de données | affichage structuré |
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Exo 3.3 "Templates dynamiques" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|
Je déclare avec fait usage de l'IA :
|
||||||
|
|
||||||
|
# Raison 1
|
||||||
|
|
||||||
|
Pour de "l'autocomplétion" avec le plan gratuit de Github Copilot (modèle inconnu) pour générer la base <!DOCTYPE> html parce que ça me casse un peu les noix de refaire ça. C'est un genre d'aamorçage qui devrait exister de base dans les IDE avec un algo à l'ancienne ultra-léger...
|
||||||
|
|
||||||
|
## Pourquoi cet usage
|
||||||
|
|
||||||
|
Parce que ça me gonfle de refaire ça pour la Xième fois parce que je ne l'ai pas retenu tellement l'HTML me gonfle.
|
||||||
|
|
||||||
|
## Ce que j'en retiens
|
||||||
|
|
||||||
|
Je dois trouver une extension qui le fait pour moi.
|
||||||
|
|
||||||
|
# Raison 2
|
||||||
|
|
||||||
|
Comprendre pourquoi mes pages Jinja2 ne "marchent pas".
|
||||||
|
|
||||||
|
## Pourquoi cet usage
|
||||||
|
|
||||||
|
Refaisant un document <!DOCTYPE> pour chaque page, je n'ai pas su comprendre par les docs accessibles (qui donnaient juste les blocs avec % et variables Python) qu'il fallait les peupler UNIQUEMENT des éléments étendant la "base". Que l'HTML s'oubliait sur ces parties-là. Je prenais les blocs en le mettant dans des balises <body> etc sans succès.
|
||||||
|
|
||||||
|
## Ce que j'en ai retenu
|
||||||
|
|
||||||
|
Les patrons Jinja2 héritent de TOUT, et on ne rajotue que ce qu'il y a besoin. y compris les bases DOCTYPE.
|
||||||
|
|
||||||
|
# Raison 3
|
||||||
|
|
||||||
|
## Pourquoi cet usage
|
||||||
|
|
||||||
|
Je cliquais sur "actives" et "details" mais je restait avec le contenu de "index". Je demande une revue de code à MiniMax M3, qui me diagnostique trois possibilités :
|
||||||
|
|
||||||
|
| Symptom | Likely cause | Remedy |
|
||||||
|
|---------|--------------|--------|
|
||||||
|
|Same content on every page | Browser cache serving the old HTML | Hard-reload: Ctrl + F5 (or Cmd + Shift + R on macOS) |
|
||||||
|
URL changes but body identical | A single route handling all paths | Verify each @app.route is distinct |
|
||||||
|
Template not overridden | Missing {% block content %} in child | Ensure children declare the block |
|
||||||
|
|
||||||
|
J'essaye, et rien.
|
||||||
|
|
||||||
|
## Ce que j'en ai retenu
|
||||||
|
|
||||||
|
Que je suis un imbécile. Je demande à l'IA de m'aider, et rien n'y a fait. Jusqu'à ce que je vois ma connerie :
|
||||||
|
|
||||||
|
```
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html', username=USERNAME, equipments=EQUIPMENTS)
|
||||||
|
|
||||||
|
@app.route('/actives')
|
||||||
|
def actives():
|
||||||
|
return render_template('index.html', username=USERNAME, equipments=EQUIPMENTS)
|
||||||
|
|
||||||
|
@app.route('/details')
|
||||||
|
def details():
|
||||||
|
return render_template('index.html', username=USERNAME, equipments=EQUIPMENTS)
|
||||||
|
```
|
||||||
|
|
||||||
|
Trois fois "index.html". Bon. Résultat, je corrige et ça marche. L'IA ne m'a servi à RIEN ici, sauf ceci : des fois, il faut aller faire un tour, boire de l'eau, revenir et relire.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from flask import Flask, render_template, abort
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
EQUIPMENTS = [
|
||||||
|
{"id": 1, "hostname": "srv-web-main-01", "type": "server", "active": True},
|
||||||
|
{"id": 2, "hostname": "sw-access-01", "type": "switch", "active": True},
|
||||||
|
{"id": 3, "hostname": "srv-api-01", "type": "server", "active": True},
|
||||||
|
{"id": 4, "hostname": "waf-front-01", "type": "waf", "active": True},
|
||||||
|
{"id": 5, "hostname": "individual-alice-01", "type": "instance", "active": True},
|
||||||
|
{"id": 6, "hostname": "sw-access-02", "type": "switch", "active": False},
|
||||||
|
{"id": 7, "hostname": "individual-bob-01", "type": "instance", "active": False},
|
||||||
|
{"id": 8, "hostname": "srv-web-replica-01", "type": "server", "active": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
USERNAME = "administratort"
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html', username=USERNAME, equipments=EQUIPMENTS)
|
||||||
|
|
||||||
|
@app.route('/actives')
|
||||||
|
def actives():
|
||||||
|
ACTIVE_EQUIPMENTS = [e for e in EQUIPMENTS if e["active"]]
|
||||||
|
return render_template('actives.html', username=USERNAME, equipments=ACTIVE_EQUIPMENTS)
|
||||||
|
|
||||||
|
@app.route('/list')
|
||||||
|
def details():
|
||||||
|
return render_template('list.html', username=USERNAME, equipments=EQUIPMENTS)
|
||||||
|
|
||||||
|
@app.route("/details/<int:equipment_id>")
|
||||||
|
def equipment_detail(equipment_id):
|
||||||
|
equipment = next((e for e in EQUIPMENTS if e["id"] == equipment_id), None)
|
||||||
|
return render_template(
|
||||||
|
"details.html",
|
||||||
|
username=USERNAME,
|
||||||
|
equipment=equipment,
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.route("/stats")
|
||||||
|
def stats():
|
||||||
|
total = len(EQUIPMENTS)
|
||||||
|
active = sum(1 for e in EQUIPMENTS if e["active"])
|
||||||
|
inactive = total - active
|
||||||
|
return render_template(
|
||||||
|
"stats.html",
|
||||||
|
username=USERNAME,
|
||||||
|
total=total,
|
||||||
|
active=active,
|
||||||
|
inactive=inactive,
|
||||||
|
)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='127.0.0.1', port=5000, debug=True)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Active devices - Network Inventory{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Active devices</h1>
|
||||||
|
<p>Signed-in as <strong>{{ username }}</strong>.</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{% for equipment in equipments %}
|
||||||
|
<li>{{ equipment.hostname }}</li>
|
||||||
|
{% else %}
|
||||||
|
<li><em>No active device.</em></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% block title %}NETWORK INVENTORY{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; margin: 2rem; }
|
||||||
|
header, footer { background: #f4f4f4; padding: 1rem; }
|
||||||
|
nav a { margin-right: 1rem; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 0.5rem; text-align: left; }
|
||||||
|
th { background: #eee; }
|
||||||
|
.up { color: green; font-weight: bold; }
|
||||||
|
.down { color: red; font-weight: bold; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<strong>- MENU -</strong>
|
||||||
|
<a href="{{ url_for('index') }}">Home</a>
|
||||||
|
<a href="{{ url_for('actives') }}">Active devices</a>
|
||||||
|
<a href="{{ url_for('details') }}">Details</a>
|
||||||
|
<a href="{{ url_for('stats') }}">Stats</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>Gauvain B. - Flask Jinja2 app</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Equipment - Network Inventory{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>Signed-in as <strong>{{ username }}</strong>.</p>
|
||||||
|
|
||||||
|
{% if equipment %}
|
||||||
|
<h1>{{ equipment.hostname }}</h1>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<td>{{ equipment.id }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Hostname</th>
|
||||||
|
<td>{{ equipment.hostname }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Type</th>
|
||||||
|
<td>{{ equipment.type }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>State</th>
|
||||||
|
<td>
|
||||||
|
{% if equipment.active %}
|
||||||
|
<span class="up">UP</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="down">DOWN</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<h1>There's no device with this ID</h1>
|
||||||
|
<p>The requested equipment (ID {{ equipment_id }}) does not exist.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p><a href="{{ url_for('details') }}">← Back to all devices</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Home - Network Inventory{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Welcome, {{ username }}.</h1>
|
||||||
|
|
||||||
|
{% if username == "administratort" %}
|
||||||
|
<p>You signed-in with ADMIN rights.</p>
|
||||||
|
{% else %}
|
||||||
|
<p>You signed-in with USER rights.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Devices list:</h2>
|
||||||
|
<ul>
|
||||||
|
{% for equipment in equipments %}
|
||||||
|
<li>{{ equipment.hostname }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Details - Network Inventory{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Device details</h1>
|
||||||
|
<p>Signed-in as <strong>{{ username }}</strong>.</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Hostname</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for equipment in equipments %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ equipment.id }}</td>
|
||||||
|
<td>
|
||||||
|
<a href="{{ url_for('equipment_detail', equipment_id=equipment.id) }}">
|
||||||
|
{{ equipment.hostname }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>{{ equipment.type }}</td>
|
||||||
|
<td>
|
||||||
|
{% if equipment.active %}
|
||||||
|
<span class="up">UP</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="down">DOWN</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Statistics - Network Inventory{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Inventory statistics</h1>
|
||||||
|
<p>Signed-in as <strong>{{ username }}</strong>.</p>
|
||||||
|
|
||||||
|
{% if total > 0 %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Metric</th>
|
||||||
|
<th>Count</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Total equipments</td>
|
||||||
|
<td>{{ total }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Active</td>
|
||||||
|
<td><span class="up">{{ active }}</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Inactive</td>
|
||||||
|
<td><span class="down">{{ inactive }}</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p><em>There's no equipment in the inventory.</em></p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Module 3 — Exercices : Flask & Requests
|
||||||
|
|
||||||
|
Fil rouge : une **API d'inventaire** en Flask, puis sa **consommation** avec la bibliothèque `requests`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consignes communes à tous les exercices
|
||||||
|
|
||||||
|
**Usage de l'IA — transparence obligatoire.**
|
||||||
|
L'IA générative n'est **pas interdite**, mais son usage doit être **assumé et documenté**. Chaque rendu **doit obligatoirement contenir un fichier `USAGE-IA.md`**. Sans ce fichier, le rendu est **incomplet**.
|
||||||
|
|
||||||
|
`USAGE-IA.md` doit préciser : (1) les **outils utilisés** et pour quelles parties, (2) l'**aide obtenue** (avec un exemple de prompt), (3) ce que vous avez **accepté** et *pourquoi vous l'avez compris/validé*, (4) ce que vous avez **refusé ou corrigé** et *pourquoi*, (5) ce que vous avez **appris**.
|
||||||
|
|
||||||
|
> Aucune IA utilisée ? Déclarez-le explicitement.
|
||||||
|
|
||||||
|
**Règle d'or : vous devez pouvoir expliquer et justifier chaque ligne de votre rendu.** Gabarit en annexe.
|
||||||
|
|
||||||
|
**Pré-requis :** environnement virtuel activé, `pip install flask requests`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 3.1 — Première API Flask en lecture
|
||||||
|
|
||||||
|
**Compétences visées :** routing Flask, réponses JSON (`jsonify`), code **404**.
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Créez une application Flask avec une liste d'équipements en mémoire (`id`, `hostname`, `ip`).
|
||||||
|
2. `GET /api/equipements` → renvoie `{"equipements": [...]}` en JSON.
|
||||||
|
3. `GET /api/equipements/<int:equipement_id>` → renvoie l'équipement, ou `{"erreur": "..."}` avec le code **404** s'il n'existe pas.
|
||||||
|
4. Lancez le serveur et testez dans le navigateur ou avec `curl`.
|
||||||
|
|
||||||
|
**Livrables :** `app.py`, `USAGE-IA.md`.
|
||||||
|
|
||||||
|
**Critères d'évaluation :** routes correctes (statique + dynamique), sérialisation JSON, gestion du 404.
|
||||||
|
|
||||||
|
|
||||||
|
## Annexe — Gabarit `USAGE-IA.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Usage de l'IA — Exercice X.Y
|
||||||
|
|
||||||
|
## 1. Outils utilisés
|
||||||
|
(Quel(s) outil(s), pour quelles parties. Ou : « aucune IA utilisée ».)
|
||||||
|
|
||||||
|
## 2. Aide obtenue
|
||||||
|
(Ce que l'IA a proposé. Exemple de prompt : « … »)
|
||||||
|
|
||||||
|
## 3. Ce que j'ai accepté
|
||||||
|
(Ce que j'ai repris et pourquoi je l'ai compris et validé.)
|
||||||
|
|
||||||
|
## 4. Ce que j'ai refusé ou corrigé
|
||||||
|
(Ce que j'ai écarté / modifié, et pourquoi.)
|
||||||
|
|
||||||
|
## 5. Ce que j'ai appris
|
||||||
|
(Ce que je retiens, ce que je ferais autrement.)
|
||||||
|
```
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Exercice 3.2 — API CRUD Flask complète
|
||||||
|
|
||||||
|
**Compétences visées :** `POST` / `PUT` / `DELETE`, lecture du corps JSON (`request.get_json`), **validation**, codes **201 / 400 / 404**.
|
||||||
|
|
||||||
|
**Consignes :** repartez de l'API du 3.1 et ajoutez :
|
||||||
|
1. `POST /api/equipements` : crée un équipement depuis le JSON reçu. `hostname` est **obligatoire** (sinon **400**). Renvoie **201** et l'objet créé (avec un `id` auto-incrémenté).
|
||||||
|
2. `PUT /api/equipements/<id>` : met à jour les champs fournis ; **404** si l'équipement n'existe pas.
|
||||||
|
3. `DELETE /api/equipements/<id>` : supprime l'équipement ; **404** si absent.
|
||||||
|
4. Vérifiez chaque opération avec `curl`.
|
||||||
|
|
||||||
|
**Livrables :** `app.py`, `TESTS.md` (vos commandes `curl` et les réponses), `USAGE-IA.md`.
|
||||||
|
|
||||||
|
**Critères d'évaluation :** CRUD complet et cohérent, validation des entrées, bons codes HTTP.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# Exo 3.2 "API CRUD" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|
## Commandes
|
||||||
|
|
||||||
|
### `POST /api/equipements`
|
||||||
|
|
||||||
|
#### Côté client
|
||||||
|
|
||||||
|
```
|
||||||
|
> curl -i -X POST http://127.0.0.1:5000/api/equipments -H "Content-Type: application/json" -d '{"hostname": "test-web-35"}'
|
||||||
|
HTTP/1.1 201 CREATED
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 09:48:45 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 72
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"actif": true,
|
||||||
|
"hostname": "test-web-35",
|
||||||
|
"id": 3,
|
||||||
|
"ip": ""
|
||||||
|
}
|
||||||
|
> curl -i -X POST http://127.0.0.1:5000/api/equipments -H "Content-Type: application/json" -d '{"hostname": "test-web-37", "ip": "192.168.250.69"}'
|
||||||
|
HTTP/1.1 201 CREATED
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 09:49:10 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 86
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"actif": true,
|
||||||
|
"hostname": "test-web-37",
|
||||||
|
"id": 4,
|
||||||
|
"ip": "192.168.250.69"
|
||||||
|
}
|
||||||
|
> curl -i -X POST http://127.0.0.1:5000/api/equipments -H "Content-Type: application/json" -d '{"ip": "192.168.250.67"}'
|
||||||
|
HTTP/1.1 400 BAD REQUEST
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 09:49:20 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 46
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"erreur": "Le hostname est obligatoire"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Côté serveur
|
||||||
|
|
||||||
|
```
|
||||||
|
127.0.0.1 - - [07/Jul/2026 11:48:45] "POST /api/equipments HTTP/1.1" 201 -
|
||||||
|
127.0.0.1 - - [07/Jul/2026 11:49:10] "POST /api/equipments HTTP/1.1" 201 -
|
||||||
|
127.0.0.1 - - [07/Jul/2026 11:49:20] "POST /api/equipments HTTP/1.1" 400 -
|
||||||
|
```
|
||||||
|
|
||||||
|
### `PUT /api/equipements/<id>`
|
||||||
|
|
||||||
|
#### Côté client
|
||||||
|
|
||||||
|
```
|
||||||
|
> curl -i http://127.0.0.1:5000/api/equipments
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 09:50:19 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 452
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"equipments": [
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"hostname": "srv-web-01",
|
||||||
|
"id": 1,
|
||||||
|
"ip": "192.168.1.10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"hostname": "sw-access-02",
|
||||||
|
"id": 2,
|
||||||
|
"ip": "192.168.1.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"actif": true,
|
||||||
|
"hostname": "test-web-35",
|
||||||
|
"id": 3,
|
||||||
|
"ip": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"actif": true,
|
||||||
|
"hostname": "test-web-37",
|
||||||
|
"id": 4,
|
||||||
|
"ip": "192.168.250.69"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
> curl -i -X PUT http://127.0.0.1:5000/api/equipments/4 -H "Content-Type: application/json" -d '{"ip": "192.168.4.20"}'
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 09:50:56 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 84
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"actif": true,
|
||||||
|
"hostname": "test-web-37",
|
||||||
|
"id": 4,
|
||||||
|
"ip": "192.168.4.20"
|
||||||
|
}
|
||||||
|
> curl -i -X PUT http://127.0.0.1:5000/api/equipments/5 -H "Content-Type: application/json" -d '{"ip": "192.168.39.45"}'
|
||||||
|
HTTP/1.1 404 NOT FOUND
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 09:51:08 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 50
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"erreur": "\u00c9quipement non trouv\u00e9"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Côté serveur
|
||||||
|
|
||||||
|
```
|
||||||
|
127.0.0.1 - - [07/Jul/2026 11:50:19] "GET /api/equipments HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [07/Jul/2026 11:50:56] "PUT /api/equipments/4 HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [07/Jul/2026 11:51:08] "PUT /api/equipments/5 HTTP/1.1" 404 -
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DELETE /api/equipements/<id>`
|
||||||
|
|
||||||
|
#### Côté client
|
||||||
|
|
||||||
|
```
|
||||||
|
> curl -i -X DELETE http://127.0.0.1:5000/api/equipments/5
|
||||||
|
HTTP/1.1 404 NOT FOUND
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 09:52:51 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 50
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"erreur": "\u00c9quipement non trouv\u00e9"
|
||||||
|
}
|
||||||
|
> curl -i -X DELETE http://127.0.0.1:5000/api/equipments/4
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 09:52:54 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 66
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"message": "\u00c9quipement supprim\u00e9 avec succ\u00e8s"
|
||||||
|
}
|
||||||
|
> curl -i -X DELETE http://127.0.0.1:5000/api/equipments/4
|
||||||
|
HTTP/1.1 404 NOT FOUND
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 09:52:57 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 50
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"erreur": "\u00c9quipement non trouv\u00e9"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Côté serveur
|
||||||
|
|
||||||
|
```
|
||||||
|
127.0.0.1 - - [07/Jul/2026 11:52:51] "DELETE /api/equipments/5 HTTP/1.1" 404 -
|
||||||
|
127.0.0.1 - - [07/Jul/2026 11:52:54] "DELETE /api/equipments/4 HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [07/Jul/2026 11:52:57] "DELETE /api/equipments/4 HTTP/1.1" 404 -
|
||||||
|
```
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Exo 3.1 "API Flask" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|
Je déclare n'avoir pas usé de l'IA. J'ai en revanche utilisé à foison la documentation du cours :
|
||||||
|
|
||||||
|
- https://github.com/Laudroid/Live-Python-Web/blob/main/3-1-3-Installation-de-Flask-et-cr%C3%A9ation-d.md
|
||||||
|
- https://github.com/Laudroid/Live-Python-Web/blob/main/3-1-4-Routing%2C-vues%2C-r%C3%A9ponses-HTTP-(JSON).md
|
||||||
|
- https://github.com/Laudroid/Live-Python-Web/blob/main/3-1-5-Cr%C3%A9ation-d'APIs-web-(GET%2C-POST%2C-PUT.md
|
||||||
|
|
||||||
|
Et la seule erreur que j'avais eu n'a pas nécessité d'IA non plus.
|
||||||
|
|
||||||
|
Ligne 34 : `@app.route('/api/equipments/<int:equipment_id>', methods=['GET'])`
|
||||||
|
|
||||||
|
> renvoyait une erreur type "TraceError callback" parce que le contenu était `@app.route('/api/equipments/<int:id_equipement>', methods=['GET'])`. Une harmonisation des noms de variables a fait l'affaire.
|
||||||
|
|
||||||
|
# Résultats
|
||||||
|
|
||||||
|
## Côté client
|
||||||
|
```
|
||||||
|
> curl -i http://127.0.0.1:5000/api/equipments
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 08:40:08 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 244
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"equipments": [
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"hostname": "srv-web-01",
|
||||||
|
"id": 1,
|
||||||
|
"ip": "192.168.1.10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"hostname": "sw-access-02",
|
||||||
|
"id": 2,
|
||||||
|
"ip": "192.168.1.2"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
> curl -i http://127.0.0.1:5000/api/equipments/2
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 08:40:10 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 85
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"active": true,
|
||||||
|
"hostname": "sw-access-02",
|
||||||
|
"id": 2,
|
||||||
|
"ip": "192.168.1.2"
|
||||||
|
}
|
||||||
|
> curl -i http://127.0.0.1:5000/api/equipments/25
|
||||||
|
HTTP/1.1 404 NOT FOUND
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Tue, 07 Jul 2026 08:43:01 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 50
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"erreur": "\u00c9quipement non trouv\u00e9"
|
||||||
|
}
|
||||||
|
(jour-02) PS D:\Users\GaWin\LiveCampus\Semaine_11\Jour_02>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Côté serveur
|
||||||
|
```
|
||||||
|
* Serving Flask app 'app'
|
||||||
|
* Debug mode: on
|
||||||
|
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
|
||||||
|
* Running on http://127.0.0.1:5000
|
||||||
|
Press CTRL+C to quit
|
||||||
|
* Restarting with stat
|
||||||
|
* Debugger is active!
|
||||||
|
* Debugger PIN: 917-152-107
|
||||||
|
127.0.0.1 - - [07/Jul/2026 10:40:08] "GET /api/equipments HTTP/1.1" 200 -
|
||||||
|
127.0.0.1 - - [07/Jul/2026 10:40:10] "GET /api/equipments/2 HTTP/1.1" 200 -
|
||||||
|
* Detected change in '.\app.py', reloading
|
||||||
|
* Restarting with stat
|
||||||
|
* Debugger is active!
|
||||||
|
* Debugger PIN: 917-152-107
|
||||||
|
127.0.0.1 - - [07/Jul/2026 10:43:01] "GET /api/equipments/25 HTTP/1.1" 404 -
|
||||||
|
```
|
||||||
|
|
||||||
|
# Exo 3.2 "API CRUD" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|
Je déclare n'avoir pas usé de l'IA là non plus. Toujours utilisé à foison la documentation du cours :
|
||||||
|
|
||||||
|
- https://github.com/Laudroid/Live-Python-Web/blob/main/3-1-3-Installation-de-Flask-et-cr%C3%A9ation-d.md
|
||||||
|
- https://github.com/Laudroid/Live-Python-Web/blob/main/3-1-4-Routing%2C-vues%2C-r%C3%A9ponses-HTTP-(JSON).md
|
||||||
|
- https://github.com/Laudroid/Live-Python-Web/blob/main/3-1-5-Cr%C3%A9ation-d'APIs-web-(GET%2C-POST%2C-PUT.md
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""
|
||||||
|
**Consignes 3_1 :**
|
||||||
|
|
||||||
|
1. Créez une application Flask avec une liste d'équipements en mémoire (`id`, `hostname`, `ip`).
|
||||||
|
|
||||||
|
2. `GET /api/equipments` → renvoie `{"equipments": [...]}` en JSON.
|
||||||
|
|
||||||
|
3. `GET /api/equipments/<int:equipment_id>` → renvoie l'équipement, ou `{"erreur": "..."}` avec le code **404** s'il n'existe pas.
|
||||||
|
|
||||||
|
4. Lancez le serveur et testez dans le navigateur ou avec `curl`.
|
||||||
|
|
||||||
|
**Consignes 3_2 :** repartez de l'API du 3.1 et ajoutez :
|
||||||
|
|
||||||
|
1. `POST /api/equipments` : crée un équipement depuis le JSON reçu. `hostname` est **obligatoire** (sinon **400**). Renvoie **201** et l'objet créé (avec un `id` auto-incrémenté).
|
||||||
|
|
||||||
|
2. `PUT /api/equipments/<id>` : met à jour les champs fournis ; **404** si l'équipement n'existe pas.
|
||||||
|
|
||||||
|
3. `DELETE /api/equipments/<id>` : supprime l'équipement ; **404** si absent.
|
||||||
|
|
||||||
|
4. Vérifiez chaque opération avec `curl`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from flask import Flask, request, jsonify
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
equipments = [
|
||||||
|
{"id": 1, "hostname": "srv-web-01", "ip": "192.168.1.10", "active": True},
|
||||||
|
{"id": 2, "hostname": "sw-access-02", "ip": "192.168.1.2", "active": True}
|
||||||
|
]
|
||||||
|
|
||||||
|
@app.route('/api/statut', methods=['GET'])
|
||||||
|
def statut_api():
|
||||||
|
return jsonify({
|
||||||
|
"statut": "succès",
|
||||||
|
"message": "L'API d'inventaire réseau fonctionne correctement",
|
||||||
|
"version": "1.0"
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
@app.route('/api/equipments', methods=['GET'])
|
||||||
|
def list_equipments():
|
||||||
|
return jsonify({"equipments": equipments}), 200
|
||||||
|
|
||||||
|
@app.route('/api/equipments/<int:equipment_id>', methods=['GET'])
|
||||||
|
def get_equipment(equipment_id):
|
||||||
|
equipment = next((e for e in equipments if e["id"] == equipment_id), None)
|
||||||
|
|
||||||
|
if equipment is None:
|
||||||
|
return jsonify({"erreur": "Équipement non trouvé"}), 404
|
||||||
|
|
||||||
|
return jsonify(equipment), 200
|
||||||
|
|
||||||
|
@app.route('/api/equipments', methods=['POST'])
|
||||||
|
def post_equipment():
|
||||||
|
data = request.get_json()
|
||||||
|
|
||||||
|
if not data or not 'hostname' in data:
|
||||||
|
return jsonify({"erreur": "Le hostname est obligatoire"}), 400
|
||||||
|
|
||||||
|
new_equipment = {
|
||||||
|
"id": max(e['id'] for e in equipments) + 1 if equipments else 1,
|
||||||
|
"hostname": data['hostname'],
|
||||||
|
"ip": data.get('ip', ""),
|
||||||
|
"actif": True
|
||||||
|
}
|
||||||
|
|
||||||
|
equipments.append(new_equipment)
|
||||||
|
|
||||||
|
return jsonify(new_equipment), 201
|
||||||
|
|
||||||
|
@app.route('/api/equipments/<int:equipment_id>', methods=['PUT'])
|
||||||
|
def put_equipment(equipment_id):
|
||||||
|
equipment = next((e for e in equipments if e["id"] == equipment_id), None)
|
||||||
|
|
||||||
|
if equipment is None:
|
||||||
|
return jsonify({"erreur": "Équipement non trouvé"}), 404
|
||||||
|
|
||||||
|
donnees = request.get_json()
|
||||||
|
|
||||||
|
if 'hostname' in donnees:
|
||||||
|
equipment['hostname'] = donnees['hostname']
|
||||||
|
if 'ip' in donnees:
|
||||||
|
equipment['ip'] = donnees['ip']
|
||||||
|
if 'actif' in donnees:
|
||||||
|
equipment['actif'] = donnees['actif']
|
||||||
|
|
||||||
|
return jsonify(equipment), 200
|
||||||
|
|
||||||
|
@app.route('/api/equipments/<int:equipment_id>', methods=['DELETE'])
|
||||||
|
def delete_equipment(equipment_id):
|
||||||
|
global equipments
|
||||||
|
|
||||||
|
equipment = next((e for e in equipments if e["id"] == equipment_id), None)
|
||||||
|
|
||||||
|
if equipment is None:
|
||||||
|
return jsonify({"erreur": "Équipement non trouvé"}), 404
|
||||||
|
|
||||||
|
equipments = [e for e in equipments if e["id"] != equipment_id]
|
||||||
|
|
||||||
|
return jsonify({"message": "Équipement supprimé avec succès"}), 200
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='127.0.0.1', port=5000, debug=True)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from jour-02!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[project]
|
||||||
|
name = "jour-02"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = [
|
||||||
|
"flask>=3.1.3",
|
||||||
|
]
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "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 = "jour-02"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "flask" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [{ name = "flask", specifier = ">=3.1.3" }]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "werkzeug"
|
||||||
|
version = "3.1.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Clé secrète Flask : utilisée pour signer les sessions et flash messages.
|
||||||
|
# Générer avec : python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
FLASK_SECRET_KEY=hex(32)
|
||||||
|
|
||||||
|
# Mode debug (True/False). Désactiver en production.
|
||||||
|
FLASK_DEBUG=False
|
||||||
|
|
||||||
|
# Hôte et port d'écoute Flask
|
||||||
|
FLASK_HOST=127.0.0.1
|
||||||
|
FLASK_PORT=5000
|
||||||
|
|
||||||
|
# Chemin optionnel vers la base SQLite (relatif au projet)
|
||||||
|
DATABASE_PATH=databases/inventory.db
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# --- Secrets ---
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# --- Python ---
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
.eggs/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# --- Base de données locale ---
|
||||||
|
databases/*.db
|
||||||
|
databases/*.sqlite
|
||||||
|
databases/*.sqlite3
|
||||||
|
|
||||||
|
# --- IDE / OS ---
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# --- Logs ---
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# --- Tests / coverage ---
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
# Exercice 3.4 — Développer une application Flask avec API REST, interface Jinja2 et base SQLite3
|
||||||
|
|
||||||
|
## Objectifs pédagogiques
|
||||||
|
|
||||||
|
À l'issue de cet exercice, vous serez capable de :
|
||||||
|
|
||||||
|
* créer une application Flask combinant une API REST et une interface Web ;
|
||||||
|
* utiliser une base de données SQLite3 pour stocker des informations ;
|
||||||
|
* réaliser les opérations essentielles d'un CRUD (Create, Read, Update, Delete) ;
|
||||||
|
* concevoir une interface HTML dynamique avec Jinja2 ;
|
||||||
|
* créer un formulaire HTML permettant d'ajouter des données ;
|
||||||
|
* consommer une API REST avec la bibliothèque `requests` ;
|
||||||
|
* gérer correctement les erreurs de communication avec une API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Contexte
|
||||||
|
|
||||||
|
Une entreprise souhaite développer une petite application permettant de gérer son parc d'équipements informatiques.
|
||||||
|
|
||||||
|
L'application devra être accessible de deux façons :
|
||||||
|
|
||||||
|
* par une API REST destinée à d'autres applications ;
|
||||||
|
* par une interface Web destinée aux utilisateurs.
|
||||||
|
|
||||||
|
Toutes les données devront être enregistrées dans une base de données SQLite3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Travail demandé
|
||||||
|
|
||||||
|
## Étape 1 — Préparer le projet
|
||||||
|
|
||||||
|
* Créez un nouveau projet Python et mettez en place un environnement virtuel.
|
||||||
|
* Installez les bibliothèques nécessaires au développement de l'application.
|
||||||
|
* Organisez votre projet de manière à séparer le code Python, les templates HTML et la base de données.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 2 — Créer la base de données
|
||||||
|
|
||||||
|
Créez une base SQLite3.
|
||||||
|
Cette base devra contenir une table permettant de stocker les équipements.
|
||||||
|
Chaque équipement devra posséder au minimum :
|
||||||
|
|
||||||
|
* un identifiant unique ;
|
||||||
|
* un nom ;
|
||||||
|
* un état (actif ou inactif).
|
||||||
|
|
||||||
|
Vérifiez que la table est correctement créée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 3 — Initialiser Flask
|
||||||
|
|
||||||
|
Créez une application Flask.
|
||||||
|
Assurez-vous que celle-ci démarre correctement et qu'une première page de test est accessible depuis un navigateur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 4 — Accéder à la base de données
|
||||||
|
|
||||||
|
Écrivez les fonctions permettant :
|
||||||
|
|
||||||
|
* d'ouvrir une connexion à SQLite ;
|
||||||
|
* d'exécuter une requête SQL ;
|
||||||
|
* de récupérer les résultats ;
|
||||||
|
* de fermer correctement la connexion.
|
||||||
|
|
||||||
|
Ces fonctions devront être réutilisées dans toute l'application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 5 — Développer l'API REST
|
||||||
|
|
||||||
|
Créez les routes permettant d'exposer les données sous forme JSON.
|
||||||
|
|
||||||
|
Votre API devra permettre :
|
||||||
|
|
||||||
|
* d'obtenir la liste complète des équipements ;
|
||||||
|
* d'ajouter un nouvel équipement.
|
||||||
|
|
||||||
|
Vérifiez le bon fonctionnement de ces routes avec un navigateur ou un outil de test d'API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 6 — Réaliser l'interface Web
|
||||||
|
|
||||||
|
Ajoutez une nouvelle page Web affichant le parc des équipements.
|
||||||
|
Cette page devra être générée avec un template Jinja2.
|
||||||
|
Les données affichées devront provenir de la base SQLite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 7 — Construire le template Jinja2
|
||||||
|
|
||||||
|
Dans votre template :
|
||||||
|
|
||||||
|
* affichez les équipements dans un tableau HTML ;
|
||||||
|
* utilisez une boucle Jinja2 pour parcourir les données ;
|
||||||
|
* affichez le nom et l'identifiant de chaque équipement ;
|
||||||
|
* affichez l'état de chaque équipement.
|
||||||
|
|
||||||
|
L'état devra être affiché différemment selon que l'équipement est actif ou inactif.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 8 — Ajouter un formulaire
|
||||||
|
|
||||||
|
Ajoutez au-dessus du tableau un formulaire HTML permettant de créer un nouvel équipement.
|
||||||
|
|
||||||
|
Le formulaire devra comporter :
|
||||||
|
|
||||||
|
* un champ de saisie pour le nom ;
|
||||||
|
* un bouton d'envoi.
|
||||||
|
|
||||||
|
Lors de la validation :
|
||||||
|
|
||||||
|
* les données devront être envoyées au serveur ;
|
||||||
|
* le nouvel équipement devra être enregistré dans SQLite ;
|
||||||
|
* la page devra être réaffichée avec la nouvelle liste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 9 — Ajouter des actions sur les équipements
|
||||||
|
|
||||||
|
Pour chaque ligne du tableau, ajoutez des boutons permettant :
|
||||||
|
|
||||||
|
* de modifier l'état de l'équipement (actif ↔ inactif) ;
|
||||||
|
* de supprimer l'équipement.
|
||||||
|
|
||||||
|
Après chaque action, l'utilisateur devra être redirigé vers la liste mise à jour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 10 — Développer un client Python
|
||||||
|
|
||||||
|
Créez un second programme Python jouant le rôle de client.
|
||||||
|
|
||||||
|
Ce programme devra communiquer avec l'API REST à l'aide de la bibliothèque `requests`.
|
||||||
|
|
||||||
|
Il devra :
|
||||||
|
|
||||||
|
1. récupérer la liste des équipements ;
|
||||||
|
2. afficher cette liste dans le terminal ;
|
||||||
|
3. ajouter un nouvel équipement via l'API ;
|
||||||
|
4. récupérer de nouveau la liste ;
|
||||||
|
5. afficher le résultat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 11 — Gérer les erreurs
|
||||||
|
|
||||||
|
Tous les appels HTTP devront être sécurisés.
|
||||||
|
|
||||||
|
Votre programme devra notamment :
|
||||||
|
|
||||||
|
* définir un délai maximal d'attente pour chaque requête ;
|
||||||
|
* vérifier que la réponse du serveur est valide ;
|
||||||
|
* afficher un message clair si le serveur est indisponible ;
|
||||||
|
* éviter tout arrêt brutal du programme en cas d'erreur réseau.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Étape 12 — Vérifier le fonctionnement
|
||||||
|
|
||||||
|
Vérifiez que les opérations suivantes fonctionnent correctement :
|
||||||
|
|
||||||
|
* création de la base de données ;
|
||||||
|
* ajout d'un équipement via le formulaire Web ;
|
||||||
|
* affichage dynamique des données dans le template Jinja2 ;
|
||||||
|
* modification de l'état d'un équipement ;
|
||||||
|
* suppression d'un équipement ;
|
||||||
|
* consultation de l'API REST ;
|
||||||
|
* ajout d'un équipement via le client Python.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Livrables attendus
|
||||||
|
|
||||||
|
Le projet devra contenir au minimum :
|
||||||
|
|
||||||
|
* le fichier principal de l'application Flask ;
|
||||||
|
* le client Python utilisant `requests` ;
|
||||||
|
* la base SQLite3 (ou son script de création) ;
|
||||||
|
* le dossier `templates` contenant les pages Jinja2 ;
|
||||||
|
* un document `USAGE-IA.md` décrivant l'utilisation éventuelle d'une IA générative pendant le développement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Critères d'évaluation
|
||||||
|
|
||||||
|
L'évaluation portera sur les points suivants :
|
||||||
|
|
||||||
|
| Critère | Points d'attention |
|
||||||
|
| ------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| Base SQLite | Création correcte et accès fonctionnel |
|
||||||
|
| API REST | Routes opérationnelles et réponses JSON conformes |
|
||||||
|
| CRUD | Ajout, consultation, modification et suppression fonctionnels |
|
||||||
|
| Interface Jinja2 | Utilisation correcte des variables, boucles et conditions |
|
||||||
|
| Formulaire HTML | Enregistrement correct des données dans SQLite |
|
||||||
|
| Client `requests` | Appels HTTP correctement réalisés |
|
||||||
|
| Gestion des erreurs | Timeout, vérification des réponses et traitement propre des exceptions |
|
||||||
|
| Qualité du code | Organisation, lisibilité et séparation des responsabilités |
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Exo 3.4 "Consommer l'API" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|
## Usage de l'IA
|
||||||
|
|
||||||
|
### Raison 1
|
||||||
|
|
||||||
|
Pour convertir la structure de SQLAlchemy en SQLite. Parce que, comme un imbécile, j'ai tout fait en SQLAlchemy, sans percuter.
|
||||||
|
|
||||||
|
#### Ce que j'en ai retenu
|
||||||
|
|
||||||
|
La somme de la conversion était colossale.
|
||||||
|
|
||||||
|
Déjà, de nombreuses méthodes (pas au sens Python du terme) m'étant inconnues ont été intégrées. J'ai lu religieusement pour comprendre, et ça va, le sens ne m'échappe pas.
|
||||||
|
|
||||||
|
Celle qui m'a fait le plus tiquer c'est `flash()`. Les strings disent `Flashes a message to the next request. In order to remove the flashed message from the session and to display it to the user, the template has to call get_flashed_messages.` C'est spécial. Je me rends compte aussi que mes propres fonctions n'ont pas de """docstring""", mais pour autant, je pense ne pas en avoir besoin.
|
||||||
|
|
||||||
|
Un de mes mentors m'avait dit : "un bon code se passe de commentaire". Et ma convention de nommage semble assez exemplaire pour s'en passer.
|
||||||
|
|
||||||
|
### Raison 2
|
||||||
|
|
||||||
|
Pour générer les fichiers .env et .gitignore, parce que ça me saoule aussi de fouiller dans mes dossiers jusqu'à satsifaction. Et faire une méthode pour ne rien lancer sans ces valeurs.
|
||||||
|
|
||||||
|
#### Ce que j'en ai retenu
|
||||||
|
|
||||||
|
Y a rien à retenir de particulier. Sauf peut-être un genre de dossier général à la racine de mes projets avec des patrons tout fait.
|
||||||
|
|
||||||
|
Quand à la fonction `_required_env`, à la base j'avais fait un simple `try: except:` mais ça semble mieux comme ça.
|
||||||
|
|
||||||
|
Et par ricochet, j'ai vu qu'on pouvait définir des variables dans l'application "Flask" avec `.config`.
|
||||||
|
|
||||||
|
### Raison 3
|
||||||
|
|
||||||
|
Générer du code CSS.
|
||||||
|
|
||||||
|
#### Ce que j'en ai retenu
|
||||||
|
|
||||||
|
La raison pour laquelle j'ai lâché mes cours CodeCademy sur le Frontend. Ca me gonfle.
|
||||||
|
|
||||||
|
### Raison 4
|
||||||
|
|
||||||
|
Choisir de faire ou non une réinitialisation de la DB en relançant app.py
|
||||||
|
|
||||||
|
#### Ce que j'en ai retenu
|
||||||
|
|
||||||
|
J'ai plusieurs fois voulu faire un choix multiples en Python sans jamais me renseigner dessus, et j'étais trop avancé pour refactoriser ça moi-même. Visiblement ça n'existe pas trop dans l'état, ça marche avec un "O/N" classique. Peut-être existe-t-il une solution (choisir avec les flèches du clavier) mais ça demande certainement un genre de moteur visuel. Je ne sais pas du moins. Je n'ai pas insisté, mais de fait je me trouve stupide de demander à l'IA ce que je savais déjà faire. Après, j'ignorais que je connaissais déjà la seule solution, donc au moins je retiens que Python ne gère pas forcément tout.
|
||||||
|
|
||||||
|
### Raison 5
|
||||||
|
|
||||||
|
Ecrire les commandes de test, notamment sur la robustesse.
|
||||||
|
|
||||||
|
#### Ce que j'en ai retenu
|
||||||
|
|
||||||
|
La sortie JSON formatée par python m'intéresse. Je ne pensais pas que Python pouvait servir aussi à ça. Dans le quotidien ça peut être utile à savoir.
|
||||||
|
|
||||||
|
Et surtout, je voulais juste une liste de commandes curl, mais l'IA m'a proposé directement après, sans lui demander, un script .sh (joint). Pourquoi m'embêter avec un .md et des copier/coller ? Je dois y repenser à chaque fois. Faire des sorties automatiques. Je le fais au travail mais jamais en exercice.
|
||||||
|
|
||||||
|
Partisan du moindre effort ? Oui, et ça fait de moi un bon informaticien.
|
||||||
|
|
||||||
|
### Raison 6
|
||||||
|
|
||||||
|
Téléversement de mon projet finalisé (le .md de l'exercice, les .py et les .html ainsi qu'une capture d'écran de l'arborescence) sur trois IA spécialisées différentes (QwenCoder 3.7 Plus, MiniMax M3, Kimi K2.6 [on le sent que je contrebalance la domination américaine par de la domination chinoise, ou pas du tout ?]) avec une demande de revue.
|
||||||
|
|
||||||
|
#### Les résultats
|
||||||
|
|
||||||
|
Les trois IA fournissent des rapports cohérents (ce qui est rassurant) même si chacune courbe son jugement par son paradigme interne :
|
||||||
|
|
||||||
|
- Qwen se concentre sur la base
|
||||||
|
- Kimi relève les soucis d'imports circulaires et les potentiels points de blocage concrets actuels
|
||||||
|
- MiniMax a relevé les potentiels problèmes futurs en anticipant une application qui prend de l'ampleur
|
||||||
|
|
||||||
|
Les bogues relevés :
|
||||||
|
|
||||||
|
- Sérialisation JSON menant à une potentielle `TypeError` (que je n'ai pas constatée)
|
||||||
|
- Fonctions critiques potentiellement bloquantes
|
||||||
|
- Fonctions critiques dans des boucles non sécurisées
|
||||||
|
- Coquilles et oublis de lignes à rajouter/retirer
|
||||||
|
|
||||||
|
#### Ce que j'ai modifié en conséquence
|
||||||
|
|
||||||
|
Corrections des bogues critiques, re-test et validation.
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from flask import Flask, jsonify, request, render_template, redirect, url_for, flash
|
||||||
|
|
||||||
|
from classes import Equipment, Interface
|
||||||
|
from database import (
|
||||||
|
init_database,
|
||||||
|
reset_database,
|
||||||
|
prompt_reset,
|
||||||
|
fetch_all_equipments,
|
||||||
|
fetch_equipment_by_id,
|
||||||
|
insert_equipment,
|
||||||
|
update_equipment,
|
||||||
|
delete_equipment,
|
||||||
|
)
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
def _required_env(key: str) -> str:
|
||||||
|
value = os.environ.get(key)
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Variable d'environnement {key!r} manquante. "
|
||||||
|
f"Vérifiez votre fichier .env (voir .env.example)."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
app.config["SECRET_KEY"] = _required_env("FLASK_SECRET_KEY")
|
||||||
|
app.config["DEBUG"] = os.environ.get("FLASK_DEBUG", "False").lower() in {"1", "true", "yes"}
|
||||||
|
app.config["DATABASE_PATH"] = os.environ.get("DATABASE_PATH", "databases/inventory.db")
|
||||||
|
|
||||||
|
ALLOWED_TYPES = {"Server", "Instance", "Switch", "Gateway", "Router", "WAF", "Database"}
|
||||||
|
|
||||||
|
def _parse_form_equipment(form) -> tuple[Equipment | None, str | None]:
|
||||||
|
hostname = (form.get("hostname") or "").strip()
|
||||||
|
if not hostname:
|
||||||
|
return None, "Hostname requis."
|
||||||
|
|
||||||
|
eq_type = form.get("type", "Server")
|
||||||
|
if eq_type not in ALLOWED_TYPES:
|
||||||
|
return None, f"Type invalide : {eq_type}."
|
||||||
|
|
||||||
|
ip = (form.get("ip_address") or "").strip() or "255.255.255.0"
|
||||||
|
is_active = form.get("is_active") == "on"
|
||||||
|
|
||||||
|
latency_raw = (form.get("latency") or "").strip()
|
||||||
|
latency = None
|
||||||
|
if latency_raw:
|
||||||
|
try:
|
||||||
|
latency = float(latency_raw)
|
||||||
|
if latency < 0:
|
||||||
|
return None, "Latence doit être positive."
|
||||||
|
except ValueError:
|
||||||
|
return None, "Latence invalide."
|
||||||
|
|
||||||
|
ports = form.getlist("ports[]")
|
||||||
|
services = form.getlist("services[]")
|
||||||
|
interfaces: list[Interface] = []
|
||||||
|
for port_raw, service in zip(ports, services):
|
||||||
|
if not port_raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
port = int(port_raw)
|
||||||
|
if not (0 <= port <= 65535):
|
||||||
|
return None, f"Port invalide : {port_raw}."
|
||||||
|
except ValueError:
|
||||||
|
return None, f"Port non numérique : {port_raw}."
|
||||||
|
if not service.strip():
|
||||||
|
return None, "Service requis pour chaque interface."
|
||||||
|
interfaces.append(Interface(port=port, service=service.strip()))
|
||||||
|
|
||||||
|
return Equipment(
|
||||||
|
hostname=hostname,
|
||||||
|
type=eq_type,
|
||||||
|
ip_address=ip,
|
||||||
|
is_active=is_active,
|
||||||
|
latency=latency,
|
||||||
|
interfaces=interfaces,
|
||||||
|
), None
|
||||||
|
|
||||||
|
def _equipment_to_dict(eq: Equipment) -> dict:
|
||||||
|
return {
|
||||||
|
"id": eq.id,
|
||||||
|
"hostname": eq.hostname,
|
||||||
|
"type": eq.type,
|
||||||
|
"ip_address": eq.ip_address,
|
||||||
|
"is_active": eq.is_active,
|
||||||
|
"latency": eq.latency,
|
||||||
|
"interfaces": [
|
||||||
|
{"id": i.id, "port": i.port, "service": i.service}
|
||||||
|
for i in eq.interfaces
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Partie FRONT
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/equipments', methods=['GET'])
|
||||||
|
def equipments_page():
|
||||||
|
equipments = fetch_all_equipments()
|
||||||
|
return render_template('equipments.html', equipments=equipments, types=sorted(ALLOWED_TYPES))
|
||||||
|
|
||||||
|
@app.route('/equipments', methods=['POST'])
|
||||||
|
def create_equipment_form():
|
||||||
|
eq, err = _parse_form_equipment(request.form)
|
||||||
|
if err:
|
||||||
|
flash(err, "error")
|
||||||
|
return redirect(url_for('equipments_page'))
|
||||||
|
insert_equipment(eq)
|
||||||
|
flash(f"Équipement « {eq.hostname} » créé.", "success")
|
||||||
|
return redirect(url_for('equipments_page'))
|
||||||
|
|
||||||
|
@app.route('/equipments/<int:equipment_id>/toggle', methods=['POST'])
|
||||||
|
def toggle_equipment(equipment_id: int):
|
||||||
|
eq = fetch_equipment_by_id(equipment_id)
|
||||||
|
if eq is None:
|
||||||
|
flash(f"Équipement {equipment_id} introuvable.", "error")
|
||||||
|
return redirect(url_for('equipments_page'))
|
||||||
|
eq.is_active = not eq.is_active
|
||||||
|
update_equipment(eq)
|
||||||
|
flash(f"État de « {eq.hostname} » mis à jour.", "success")
|
||||||
|
return redirect(url_for('equipments_page'))
|
||||||
|
|
||||||
|
@app.route('/equipments/<int:equipment_id>/delete', methods=['POST'])
|
||||||
|
def delete_equipment_form(equipment_id: int):
|
||||||
|
eq = fetch_equipment_by_id(equipment_id)
|
||||||
|
if eq is None:
|
||||||
|
flash(f"Équipement {equipment_id} introuvable.", "error")
|
||||||
|
return redirect(url_for('equipments_page'))
|
||||||
|
delete_equipment(equipment_id)
|
||||||
|
flash(f"Équipement « {eq.hostname} » supprimé.", "success")
|
||||||
|
return redirect(url_for('equipments_page'))
|
||||||
|
|
||||||
|
# Partie API
|
||||||
|
|
||||||
|
@app.get("/api/equipments")
|
||||||
|
def list_equipments_api():
|
||||||
|
return jsonify([_equipment_to_dict(eq) for eq in fetch_all_equipments()])
|
||||||
|
|
||||||
|
@app.get("/api/equipments/<int:equipment_id>")
|
||||||
|
def get_equipment_api(equipment_id: int):
|
||||||
|
eq = fetch_equipment_by_id(equipment_id)
|
||||||
|
if eq is None:
|
||||||
|
return jsonify({"error": "Équipement introuvable."}), 404
|
||||||
|
return jsonify(_equipment_to_dict(eq))
|
||||||
|
|
||||||
|
@app.post("/api/equipments")
|
||||||
|
def create_equipment_api():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
hostname = (data.get("hostname") or "").strip()
|
||||||
|
if not hostname:
|
||||||
|
return jsonify({"error": "Hostname requis."}), 400
|
||||||
|
eq_type = data.get("type", "Server")
|
||||||
|
if eq_type not in ALLOWED_TYPES:
|
||||||
|
return jsonify({"error": f"Type invalide : {eq_type}."}), 400
|
||||||
|
eq = Equipment(
|
||||||
|
hostname=hostname,
|
||||||
|
type=eq_type,
|
||||||
|
ip_address=(data.get("ip_address") or "255.255.255.0").strip(),
|
||||||
|
is_active=bool(data.get("is_active", True)),
|
||||||
|
latency=data.get("latency"),
|
||||||
|
interfaces=[],
|
||||||
|
)
|
||||||
|
new_id = insert_equipment(eq)
|
||||||
|
eq.id = new_id
|
||||||
|
return jsonify(_equipment_to_dict(eq)), 201
|
||||||
|
|
||||||
|
@app.put("/api/equipments/<int:equipment_id>")
|
||||||
|
def update_equipment_api(equipment_id: int):
|
||||||
|
eq = fetch_equipment_by_id(equipment_id)
|
||||||
|
if eq is None:
|
||||||
|
return jsonify({"error": f"Équipement {equipment_id} introuvable."}), 404
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
hostname = (data.get("hostname") or "").strip()
|
||||||
|
if not hostname:
|
||||||
|
return jsonify({"error": "Hostname requis."}), 400
|
||||||
|
|
||||||
|
eq_type = data.get("type", "Server")
|
||||||
|
if eq_type not in ALLOWED_TYPES:
|
||||||
|
return jsonify({"error": f"Type invalide : {eq_type}."}), 400
|
||||||
|
|
||||||
|
latency = data.get("latency")
|
||||||
|
if latency is not None:
|
||||||
|
try:
|
||||||
|
latency = float(latency)
|
||||||
|
if latency < 0:
|
||||||
|
return jsonify({"error": "Latence doit être positive."}), 400
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return jsonify({"error": "Latence invalide."}), 400
|
||||||
|
|
||||||
|
eq.hostname = hostname
|
||||||
|
eq.type = eq_type
|
||||||
|
eq.ip_address = (data.get("ip_address") or "255.255.255.0").strip()
|
||||||
|
eq.is_active = bool(data.get("is_active", True))
|
||||||
|
eq.latency = latency
|
||||||
|
|
||||||
|
update_equipment(eq)
|
||||||
|
return jsonify(_equipment_to_dict(eq)), 200
|
||||||
|
|
||||||
|
@app.patch("/api/equipments/<int:equipment_id>/toggle")
|
||||||
|
def toggle_equipment_api(equipment_id: int):
|
||||||
|
eq = fetch_equipment_by_id(equipment_id)
|
||||||
|
if eq is None:
|
||||||
|
return jsonify({"error": f"Équipement {equipment_id} introuvable."}), 404
|
||||||
|
eq.is_active = not eq.is_active
|
||||||
|
update_equipment(eq)
|
||||||
|
return jsonify(_equipment_to_dict(eq)), 200
|
||||||
|
|
||||||
|
@app.delete("/api/equipments/<int:equipment_id>")
|
||||||
|
def delete_equipment_api(equipment_id: int):
|
||||||
|
eq = fetch_equipment_by_id(equipment_id)
|
||||||
|
if eq is None:
|
||||||
|
return jsonify({"error": f"Équipement {equipment_id} introuvable."}), 404
|
||||||
|
delete_equipment(equipment_id)
|
||||||
|
return jsonify({"message": f"Équipement « {eq.hostname} » supprimé."}), 200
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
host = os.environ.get("FLASK_HOST", "127.0.0.1")
|
||||||
|
port = int(os.environ.get("FLASK_PORT", "5000"))
|
||||||
|
|
||||||
|
if prompt_reset():
|
||||||
|
reset_database()
|
||||||
|
print("Base réinitialisée.")
|
||||||
|
else:
|
||||||
|
init_database()
|
||||||
|
|
||||||
|
app.run(host=host, port=port, debug=app.config["DEBUG"])
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Interface:
|
||||||
|
id: Optional[int] = None
|
||||||
|
port: int = 0
|
||||||
|
service: str = "[SERVICE]"
|
||||||
|
equipment_id: Optional[int] = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Equipment:
|
||||||
|
id: Optional[int] = None
|
||||||
|
hostname: str = "[HOSTNAME]"
|
||||||
|
type: str = "Server"
|
||||||
|
ip_address: str = "255.255.255.0"
|
||||||
|
is_active: bool = True
|
||||||
|
latency: Optional[float] = None
|
||||||
|
interfaces: List[Interface] = field(default_factory=list)
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import sys
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE_URL = "http://127.0.0.1:5000"
|
||||||
|
TIMEOUT = 5
|
||||||
|
|
||||||
|
def list_equipments() -> list[dict] | None:
|
||||||
|
try:
|
||||||
|
response = requests.get(f"{BASE_URL}/api/equipments", timeout=TIMEOUT)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
print(f"[ERREUR] Délai d'attente dépassé ({TIMEOUT}s) lors de GET /api/equipments.")
|
||||||
|
return None
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print(f"[ERREUR] Impossible de joindre le serveur {BASE_URL}. Est-il démarré ?")
|
||||||
|
return None
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
print(f"[ERREUR] Réponse HTTP invalide : {e}")
|
||||||
|
return None
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"[ERREUR] Échec de la requête : {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError:
|
||||||
|
print("[ERREUR] Réponse non-JSON reçue du serveur.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not isinstance(data, list):
|
||||||
|
print("[ERREUR] Format de réponse inattendu (liste attendue).")
|
||||||
|
return None
|
||||||
|
return data
|
||||||
|
|
||||||
|
def add_equipment(hostname: str, type_: str, ip_address: str) -> dict | None:
|
||||||
|
payload = {
|
||||||
|
"hostname": hostname,
|
||||||
|
"type": type_,
|
||||||
|
"ip_address": ip_address,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{BASE_URL}/api/equipments",
|
||||||
|
json=payload,
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
print(f"[ERREUR] Délai d'attente dépassé ({TIMEOUT}s) lors de POST /api/equipments.")
|
||||||
|
return None
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
print(f"[ERREUR] Impossible de joindre le serveur {BASE_URL}.")
|
||||||
|
return None
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
print(f"[ERREUR] Réponse HTTP invalide : {e}")
|
||||||
|
return None
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
print(f"[ERREUR] Échec de la requête : {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except ValueError:
|
||||||
|
print("[ERREUR] Réponse non-JSON reçue après création.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("=== Liste initiale ===")
|
||||||
|
initial = list_equipments()
|
||||||
|
if initial is None:
|
||||||
|
return 1
|
||||||
|
for eq in initial:
|
||||||
|
print(f" #{eq['id']} {eq['hostname']} ({eq['type']}) - {'actif' if eq['is_active'] else 'inactif'}")
|
||||||
|
|
||||||
|
print("\n=== Ajout d'un équipement ===")
|
||||||
|
new_eq = add_equipment("client-test-01", "Router", "10.0.99.1")
|
||||||
|
if new_eq is None:
|
||||||
|
return 1
|
||||||
|
print(f" Créé : #{new_eq['id']} {new_eq['hostname']}")
|
||||||
|
|
||||||
|
print("\n=== Liste mise à jour ===")
|
||||||
|
updated = list_equipments()
|
||||||
|
if updated is None:
|
||||||
|
return 1
|
||||||
|
for eq in updated:
|
||||||
|
print(f" #{eq['id']} {eq['hostname']} ({eq['type']}) - {'actif' if eq['is_active'] else 'inactif'}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
|
from classes import Equipment, Interface
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
DB_DIR = BASE_DIR / "databases"
|
||||||
|
DB_PATH = DB_DIR / "inventory.db"
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS equipments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hostname TEXT NOT NULL DEFAULT '[HOSTNAME]',
|
||||||
|
type TEXT NOT NULL DEFAULT 'Server',
|
||||||
|
ip_address TEXT NOT NULL DEFAULT '255.255.255.0',
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
latency REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS interfaces (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
service TEXT NOT NULL DEFAULT '[SERVICE]',
|
||||||
|
equipment_id INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (equipment_id) REFERENCES equipments(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
def _ensure_db_dir() -> None:
|
||||||
|
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection():
|
||||||
|
_ensure_db_dir()
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def init_database() -> None:
|
||||||
|
with get_connection() as conn:
|
||||||
|
conn.executescript(SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
def execute_query(query: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.execute(query, tuple(params))
|
||||||
|
return cursor.fetchall()
|
||||||
|
|
||||||
|
def execute_modification(query: str, params: Iterable[Any] = ()) -> int:
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.execute(query, tuple(params))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
|
||||||
|
def _row_to_equipment(row: sqlite3.Row) -> Equipment:
|
||||||
|
return Equipment(
|
||||||
|
id=row["id"],
|
||||||
|
hostname=row["hostname"],
|
||||||
|
type=row["type"],
|
||||||
|
ip_address=row["ip_address"],
|
||||||
|
is_active=bool(row["is_active"]),
|
||||||
|
latency=row["latency"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _row_to_interface(row: sqlite3.Row) -> Interface:
|
||||||
|
return Interface(
|
||||||
|
id=row["id"],
|
||||||
|
port=row["port"],
|
||||||
|
service=row["service"],
|
||||||
|
equipment_id=row["equipment_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def fetch_all_equipments() -> list[Equipment]:
|
||||||
|
rows = execute_query(
|
||||||
|
"SELECT id, hostname, type, ip_address, is_active, latency "
|
||||||
|
"FROM equipments ORDER BY id"
|
||||||
|
)
|
||||||
|
equipments = [_row_to_equipment(r) for r in rows]
|
||||||
|
for eq in equipments:
|
||||||
|
eq.interfaces = fetch_interfaces_for_equipment(eq.id) #type: ignore
|
||||||
|
return equipments
|
||||||
|
|
||||||
|
def fetch_equipment_by_id(equipment_id: int) -> Optional[Equipment]:
|
||||||
|
rows = execute_query(
|
||||||
|
"SELECT id, hostname, type, ip_address, is_active, latency "
|
||||||
|
"FROM equipments WHERE id = ?",
|
||||||
|
(equipment_id,),
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
eq = _row_to_equipment(rows[0])
|
||||||
|
eq.interfaces = fetch_interfaces_for_equipment(eq.id) #type: ignore
|
||||||
|
return eq
|
||||||
|
|
||||||
|
def fetch_interfaces_for_equipment(equipment_id: int) -> list[Interface]:
|
||||||
|
rows = execute_query(
|
||||||
|
"SELECT id, port, service, equipment_id FROM interfaces "
|
||||||
|
"WHERE equipment_id = ? ORDER BY id",
|
||||||
|
(equipment_id,),
|
||||||
|
)
|
||||||
|
return [_row_to_interface(r) for r in rows]
|
||||||
|
|
||||||
|
def insert_equipment(eq: Equipment) -> int:
|
||||||
|
with get_connection() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO equipments (hostname, type, ip_address, is_active, latency) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(eq.hostname, eq.type, eq.ip_address, int(eq.is_active), eq.latency),
|
||||||
|
)
|
||||||
|
eq_id = cursor.lastrowid
|
||||||
|
eq.id = eq_id # <-- ajout
|
||||||
|
for iface in eq.interfaces:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO interfaces (port, service, equipment_id) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
(iface.port, iface.service, eq_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return eq_id #type:ignore
|
||||||
|
|
||||||
|
def update_equipment(eq: Equipment) -> bool:
|
||||||
|
return execute_modification(
|
||||||
|
"UPDATE equipments SET hostname=?, type=?, ip_address=?, "
|
||||||
|
"is_active=?, latency=? WHERE id=?",
|
||||||
|
(eq.hostname, eq.type, eq.ip_address, int(eq.is_active),
|
||||||
|
eq.latency, eq.id),
|
||||||
|
) > 0
|
||||||
|
|
||||||
|
def delete_equipment(equipment_id: int) -> bool:
|
||||||
|
return execute_modification(
|
||||||
|
"DELETE FROM equipments WHERE id = ?", (equipment_id,)
|
||||||
|
) > 0
|
||||||
|
|
||||||
|
def reset_database() -> None:
|
||||||
|
from init_db import main as seed
|
||||||
|
|
||||||
|
if DB_PATH.exists():
|
||||||
|
DB_PATH.unlink()
|
||||||
|
init_database()
|
||||||
|
seed()
|
||||||
|
|
||||||
|
def prompt_reset() -> bool:
|
||||||
|
while True:
|
||||||
|
choice = input("Réinitialiser la base de données ? [o/N] : ").strip().lower()
|
||||||
|
if choice in {"o", "oui", "y", "yes"}:
|
||||||
|
return True
|
||||||
|
if choice in {"", "n", "non", "no"}:
|
||||||
|
return False
|
||||||
|
print("Réponse invalide. Saisissez 'o' (oui) ou 'n' (non).")
|
||||||
|
|
||||||
|
# Gestion API
|
||||||
|
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
from random import choice, randint
|
||||||
|
from database import init_database, insert_equipment, fetch_all_equipments
|
||||||
|
from classes import Equipment, Interface
|
||||||
|
|
||||||
|
NUMBER_OF_EQUIPMENTS_TO_POPULATE = 15
|
||||||
|
|
||||||
|
COMMON_SERVICES: dict[str, int] = {
|
||||||
|
"FTP": 21,
|
||||||
|
"SSH": 22,
|
||||||
|
"Telnet": 23,
|
||||||
|
"SMTP": 25,
|
||||||
|
"DNS": 53,
|
||||||
|
"HTTP": 80,
|
||||||
|
"POP3": 110,
|
||||||
|
"NetBIOS": 139,
|
||||||
|
"IMAP": 143,
|
||||||
|
"HTTPS": 443,
|
||||||
|
"LDAPS": 636,
|
||||||
|
"RDP": 3389,
|
||||||
|
}
|
||||||
|
|
||||||
|
TYPES = [
|
||||||
|
"Server",
|
||||||
|
"Instance",
|
||||||
|
"Switch",
|
||||||
|
"Gateway",
|
||||||
|
"Router",
|
||||||
|
"WAF",
|
||||||
|
"Database",
|
||||||
|
]
|
||||||
|
|
||||||
|
def random_ipv4() -> str:
|
||||||
|
return ".".join(str(randint(0, 255)) for _ in range(4))
|
||||||
|
|
||||||
|
def load_words(path: str = "wordlist.txt") -> list[str]:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return [line.strip() for line in f if line.strip()]
|
||||||
|
|
||||||
|
def random_hostname(words: list[str]) -> str:
|
||||||
|
triplet = "-".join(choice(words) for _ in range(3))
|
||||||
|
return f"{triplet}-{randint(1, 999):03d}"
|
||||||
|
|
||||||
|
def random_interfaces() -> list[Interface]:
|
||||||
|
count = randint(2, 5)
|
||||||
|
services = list(COMMON_SERVICES.items())
|
||||||
|
return [Interface(port=port, service=svc) for svc, port in
|
||||||
|
(choice(services) for _ in range(count))]
|
||||||
|
|
||||||
|
def main(n: int = NUMBER_OF_EQUIPMENTS_TO_POPULATE) -> None:
|
||||||
|
init_database()
|
||||||
|
if fetch_all_equipments():
|
||||||
|
print("Base déjà peuplée, peuplement ignoré.")
|
||||||
|
return
|
||||||
|
words = load_words()
|
||||||
|
for _ in range(n):
|
||||||
|
insert_equipment(Equipment(
|
||||||
|
hostname=random_hostname(words),
|
||||||
|
type=choice(TYPES),
|
||||||
|
ip_address=random_ipv4(),
|
||||||
|
is_active=True,
|
||||||
|
latency=round(randint(1, 500) / 10, 1),
|
||||||
|
interfaces=random_interfaces(),
|
||||||
|
))
|
||||||
|
print(f"{n} équipements insérés.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "3_4_API_REST"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Add your description here"
|
||||||
|
readme = "USAGE-IA.md"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = [
|
||||||
|
"dotenv>=0.9.9",
|
||||||
|
"flask>=3.1.3",
|
||||||
|
"requests>=2.34.2",
|
||||||
|
"sqlalchemy>=2.0.51",
|
||||||
|
]
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# Exo 3.4 "Consommer l'API" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|
## Requêtes type
|
||||||
|
|
||||||
|
### Lister
|
||||||
|
|
||||||
|
```
|
||||||
|
# Liste complète
|
||||||
|
curl -i http://127.0.0.1:5000/api/equipments
|
||||||
|
|
||||||
|
# Equipement précis
|
||||||
|
curl -i http://127.0.0.1:5000/api/equipments/1
|
||||||
|
|
||||||
|
# Equipement inexistant
|
||||||
|
curl -i http://127.0.0.1:5000/api/equipments/9999
|
||||||
|
|
||||||
|
# Sortie formatée
|
||||||
|
curl -s http://127.0.0.1:5000/api/equipments | python -m json.tool
|
||||||
|
|
||||||
|
# Compter les équipements
|
||||||
|
curl -s http://127.0.0.1:5000/api/equipments | python -c "import json,sys; print(len(json.load(sys.stdin)))"
|
||||||
|
|
||||||
|
# Filtrage par champ
|
||||||
|
curl -s http://127.0.0.1:5000/api/equipments | jq '.[] | select(.is_active==true) | {id, hostname}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Créer
|
||||||
|
|
||||||
|
```
|
||||||
|
# Normal
|
||||||
|
curl -i -X POST http://127.0.0.1:5000/api/equipments \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hostname":"curl-test-01","type":"router","ip_address":"10.0.50.1","is_active":true,"latency":12.5}'
|
||||||
|
|
||||||
|
# Mal formaté
|
||||||
|
curl -i -X POST http://127.0.0.1:5000/api/equipments \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{hostname: "sans-guillemets"}'
|
||||||
|
|
||||||
|
# Avec mentions obligatoires manquantes
|
||||||
|
curl -i -X POST http://127.0.0.1:5000/api/equipments \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"type":"router"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patch partiel (actif <-> inactif)
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -i -X PATCH http://127.0.0.1:5000/api/equipments/1/toggle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mise à jour
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -i -X PUT http://127.0.0.1:5000/api/equipments/1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hostname":"serveur-prod-01","type":"server","ip_address":"10.0.10.1","is_active":true,"latency":8.3}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supprimer
|
||||||
|
|
||||||
|
```
|
||||||
|
# Autorisé
|
||||||
|
curl -i -X DELETE http://127.0.0.1:5000/api/equipments/1
|
||||||
|
|
||||||
|
# Interdit
|
||||||
|
curl -i -X DELETE http://127.0.0.1:5000/api/equipments
|
||||||
|
```
|
||||||
|
|
||||||
|
## Réponses
|
||||||
|
|
||||||
|
### Lister
|
||||||
|
|
||||||
|
```
|
||||||
|
> curl -i http://127.0.0.1:5000/api/equipments
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Wed, 08 Jul 2026 12:58:59 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 6850
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"hostname": "bird-dawn-peas-031",
|
||||||
|
"id": 1,
|
||||||
|
"interfaces": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"port": 25,
|
||||||
|
"service": "SMTP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"port": 21,
|
||||||
|
"service": "FTP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"port": 80,
|
||||||
|
"service": "HTTP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"port": 21,
|
||||||
|
"service": "FTP"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip_address": "242.48.158.201",
|
||||||
|
"is_active": true,
|
||||||
|
"latency": 33.6,
|
||||||
|
"type": "Router"
|
||||||
|
}, [...]
|
||||||
|
|
||||||
|
> curl -i http://127.0.0.1:5000/api/equipments/12
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Wed, 08 Jul 2026 13:21:47 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 314
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"hostname": "barn-lamps-bottle-151",
|
||||||
|
"id": 12,
|
||||||
|
"interfaces": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"port": 53,
|
||||||
|
"service": "DNS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 43,
|
||||||
|
"port": 139,
|
||||||
|
"service": "NetBIOS"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip_address": "76.143.32.122",
|
||||||
|
"is_active": true,
|
||||||
|
"latency": 42.1,
|
||||||
|
"type": "Server"
|
||||||
|
}
|
||||||
|
|
||||||
|
> curl -i http://127.0.0.1:5000/api/equipments/1267
|
||||||
|
HTTP/1.1 404 NOT FOUND
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Wed, 08 Jul 2026 13:22:01 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 46
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"error": "\u00c9quipement introuvable."
|
||||||
|
}
|
||||||
|
|
||||||
|
> curl -s http://127.0.0.1:5000/api/equipments | python -c "import json,sys; print(len(json.load(sys.stdin)))"
|
||||||
|
15
|
||||||
|
```
|
||||||
|
|
||||||
|
### Créer
|
||||||
|
|
||||||
|
```
|
||||||
|
> curl -i -X POST http://127.0.0.1:5000/api/equipments -H "Content-Type: application/json" -d '{"hostname":"curl-test-01","type":"Router","ip_address":"10.0.50.1","is_active":true,"latency":12.5}'
|
||||||
|
HTTP/1.1 201 CREATED
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Wed, 08 Jul 2026 13:23:15 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 154
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"hostname": "curl-test-01",
|
||||||
|
"id": 16,
|
||||||
|
"interfaces": [],
|
||||||
|
"ip_address": "10.0.50.1",
|
||||||
|
"is_active": true,
|
||||||
|
"latency": 12.5,
|
||||||
|
"type": "Router"
|
||||||
|
}
|
||||||
|
|
||||||
|
> curl -i -X POST http://127.0.0.1:5000/api/equipments -H "Content-Type: application/json" -d '{"hostname":"curl-test-01","type":"router","ip_address":"10.0.50.1","is_active":true,"latency":12.5}'
|
||||||
|
HTTP/1.1 400 BAD REQUEST
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Wed, 08 Jul 2026 13:23:04 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 41
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"error": "Type invalide : router."
|
||||||
|
}
|
||||||
|
|
||||||
|
> curl -i -X POST http://127.0.0.1:5000/api/equipments -H "Content-Type: application/json" -d '{"type":"Router"}'
|
||||||
|
HTTP/1.1 400 BAD REQUEST
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Wed, 08 Jul 2026 13:23:58 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 34
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"error": "Hostname requis."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Patch partiel (actif <-> inactif)
|
||||||
|
|
||||||
|
```
|
||||||
|
> curl -i -X PATCH http://127.0.0.1:5000/api/equipments/2/toggle
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Wed, 08 Jul 2026 13:46:11 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 387
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"hostname": "gulf-manage-order-022",
|
||||||
|
"id": 2,
|
||||||
|
"interfaces": [
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"port": 21,
|
||||||
|
"service": "FTP"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"port": 139,
|
||||||
|
"service": "NetBIOS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"port": 143,
|
||||||
|
"service": "IMAP"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip_address": "183.139.112.112",
|
||||||
|
"is_active": false,
|
||||||
|
"latency": 28.1,
|
||||||
|
"type": "Database"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mise à jour
|
||||||
|
|
||||||
|
```
|
||||||
|
> curl -i -X PUT http://127.0.0.1:5000/api/equipments/1 -H "Content-Type: application/json" -d '{"hostname":"serveur-prod-01","type":"Server","ip_address":"10.0.10.1","is_active":true,"latency":8.3}'
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Wed, 08 Jul 2026 13:49:06 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 297
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"hostname": "serveur-prod-01",
|
||||||
|
"id": 1,
|
||||||
|
"interfaces": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"port": 53,
|
||||||
|
"service": "DNS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"port": 3389,
|
||||||
|
"service": "RDP"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip_address": "10.0.10.1",
|
||||||
|
"is_active": true,
|
||||||
|
"latency": 8.3,
|
||||||
|
"type": "Server"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Supprimer
|
||||||
|
|
||||||
|
```
|
||||||
|
> curl -i -X DELETE http://127.0.0.1:5000/api/equipments/1
|
||||||
|
HTTP/1.1 200 OK
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Wed, 08 Jul 2026 13:49:49 GMT
|
||||||
|
Content-Type: application/json
|
||||||
|
Content-Length: 80
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
{
|
||||||
|
"message": "\u00c9quipement \u00ab serveur-prod-01 \u00bb supprim\u00e9."
|
||||||
|
}
|
||||||
|
|
||||||
|
> curl -i -X DELETE http://127.0.0.1:5000/api/equipments
|
||||||
|
HTTP/1.1 405 METHOD NOT ALLOWED
|
||||||
|
Server: Werkzeug/3.1.8 Python/3.14.6
|
||||||
|
Date: Wed, 08 Jul 2026 13:50:04 GMT
|
||||||
|
Content-Type: text/html; charset=utf-8
|
||||||
|
Allow: OPTIONS, POST, HEAD, GET
|
||||||
|
Content-Length: 153
|
||||||
|
Connection: close
|
||||||
|
|
||||||
|
<!doctype html>
|
||||||
|
<html lang=en>
|
||||||
|
<title>405 Method Not Allowed</title>
|
||||||
|
<h1>Method Not Allowed</h1>
|
||||||
|
<p>The method is not allowed for the requested URL.</p>
|
||||||
|
```
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--card: #ffffff;
|
||||||
|
--border: #d2d2d7;
|
||||||
|
--accent: #0071e3;
|
||||||
|
--danger: #ff3b30;
|
||||||
|
--success: #34c759;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); margin: 2rem; }
|
||||||
|
nav { margin-bottom: 1.5rem; }
|
||||||
|
nav a { color: var(--accent); text-decoration: none; margin-right: 1rem; }
|
||||||
|
|
||||||
|
.flash { padding: .75rem 1rem; border-radius: 6px; margin-bottom: 1rem; }
|
||||||
|
.flash-success { background: #d1f5d3; color: #0a6b1f; }
|
||||||
|
.flash-error { background: #ffd5d2; color: #a0190d; }
|
||||||
|
|
||||||
|
form { background: var(--card); padding: 1.5rem; border-radius: 8px; border: 1px solid var(--border); }
|
||||||
|
form label { display: block; margin-bottom: .75rem; }
|
||||||
|
form input, form select { padding: .5rem; border: 1px solid var(--border); border-radius: 4px; }
|
||||||
|
|
||||||
|
fieldset { margin-top: 1rem; border: 1px solid var(--border); padding: 1rem; border-radius: 6px; }
|
||||||
|
.interface-row { display: flex; gap: .5rem; margin-bottom: .5rem; }
|
||||||
|
.interface-row input { flex: 1; }
|
||||||
|
|
||||||
|
.equipment-grid { list-style: none; padding: 0; display: grid; gap: 1rem; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
|
||||||
|
.equipment { background: var(--card); padding: 1rem; border-radius: 8px; border: 1px solid var(--border); }
|
||||||
|
.equipment.inactive { opacity: .6; }
|
||||||
|
.equipment header { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.badge { background: var(--accent); color: white; padding: .2rem .6rem; border-radius: 12px; font-size: .8rem; }
|
||||||
|
.equipment footer { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||||
|
.equipment footer form { flex: 1; padding: 0; border: none; background: none; }
|
||||||
|
.equipment button { width: 100%; padding: .5rem; border: none; border-radius: 4px; cursor: pointer; color: white; }
|
||||||
|
.btn-toggle { background: var(--accent); }
|
||||||
|
.btn-delete { background: var(--danger); }
|
||||||
|
.empty { color: #6e6e73; font-style: italic; }
|
||||||
|
|
||||||
|
.equipments-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.equipments-table th,
|
||||||
|
.equipments-table td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.equipments-table thead {
|
||||||
|
background: #f4f4f4;
|
||||||
|
}
|
||||||
|
.equipments-table .row-inactive {
|
||||||
|
opacity: 0.6;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-active { color: #1a7f37; }
|
||||||
|
.status-inactive { color: #b42318; }
|
||||||
|
.actions form { display: inline-block; margin-right: 0.25rem; }
|
||||||
|
.interfaces-list { margin: 0; padding-left: 1rem; }
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% block title %}Gestion du parc{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/equipments.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav><a href="{{ url_for('index') }}">Accueil</a> | <a href="{{ url_for('equipments_page') }}">Équipements</a></nav>
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flash flash-{{ category }}" role="alert">{{ message }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Équipements{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Inventaire des équipements</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Ajouter un équipement</h2>
|
||||||
|
<form method="post" action="{{ url_for('create_equipment_form') }}">
|
||||||
|
<label>Hostname <input type="text" name="hostname" required></label>
|
||||||
|
<label>Type
|
||||||
|
<select name="type" required>
|
||||||
|
{% for t in types %}
|
||||||
|
<option value="{{ t }}">{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Adresse IP <input type="text" name="ip_address" required></label>
|
||||||
|
<label>Latence (ms) <input type="number" name="latency" step="0.1" min="0"></label>
|
||||||
|
<label><input type="checkbox" name="is_active" checked> Actif</label>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Interfaces</legend>
|
||||||
|
<div class="interface-row">
|
||||||
|
<input type="number" name="ports[]" placeholder="Port" min="0" max="65535">
|
||||||
|
<input type="text" name="services[]" placeholder="Service">
|
||||||
|
</div>
|
||||||
|
<div class="interface-row">
|
||||||
|
<input type="number" name="ports[]" placeholder="Port" min="0" max="65535">
|
||||||
|
<input type="text" name="services[]" placeholder="Service">
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<button type="submit">Créer</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Équipements enregistrés ({{ equipments|length }})</h2>
|
||||||
|
{% if equipments %}
|
||||||
|
<table class="equipments-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Hostname</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Adresse IP</th>
|
||||||
|
<th>Latence</th>
|
||||||
|
<th>État</th>
|
||||||
|
<th>Interfaces</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for eq in equipments %}
|
||||||
|
<tr class="{% if not eq.is_active %}row-inactive{% else %}row-active{% endif %}">
|
||||||
|
<td>{{ eq.id }}</td>
|
||||||
|
<td>{{ eq.hostname }}</td>
|
||||||
|
<td><span class="badge">{{ eq.type }}</span></td>
|
||||||
|
<td>{{ eq.ip_address }}</td>
|
||||||
|
<td>{{ eq.latency if eq.latency is not none else '-' }} ms</td>
|
||||||
|
<td>
|
||||||
|
{% if eq.is_active %}
|
||||||
|
<span class="status status-active">Actif</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status status-inactive">Inactif</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if eq.interfaces %}
|
||||||
|
<ul class="interfaces-list">
|
||||||
|
{% for i in eq.interfaces %}
|
||||||
|
<li>{{ i.service }} : {{ i.port }}/tcp</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
-
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<form method="post" action="{{ url_for('toggle_equipment', equipment_id=eq.id) }}">
|
||||||
|
<button type="submit" class="btn-toggle">
|
||||||
|
{% if eq.is_active %}Désactiver{% else %}Activer{% endif %}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="{{ url_for('delete_equipment_form', equipment_id=eq.id) }}"
|
||||||
|
onsubmit="return confirm('Confirmer la suppression de {{ eq.hostname }} ?');">
|
||||||
|
<button type="submit" class="btn-delete">Supprimer</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="empty">Aucun équipement enregistré.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Accueil{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Bienvenue</h1>
|
||||||
|
<p><a href="{{ url_for('equipments_page') }}">Accéder à l'inventaire</a></p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
BASE="http://127.0.0.1:5000/api/equipments"
|
||||||
|
|
||||||
|
echo "=== GET liste ==="
|
||||||
|
curl -sf "$BASE" | python -m json.tool
|
||||||
|
|
||||||
|
echo "=== POST création ==="
|
||||||
|
NEW=$(curl -sf -X POST "$BASE" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"hostname":"bash-test","type":"switch","ip_address":"10.0.60.1","is_active":true}')
|
||||||
|
echo "$NEW" | python -m json.tool
|
||||||
|
ID=$(echo "$NEW" | python -c "import json,sys; print(json.load(sys.stdin)['id'])")
|
||||||
|
|
||||||
|
echo "=== GET #$ID ==="
|
||||||
|
curl -sf "$BASE/$ID" | python -m json.tool
|
||||||
|
|
||||||
|
echo "=== PATCH toggle #$ID ==="
|
||||||
|
curl -sf -X PATCH "$BASE/$ID/toggle" | python -m json.tool
|
||||||
|
|
||||||
|
echo "=== DELETE #$ID ==="
|
||||||
|
curl -sf -X DELETE "$BASE/$ID" -w "HTTP %{http_code}\n"
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "3-4-api-rest"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { virtual = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "dotenv" },
|
||||||
|
{ name = "flask" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||||
|
{ name = "flask", specifier = ">=3.1.3" },
|
||||||
|
{ name = "requests", specifier = ">=2.34.2" },
|
||||||
|
{ name = "sqlalchemy", specifier = ">=2.0.51" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "certifi"
|
||||||
|
version = "2026.6.17"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "dotenv"
|
||||||
|
version = "0.9.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dotenv" },
|
||||||
|
]
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "greenlet"
|
||||||
|
version = "3.5.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e2/f1/fbbfef6af0bad0548f09bc28948ea3c275b4edb19e17fc5ca9900a6a634d/greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1", size = 200270, upload-time = "2026-06-26T19:28:24.832Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/06/68d0983e79e02138f64b4d303c500c27ddb48e5e77f3debb80888a921eae/greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605", size = 239549, upload-time = "2026-06-26T18:22:42.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/e8/883785b44c5780ed71e83d3e4437e710470be17a2e181e8b601e2da0dc4a/greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227", size = 240085, upload-time = "2026-06-26T18:23:54.217Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/0d/ca7d15afbdc397e3401134c9e1800d51d12b829661786187a4ad08fe484f/greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31", size = 242586, upload-time = "2026-06-26T18:23:37.93Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.18"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dotenv"
|
||||||
|
version = "1.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.34.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.0.51"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/f1/a7a892f18d4d224e6b26f706531eafccc41e37594d37d304786969ee13cb/sqlalchemy-2.0.51.tar.gz", hash = "sha256:804dccd8a4a6242c4e30ad961e540e18a588f6527202f2d6791b01845d59fdc9", size = 9912201, upload-time = "2026-06-15T15:41:20.012Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/49/a739be2e1d02a96a658eb71ab45d921c874249252358ad24a5bffdd02525/sqlalchemy-2.0.51-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ea306caaae6bd5afd0a46050003c88f6bf33227377a49298c498c3cb88ff491", size = 2158999, upload-time = "2026-06-15T16:08:51.759Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/6b/2e0e38cf75c8780eca78d9b2e78164f8bcfd70125e5caa588ff5cbb9c9f4/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c45a496d6bc05dec41dcd4c3a2b183723f47473255c159cd80b503c8f246424d", size = 3282539, upload-time = "2026-06-15T16:19:51.065Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/a1/e77854cb5336fd37dc3c6ae3b71de242c98caac5725120be0b526b31cbd0/sqlalchemy-2.0.51-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4004ada0aafe8ae1991b2cd1d99c6d9146126e123bd6f883c260d974aa012e54", size = 3287545, upload-time = "2026-06-15T16:26:44.735Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/ab/9e17272fd4dac8df3b83c4fbe52b998a1c9d89a843c8c35ff29b74ff7364/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f6bcad487aee1c638d707235682fc96f741de00663619881ab235400d03289e", size = 3230929, upload-time = "2026-06-15T16:19:52.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/3c/52f408ea701781caee975606beccc48845f2aee8711ac29843d612c0306c/sqlalchemy-2.0.51-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:39a76529db6305693d8d4affa58ad5b5e2e18edd62daea628b29b97930b3513d", size = 3252888, upload-time = "2026-06-15T16:26:46.454Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/24/16/3efd2ee6bc4ca4693a30a1dd17a91b606cae15d517d2a4746611d9b73ce8/sqlalchemy-2.0.51-cp314-cp314-win32.whl", hash = "sha256:08a204d8b5638717c26a24df18fcf40af45a6b22e35b70b1d62f0113c2e278e8", size = 2120551, upload-time = "2026-06-15T16:23:15.629Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/78/55b12e70f45bccc40d9e483925c065027b3b98ea4cbbdf6f8c2546feaf6c/sqlalchemy-2.0.51-cp314-cp314-win_amd64.whl", hash = "sha256:96747bfbadb055466e5b46d572618170046b45ce5a4879167f50d70a5319a499", size = 2146318, upload-time = "2026-06-15T16:23:17.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/21/db/a9574ed40fed418924b1b1a3e54f47ee3963053b3d3d325a0d36b41f2c08/sqlalchemy-2.0.51-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5ea1a213be1fcd5e49d9904c3b9939211ded90bc2a64e93f4c01963474285de", size = 2178920, upload-time = "2026-06-15T15:59:56.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/90/a1bb5c7cbba76b7bc1fbd586d0a5479a7bc9c27b4a8298f22ec9423b2bb3/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c6b36ed71f41942bdcd2ad2522be46bfce09d5705be5640ecf19bbc7660e4b7", size = 3566534, upload-time = "2026-06-15T15:58:35.024Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/4b/481f1fed30e0e9e8dd24aecbb49f29eb57fe7657ece5cf06ee9b84bb97d8/sqlalchemy-2.0.51-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0c2c62877097e1a0db401fba5cb4debee33265e5b2a55c4ccb489c02c53b4f72", size = 3535844, upload-time = "2026-06-15T16:02:43.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/71/0aa64aeda645510af0a43f7d9ee70932f0d1dc4263aed34c50ee891d9df3/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0378d055e9e8cd6ce4d8dff683bdd3d7d413533c4ee51d67a2b1e0f9eacc0f23", size = 3475355, upload-time = "2026-06-15T15:58:36.592Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/db/6061db32316446135a3abae5f308d144ab988a34234726042da3e58b1c63/sqlalchemy-2.0.51-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e46fc36029eff666391e0531e5387b62ce6c4f1d8e50b3fb3099eaca1b42522", size = 3486591, upload-time = "2026-06-15T16:02:45.346Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0d/c9/f14fdf71bb8957e0c7e39db69bbdf12b5c80f4ef775fdfa127bf4e0d6760/sqlalchemy-2.0.51-cp314-cp314t-win32.whl", hash = "sha256:9161cfc9efce70d1715f47d6ff40f79c6778c00d53be4fbc09d70301e4b83ba7", size = 2151313, upload-time = "2026-06-15T16:03:39.127Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/c6/673e618e6f4f297e126d9b56ea2f6478708f6c1af4e3223835c22e2c3697/sqlalchemy-2.0.51-cp314-cp314t-win_amd64.whl", hash = "sha256:159bb6ba32059f57ad7375a8f50d844dd2f19d14954ecf820cd33e20debd46b2", size = 2186280, upload-time = "2026-06-15T16:03:40.569Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e2/22/dbf013a12ec759e54a34a119e9e217435b3f71b2dd5c61a7ade0a25dae87/sqlalchemy-2.0.51-py3-none-any.whl", hash = "sha256:bb024d8b621d0be75f4f44ecc7c950450026e76d66dc8f791bb5331d7fed59d5", size = 1944334, upload-time = "2026-06-15T16:09:22.418Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "werkzeug"
|
||||||
|
version = "3.1.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markupsafe" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
|
||||||
|
]
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DJANGO_SECRET_KEY=252bc4d9151dcc863bb17914e257bdaed66e520d351f0d8a5d15cfa4c21e2ff8
|
||||||
|
DJANGO_DEBUG=False
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Exo 5.6 "Audit Capstone"
|
||||||
|
|
||||||
|
## Le code à auditer
|
||||||
|
|
||||||
|
```python
|
||||||
|
# audit/views.py (API VOLONTAIREMENT VULNÉRABLE — à auditer)
|
||||||
|
import requests
|
||||||
|
from rest_framework import viewsets, serializers
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from .models import Equipement
|
||||||
|
|
||||||
|
class EquipementSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Equipement
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
class EquipementViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Equipement.objects.all()
|
||||||
|
serializer_class = EquipementSerializer
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def tester_webhook(self, request):
|
||||||
|
url = request.data["url"]
|
||||||
|
reponse = requests.get(url)
|
||||||
|
return Response({"status": reponse.status_code, "corps": reponse.text})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Constats
|
||||||
|
|
||||||
|
### 1. "fields" ouvert à tous les champs [API3]
|
||||||
|
|
||||||
|
L'usage de `fields = "__all__"` permet une récupération dynamique de tous les champs de l'élément, y compris les champs éventuellement sensibles.
|
||||||
|
|
||||||
|
#### Correction
|
||||||
|
|
||||||
|
Usage d'un `field = ["champ1", "champ2", "champ3"]` selon les informations souhaitées.
|
||||||
|
|
||||||
|
### 2. queryset trop permissif [API1]
|
||||||
|
|
||||||
|
`queryset = Equipement.objects.all()` permet la récupération de tous les objets d'une table, sans limite de propriété ou d'état connu.
|
||||||
|
|
||||||
|
#### Correction
|
||||||
|
|
||||||
|
Définir une fonction `def get_queryset(self):` permettant de trier selon une propriété ciblée des objets itérables par l'utilisateur, par exemple s'il est propriétaire avec `return Equipement.objects.filter(owner=self.request.user)`
|
||||||
|
|
||||||
|
### 3. permission_classes trop permissif [API5]
|
||||||
|
|
||||||
|
`permission_classes = [AllowAny]` permet une connexion et des actions depuis n'importe quel compte, y compris les non-authentifiés.
|
||||||
|
|
||||||
|
#### Correction
|
||||||
|
|
||||||
|
Utiliser des permissions restrictives comme `permission_classes = [isAuthenticated]` voire des classes "isOwner" récupérant dynamiquement les infos.
|
||||||
|
|
||||||
|
### 4. url = request.data trop ouvert [API7]
|
||||||
|
|
||||||
|
Le bloc :
|
||||||
|
|
||||||
|
```
|
||||||
|
url = request.data["url"]
|
||||||
|
reponse = requests.get(url)
|
||||||
|
```
|
||||||
|
|
||||||
|
Permet à n'importe quelle source d'imposer une requête, avec Falsification des Requêtes Côté-Serveur. Non je ne le dirais pas en anglais (parce que j'ai le droit).
|
||||||
|
|
||||||
|
Par exemple (pour l'avoir déjà vu auparavant), la requête avec une URL à `http://169.254.169.254/latest/meta-data/iam/security-credentials/` peut faire afficher des crédits AWS, avec ID et clef secrète. Et on peut taper avec des noms de services genre `http://postgres:5432/` et potentiellement tomber sur une page de connexion. Pire, si on ne limite pas le type de protocole à `http://` on peut aussi bien tenter un `file:///etc/passwd`.
|
||||||
|
|
||||||
|
De plus, une absence d'URL peut mener à des erreurs serveurs et un Déni de Service.
|
||||||
|
|
||||||
|
#### Correction
|
||||||
|
|
||||||
|
Utiliser des sources précises validées avec une fonction `_hostname_resolve` pour vérifier que le point d'entrée correspond au point de sortie, avec système de liste blanche explicite - sinon liste noire par défaut -, interdire les redirections, restreindre à HTTP et HTTPS, mettre des `timeout` et autre limite de requêtes, etc.
|
||||||
|
|
||||||
|
Contrôler la présence du champ "URL" ou renvoyer une erreur avec un bloc `try: except:`.
|
||||||
|
|
||||||
|
### 5. Requêtes sans garde-fou / pas de limite [API4]
|
||||||
|
|
||||||
|
`reponse = requests.get(url)` se fait sans délai précisé entre deux requêtes. L'attaque par déni de service est probable.
|
||||||
|
|
||||||
|
#### Correction
|
||||||
|
|
||||||
|
Ralentir avec une API externe (genre Fail2Ban) ou un `sleep()`. Possibilité de limiter avec une énumération des requêtes.
|
||||||
|
|
||||||
|
### 6. Réponse de la requête brute [API3]
|
||||||
|
|
||||||
|
`reponse.text` renvoie une réponse brute, non formatée. Cela peut faire fuiter une surface d'attaque par injection de script.
|
||||||
|
|
||||||
|
#### Correction
|
||||||
|
|
||||||
|
Retourner une ligne de caractères fixée côté serveur avant renvoi au client. Aucun envoi direct d'une variable. Du genre :
|
||||||
|
|
||||||
|
```python
|
||||||
|
contenu = reponse.raw.read(settings.WEBHOOK_MAX_RESPONSE_BYTES)
|
||||||
|
return Response({"status": reponse.status_code, "corps": contenu.decode("utf-8", errors="replace")})
|
||||||
|
```
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Module 4 — Exercices : Django & Django REST Framework
|
||||||
|
|
||||||
|
Fil rouge : une application Django de **gestion de parc** (`gestion_parc` / app `parc`), puis son **API DRF** sécurisée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Consignes communes à tous les exercices
|
||||||
|
|
||||||
|
**Usage de l'IA — transparence obligatoire.**
|
||||||
|
L'IA générative n'est **pas interdite**, mais son usage doit être **assumé et documenté**. Chaque rendu **doit obligatoirement contenir un fichier `USAGE-IA.md`**. Sans ce fichier, le rendu est **incomplet**.
|
||||||
|
|
||||||
|
`USAGE-IA.md` doit préciser : (1) les **outils utilisés** et pour quelles parties, (2) l'**aide obtenue** (avec un exemple de prompt), (3) ce que vous avez **accepté** et *pourquoi vous l'avez compris/validé*, (4) ce que vous avez **refusé ou corrigé** et *pourquoi*, (5) ce que vous avez **appris**.
|
||||||
|
|
||||||
|
> Aucune IA utilisée ? Déclarez-le explicitement.
|
||||||
|
|
||||||
|
**Règle d'or : vous devez pouvoir expliquer et justifier chaque ligne de votre rendu.** Gabarit en annexe.
|
||||||
|
|
||||||
|
**Pré-requis :** environnement virtuel activé, `pip install django djangorestframework`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 4.1 — Projet Django : modèle `Equipement` et admin
|
||||||
|
|
||||||
|
**Compétences visées :** projet vs application, modèle, migrations, interface d'administration.
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Créez un projet `gestion_parc` et une application `parc`. Déclarez `parc` dans `INSTALLED_APPS`.
|
||||||
|
2. Définissez un modèle `Equipement` avec les champs `hostname`, `ip` (utilisez `GenericIPAddressField`) et `actif` (booléen, défaut `True`), plus une méthode `__str__`.
|
||||||
|
3. Générez et appliquez les migrations (`makemigrations`, `migrate`).
|
||||||
|
4. Enregistrez le modèle dans l'admin, créez un super-utilisateur (`createsuperuser`) et ajoutez 2-3 équipements via `/admin/`.
|
||||||
|
|
||||||
|
**Livrables :** `models.py`, `admin.py`, un court `RENDU.md` décrivant ce que vous voyez dans l'admin (+ capture d'écran si possible), `USAGE-IA.md`.
|
||||||
|
|
||||||
|
|
||||||
|
## Annexe — Gabarit `USAGE-IA.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Usage de l'IA — Exercice X.Y
|
||||||
|
|
||||||
|
## 1. Outils utilisés
|
||||||
|
(Quel(s) outil(s), pour quelles parties. Ou : « aucune IA utilisée ».)
|
||||||
|
|
||||||
|
## 2. Aide obtenue
|
||||||
|
(Ce que l'IA a proposé. Exemple de prompt : « … »)
|
||||||
|
|
||||||
|
## 3. Ce que j'ai accepté
|
||||||
|
(Ce que j'ai repris et pourquoi je l'ai compris et validé.)
|
||||||
|
|
||||||
|
## 4. Ce que j'ai refusé ou corrigé
|
||||||
|
(Ce que j'ai écarté / modifié, et pourquoi.)
|
||||||
|
|
||||||
|
## 5. Ce que j'ai appris
|
||||||
|
(Ce que je retiens, ce que je ferais autrement.)
|
||||||
|
```
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
## Exercice 4.2 — Lister les équipements actifs (vue + template)
|
||||||
|
|
||||||
|
**Compétences visées :** vue, URL, template, requête ORM avec **filtre**.
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Écrivez une vue `liste_equipements_actifs` qui récupère `Equipement.objects.filter(actif=True)`.
|
||||||
|
2. Associez-la à l'URL `/equipements/`.
|
||||||
|
3. Créez un template affichant les équipements actifs dans une liste, avec un message si la liste est vide (balise `{% empty %}`).
|
||||||
|
4. **Bonus :** réécrivez la même logique en vue basée sur une classe (`ListView` + `get_queryset`).
|
||||||
|
|
||||||
|
**Livrables :** `views.py`, `urls.py`, `templates/parc/liste.html`, `USAGE-IA.md`.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
## Exercice 4.3 — API DRF avec permissions
|
||||||
|
|
||||||
|
**Compétences visées :** DRF (sérialiseur, `ModelViewSet`, routeur), **permissions**.
|
||||||
|
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
|
||||||
|
**Installation de DRF :**
|
||||||
|
```bash
|
||||||
|
pip install djangorestframework
|
||||||
|
```
|
||||||
|
|
||||||
|
1. Ajoutez `rest_framework` à `INSTALLED_APPS`.
|
||||||
|
2. Créez un `EquipementSerializer` (`ModelSerializer`) exposant `id`, `hostname`, `ip`, `actif`.
|
||||||
|
3. Créez un `EquipementViewSet` (`ModelViewSet`) enregistré via un `DefaultRouter` sous `/api/equipements/`.
|
||||||
|
4. Appliquez la permission `IsAuthenticatedOrReadOnly` : **lecture ouverte à tous**, mais **création / modification / suppression réservées aux utilisateurs authentifiés**.
|
||||||
|
5. Démontrez le comportement : lecture anonyme **OK (200)**, écriture anonyme **refusée (401/403)**, écriture authentifiée **OK (201)**.
|
||||||
|
|
||||||
|
**Livrables :** `serializers.py`, `views.py`, `urls.py`, `TESTS.md` (vos essais et les codes obtenus), `USAGE-IA.md`.
|
||||||
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
## Consignes communes à tous les exercices
|
||||||
|
|
||||||
|
**Usage de l'IA — transparence obligatoire.** L'IA générative n'est **pas interdite**, mais son usage doit être **assumé et documenté**. Chaque rendu **doit contenir un fichier `USAGE-IA.md`** (sinon rendu **incomplet**) précisant : (1) les **outils** utilisés et pour quoi, (2) l'**aide obtenue** (avec un exemple de prompt), (3) ce que vous avez **accepté** et *pourquoi vous l'avez compris/validé*, (4) ce que vous avez **refusé ou corrigé** et *pourquoi*, (5) ce que vous avez **appris**. Aucune IA utilisée ? Déclarez-le.
|
||||||
|
|
||||||
|
> **Règle d'or (renforcée en sécurité) :** vous devez pouvoir **expliquer la faille, son impact et votre correctif**. En sécurité, copier un correctif sans le comprendre est un risque en soi.
|
||||||
|
|
||||||
|
**Pré-requis technique :** environnement virtuel, `pip install django djangorestframework`. Pour l'exercice 6, `pip install requests` (déjà vu au module 3).
|
||||||
|
|
||||||
|
**Livrables communs :** le code modifié (fichiers `.py`), un court `TESTS.md` montrant vos essais (commandes/`curl`/captures) et `USAGE-IA.md`. Gabarit de `USAGE-IA.md` en annexe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exercice 1 — Exiger l'authentification (API2)
|
||||||
|
|
||||||
|
**Risque OWASP :** API2 Broken Authentication.
|
||||||
|
|
||||||
|
**Contexte :** l'API d'inventaire actuelle répond à tout le monde, y compris aux requêtes anonymes.
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Activez l'authentification par **jeton** de DRF : ajoutez `rest_framework.authtoken` à `INSTALLED_APPS`, appliquez la migration, et configurez `DEFAULT_AUTHENTICATION_CLASSES` (`TokenAuthentication`).
|
||||||
|
2. Rendez l'`EquipementViewSet` accessible uniquement aux utilisateurs authentifiés (`IsAuthenticated`).
|
||||||
|
3. Créez un utilisateur et générez son jeton (`drf_create_token` ou via le shell).
|
||||||
|
python manage.py createsuperuser
|
||||||
|
Créer un utilisateur 'admin'
|
||||||
|
python manage.py drf_create_token admin
|
||||||
|
Generated token e94d4f34ead7c5ab074cf8b7cd7d7adc19ee441f for user admin
|
||||||
|
4. Exiger l'authentification dans views.py :
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
5. Vérifiez : une requête **sans** jeton est refusée (401/403), une requête **avec** l'en-tête `Authorization: Token <clé>` réussit (200).
|
||||||
|
|
||||||
|
**Critères :** authentification effective, endpoint protégé, démonstration avec et sans jeton.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
## Exercice 2 — Cloisonner l'accès aux objets / BOLA (API1)
|
||||||
|
|
||||||
|
**Risque OWASP :** API1 Broken Object Level Authorization.
|
||||||
|
|
||||||
|
**Contexte :** tout utilisateur authentifié peut lire/modifier **n'importe quel** équipement via son ID.
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Ajoutez au modèle `Equipement` un champ `proprietaire` (clé étrangère vers `User`) ;
|
||||||
|
|
||||||
|
proprietaire = models.ForeignKey(settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="equipements")
|
||||||
|
|
||||||
|
|
||||||
|
migrez.
|
||||||
|
|
||||||
|
2. Dans l'`EquipementViewSet`, surchargez `get_queryset()` pour ne renvoyer que les équipements du `request.user`.
|
||||||
|
3. Faites en sorte que la création affecte automatiquement le propriétaire (`perform_create`).
|
||||||
|
4. Ajoutez une permission au niveau **objet** (`has_object_permission`) qui vérifie l'appartenance.
|
||||||
|
5. Vérifiez : l'utilisateur A ne peut pas accéder (404/403) à l'équipement de l'utilisateur B.
|
||||||
|
|
||||||
|
**Critères :** cloisonnement effectif par propriétaire (queryset + permission objet), démonstration A/B.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Exercice 3 — Propriétés sensibles / BOPLA (API3)
|
||||||
|
|
||||||
|
**Risque OWASP :** API3 Broken Object Property Level Authorization.
|
||||||
|
|
||||||
|
**Contexte :** le sérialiseur expose `fields = "__all__"`. Or `Equipement` possède un champ sensible `notes_internes`, et le champ `proprietaire` ne doit pas être imposable par le client (mass assignment).
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Ajoutez le champ `notes_internes` (texte) au modèle s'il n'existe pas ; migrez.
|
||||||
|
2. Remplacez `fields = "__all__"` par une **liste explicite** de champs qui **n'expose pas** `notes_internes`.
|
||||||
|
3. Empêchez le **mass assignment** de `proprietaire` (`read_only_fields`) — il doit toujours être défini côté serveur.
|
||||||
|
4. Vérifiez : (a) la réponse JSON ne contient pas `notes_internes` ; (b) un `POST` qui tente de fixer `proprietaire` à un autre utilisateur est ignoré (le propriétaire reste l'appelant).
|
||||||
|
|
||||||
|
**Critères :** aucun champ sensible exposé, mass assignment neutralisé, tests probants.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## Exercice 4 — Limiter la consommation (API4) *(Intermédiaire — 30 min)*
|
||||||
|
|
||||||
|
**Risque OWASP :** API4 Unrestricted Resource Consumption (et prévention API6).
|
||||||
|
|
||||||
|
**Contexte :** l'API renvoie toute la table d'un coup et n'impose aucune limite de débit.
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Activez la **pagination** (`PageNumberPagination`, `PAGE_SIZE` raisonnable).
|
||||||
|
2. Activez la **limitation de débit** (`UserRateThrottle` / `AnonRateThrottle`) avec des taux dans `DEFAULT_THROTTLE_RATES`. Pour tester facilement, fixez temporairement un taux bas (ex. `3/min`).
|
||||||
|
3. Limitez la taille des corps de requête (`DATA_UPLOAD_MAX_MEMORY_SIZE`).
|
||||||
|
4. Vérifiez : après dépassement du taux, l'API renvoie **429 Too Many Requests** ; la liste est paginée.
|
||||||
|
|
||||||
|
**Critères :** pagination effective, throttling déclenchant un 429, limite de taille en place.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Exercice 5 — Durcir la configuration (API8)
|
||||||
|
|
||||||
|
**Risque OWASP :** API8 Security Misconfiguration (+ A09 journalisation).
|
||||||
|
|
||||||
|
**Contexte :** le `settings.py` est en configuration de développement (peu sûr pour la production).
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. Sortez la `SECRET_KEY` du code : lisez-la depuis une **variable d'environnement** ; faites de même pour `DEBUG`.
|
||||||
|
2. Passez `DEBUG = False` et renseignez `ALLOWED_HOSTS` explicitement.
|
||||||
|
3. Activez les **en-têtes de sécurité** (`SECURE_CONTENT_TYPE_NOSNIFF`, `SECURE_REFERRER_POLICY`, `SECURE_SSL_REDIRECT`, `SECURE_HSTS_SECONDS`, cookies `Secure`).
|
||||||
|
4. Restreignez **CORS** (pas de `CORS_ALLOW_ALL_ORIGINS = True`) et **désactivez l'API navigable** en production (rendu JSON uniquement).
|
||||||
|
5. Ajoutez une **journalisation** des échecs d'authentification/autorisation (logger dédié), sans journaliser de secret.
|
||||||
|
6. Lancez `python manage.py check --deploy` et corrigez les avertissements.
|
||||||
|
|
||||||
|
**Critères :** secrets hors du code, `DEBUG=False`, en-têtes présents, CORS restreint, `check --deploy` propre, journalisation en place.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
## Exercice 6 — Capstone : audit et durcissement d'une API vulnérable *(Avancé — 45 min)*
|
||||||
|
|
||||||
|
**Risques OWASP visés :** API1, API2, API3, API7 (SSRF), API8, API10.
|
||||||
|
|
||||||
|
**Contexte :** on vous fournit l'extrait suivant d'une API DRF de supervision. Elle contient **plusieurs failles**, dont un endpoint qui teste une URL de webhook fournie par l'utilisateur.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# audit/views.py (API VOLONTAIREMENT VULNÉRABLE — à auditer)
|
||||||
|
import requests
|
||||||
|
from rest_framework import viewsets, serializers
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from .models import Equipement
|
||||||
|
|
||||||
|
class EquipementSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Equipement
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
class EquipementViewSet(viewsets.ModelViewSet):
|
||||||
|
queryset = Equipement.objects.all()
|
||||||
|
serializer_class = EquipementSerializer
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
@action(detail=False, methods=["post"])
|
||||||
|
def tester_webhook(self, request):
|
||||||
|
url = request.data["url"]
|
||||||
|
reponse = requests.get(url)
|
||||||
|
return Response({"status": reponse.status_code, "corps": reponse.text})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Consignes :**
|
||||||
|
1. **Auditez** cet extrait : dressez la liste des failles en les rattachant à leur identifiant OWASP (API1, API2, API3, API7, API8, API10). Rédigez cet audit dans `AUDIT.md`.
|
||||||
|
2. **Corrigez** le code :
|
||||||
|
- authentification requise et autorisation au niveau objet (cloisonnement par propriétaire) ;
|
||||||
|
- sérialiseur à champs explicites, sans champ sensible, sans mass assignment ;
|
||||||
|
- endpoint `tester_webhook` : **valider l'URL** (schéma autorisé, blocage des IP privées / loopback / link-local comme `169.254.169.254`), ajouter un `timeout`, ne pas suivre aveuglément les redirections, et **ne pas** renvoyer tel quel le corps distant ;
|
||||||
|
- signalez au moins un point de configuration à durcir (`DEBUG`, `AllowAny` global, etc.).
|
||||||
|
3. Vérifiez que votre validateur d'URL refuse `http://169.254.169.254/`, `http://127.0.0.1/`, `http://10.0.0.5/` et accepte une URL publique légitime.
|
||||||
|
|
||||||
|
**Livrables :** `AUDIT.md` (failles + OWASP), le code corrigé, `TESTS.md`, `USAGE-IA.md`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Exo 4.1 "Projet Django" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# Exo 5.1
|
||||||
|
|
||||||
|
```
|
||||||
|
> uv run .\manage.py drf_create_token admin
|
||||||
|
Generated token 5e2327313ba81b67bec97c2b1adf333842cfe81c for user admin
|
||||||
|
```
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Exo 4.1 "Projet Django" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|
Je déclare n'avoir pas utilisé l'IA sur ce projet au profit de la [documentation du cours](https://github.com/Laudroid/Live-Python-Web/blob/main/4-1-5-Cr%C3%A9ation-de-pages-web-simples-et-ge.md) et [celle de Django](https://docs.djangoproject.com/fr/3.1/ref/contrib/auth/).
|
||||||
|
|
||||||
|
# Exo 4.2 "Lister des équipements" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|
Je déclare avoir utilisé l'IA dans ces cadres :
|
||||||
|
|
||||||
|
## Raison 1
|
||||||
|
|
||||||
|
Génération de "list.html"
|
||||||
|
|
||||||
|
### Pourquoi ?
|
||||||
|
|
||||||
|
Je n'aime pas l'HTML.
|
||||||
|
|
||||||
|
### Ce que j'en ai retenu
|
||||||
|
|
||||||
|
Tout le contenu donné semble correct.
|
||||||
|
|
||||||
|
## Raison 2
|
||||||
|
|
||||||
|
Pour déboguer la raison pour laquelle mon fichier "list.html" n'était pas trouvé par le projet.
|
||||||
|
|
||||||
|
### Ce que l'IA m'a répondu
|
||||||
|
|
||||||
|
> The template parc/list.html exists at templates/parc/list.html, but Django's app_directories loader is searching inside each app's own templates/ folder — not the project-level one.
|
||||||
|
|
||||||
|
> Fix: declare the project-level templates directory in TEMPLATES settings.
|
||||||
|
|
||||||
|
### Ce que j'ai rajouté
|
||||||
|
|
||||||
|
De :
|
||||||
|
|
||||||
|
```
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
à :
|
||||||
|
|
||||||
|
```
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [BASE_DIR / "templates"],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conséquences
|
||||||
|
|
||||||
|
Ca ne marchait toujours pas, et l'IA m'a mis sur une piste à la noix qui n'est pas évidente : j'ai réorganisé un peu mon arborescence, et dans `settings.py` je devais faire ça :
|
||||||
|
|
||||||
|
```
|
||||||
|
# BASE_DIR = Path(__file__).resolve().parent.parent # old
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent # new: 4_1/
|
||||||
|
```
|
||||||
|
|
||||||
|
# Exo 5.1 "Exiger Auth" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|
Je déclare n'avoir pas usé d'IA au profit [de la documentation officielle](https://www.django-rest-framework.org/api-guide/authentication/#tokenauthentication).
|
||||||
|
|
||||||
|
Par contre je déclare l'avoir utilisé hier soir pour mettre tout d'a-plomb pour faire marcher l'application, en générant les patrons HTML notamment.
|
||||||
|
|
||||||
|
# Exo 5.2 "Cloisonner" - BOICHÉ Gauvain
|
||||||
|
|
||||||
|
J'allais l'utiliser, mais j'ai décidé de relire le cours pour `perform_create`. Des fois je pense que tout est trop compliqué.
|
||||||
|
|
||||||
|
# Exo 5.3 "Propiétés sensibles"
|
||||||
|
|
||||||
|
Je n'ai pas utilisé d'IA... en fait j'avais déjà fait ça :
|
||||||
|
|
||||||
|
```
|
||||||
|
from rest_framework import serializers
|
||||||
|
from .models import Equipment
|
||||||
|
|
||||||
|
class EquipmentSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = Equipment
|
||||||
|
fields = ["id", "hostname", "ip", "is_active"]
|
||||||
|
# fields = ["id", "hostname", "description", "date_ajout", "is_active"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pris d'un cours précédent. Sécurité béton accidentelle.
|
||||||
|
|
||||||
|
### Si, j'en ai usé : Raison 1
|
||||||
|
|
||||||
|
Je voulais un script qui génère des utilisateurs, les jetons et tout directement après un gros HARD-RESET du projet (restructuration des dossiers)
|
||||||
|
|
||||||
|
### Raison 2
|
||||||
|
|
||||||
|
En voulant créer, depuis le compte "Bob" un équipement avec "owner": "Alice", j'obtiens malgré tout une réponse 201. Maintenant, la sécurité "marche" en soit parce que le "owner" passe à "Bob". Autant il ne peut PAS créer de réponse en tant qu'Alice, autant... et bien ce n'est pas ce que j'ai demandé.
|
||||||
|
|
||||||
|
J'ai passé l'info à une IA et elle a voulu tout ré-écrire pour... permettre à Bob de le faire pour Alice. J'ai reprécisé 4 fois le périmètre, pas moyen de lui faire comprendre.
|
||||||
|
|
||||||
|
J'ai fini par lui insulter ses grands morts et j'ai laissé comme ça.
|
||||||
|
|
||||||
|
# Eco 5.4 "Limiter conso"
|
||||||
|
|
||||||
|
Pas d'IA.
|
||||||
|
|
||||||
|
# Exo 5.5 "Durcir config"
|
||||||
|
|
||||||
|
## Raison 1
|
||||||
|
|
||||||
|
Usage pour bien définir les CORS et les hôtes.
|
||||||
|
|
||||||
|
### Avant
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ["127.0.0.1", "http://127.0.0.1", "localhost"]
|
||||||
|
CORS_ALLOWED_ORIGINS = ["127.0.0.1", "http://127.0.0.1", "localhost"]
|
||||||
|
|
||||||
|
### Après
|
||||||
|
|
||||||
|
```
|
||||||
|
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||||
|
CORS_ALLOWED_ORIGINS = ["http://127.0.0.1:8000", "http://localhost:8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Raison 2
|
||||||
|
|
||||||
|
Pour la journalisation, parce que c'était chaud comme bloc.
|
||||||
|
|
||||||
|
# Exo 5.6
|
||||||
|
|
||||||
|
Pas eu le temps.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# 5.3
|
||||||
|
|
||||||
|
## Créer les utilisateurs
|
||||||
|
|
||||||
|
```
|
||||||
|
uv run .\manage.py shell -c "from django.contrib.auth.models import User; User.objects.create_user('alice', 'alice@example.com', 'alicepass123'); User.objects.create_user('bob', 'bob@example.com', 'bobpass123'); print('Users created')"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Créer les jetons
|
||||||
|
|
||||||
|
```
|
||||||
|
uv run .\manage.py shell -c "from django.contrib.auth.models import User; from rest_framework.authtoken.models import Token; print('ALICE:', Token.objects.get_or_create(user=User.objects.get(username='alice'))[0].key); print('BOB:', Token.objects.get_or_create(user=User.objects.get(username='bob'))[0].key)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exposer les jetons
|
||||||
|
|
||||||
|
```
|
||||||
|
$ALICE = "49a597b676ef22f8bd33ed94d3e9e657c05c4338"
|
||||||
|
$BOB = "b710b34ee3244c4da396f6d7a81d5d6d02d94441"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Créer équipement ALICE
|
||||||
|
|
||||||
|
```
|
||||||
|
$body = '{"hostname":"alice-srv","ip":"192.168.1.10","is_active":true}'
|
||||||
|
curl.exe -i -X POST -H "Authorization: Token $ALICE" -H "Content-Type: application/json" -d $body http://127.0.0.1:8000/api/equipments/
|
||||||
|
```
|
||||||
|
|
||||||
|
=> Résultat : 201
|
||||||
|
|
||||||
|
## Créer équipement BOB
|
||||||
|
|
||||||
|
```
|
||||||
|
$body = '{"hostname":"bob-srv","ip":"192.168.1.11","is_active":true}'
|
||||||
|
curl.exe -i -X POST -H "Authorization: Token $BOB" -H "Content-Type: application/json" -d $body http://127.0.0.1:8000/api/equipments/
|
||||||
|
```
|
||||||
|
|
||||||
|
=> Résultat : 201
|
||||||
|
|
||||||
|
## Lister équipement ALICE
|
||||||
|
|
||||||
|
```
|
||||||
|
curl.exe -i -H "Authorization: Token $ALICE" http://127.0.0.1:8000/api/equipments/
|
||||||
|
```
|
||||||
|
|
||||||
|
=> Résultat : 200
|
||||||
|
|
||||||
|
## Lister équipement BOB
|
||||||
|
|
||||||
|
```
|
||||||
|
curl.exe -i -H "Authorization: Token $BOB" http://127.0.0.1:8000/api/equipments/
|
||||||
|
```
|
||||||
|
|
||||||
|
=> Résultat : 200
|
||||||
|
|
||||||
|
## BOB tente de créer un équipement ALICE
|
||||||
|
|
||||||
|
```
|
||||||
|
$body = '{"hostname":"forged-srv","ip":"192.168.1.99","is_active":true,"owner":1}'
|
||||||
|
curl.exe -i -X POST -H "Authorization: Token $BOB" -H "Content-Type: application/json" -d $body http://127.0.0.1:8000/api/equipments/
|
||||||
|
```
|
||||||
|
|
||||||
|
=> Résultat : 201 (c'est pas normal) (alors en fait, même en forçant le "owner" avec "Alice" ça le créé pour Bob. Donc ça n'empêche pas la création mais ça empêche de le faire en un autre nom. J'investiguerai plus tard)
|
||||||
|
|
||||||
|
# 5.4
|
||||||
|
|
||||||
|
## 100 requêtes en tant que "Bob"
|
||||||
|
|
||||||
|
```
|
||||||
|
for ($i = 1; $i -le 100; $i++) {
|
||||||
|
curl.exe -s -o /dev/null -w "%{http_code}`n" -H "Authorization: Token $BOB" http://127.0.0.1:8000/api/equipments/
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
=> Résultat : que des 200.
|
||||||
|
|
||||||
|
En changeant :
|
||||||
|
|
||||||
|
`"DEFAULT_THROTTLE_RATES": {"user": "1000/day", "anon": "50/day"},`
|
||||||
|
en
|
||||||
|
`"DEFAULT_THROTTLE_RATES": {"user": "5/minute", "anon": "50/day"},`
|
||||||
|
|
||||||
|
J'obtiens bien :
|
||||||
|
|
||||||
|
```
|
||||||
|
> for ($i = 1; $i -le 100; $i++) {
|
||||||
|
>> curl.exe -s -o /dev/null -w "%{http_code}`n" -H "Authorization: Token $BOB" http://127.0.0.1:8000/api/equipments/
|
||||||
|
>> }
|
||||||
|
200
|
||||||
|
200
|
||||||
|
200
|
||||||
|
200
|
||||||
|
200
|
||||||
|
429
|
||||||
|
429
|
||||||
|
429
|
||||||
|
429
|
||||||
|
```
|
||||||
|
|
||||||
|
## 100 requêtes en tant que "Anon"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for gestion_parc project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gestion_parc.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"""
|
||||||
|
Django settings for gestion_parc project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 6.0.7.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
### Bloc pour utiliser un fichier .env
|
||||||
|
def _required_env(key: str) -> str:
|
||||||
|
from os import environ
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
value = environ.get(key)
|
||||||
|
if not value:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Variable {key!r} manquante"
|
||||||
|
f"Vérifiez votre fichier .env"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
### Fin du bloc .env
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
SECRET_KEY = _required_env("DJANGO_SECRET_KEY")
|
||||||
|
|
||||||
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
|
# DEBUG = _required_env("DJANGO_DEBUG")
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ["127.0.0.1", "localhost"]
|
||||||
|
CORS_ALLOWED_ORIGINS = ["http://127.0.0.1:8000", "http://localhost:8000"]
|
||||||
|
|
||||||
|
SECURE_HSTS_PRELOAD = True
|
||||||
|
SECURE_HSTS_SECONDS = 31536000
|
||||||
|
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||||
|
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||||
|
SECURE_REFERRER_POLICY = "same-origin"
|
||||||
|
|
||||||
|
### C'est en FALSE exprès. Oui, pas en environnement local. Le HTTPS en local c'est trop galère.
|
||||||
|
### Ils passent à "True" et le `check --deploy` est propre, mais ça devient impraticable en local
|
||||||
|
SECURE_SSL_REDIRECT = False
|
||||||
|
SESSION_COOKIE_SECURE = False
|
||||||
|
CSRF_COOKIE_SECURE = False
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"django.contrib.admin",
|
||||||
|
"django.contrib.auth",
|
||||||
|
"django.contrib.contenttypes",
|
||||||
|
"django.contrib.sessions",
|
||||||
|
"django.contrib.messages",
|
||||||
|
"django.contrib.staticfiles",
|
||||||
|
"rest_framework",
|
||||||
|
"rest_framework.authtoken",
|
||||||
|
"parc",
|
||||||
|
"whitenoise.runserver_nostatic", # Sinon le CSS est cassé avec DEBUG = False
|
||||||
|
]
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
|
||||||
|
"PAGE_SIZE": 50,
|
||||||
|
"DEFAULT_THROTTLE_CLASSES": [
|
||||||
|
"rest_framework.throttling.UserRateThrottle",
|
||||||
|
"rest_framework.throttling.AnonRateThrottle",
|
||||||
|
],
|
||||||
|
"DEFAULT_THROTTLE_RATES": {"user": "1000/day", "anon": "50/day"},
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||||
|
"rest_framework.authentication.SessionAuthentication",
|
||||||
|
"rest_framework.authentication.BasicAuthentication",
|
||||||
|
"rest_framework.authentication.TokenAuthentication",
|
||||||
|
],
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": [
|
||||||
|
"rest_framework.permissions.IsAuthenticatedOrReadOnly",
|
||||||
|
"rest_framework.permissions.IsAuthenticated",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
DATA_UPLOAD_MAX_MEMORY_SIZE = 2 * 1024 * 1024 # 2 Mo
|
||||||
|
DATA_UPLOAD_MAX_NUMBER_FIELDS = 1000
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'gestion_parc.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [BASE_DIR / "templates"],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
"OPTIONS": {
|
||||||
|
"context_processors": [
|
||||||
|
"django.template.context_processors.request",
|
||||||
|
"django.contrib.auth.context_processors.auth",
|
||||||
|
"django.contrib.messages.context_processors.messages",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'gestion_parc.wsgi.application'
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'fr-fr'
|
||||||
|
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = "static/"
|
||||||
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
LOGIN_REDIRECT_URL = "/equipments/"
|
||||||
|
LOGOUT_REDIRECT_URL = "/login/"
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"verbose": {
|
||||||
|
"format": "{asctime} {levelname} {name} {message}",
|
||||||
|
"style": "{",
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"format": "{asctime} {levelname} SECURITY {name} {message}",
|
||||||
|
"style": "{",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "verbose",
|
||||||
|
},
|
||||||
|
"security_file": {
|
||||||
|
"class": "logging.FileHandler",
|
||||||
|
"filename": BASE_DIR / "logs" / "security.log",
|
||||||
|
"formatter": "security",
|
||||||
|
},
|
||||||
|
"auth_file": {
|
||||||
|
"class": "logging.FileHandler",
|
||||||
|
"filename": BASE_DIR / "logs" / "auth.log",
|
||||||
|
"formatter": "security",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"django.security": {
|
||||||
|
"handlers": ["security_file", "console"],
|
||||||
|
"level": "WARNING",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
"parc.auth": {
|
||||||
|
"handlers": ["auth_file", "console"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"rest_framework.throttling": {
|
||||||
|
"handlers": ["security_file", "console"],
|
||||||
|
"level": "INFO",
|
||||||
|
"propagate": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for gestion_parc project.
|
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
|
https://docs.djangoproject.com/en/6.0/topics/http/urls/
|
||||||
|
Examples:
|
||||||
|
Function views
|
||||||
|
1. Add an import: from my_app import views
|
||||||
|
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||||
|
Class-based views
|
||||||
|
1. Add an import: from other_app.views import Home
|
||||||
|
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||||
|
Including another URLconf
|
||||||
|
1. Import the include() function: from django.urls import include, path
|
||||||
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
|
"""
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("admin/", admin.site.urls),
|
||||||
|
path("login/", auth_views.LoginView.as_view(), name="login"),
|
||||||
|
path("logout/", auth_views.LoginView.as_view(), name="logout"),
|
||||||
|
|
||||||
|
path("", include("parc.urls")),
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for gestion_parc project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'gestion_parc.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
2026-07-10 15:16:49,436 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
|
2026-07-10 15:27:57,853 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
|
2026-07-10 15:27:57,882 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
|
2026-07-10 15:27:57,909 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
|
2026-07-10 15:27:57,937 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
|
2026-07-10 15:27:57,963 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
|
2026-07-10 15:27:58,000 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
|
2026-07-10 15:27:58,031 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
|
2026-07-10 15:27:58,067 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
|
2026-07-10 15:27:58,099 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
|
2026-07-10 15:27:58,128 WARNING SECURITY parc.auth Authentication failure: user= path=/api/equipments/ method=GET status=403
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
2026-07-10 15:18:00,648 WARNING SECURITY django.security.SuspiciousSession Session data corrupted
|
||||||
|
2026-07-10 15:18:11,700 WARNING SECURITY django.security.SuspiciousSession Session data corrupted
|
||||||
|
2026-07-10 15:18:11,712 WARNING SECURITY django.security.SuspiciousSession Session data corrupted
|
||||||
|
2026-07-10 15:18:19,156 WARNING SECURITY django.security.SuspiciousSession Session data corrupted
|
||||||