gmao/app_new/lib_ext/pronote_client.py
2026-08-14 16:02:25 +00:00

432 lines
No EOL
15 KiB
Python

"""
Client PRONOTE pour GMAO Collège
Utilise la bibliothèque pronotepy pour se connecter à Pronote via QR Code
"""
import json
import logging
import uuid
from datetime import datetime, timezone, timedelta
from typing import Optional, Dict, Any
# Import de pronotepy bundlée - la classe s'appelle 'Client'
# VieScolaireClient pour les établissements scolaires
from .pronotepy import Client as PronoteClient, VieScolaireClient
logger = logging.getLogger(__name__)
# Client global en mémoire
_pronote_client: Optional[PronoteClient] = None
_pronote_session: Optional[Dict[str, Any]] = None
def _get_or_create_device_uuid() -> str:
"""
Récupère l'UUID stocké en base ou en génère un nouveau.
L'UUID doit rester constant pour que PRONOTE reconnaisse l'appareil.
Ne crée PAS de session partielle - juste retourne l'UUID.
"""
try:
from app_new.pronote.models import PronoteSession
session = PronoteSession.get()
if session and session.device_uuid:
logger.info(f"UUID existant récupéré: {session.device_uuid}")
return session.device_uuid
# Générer un nouvel UUID (sera sauvegardé lors de la connexion)
new_uuid = str(uuid.uuid4())
logger.info(f"Nouveau UUID généré: {new_uuid}")
return new_uuid
except Exception as e:
logger.error(f"Erreur récupération UUID: {e}")
# Fallback: générer un UUID temporaire
return str(uuid.uuid4())
def connect_with_qr(qr_code_json: str, pin: str, account_pin: Optional[str] = None) -> Optional[PronoteClient]:
"""
Connecte à Pronote via un QR Code JSON.
IMPORTANT: Une connexion QR est considérée comme un "nouvel appareil" par PRONOTE.
On génère donc TOUJOURS un nouvel UUID pour chaque connexion QR.
Args:
qr_code_json: JSON du QR code (string ou dict)
pin: PIN du QR Code (4 chiffres)
account_pin: PIN du compte si différent du PIN QR
Returns:
PronoteClient si connexion réussie, None sinon
"""
global _pronote_client, _pronote_session
try:
# Parser le JSON si c'est une string
if isinstance(qr_code_json, str):
qr_data = json.loads(qr_code_json)
else:
qr_data = qr_code_json
# Vérifier les clés requises
required_keys = ['login', 'jeton', 'url']
if not all(k in qr_data for k in required_keys):
raise ValueError("Le QR Code doit contenir les clés: login, jeton, url")
# IMPORTANT: Pour une connexion QR, PRONOTE attend un NOUVEL UUID
# (c'est un "nouvel appareil" pour lui)
device_uuid = str(uuid.uuid4())
logger.info(f"Nouvel UUID généré pour connexion QR: {device_uuid}")
# Utiliser le PIN du compte si fourni, sinon le PIN QR
effective_pin = account_pin if account_pin else pin
# Appliquer la méthode de classe qrcode_login
_pronote_client = PronoteClient.qrcode_login(
qr_code=qr_data,
pin=pin,
uuid=device_uuid,
account_pin=effective_pin,
device_name='GMAO College'
)
# Stocker les infos de session
# Utiliser client.password directement (comme l'ancien code)
_pronote_session = {
'url': qr_data.get('url'),
'token': _pronote_client.password, # Token en clair direct
'pin': pin,
'account_pin': account_pin,
'device_uuid': device_uuid,
'connected_at': datetime.now(timezone.utc)
}
# Sauvegarder en base
save_session_to_db(_pronote_session)
logger.info(f"Connexion PRONOTE réussie avec UUID: {device_uuid}")
return _pronote_client
except Exception as e:
logger.error(f"Erreur connexion PRONOTE: {e}")
_pronote_client = None
_pronote_session = None
return None
def connect_with_credentials(url: str, username: str, password: str, account_pin: Optional[str] = None) -> Optional['PronoteClient']:
"""
Connecte à Pronote via identifiants classiques.
Args:
url: URL du serveur Pronote
username: Nom d'utilisateur
password: Mot de passe
account_pin: PIN du compte (optionnel)
Returns:
Client si connexion réussie, None sinon
"""
global _pronote_client, _pronote_session
try:
from .pronotepy import VieScolaireClient
_pronote_client = VieScolaireClient(
pronote_url=url,
username=username,
password=password,
mode="token",
uuid=str(uuid.uuid4()),
account_pin=account_pin
)
# Récupérer le token pour sauvegarde
token = _pronote_client.password if hasattr(_pronote_client, 'password') else None
device_uuid = str(_pronote_client.uuid) if hasattr(_pronote_client, 'uuid') else str(uuid.uuid4())
_pronote_session = {
'url': url,
'username': username,
'token': token,
'device_uuid': device_uuid,
'account_pin': account_pin,
'connected_at': datetime.now(timezone.utc)
}
# Sauvegarder en base
save_session_to_db(_pronote_session)
logger.info(f"Connexion PRONOTE réussie pour {username}")
return _pronote_client
except Exception as e:
logger.error(f"Erreur connexion PRONOTE: {e}")
return None
def get_client() -> Optional['PronoteClient']:
"""
Retourne le client PRONOTE actuel s'il existe.
Tente de reconstituer la session depuis la base si le client est None.
Returns:
PronoteClient ou None
"""
global _pronote_client
if _pronote_client is not None:
return _pronote_client
# Essayer de reconstituer depuis la base
try:
from app_new.extensions import db
from app_new.pronote.models import PronoteSession
from .pronotepy import VieScolaireClient as PronoteClient
session = PronoteSession.get()
if session and session.session_token and session.session_url:
logger.info("Reconstruction session PRONOTE depuis la base")
# Reconstituer le client avec token_login
# L'ancien token devrait avoir été sauvegardé avec le UUID utilisé
token_uuid = session.device_uuid or str(uuid.uuid4())
_pronote_client = PronoteClient.token_login(
pronote_url=session.session_url,
username=session.username,
password=session.session_token,
uuid=token_uuid,
device_name='GMAO College',
account_pin=session.account_pin # Utiliser le PIN de la base
)
# Sauvegarder le nouveau token après reconnexion (le token peut tourner)
if _pronote_client:
new_token = _pronote_client.password if hasattr(_pronote_client, 'password') else None
if new_token and new_token != session.session_token:
logger.info("Mise à jour du token après reconnexion")
session.session_token = new_token
db.session.commit()
return _pronote_client
except Exception as e:
logger.warning(f"Impossible de reconstituer session: {e}")
return None
def get_session() -> Optional[Dict[str, Any]]:
"""
Retourne les informations de session actuelles.
Returns:
Dict avec infos de session ou None
"""
return _pronote_session
def disconnect():
"""Déconnecte le client PRONOTE."""
global _pronote_client, _pronote_session
_pronote_client = None
_pronote_session = None
logger.info("Déconnexion PRONOTE")
def is_connected() -> bool:
"""Vérifie si un client pronote est connecté."""
return _pronote_client is not None
def get_all_salles(client) -> list:
"""
Récupère toutes les salles depuis Pronote.
Args:
client: Client PRONOTE connecté
Returns:
Liste des salles avec leurs informations
"""
try:
# Utiliser l'API PRONOTE pour récupérer les ressources
result = client.post('RessourceEDT', 16, {})
ressources = result['dataSec']['data']['listeRessources']['V']
salles = []
for r in ressources:
if r.get('G') == 81: # Type salle = 81
salles.extend(r.get('liste', {}).get('V', []))
logger.info(f"Récupéré {len(salles)} salles depuis PRONOTE")
return salles
except Exception as e:
logger.error(f"Erreur récupération salles: {e}")
return []
def get_lessons_for_room(client, room_name: str, date_start, date_end) -> list:
"""Récupère les cours pour une salle donnée en utilisant l'API directe."""
try:
# 1. Récupérer les salles
result = client.post('RessourceEDT', 16, {})
salles = []
for r in result['dataSec']['data']['listeRessources']['V']:
if r.get('G') == 81:
salles = r.get('liste', {}).get('V', [])
break
# 2. Trouver l'ID de la salle
salle_id = None
for s in salles:
if s.get('L') == room_name:
salle_id = s.get('N')
break
if not salle_id:
logger.warning(f"Salle {room_name} non trouvée dans Pronote")
return []
room_lessons = []
# 3. Pour chaque jour de la semaine
for d in range(7):
target_date = date_start + timedelta(days=d)
date_pronote = {"_T": 7, "V": f"{target_date.day}/{target_date.month}/{target_date.year} 0:0:0"}
try:
resultat = client.post('PageEmploiDuTemps', 16, {
"ressource": {"N": salle_id, "G": 17, "L": room_name},
"dateDebut": date_pronote,
"avecAbsencesEleve": False,
"avecConseilDeClasse": True,
"avecRetenuesEleve": True,
"estEDTPermanence": False,
"estEDTAnnuel": False,
"avecAbsencesRessource": True,
"avecCoursSortiePeda": True,
"avecDisponibilites": False,
"avecInfosPrefsGrille": True,
"edt": {"G": 81, "L": "Salles"},
"Ressource": {"N": salle_id, "G": 17, "L": room_name},
"DateDebut": date_pronote
})
except Exception as api_err:
logger.warning(f"Erreur API pour {room_name} le {target_date}: {api_err}")
continue
# 4. Parser les cours
liste_cours = resultat.get('dataSec', {}).get('data', {}).get('ListeCours', [])
if not liste_cours:
continue
for c in liste_cours:
statut = c.get('Statut', 'OK')
annule = c.get('estAnnule', False)
liste = c.get('ListeContenus', {}).get('V', [])
matiere = next((x.get('L') for x in liste if x.get('G') == 16), '')
classe = next((x.get('L') for x in liste if x.get('G') == 1), '')
prof = next((x.get('L') for x in liste if x.get('G') == 3), '')
# Parser l'heure - format "jj/mm/aaaa HH:MM"
heure_str = c.get('DateDuCours', {}).get('V', '')
# Calculer start_time et end_time
start_time = None
end_time = None
if ' ' in heure_str:
time_part = heure_str.split(' ')[-1]
try:
hours, minutes = map(int, time_part.split(':')[:2])
start_time = f"{hours:02d}:{minutes:02d}"
# Durée par défaut 1h, ou utiliser 'duree'
duree = c.get('duree', 1)
end_hours = hours + duree
end_time = f"{end_hours:02d}:{minutes:02d}"
except:
pass
room_lessons.append({
'day_of_week': d, # 0-6 (lundi=0)
'start_time': start_time,
'end_time': end_time,
'subject': matiere,
'teacher': prof,
'class_name': classe,
'statut': statut,
'annule': annule
})
logger.info(f"Récupéré {len(room_lessons)} cours pour salle {room_name}")
# Trier par jour puis par heure
room_lessons.sort(key=lambda x: (x['day_of_week'], x['start_time'] or ''))
return room_lessons
except Exception as e:
logger.error(f"Erreur récupération cours pour {room_name}: {e}")
import traceback
logger.error(traceback.format_exc())
return []
# Fonction pour sauvegarder la session en base
def save_session_to_db(session_data: Dict[str, Any]):
"""
Sauvegarde les informations de session en base.
Args:
session_data: Données de session à sauvegarder
"""
try:
from app_new.extensions import db
from app_new.pronote.models import PronoteSession
# Supprimer les anciennes sessions
PronoteSession.query.delete()
# Créer nouvelle session
session = PronoteSession(
session_token=session_data.get('token'),
session_url=session_data.get('url'),
username=session_data.get('username'),
device_uuid=session_data.get('device_uuid'),
account_pin=session_data.get('account_pin'),
last_connection=datetime.now(timezone.utc)
)
db.session.add(session)
db.session.commit()
logger.info("Session PRONOTE sauvegardée en base")
except Exception as e:
logger.error(f"Erreur sauvegarde session: {e}")
try:
db.session.rollback()
except:
pass
def load_session_from_db() -> Optional[Dict[str, Any]]:
"""
Charge les informations de session depuis la base.
Returns:
Dict avec infos de session ou None
"""
try:
from app_new.pronote.models import PronoteSession
session = PronoteSession.get()
if session:
return {
'token': session.session_token,
'url': session.session_url,
'username': session.username,
'last_connection': session.last_connection
}
return None
except Exception as e:
logger.error(f"Erreur chargement session: {e}")
return None