Épreuve pratique · 2026 · Sujet 15

Bac NSI pratique 2026 sujet 15 : Cabinet vétérinaire, jointures SQL et dernières vaccinations

Le cabinet veut préparer des rappels de vaccination par SMS, dans le cadre annuel défini par le sujet. Le travail porte sur le nettoyage des téléphones, leur validation, l’extraction des consultations puis la conservation de la dernière vaccination par chat. L’envoi de SMS et le filtrage final des rappels entre 10 et 13 mois constituent le contexte du projet ; ils ne font pas partie des quatre questions à programmer.

Dans ce sujet

Un corrigé pédagogique pour comprendre et justifier vos réponses. Les conseils de rédaction ne constituent pas un barème officiel détaillé.

Application

Cabinet vétérinaire, jointures SQL et dernières vaccinations

Le cabinet veut préparer des rappels de vaccination par SMS, dans le cadre annuel défini par le sujet. Le travail porte sur le nettoyage des téléphones, leur validation, l’extraction des consultations puis la conservation de la dernière vaccination par chat. L’envoi de SMS et le filtrage final des rappels entre 10 et 13 mois constituent le contexte du projet ; ils ne font pas partie des quatre questions à programmer.

TableChampsRelations
proprietaireid (clé primaire), nom, prenom, telephoneUn propriétaire peut avoir plusieurs animaux
animalid (clé primaire), espece, nom, date_naissance, id_proprietaireid_proprietaire référence proprietaire.id
consultationid (clé primaire), id_animal, date, motifid_animal référence animal.id

Les identifiants sont des entiers ; les autres champs sont des chaînes. Les dates utilisent AAAAMMJJ, ce qui permet de les comparer dans l’ordre lexicographique. Le schéma se lit consultation.id_animal → animal.id, puis animal.id_proprietaire → proprietaire.id. Extraits reproduits du PDF :

proprietaire.idnomprenomtelephone
1DuhamelFrançois06-57-84-52-41
2DumontMarcelle0648747223
3JoubertAnouk0 4 76 33 78 15
animal.idespecenomdate_naissanceid_proprietaire
1oiseauFlocon201711111
2lapinRoudoudou202109012
3chatMistral202512062
consultation.idid_animaldatemotif
1120171229vaccination
25420050621bilan
34520131106maladie

sqlite 3 fait partie de la bibliothèque standard. Pour une requête paramétrée, le texte SQL contient ? et execute reçoit un tuple de paramètres, par exemple (2,) ou (date,). Le numéro mobile attendu comporte exactement 10 chiffres et commence par 06 ou 07. Le PDF comporte un X surnuméraire dans un des patrons : la longueur 10 et les tests lèvent l’ambiguïté.

Le dossier comporte le sujet et les fichiers listés dans les téléchargements. Les codes de départ sont reproduits ci-dessous. Travaillez sur une copie. Les appels professeur signalés dans les questions servent à présenter votre démarche ; vous pouvez aussi demander de l’aide en cas de difficulté.

Code fourni : veto.py
# -----------------------------------------------------------------------------
# Numéros de téléphones
# Question 1 : nettoyage des numéros de téléphone

# Écrire la fonction normalisation_tel ici


import sqlite3


def test_normalisation_tel():
    """
    Tous les tests doivent passer...
    """
    assert normalisation_tel("06 12 99 90 12") == "0612999012"
    assert normalisation_tel("02 12 99 90 12") == "0212999012"
    assert normalisation_tel("02.12.99.90.12") == "0212999012"
    assert normalisation_tel("0.6.12.99.90.12") == "0612999012"
    assert normalisation_tel("06-12-99-90-12") == "0612999012"
    assert normalisation_tel("(0)6.12.99-90-12 gilbert") == "0612999012"
    assert normalisation_tel("061299901") == "061299901"
    assert normalisation_tel("06129990123") == "06129990123"
    print('Les tests de la fonction normalisation_tel sont passés')


# -----------------------------------------------------------------------------
# Question 2 : validation des numéros de téléphone


def validation_tel(tel):
    """
    Validation des numéros de téléphone portable français
    selon les conditions spécifiées.
    :param tel: numéro de téléphone
    :return: True si le numéro est valide, False sinon
    """
    if len(tel) != 10:
        return False
    if tel[0] != "0":
        return False
    if tel[1] != "6" and tel[1] != "7":
        return False
    return True


# Ecrire votre jeu de tests permettant
# de vérifier le bon fonctionnement de la fonction.


# -----------------------------------------------------------------------------
# Détermination de la liste des chats à vacciner
# Question 3 : interrogation de la base de données

DB_PATH = "cabinet.sqlite"


def proprietaires_animaux_nes_apres(date):
    """
    Renvoie les noms et prénoms des propriétaires d'animaux nés après la date `date`,
    triés par ordre alphabétique de noms puis prénoms

    :param: date: date de naissance minimale
    :return: liste [(nom_proprietaire, prenom_ proprietaire)]
    """
    conn = sqlite3.connect(DB_PATH)
    cursor = conn.cursor()
    resultat = cursor.execute(
        """
    SELECT proprietaire.nom, proprietaire.prenom
    FROM proprietaire
        JOIN  animal ON proprietaire.id = animal.id_proprietaire 
    WHERE animal.date_naissance > ?
    ORDER BY proprietaire.nom, proprietaire.prenom;
    """,
        (date,),
    )
    return list(resultat)


def consultation_vaccination_chat(date):
    """
    Renvoie les consultations de vaccination de chats dont la date
    est supérieure à la date `date`.

    :param: date: date minimale
    :return: liste [(id_animal, nom_animal, tel_proprietaire, date_consultation)]
    """
    pass


def test_consultation_vaccination_chat():
    vaccinations = consultation_vaccination_chat("20240923")
    assert len(vaccinations) == 118
    assert vaccinations[0] == (16, "Plume", "0.6.36.96.89.83", "20241024")
    assert vaccinations[1] == (16, "Plume", "0.6.36.96.89.83", "20251125")
    assert vaccinations[2] == (17, "Gollum", "0.6.36.96.89.83", "20250113")
    assert vaccinations[3] == (26, "Olympe", "(0)4 73 98 01 23", "20250109")
    assert vaccinations[4] == (32, "Chopin", "06.37.97.66.64", "20241201")
    assert vaccinations[5] == (32, "Chopin", "06.37.97.66.64", "20251119")
    assert vaccinations[6] == (34, "Jazz", "0.6.37.51.65.52", "20250801")
    assert vaccinations[7] == (35, "Tango", "0324182", "20250706")
    assert vaccinations[8] == (38, "Loulou", "05-35-95-87-54", "20250209")

    # Vérification stricte du tri (ORDER BY id_animal, date_consultation)
    assert vaccinations == sorted(vaccinations, key=lambda x: (x[0], x[3]))

    print('Les tests de la fonction consultation_vaccination_chat sont passés')

# test_consultation_vaccination_chat()

# -----------------------------------------------------------------------------
# Question 4 : détermination de la date de dernière vaccination


def derniere_vaccination(consultations):
    """
    Renvoie un dictionnaire ayant pour clef l'identifiant de l'animal,
    et dont la valeur associée est la dernière consultation de cet animal.

    Chaque consultation est un tuple :

    (id_animal, nom_animal, tel_proprietaire, date_consultation)
    """
    derniere = {}
    for consult in consultations:
        id_animal = consult[0]
        date = consult[3]
        if id_animal not in derniere:
            derniere[id_animal] = consult
        elif date < derniere[id_animal][3]:
            derniere[id_animal] = consult
    return derniere


def test_derniere_vaccination():
    consultations_pour_test = [
        (16, "Plume", "0.6.36.96.89.83", "20241024"),
        (16, "Plume", "0.6.36.96.89.83", "20251125"),
        (17, "Gollum", "0.6.36.96.89.83", "20250113"),
        (26, "Olympe", "(0)4 73 98 01 23", "20250109"),
        (32, "Chopin", "06.37.97.66.64", "20241201"),
        (32, "Chopin", "06.37.97.66.64", "20251119"),
        (34, "Jazz", "0.6.37.51.65.52", "20250801"),
        (35, "Tango", "0324182", "20250706"),
        (38, "Loulou", "05-35-95-87-54", "20250209"),
        (39, "Tango", "07.45.48.02.42", "20250329"),
        (40, "Sésame", "07.45.48.02.42", "20250228"),
        (41, "Pixel", "0130709285", "20241204"),
        (41, "Pixel", "0130709285", "20251222"),
    ]

    resultat = derniere_vaccination(consultations_pour_test)
    # Affichage des résultats, décommenter si besoin
    # for a,r in resultat.items():
    #     print(a, ":", r)

    assert derniere_vaccination(consultations_pour_test) == {
        16: (16, "Plume", "0.6.36.96.89.83", "20251125"),
        17: (17, "Gollum", "0.6.36.96.89.83", "20250113"),
        26: (26, "Olympe", "(0)4 73 98 01 23", "20250109"),
        32: (32, "Chopin", "06.37.97.66.64", "20251119"),
        34: (34, "Jazz", "0.6.37.51.65.52", "20250801"),
        35: (35, "Tango", "0324182", "20250706"),
        38: (38, "Loulou", "05-35-95-87-54", "20250209"),
        39: (39, "Tango", "07.45.48.02.42", "20250329"),
        40: (40, "Sésame", "07.45.48.02.42", "20250228"),
        41: (41, "Pixel", "0130709285", "20251222"),
    }
    print('Les tests de la fonction derniere_vaccination sont passés')

Question 1

#

Écrire normalisation_tel(tel), qui ne conserve que les chiffres. Exemples : "(0)2 12 99 90 12" donne 0212999012, "0.6.12.99.90.12" donne 0612999012 et "0-6-12-9-90-1" donne 06129901. Utiliser le test fourni.

Indice

Tester chaque caractère avec isdigit.

Comprendre la correction
def normalisation_tel(tel):
    resultat = ''
    for caractere in tel:
        if caractere.isdigit():
            resultat += caractere
    return resultat

On filtre les caractères sans modifier l’ordre ni compléter un numéro trop court. Le résultat reste une chaîne pour préserver le zéro initial. Cette étape ne décide pas encore si le numéro est un mobile valide : elle prépare la validation.

test_normalisation_tel()
assert normalisation_tel('') == ''
assert normalisation_tel('pas de numéro') == ''
Voir la question dans le sujet PDF, p. 4 (nouvel onglet)

Question 2

#

Écrire un jeu de tests vérifiant validation_tel sur un numéro déjà nettoyé. Il doit contenir 10 chiffres et commencer par 06 ou 07.

Comprendre la correction
def test_validation_tel():
    assert validation_tel('0612999012')
    assert validation_tel('0712999012')
    assert not validation_tel('0212999012')  # fixe
    assert not validation_tel('1612999012')  # premier chiffre
    assert not validation_tel('061299901')   # neuf chiffres
    assert not validation_tel('06129990123') # onze chiffres
    assert not validation_tel('')            # liste vide de caractères

test_validation_tel()

On couvre chaque condition et les deux préfixes autorisés. Le contrat précise un numéro déjà nettoyé ; le code fourni ne revérifie donc pas que chaque caractère est un chiffre. Pour une fonction destinée à des entrées arbitraires, on ajouterait une validation ASCII explicite, mais il ne faut pas mélanger ce changement avec les tests du contrat demandé.

Voir la question dans le sujet PDF, p. 4 (nouvel onglet)

Question 3

#

Écrire consultation_vaccination_chat(date) : renvoyer les consultations de motif vaccination pour l’espèce chat, à une date strictement supérieure au paramètre, avec id animal, nom animal, téléphone propriétaire et date, triés par id puis date croissants. Passer les tests fournis.

SQLExécuter la requête des vaccinations de chatsÉcrivez votre solution et mettez-la à l’épreuve

Travaillez uniquement la partie SQL de consultation_vaccination_chat pour l’appel avec la date 20240923. Renvoyez animal.id, animal.nom, proprietaire.telephone brut et consultation.date, pour les chats vaccinés après cette date strictement. Triez par animal.id, puis consultation.date croissants. Dans cet atelier SQL, écrivez la date '20240923' comme littéral à la place du paramètre Python.

SELECT ...
FROM ...
WHERE ...;

Les cas de test proposés :

  • Sous-ensemble de cabinet.sqlite fourni : Schéma extrait directement de sqlite_master et lignes lues dans le fichier cabinet.sqlite fourni. Les téléphones sont conservés tels quels ; leur nettoyage appartient à une autre question.
  • Cas complémentaire : date stricte, espèce et motif : Jeu complémentaire pédagogique, distinct des données officielles. La date exactement égale au seuil est exclue. Une consultation de bilan et la vaccination d’un chien ne correspondent pas au filtre.
  • Cas complémentaire : deux consultations distinctes le même jour : Jeu complémentaire pédagogique, distinct des données officielles. La base identifie une consultation par son id, pas par son couple animal/date. La requête demandée conserve donc les deux lignes, avant la sélection d’une dernière vaccination à la question suivante.
Indice

Partir de animal, rejoindre son propriétaire et ses consultations.

Comprendre la correction
def consultation_vaccination_chat(date):
    conn = sqlite3.connect(DB_PATH)
    try:
        cursor = conn.cursor()
        resultat = cursor.execute("""
SELECT animal.id, animal.nom, proprietaire.telephone, consultation.date
FROM animal
JOIN proprietaire ON animal.id_proprietaire = proprietaire.id
JOIN consultation ON consultation.id_animal = animal.id
WHERE animal.espece = 'chat'
  AND consultation.motif = 'vaccination'
  AND consultation.date > ?
ORDER BY animal.id, consultation.date
        """, (date,))
        return list(resultat)
    finally:
        conn.close()

Deux jointures relient les trois tables par leurs clés. Le WHERE combine trois restrictions indépendantes. La date reste un paramètre : ne pas la concaténer au SQL. La virgule dans (date,) construit un tuple à un seul élément. La conversion list est effectuée avant de fermer la connexion.

test_consultation_vaccination_chat()
print(consultation_vaccination_chat('20240923')[:3])
idnomtéléphonedate
16Plume0.6.36.96.89.8320241024
16Plume0.6.36.96.89.8320251125
17Gollum0.6.36.96.89.8320250113

La base réellement fournie renvoie 118 consultations pour 20240923, dans l’ordre attendu. Les téléphones restent bruts dans cette requête, comme le demandent ses tests ; le nettoyage est une étape distincte.

Vérifiez la requête en suivant un résultat. La consultation porte un identifiant d’animal ; la première relation retrouve le chat et son nom ; l’animal porte l’identifiant de son propriétaire ; la seconde relation retrouve le téléphone. Un propriétaire peut avoir plusieurs animaux et un animal plusieurs consultations : il est donc normal de voir plusieurs lignes avec le même téléphone ou le même chat. Ajouter DISTINCT au hasard risquerait de masquer cette structure. Le tri par identifiant puis date prépare la lecture, mais ne remplace pas encore la sélection de la dernière vaccination.

Voir la question dans le sujet PDF, p. 5 (nouvel onglet)

Question 4

#

Expliquer pourquoi derniere_vaccination ne passe pas ses tests et corriger. Pour Plume, conserver 20251125 plutôt que 20241024 ; pour Gollum, conserver 20250113.

PythonRetenir la dernière vaccination dans n’importe quel ordreÉcrivez votre solution et mettez-la à l’épreuve

Corrigez derniere_vaccination(consultations). Chaque tuple contient (id_animal, nom, téléphone, date au format AAAAMMJJ). Renvoyez un dictionnaire associant chaque identifiant au tuple complet de sa consultation la plus récente. Les dates de longueur fixe sont comparables comme des chaînes ; les entrées ne sont pas forcément triées.

def derniere_vaccination(consultations):
    # À vous de jouer
    pass

Les cas de test proposés :

  • Plume et Gollum : Chaque animal conserve sa consultation la plus récente.
  • Plus récent en premier : Écraser à chaque passage ne fonctionne pas sans tri garanti.
  • Deux animaux de même nom : La clé est l’identifiant, pas le nom.
  • Le téléphone vient du tuple retenu : On conserve toute la ligne de la consultation gagnante.
  • Liste vide et entrée intacte : Regrouper ne demande pas de modifier la liste de consultations.
Indice

Dernière signifie maximum de date.

Comprendre la correction

La comparaison date<date_actuelle conserve la vaccination la plus ancienne. On cherche la plus récente, donc le maximum : il faut inverser le comparateur. La première consultation de chaque animal initialise son entrée, puis les suivantes ne la remplacent que si elles sont postérieures.

def derniere_vaccination(consultations):
    derniere = {}
    for consult in consultations:
        identifiant = consult[0]
        if identifiant not in derniere or consult[3] > derniere[identifiant][3]:
            derniere[identifiant] = consult
    return derniere
test_derniere_vaccination()
assert derniere_vaccination([]) == {}
cas=[(16,'Plume','x','20251125'),(16,'Plume','x','20241024')]
assert derniere_vaccination(cas)[16][3] == '20251125'

Cette version fonctionne aussi lorsque les consultations arrivent dans un ordre quelconque. Un simple écrasement à chaque passage ne serait correct qu’avec un tri chronologique croissant garanti. La troisième composante est le téléphone dans le code et les exemples, malgré une phrase du PDF qui la nomme id_proprietaire.

Après la première consultation de Plume, le dictionnaire associe 16 au tuple daté 20241024. À la deuxième, la date 20251125 est plus grande, donc le tuple entier remplace l’ancien. Le passage à Gollum crée une autre clé sans toucher celle de Plume. La propriété à conserver après chaque étape est : pour chaque animal déjà rencontré, le dictionnaire contient sa consultation la plus récente parmi celles déjà lues. Un ordre inversé des entrées est un bon test : le maximum doit rester le même.

Jointure et dernier enregistrementUn atelier pour expérimenter

Choisissez une réponse puis confrontez votre intuition à l’explication. Vous pouvez modifier vos choix.

Lire les réponses et leurs explications
  1. Quelle clé relie consultation à animal ?

    consultation.id_animal=animal.id. La clé étrangère désigne précisément l’animal.

  2. Pourquoi le maximum sur AAAAMMJJ fonctionne-t-il ?

    Le format fixe place les unités les plus grandes d’abord.. Année, mois puis jour reproduisent l’ordre chronologique.

Appuyez votre explication sur les valeurs et les conditions exactes du programme.

Voir la question dans le sujet PDF, p. 5 (nouvel onglet)
Observer l’accumulation de la dernière vaccinationUn atelier pour expérimenter

Choisissez l’ordre des trois consultations de Plume et Gollum, puis le comparateur. Le tableau montre le dictionnaire après chaque traitement.

Lire le résultat de l’expérience initiale

Dernière date stockée pour Plume : 20251125.

Le sens du comparateur choisit un minimum ou un maximum. L’ordre d’entrée ne change pas le maximum final.

AnimalDate lueActionDictionnaire courant
Plume20241024Conserver/remplacer16: 20241024
Plume20251125Conserver/remplacer16: 20251125
Gollum20250113Conserver/remplacer16: 20251125 ; 17: 20250113

Un dictionnaire peut conserver le meilleur enregistrement de chaque groupe en un seul parcours.

Revoir les notions de cet exercice

Du sujet à la méthode

Votre prochaine séance de révision

  • Séparer nettoyage, validation et requête de sélection.
  • Justifier les clés de jointure et le sens du comparateur de date.

Retrouver ces notions dans d’autres sujets

Toutes les annales de NSI · Le guide pour préparer le bac NSI 2027

Énoncé : sujet 26_BCG_NSI_15 (PDF). Corrigé et explications pédagogiques proposés par Sofien.