Projet 03 : Gestionnaire de tâches professionnel (POO, Persistance JSON et Tests Pytest)#
Dans l'industrie logicielle contemporaine, savoir écrire des scripts isolés est utile, mais concevoir une architecture logicielle robuste, maintenable et testable est la véritable marque d'un développeur professionnel.
Ce troisième et dernier projet synthétise l'ensemble des compétences acquises tout au long du cours : Programmation Orientée Objet (POO), héritage et polymorphisme, gestion fine des dates (datetime), persistance sur disque au standard JSON, hiérarchie d'exceptions personnalisées et validation automatique par une suite de tests unitaires avec Pytest.
1. Cahier des charges et spécifications techniques#
Nous souhaitons développer le cœur applicatif d'un système de gestion de tâches (Task Manager) pour équipes de développement.
Spécifications fonctionnelles :#
- Modèle de données & Typage fort :
- Définition d'énumérations (
enum.Enum) pour les statuts (A_FAIRE,EN_COURS,TERMINEE) et les niveaux de priorité (BASSE,MOYENNE,HAUTE,URGENTE). - Classe de base
Tache:- Identifiant unique auto-incrémenté.
- Titre, description, date d'échéance (
datetime.date), priorité et statut. - Propriété calculée
est_en_retard: compare la date d'échéance à la date du jour si la tâche n'est pas encore terminée. - Méthodes de sérialisation
to_dict()et de désérialisationfrom_dict().
- Spécialisation par Héritage (
TacheUrgente) : - Hérite de
Tache. - Force automatiquement la priorité à
URGENTE. - Possède un attribut supplémentaire
responsable_alerte: stret une méthode dédiéeemettre_rappel(). - Moteur de gestion (
GestionnaireTaches) : - Collection centrale de tâches encapsulée dans un dictionnaire interne
{ id: Tache }. - Méthodes CRUD : ajout, consultation par identifiant, suppression, modification de statut.
- Filtrage : liste des tâches par statut, liste des tâches en retard de livraison.
- Persistance : méthodes
sauvegarder(fichier: Path)etcharger(fichier: Path)gérant les fichiers JSON. - Exceptions métier dédiées :
TacheNonTrouveeError: levée lorsqu'un identifiant inexistant est interrogé.StatutInvalideError: levée en cas de transition interdite ou de valeur inattendue.- Couverture de tests avec Pytest :
- Mise en place de fixtures Pytest pour injecter des jeux de données isolés.
- Utilisation de
tmp_pathpour tester la lecture et l'écriture sur disque sans polluer l'espace de travail. - Tests de conformité des exceptions avec
pytest.raises.
2. Diagramme de classes UML#
Le modèle respecte le principe de responsabilité unique (SRP - Single Responsibility Principle) :
classDiagram
class StatutTache {
<<enumeration>>
A_FAIRE
EN_COURS
TERMINEE
}
class Priorite {
<<enumeration>>
BASSE
MOYENNE
HAUTE
URGENTE
}
class Tache {
+int id_tache
+str titre
+str description
+date date_echeance
+Priorite priorite
+StatutTache statut
+bool est_en_retard
+marquer_terminee()
+to_dict() dict
+from_dict(dict) Tache
}
class TacheUrgente {
+str responsable_alerte
+emettre_rappel() str
+to_dict() dict
+from_dict(dict) TacheUrgente
}
class GestionnaireTaches {
-dict _taches
-int _compteur_id
+ajouter_tache(tache) int
+obtenir_tache(id_tache) Tache
+supprimer_tache(id_tache)
+filtrer_par_statut(statut) list
+obtenir_taches_en_retard() list
+sauvegarder_json(chemin)
+charger_json(chemin)
}
Tache <|-- TacheUrgente : Hérite de
Tache ..> StatutTache : Utilise
Tache ..> Priorite : Utilise
GestionnaireTaches o-- Tache : Agrège
3. Implémentation du cœur applicatif (task_manager.py)#
Créez le fichier task_manager.py dans votre répertoire de projet :
"""
Système de gestion de tâches orienté objet avec persistance JSON.
Auteur : Joseph Konka (AlgoJungle)
"""
from __future__ import annotations
import json
from datetime import date, datetime
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional
# =====================================================================
# 1. ÉNUMÉRATIONS ET EXCEPTIONS SUR MESURE
# =====================================================================
class StatutTache(str, Enum):
"""Énumération des statuts d'avancement possibles d'une tâche."""
A_FAIRE = "À faire"
EN_COURS = "En cours"
TERMINEE = "Terminée"
class Priorite(str, Enum):
"""Énumération des niveaux de priorité d'une tâche."""
BASSE = "Basse"
MOYENNE = "Moyenne"
HAUTE = "Haute"
URGENTE = "Urgente"
class TaskManagerError(Exception):
"""Exception de base pour les erreurs du gestionnaire de tâches."""
pass
class TacheNonTrouveeError(TaskManagerError):
"""Levée lorsqu'une tâche n'existe pas dans le registre."""
pass
# =====================================================================
# 2. CLASSES MÉTIER : TACHE ET TACHEURGENTE
# =====================================================================
class Tache:
"""Représente une unité de travail élémentaire."""
def __init__(
self,
titre: str,
description: str,
date_echeance: date,
priorite: Priorite = Priorite.MOYENNE,
statut: StatutTache = StatutTache.A_FAIRE,
id_tache: Optional[int] = None,
) -> None:
if not titre.strip():
raise ValueError("Le titre de la tâche ne peut pas être vide.")
self.id_tache: Optional[int] = id_tache
self.titre: str = titre.strip()
self.description: str = description.strip()
self.date_echeance: date = date_echeance
self.priorite: Priorite = priorite
self.statut: StatutTache = statut
@property
def est_en_retard(self) -> bool:
"""Détermine si la tâche a dépassé son échéance sans être terminée."""
if self.statut == StatutTache.TERMINEE:
return False
return date.today() > self.date_echeance
def marquer_terminee(self) -> None:
"""Bascule le statut de la tâche à TERMINÉE."""
self.statut = StatutTache.TERMINEE
def to_dict(self) -> Dict[str, Any]:
"""Convertit l'instance en dictionnaire sérialisable au format JSON."""
return {
"type": "Tache",
"id_tache": self.id_tache,
"titre": self.titre,
"description": self.description,
"date_echeance": self.date_echeance.isoformat(),
"priorite": self.priorite.value,
"statut": self.statut.value,
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> Tache:
"""Reconstitue une instance de Tache à partir d'un dictionnaire brut."""
return cls(
id_tache=data.get("id_tache"),
titre=data["titre"],
description=data["description"],
date_echeance=datetime.strptime(data["date_echeance"], "%Y-%m-%d").date(),
priorite=Priorite(data["priorite"]),
statut=StatutTache(data["statut"]),
)
def __repr__(self) -> str:
retard = " ⚠️ EN RETARD" if self.est_en_retard else ""
return (
f"<Tache #{self.id_tache} [{self.priorite.value}] '{self.titre}' "
f"({self.statut.value}){retard}>"
)
class TacheUrgente(Tache):
"""Spécialisation d'une tâche avec protocole d'escalade d'urgence."""
def __init__(
self,
titre: str,
description: str,
date_echeance: date,
responsable_alerte: str,
statut: StatutTache = StatutTache.A_FAIRE,
id_tache: Optional[int] = None,
) -> None:
super().__init__(
titre=titre,
description=description,
date_echeance=date_echeance,
priorite=Priorite.URGENTE,
statut=statut,
id_tache=id_tache,
)
self.responsable_alerte: str = responsable_alerte.strip()
def emettre_rappel(self) -> str:
"""Génère un message d'alerte haute priorité."""
return (
f"🚨 RAPPEL CRITIQUE : La tâche #{self.id_tache} '{self.titre}' "
f"doit être traitée d'urgence par {self.responsable_alerte} !"
)
def to_dict(self) -> Dict[str, Any]:
data = super().to_dict()
data["type"] = "TacheUrgente"
data["responsable_alerte"] = self.responsable_alerte
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> TacheUrgente:
return cls(
id_tache=data.get("id_tache"),
titre=data["titre"],
description=data["description"],
date_echeance=datetime.strptime(data["date_echeance"], "%Y-%m-%d").date(),
responsable_alerte=data["responsable_alerte"],
statut=StatutTache(data["statut"]),
)
# =====================================================================
# 3. GESTIONNAIRE CENTRAL DE TÂCHES
# =====================================================================
class GestionnaireTaches:
"""Service d'agrégation, de manipulation et de persistance des tâches."""
def __init__(self) -> None:
self._taches: Dict[int, Tache] = {}
self._prochain_id: int = 1
def ajouter_tache(self, tache: Tache) -> int:
"""Enregistre une nouvelle tâche et lui assigne un identifiant unique."""
tache.id_tache = self._prochain_id
self._taches[self._prochain_id] = tache
self._prochain_id += 1
return tache.id_tache
def obtenir_tache(self, id_tache: int) -> Tache:
"""Retourne la tâche correspondant à l'identifiant ou lève une exception."""
if id_tache not in self._taches:
raise TacheNonTrouveeError(f"Aucune tâche trouvée avec l'ID #{id_tache}.")
return self._taches[id_tache]
def supprimer_tache(self, id_tache: int) -> None:
"""Supprime une tâche du registre."""
if id_tache not in self._taches:
raise TacheNonTrouveeError(f"Impossible de supprimer : ID #{id_tache} inexistant.")
del self._taches[id_tache]
def filtrer_par_statut(self, statut: StatutTache) -> List[Tache]:
"""Retourne toutes les tâches ayant un statut particulier."""
return [t for t in self._taches.values() if t.statut == statut]
def obtenir_taches_en_retard(self) -> List[Tache]:
"""Retourne toutes les tâches non terminées dont l'échéance est passée."""
return [t for t in self._taches.values() if t.est_en_retard]
def sauvegarder_json(self, chemin: Path) -> None:
"""Sérialise l'ensemble du registre dans un fichier JSON structuré."""
export_data = {
"prochain_id": self._prochain_id,
"taches": [t.to_dict() for t in self._taches.values()],
}
with open(chemin, mode="w", encoding="utf-8") as f:
json.dump(export_data, f, ensure_ascii=False, indent=4)
def charger_json(self, chemin: Path) -> None:
"""Désérialise les données d'un fichier JSON et peuple le registre."""
if not chemin.exists():
raise FileNotFoundError(f"Le fichier '{chemin}' n'existe pas.")
with open(chemin, mode="r", encoding="utf-8") as f:
data = json.load(f)
self._taches.clear()
self._prochain_id = data.get("prochain_id", 1)
for item in data.get("taches", []):
if item.get("type") == "TacheUrgente":
instance = TacheUrgente.from_dict(item)
else:
instance = Tache.from_dict(item)
if instance.id_tache is not None:
self._taches[instance.id_tache] = instance
4. Suite de tests unitaires automatisée (test_task_manager.py)#
Créez le fichier de tests test_task_manager.py dans le même dossier pour valider toutes les fonctionnalités avec Pytest :
"""
Suite de tests unitaires automatisés pour le gestionnaire de tâches.
Exécution : pytest -v
"""
from datetime import date, timedelta
from pathlib import Path
import pytest
from task_manager import (
GestionnaireTaches,
Priorite,
StatutTache,
Tache,
TacheNonTrouveeError,
TacheUrgente,
)
@pytest.fixture
def gestionnaire_vide() -> GestionnaireTaches:
"""Fournit une instance fraîche de GestionnaireTaches avant chaque test."""
return GestionnaireTaches()
@pytest.fixture
def tache_standard() -> Tache:
"""Fournit une tâche standard d'échéance à J+7."""
return Tache(
titre="Rédiger la documentation API",
description="Décrire les endpoints REST et codes d'erreur.",
date_echeance=date.today() + timedelta(days=7),
priorite=Priorite.HAUTE,
)
def test_creation_tache_titre_vide_invalide():
"""Vérifie qu'un titre vide lève une exception ValueError."""
with pytest.raises(ValueError, match="ne peut pas être vide"):
Tache(titre=" ", description="Description", date_echeance=date.today())
def test_ajout_tache_assignation_id(gestionnaire_vide: GestionnaireTaches, tache_standard: Tache):
"""Vérifie que l'ajout d'une tâche incrémente et assigne correctement un ID."""
id_1 = gestionnaire_vide.ajouter_tache(tache_standard)
assert id_1 == 1
assert tache_standard.id_tache == 1
tache_recuperee = gestionnaire_vide.obtenir_tache(1)
assert tache_recuperee.titre == "Rédiger la documentation API"
assert tache_recuperee.statut == StatutTache.A_FAIRE
def test_tache_en_retard():
"""Valide la détection dynamique des retards sur une tâche échue."""
hier = date.today() - timedelta(days=1)
tache_echue = Tache(titre="Corriger bug critique", description="Hotfix", date_echeance=hier)
assert tache_echue.est_en_retard is True
# Une tâche terminée n'est plus considérée comme en retard
tache_echue.marquer_terminee()
assert tache_echue.statut == StatutTache.TERMINEE
assert tache_echue.est_en_retard is False
def test_tache_urgente_heritage_et_alerte():
"""Valide le comportement spécialisé de la classe fille TacheUrgente."""
demain = date.today() + timedelta(days=1)
tache_urg = TacheUrgente(
titre="Panne de serveur de production",
description="Le cluster Kubernetes ne répond plus.",
date_echeance=demain,
responsable_alerte="Sophie DevOps",
)
assert isinstance(tache_urg, Tache)
assert tache_urg.priorite == Priorite.URGENTE
assert "Sophie DevOps" in tache_urg.emettre_rappel()
def test_gestionnaire_recherche_id_inexistant(gestionnaire_vide: GestionnaireTaches):
"""Vérifie que la consultation d'un ID absent lève bien TacheNonTrouveeError."""
with pytest.raises(TacheNonTrouveeError):
gestionnaire_vide.obtenir_tache(999)
def test_suppression_tache(gestionnaire_vide: GestionnaireTaches, tache_standard: Tache):
"""Vérifie la suppression d'une tâche."""
id_t = gestionnaire_vide.ajouter_tache(tache_standard)
gestionnaire_vide.supprimer_tache(id_t)
with pytest.raises(TacheNonTrouveeError):
gestionnaire_vide.obtenir_tache(id_t)
def test_persistance_json(gestionnaire_vide: GestionnaireTaches, tmp_path: Path):
"""Valide le cycle complet sauvegarde -> rechargement depuis le disque."""
fichier_json = tmp_path / "registre_test.json"
# Création et ajout de deux tâches (une standard et une urgente)
t1 = Tache(titre="Tâche 1", description="Desc 1", date_echeance=date.today())
t2 = TacheUrgente(
titre="Tâche 2 Urgente",
description="Desc 2",
date_echeance=date.today(),
responsable_alerte="Alexandre",
)
gestionnaire_vide.ajouter_tache(t1)
gestionnaire_vide.ajouter_tache(t2)
# Sauvegarde sur disque
gestionnaire_vide.sauvegarder_json(fichier_json)
assert fichier_json.exists()
# Rechargement dans un nouveau gestionnaire
nouveau_gestionnaire = GestionnaireTaches()
nouveau_gestionnaire.charger_json(fichier_json)
recup_1 = nouveau_gestionnaire.obtenir_tache(1)
recup_2 = nouveau_gestionnaire.obtenir_tache(2)
assert recup_1.titre == "Tâche 1"
assert isinstance(recup_2, TacheUrgente)
assert recup_2.responsable_alerte == "Alexandre"
5. Exécution des tests unitaires et résultat#
Pour exécuter la suite de tests automatisés, lancez simplement la commande pytest dans votre terminal :
Vous devez obtenir un rapport où l'ensemble des cas de test passent au vert avec succès :
============================= test session starts ==============================
platform win32 -- Python 3.10.x, pytest-8.x.x
rootdir: d:\Workspace\projects\task-manager
collected 7 items
test_task_manager.py::test_creation_tache_titre_vide_invalide PASSED [ 14%]
test_task_manager.py::test_ajout_tache_assignation_id PASSED [ 28%]
test_task_manager.py::test_tache_en_retard PASSED [ 42%]
test_task_manager.py::test_tache_urgente_heritage_et_alerte PASSED [ 57%]
test_task_manager.py::test_gestionnaire_recherche_id_inexistant PASSED [ 71%]
test_task_manager.py::test_suppression_tache PASSED [ 85%]
test_task_manager.py::test_persistance_json PASSED [100%]
============================== 7 passed in 0.12s ===============================
6. Mise en pratique & Défis d'extension#
Félicitations pour la réalisation de cette architecture logicielle ! Pour parfaire votre maîtrise :
Défi 1 : Générateur de rapport Markdown#
- Objectif : Implémentez une méthode
exporter_markdown(chemin: Path) -> NonedansGestionnaireTachesqui génère un fichier Markdown formaté avec des cases à cocher[ ]pour les tâches à faire et[x]pour les tâches terminées.
Défi 2 : Recherche plein texte avec le module re#
- Objectif : Ajoutez une méthode
rechercher(motif_regex: str) -> List[Tache]qui filtre les tâches dont le titre ou la description correspondent à une expression régulière donnée.
Défi 3 : Décorateur de journalisation temporelle#
- Objectif : Créez un décorateur
@mesurer_tempsqui logue automatiquement le temps d'exécution en millisecondes des méthodes de sauvegarde et de chargement JSON du gestionnaire.
Commentaires