295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""Service d'import des vacances scolaires depuis l'API gouvernementale."""
|
|
import requests
|
|
from datetime import datetime, timedelta
|
|
from zoneinfo import ZoneInfo
|
|
from app_new.extensions import db
|
|
from app_new.core.models import CollegeClosure
|
|
|
|
|
|
# API du gouvernement pour les vacances scolaires
|
|
API_URL = "https://data.education.gouv.fr/api/explore/v2.1/catalog/datasets/fr-en-calendrier-scolaire/records"
|
|
|
|
|
|
def fetch_vacances_scolaires(zone="A", annee=None):
|
|
"""
|
|
Récupère les vacances scolaires depuis l'API gouvernementale.
|
|
|
|
Args:
|
|
zone: Zone académique (A, B ou C)
|
|
annee: Année scolaire (ex: 2024 pour 2024-2025). Si None, année actuelle.
|
|
|
|
Returns:
|
|
Liste de dictionnaires avec les vacances
|
|
"""
|
|
if annee is None:
|
|
annee = datetime.now().year
|
|
|
|
# L'API du gouvernement filtre par zone et année
|
|
params = {
|
|
"where": f"zones='Zone {zone}' AND annee_scolaire='{annee}-{annee + 1}'",
|
|
"order_by": "start_date",
|
|
"limit": 100,
|
|
}
|
|
|
|
try:
|
|
response = requests.get(API_URL, params=params, timeout=30)
|
|
response.raise_for_status()
|
|
data = response.json().get("results", [])
|
|
|
|
vacances = []
|
|
for item in data:
|
|
start = item.get("start_date")
|
|
end = item.get("end_date")
|
|
description = item.get("description", "Vacances scolaires")
|
|
|
|
if start and end:
|
|
# Filtrer pour l'année scolaire demandée
|
|
start_date = datetime.fromisoformat(start.replace("Z", "+00:00")).astimezone(ZoneInfo("Europe/Paris"))
|
|
if start_date.year >= annee - 1:
|
|
raw_end_date = datetime.fromisoformat(end.replace("Z", "+00:00")).astimezone(ZoneInfo("Europe/Paris")).date()
|
|
# Les fermetures ponctuelles (ex. pont de l'Ascension)
|
|
# sont représentées par le même début et la même fin.
|
|
end_date = raw_end_date if raw_end_date == start_date.date() else raw_end_date - timedelta(days=1)
|
|
# Pour l'agent, le 31 août reste une journée de vacances
|
|
# (la rentrée des enseignants ne déclenche pas la reprise).
|
|
is_summer = "grandes vacances" in description.lower() or "vacances d'été" in description.lower()
|
|
if is_summer and raw_end_date.month == 8:
|
|
end_date = raw_end_date
|
|
vacances.append({
|
|
"start_date": start_date.date(),
|
|
# L'API indique la date de rentrée comme borne de fin.
|
|
# Elle ne doit pas être affichée comme un jour de vacances.
|
|
"end_date": end_date,
|
|
"description": description,
|
|
"zone": zone
|
|
})
|
|
|
|
# Lorsque l'API fournit plusieurs calendriers (enseignants, élèves),
|
|
# retenir la rentrée la plus tardive : la permanence reste possible
|
|
# jusqu'à la veille de la rentrée des élèves.
|
|
# Une ligne existe par académie et parfois par population. Dédupliquer
|
|
# en conservant la date de rentrée la plus tardive (élèves).
|
|
deduped = {}
|
|
for item in vacances:
|
|
key = (item["description"], item["start_date"])
|
|
if key not in deduped or item["end_date"] > deduped[key]["end_date"]:
|
|
deduped[key] = item
|
|
vacances = list(deduped.values())
|
|
grandes = [item for item in vacances if "grandes vacances" in item["description"].lower() or "vacances d'été" in item["description"].lower()]
|
|
if grandes:
|
|
max_end = max(item["end_date"] for item in grandes)
|
|
for item in grandes:
|
|
item["end_date"] = max_end
|
|
return vacances
|
|
except Exception as e:
|
|
print(f"Erreur lors de la récupération des vacances: {e}")
|
|
return []
|
|
|
|
|
|
def import_vacances_scolaires(zone="A", annee=None, user_id=None):
|
|
"""
|
|
Importe les vacances scolaires dans la base de données.
|
|
|
|
Args:
|
|
zone: Zone académique (A, B ou C)
|
|
annee: Année scolaire
|
|
user_id: ID de l'utilisateur qui importe
|
|
|
|
Returns:
|
|
(nombre_ajoutees, nombre_ignorees, erreurs)
|
|
"""
|
|
vacances = fetch_vacances_scolaires(zone, annee)
|
|
|
|
if not vacances:
|
|
# Données de fallback si l'API n'est pas accessible
|
|
vacances = get_vacances_fallback(zone, annee)
|
|
|
|
# Les périodes importées représentent une seule zone de référence.
|
|
# Lors d'un changement de zone, retirer uniquement les anciennes
|
|
# périodes issues d'un import officiel (jamais les périodes manuelles).
|
|
imported_closures = CollegeClosure.query.filter(
|
|
CollegeClosure.closure_type == "vacances_scolaires",
|
|
CollegeClosure.notes.like("Zone académique % — import officiel"),
|
|
).all()
|
|
for existing in imported_closures:
|
|
if not (existing.notes or "").startswith(f"Zone académique {zone} "):
|
|
db.session.delete(existing)
|
|
if imported_closures:
|
|
db.session.flush()
|
|
|
|
ajoutees = 0
|
|
ignorees = 0
|
|
erreurs = []
|
|
|
|
for vac in vacances:
|
|
# Vérifier si cette fermeture existe déjà
|
|
existing = CollegeClosure.query.filter(
|
|
CollegeClosure.closure_type == "vacances_scolaires",
|
|
CollegeClosure.start_date == vac["start_date"],
|
|
CollegeClosure.end_date == vac["end_date"]
|
|
).first()
|
|
|
|
if existing:
|
|
ignorees += 1
|
|
continue
|
|
|
|
try:
|
|
closure = CollegeClosure(
|
|
name=vac["description"],
|
|
start_date=vac["start_date"],
|
|
end_date=vac["end_date"],
|
|
closure_type="vacances_scolaires",
|
|
work_hours_type="none",
|
|
notes=f"Zone académique {zone} — import officiel",
|
|
)
|
|
db.session.add(closure)
|
|
ajoutees += 1
|
|
except Exception as e:
|
|
erreurs.append(f"{vac['description']}: {str(e)}")
|
|
|
|
if ajoutees > 0:
|
|
db.session.commit()
|
|
|
|
return ajoutees, ignorees, erreurs
|
|
|
|
|
|
def get_vacances_fallback(zone, annee):
|
|
"""
|
|
Retourne les vacances scolaires par défaut si l'API n'est pas accessible.
|
|
Basé sur le calendrier officiel français.
|
|
"""
|
|
from datetime import timedelta
|
|
|
|
if annee is None:
|
|
annee = datetime.now().year
|
|
|
|
# Calculer l'année scolaire (2024-2025 pour annee=2024)
|
|
annee_scolaire = f"{annee}-{annee + 1}"
|
|
|
|
# Vacances standardisées (approximatives, à ajuster selon la zone)
|
|
vacances = [
|
|
# Toussaint
|
|
{
|
|
"start_date": datetime(annee, 10, 19).date(),
|
|
"end_date": datetime(annee, 11, 3).date(),
|
|
"description": f"Vacances de la Toussaint {annee_scolaire}",
|
|
"zone": zone
|
|
},
|
|
# Noël
|
|
{
|
|
"start_date": datetime(annee, 12, 21).date(),
|
|
"end_date": datetime(annee + 1, 1, 6).date(),
|
|
"description": f"Vacances de Noël {annee_scolaire}",
|
|
"zone": zone
|
|
},
|
|
# Hiver (diffère selon la zone)
|
|
{
|
|
"start_date": datetime(annee + 1, 2, 15).date() if zone != "C" else datetime(annee + 1, 2, 22).date(),
|
|
"end_date": datetime(annee + 1, 3, 2).date() if zone != "C" else datetime(annee + 1, 3, 9).date(),
|
|
"description": f"Vacances d'hiver {annee_scolaire}",
|
|
"zone": zone
|
|
},
|
|
# Printemps (diffère selon la zone) - CORRIGÉ: annee + 1 au lieu de annee + 2
|
|
{
|
|
"start_date": datetime(annee + 1, 4, 12).date() if zone == "A" else (datetime(annee + 1, 4, 19).date() if zone == "B" else datetime(annee + 1, 4, 26).date()),
|
|
"end_date": datetime(annee + 1, 4, 27).date() if zone == "A" else (datetime(annee + 1, 5, 4).date() if zone == "B" else datetime(annee + 1, 5, 11).date()),
|
|
"description": f"Vacances de printemps {annee_scolaire}",
|
|
"zone": zone
|
|
},
|
|
# Grandes vacances
|
|
{
|
|
"start_date": datetime(annee + 1, 7, 5).date(),
|
|
"end_date": datetime(annee + 1, 8, 31).date(),
|
|
"description": f"Grandes vacances {annee_scolaire}",
|
|
"zone": zone
|
|
},
|
|
]
|
|
|
|
return [{**vac, "end_date": vac["end_date"] if "grandes vacances" in vac["description"].lower() else vac["end_date"] - timedelta(days=1)} for vac in vacances]
|
|
|
|
|
|
def get_jours_feries(annee=None):
|
|
"""
|
|
Retourne les jours fériés français pour une année donnée.
|
|
Calculés dynamiquement (Pâques est calculé).
|
|
"""
|
|
from datetime import timedelta
|
|
|
|
if annee is None:
|
|
annee = datetime.now().year
|
|
|
|
# Calcul de Pâques (algorithme de Butcher-Meeus)
|
|
a = annee % 19
|
|
b = annee // 100
|
|
c = annee % 100
|
|
d = b // 4
|
|
e = b % 4
|
|
f = (b + 8) // 25
|
|
g = (b - f + 1) // 3
|
|
h = (19 * a + b - d - g + 15) % 30
|
|
i = c // 4
|
|
k = c % 4
|
|
l = (32 + 2 * e + 2 * i - h - k) % 7
|
|
m = (a + 11 * h + 22 * l) // 451
|
|
mois = (h + l - 7 * m + 114) // 31
|
|
jour = ((h + l - 7 * m + 114) % 31) + 1
|
|
|
|
paques = datetime(annee, mois, jour)
|
|
|
|
# Utiliser timedelta au lieu de replace pour éviter les jours invalides
|
|
jours_feries = [
|
|
(datetime(annee, 1, 1), "Jour de l'an"),
|
|
(paques, "Pâques"),
|
|
(paques + timedelta(days=1), "Lundi de Pâques"),
|
|
(datetime(annee, 5, 1), "Fête du travail"),
|
|
(datetime(annee, 5, 8), "Victoire 1945"),
|
|
(paques + timedelta(days=39), "Ascension"),
|
|
(paques + timedelta(days=49), "Pentecôte"),
|
|
(paques + timedelta(days=50), "Lundi de Pentecôte"),
|
|
(datetime(annee, 7, 14), "Fête nationale"),
|
|
(datetime(annee, 8, 15), "Assomption"),
|
|
(datetime(annee, 11, 1), "Toussaint"),
|
|
(datetime(annee, 11, 11), "Armistice 1918"),
|
|
(datetime(annee, 12, 25), "Noël"),
|
|
]
|
|
|
|
return [(d.date(), n) for d, n in jours_feries]
|
|
|
|
|
|
def import_jours_feries(annee=None, user_id=None):
|
|
"""
|
|
Importe les jours fériés dans la base de données.
|
|
|
|
Returns:
|
|
(nombre_ajoutees, nombre_ignorees)
|
|
"""
|
|
jours = get_jours_feries(annee)
|
|
ajoutees = 0
|
|
ignorees = 0
|
|
|
|
for date, nom in jours:
|
|
existing = CollegeClosure.query.filter(
|
|
CollegeClosure.closure_type == "jour_ferie",
|
|
CollegeClosure.start_date == date,
|
|
CollegeClosure.end_date == date
|
|
).first()
|
|
|
|
if existing:
|
|
ignorees += 1
|
|
continue
|
|
|
|
closure = CollegeClosure(
|
|
name=nom,
|
|
start_date=date,
|
|
end_date=date,
|
|
closure_type="jour_ferie",
|
|
work_hours_type="none",
|
|
notes="Import automatique des jours fériés français",
|
|
)
|
|
db.session.add(closure)
|
|
ajoutees += 1
|
|
|
|
if ajoutees > 0:
|
|
db.session.commit()
|
|
|
|
return ajoutees, ignorees
|