Semaine 11

This commit is contained in:
gauvainboiche
2026-07-11 21:38:00 +02:00
parent d3d2416dea
commit 0d071d2843
281 changed files with 54412 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
3.12
Binary file not shown.
@@ -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.
---
Binary file not shown.
View File
+41
View File
@@ -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)
+130
View File
@@ -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 -
```
+75
View File
@@ -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()
+57
View File
@@ -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))
Binary file not shown.
+35
View File
@@ -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))
+94
View File
@@ -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}")
+6
View File
@@ -0,0 +1,6 @@
def main():
print("Hello from jour-01!")
if __name__ == "__main__":
main()
+9
View File
@@ -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",
]
+119
View File
@@ -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" },
]
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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)**.
Lapplication doit permettre :
* dafficher 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 lapplication Flask
Créer une application Flask fonctionnelle.
Lapplication doit pouvoir afficher une page daccueil.
---
## É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 daccueil (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 dun équipement
Créer une route dynamique permettant dafficher :
* un équipement spécifique selon son identifiant.
La page doit afficher :
* toutes les informations de l’équipement ;
* un message derreur si lidentifiant nexiste 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 daccé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 dactifs ;
* nombre dinactifs ;
* 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é |
+61
View File
@@ -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.
+54
View File
@@ -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 %}
Binary file not shown.
Binary file not shown.
@@ -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.
---
View File
+181
View File
@@ -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 -
```
+96
View File
@@ -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
+103
View File
@@ -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)
+6
View File
@@ -0,0 +1,6 @@
def main():
print("Hello from jour-02!")
if __name__ == "__main__":
main()
+9
View File
@@ -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",
]
+124
View File
@@ -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" },
]
+13
View File
@@ -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
+40
View File
@@ -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/
+1
View File
@@ -0,0 +1 @@
3.14
Binary file not shown.
@@ -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 |
+80
View File
@@ -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.
+234
View File
@@ -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"])
+19
View File
@@ -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)
+92
View File
@@ -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())
+157
View File
@@ -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
+67
View File
@@ -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()
+12
View File
@@ -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",
]
+306
View File
@@ -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; }
+21
View File
@@ -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 %}
+6
View File
@@ -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 %}
+23
View File
@@ -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"
+304
View File
@@ -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" },
]
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
DJANGO_SECRET_KEY=252bc4d9151dcc863bb17914e257bdaed66e520d351f0d8a5d15cfa4c21e2ff8
DJANGO_DEBUG=False
+1
View File
@@ -0,0 +1 @@
3.14
+97
View File
@@ -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")})
```
Binary file not shown.
Binary file not shown.
@@ -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`.
View File
+22
View File
@@ -0,0 +1,22 @@
# Exo 4.1 "Projet Django" - BOICHÉ Gauvain
![alt text](image.png)
![alt text](image-1.png)
![alt text](image-2.png)
![alt text](image-3.png)
![alt text](image-4.png)
![alt text](image-5.png)
![alt text](image-6.png)
# Exo 5.1
```
> uv run .\manage.py drf_create_token admin
Generated token 5e2327313ba81b67bec97c2b1adf333842cfe81c for user admin
```
+147
View File
@@ -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.
+100
View File
@@ -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"
Binary file not shown.
+16
View File
@@ -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()
+220
View File
@@ -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,
},
}
+27
View File
@@ -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")),
]
+16
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+11
View File
@@ -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
+4
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More