""" 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