"""Service de vérification des messages ENT77 et création automatique d'interventions. - Authentification via API ENT77 - Récupération des nouveaux messages (paginée) - Détection des messages transférés - Création d'interventions GMAO automatiques - Préservation de l'état lu/non-lu """ import re import logging from datetime import datetime, timezone import requests from flask import current_app from app_new.extensions import db from app_new.lib_ext.ent_crypto import decrypt_value from app_new.ent.models import EntCredential, EntMessage, EntConfig from app_new.core.models import Intervention, InterventionComment, StatusChange, CollegeClosure, Room logger = logging.getLogger(__name__) BASE_URL = 'https://ent77.seine-et-marne.fr' LOGIN_URL = f'{BASE_URL}/auth/login' LIST_URL = f'{BASE_URL}/conversation/list/inbox' DETAIL_URL = f'{BASE_URL}/conversation/api/messages/' TOGGLE_UNREAD_URL = f'{BASE_URL}/conversation/toggleUnread' MOVE_URL = f'{BASE_URL}/conversation/api/messages/' # Ajouté pour déplacer les messages FOLDERS_URL = f'{BASE_URL}/conversation/api/folders' # Ajouté pour lister les dossiers # Mots-clés pour détecter une demande d'intervention INTERVENTION_KEYWORDS = [ 'panne', 'cassé', 'cassée', 'casser', 'brisé', 'brisée', 'dysfonctionn', 'défectueux', 'défectueuse', 'en panne', 'hors service', 'fuit', 'fuite', 'bloqué', 'bloquée', 'ne fonctionne pas', 'ne marche plus', 'ne s\'allume', 'urgen', 'signal', 'problème', 'dégradé', 'à remplacer', 'remplacement', 'réparation', 'réparer', 'fenêtre fendue', 'vitre cassée', 'porte bloquée', 'fuite d\'eau', 'pas d\'eau', 'pas d\'électricité', 'coupure', 'infiltration', 'humidité', 'moisissure', 'chasse d\'eau', ] def is_intervention_request(text: str) -> bool: """Détecte si le texte contient des mots-clés de demande d'intervention.""" text_lower = text.lower() return any(kw in text_lower for kw in INTERVENTION_KEYWORDS) def extract_sender(msg_json: dict) -> tuple: """Extrait l'expéditeur réel d'un message (direct ou transféré). Retourne (sender_name, is_forwarded, original_sender). - Message direct: (from.displayName, False, '') - Message transféré: (from.displayName, True, 'NOM ORIGINAL') - Si parsing échoue sur transfert: fallback sur from.displayName """ subject = msg_json.get('subject', '') body = msg_json.get('body', '') from_obj = msg_json.get('from', {}) sender_display = from_obj.get('displayName', 'Inconnu') if isinstance(from_obj, dict) else 'Inconnu' is_forwarded = subject.startswith('Tr :') or 'Message transféré' in body if is_forwarded: # Parser le bloc "De : XXX" dans le body (format standard ENT77) body_text = re.sub('<[^<]+?>', '', body) # Rechercher "De : Nom " ou "De : Nom" — ne capturer que le nom # Arrêter avant les métadonnées suivantes (Date :, Objet :, À :, Envoyé :) match = re.search(r'De\s*:\s*(.+?)(?:\s*<.+?>|\s*(?:Date\s*:|Objet\s*:|À\s*:|Envoyé\s*:)|$)', body_text, re.MULTILINE) if match: original_sender = match.group(1).strip() # Sécurité : limiter à 60 caractères if len(original_sender) > 60: original_sender = original_sender[:60].rsplit(' ', 1)[0] + '…' return sender_display, True, original_sender # Fallback : si "De :" introuvable, on garde le transféreur return sender_display, True, sender_display return sender_display, False, '' def login_ent(session, username: str, password: str) -> dict: """Authentification ENT77, retourne les headers avec XSRF token.""" try: login_resp = session.get(LOGIN_URL, timeout=10) logger.info(f"Login page status: {login_resp.status_code}") except Exception as e: logger.error(f"Failed to reach login page: {e}") return None headers = { 'User-Agent': 'Mozilla/5.0 (GMAO-College)', 'X-Requested-With': 'XMLHttpRequest', 'Referer': LOGIN_URL, 'Accept': 'application/json', } resp = session.post(LOGIN_URL, data={'email': username, 'password': password}, headers=headers, allow_redirects=True, timeout=10) logger.info(f"Login response status: {resp.status_code}") cookies = session.cookies.get_dict() logger.info(f"Cookies after login: {list(cookies.keys())}") if not any(k in cookies for k in ('oneSessionId', 'XSRF-TOKEN')) \ and cookies.get('authenticated') != 'true': logger.error(f"Login failed. Cookies: {list(cookies.keys())}") logger.error(f"Response text (first 500 chars): {resp.text[:500]}") return None xsrf = cookies.get('XSRF-TOKEN', '') headers['X-XSRF-TOKEN'] = xsrf logger.info(f"ENT login OK. XSRF: {'present' if xsrf else 'MISSING'}") return headers def fetch_new_messages(session, headers, last_msg_id: str = None, max_messages: int = 20): """Récupère les nouveaux messages depuis last_msg_id.""" all_messages = [] page = 0 while True: url = f'{LIST_URL}?page={page}' try: resp = session.get(url, headers=headers, timeout=10) except Exception as e: logger.error(f"Error fetching page {page}: {e}") break if resp.status_code != 200: break try: messages = resp.json() except Exception: break if not isinstance(messages, list) or len(messages) == 0: break seen_ids = {m['id'] for m in all_messages} new_count = 0 for m in messages: if m.get('id') not in seen_ids: all_messages.append(m) seen_ids.add(m.get('id')) new_count += 1 if new_count == 0 or len(messages) < 25: break page += 1 # Filtrer les nouveaux messages new_messages = [] for msg in all_messages: mid = str(msg.get('id')) if mid == last_msg_id: break new_messages.append(msg) if len(new_messages) >= max_messages: break return new_messages def check_ent_messages(app=None): """Vérifie les nouveaux messages ENT77 pour tous les comptes actifs. Called by APScheduler or manually. Crée des interventions automatiquement si config.auto_create_intervention. """ # On doit travailler dans un app context if app is None: from app_new import create_app app = create_app() with app.app_context(): config = EntConfig.query.first() if not config or not config.is_enabled: logger.info("ENT checking disabled.") return 0 if not config.is_working_time(): logger.info("Outside working hours, skipping ENT check.") return 0 credentials = EntCredential.query.filter_by(is_active=True).all() if not credentials: logger.info("No active ENT credentials.") return 0 total_created = 0 for cred in credentials: try: created = _process_credential(cred, config, app) total_created += created except Exception as e: logger.error(f"Error processing credential {cred.id}: {e}") logger.info(f"ENT check done. {total_created} intervention(s) created.") return total_created def _process_credential(cred, config, app): """Traite un compte ENT : vérifie les messages et crée les interventions.""" # Déchiffrer les identifiants try: username = decrypt_value(cred.ent_username_encrypted, app) password = decrypt_value(cred.ent_password_encrypted, app) except Exception as e: logger.error(f"Failed to decrypt credentials for user {cred.user_id}: {str(e)}") return 0 sess = requests.Session() headers = login_ent(sess, username, password) if not headers: logger.error(f"Login failed for credential {cred.id}") return 0 # Récupérer les nouveaux messages new_messages = fetch_new_messages(sess, headers, cred.last_msg_id) if not new_messages: logger.info(f"No new messages for credential {cred.id}.") cred.last_check = datetime.now(timezone.utc) db.session.commit() return 0 created_count = 0 unread_to_restore = [] for msg in new_messages: msg_id = str(msg.get('id')) was_unread = msg.get('unread') is True or msg.get('unread') == 1 # Récupérer le détail try: detail = sess.get(DETAIL_URL + msg_id, headers=headers, timeout=10) if detail.status_code != 200: logger.warning(f"Detail fetch {msg_id}: {detail.status_code}") continue detail_json = detail.json() except Exception as e: logger.error(f"Error fetching detail {msg_id}: {e}") continue body_html = detail_json.get('body', '') body_text = re.sub('<[^<]+?>', '', body_html) sender_display, is_forwarded, original_sender = extract_sender(detail_json) subject = detail_json.get('subject', 'N/A') ts = detail_json.get('date') msg_date = datetime.fromtimestamp(ts / 1000) if ts else datetime.now(timezone.utc) # Vérifier si message déjà traité existing = EntMessage.query.get(msg_id) if existing: continue # Sauvegarder le message en cache ent_msg = EntMessage( id=msg_id, credential_id=cred.id, subject=subject, sender_name=sender_display, sender_id=detail_json.get('from', {}).get('id', '') if isinstance(detail_json.get('from'), dict) else '', date=msg_date, body=body_html, is_forwarded=is_forwarded, original_sender=original_sender, is_unread=was_unread, has_attachment=bool(detail_json.get('attachments')), ) db.session.add(ent_msg) # Déterminer le demandeur requester = original_sender if original_sender else sender_display # Créer intervention si auto_create activé if config.auto_create_intervention and config.default_equipment_id: # Vérifier anti-doublon existing_interv = Intervention.query.filter_by(ent_message_id=msg_id).first() if not existing_interv: # Déterminer la priorité à partir du sujet/corps priority = 'normale' text_lower = (subject + ' ' + body_text).lower() if 'urgent' in text_lower: priority = 'urgente' elif any(kw in text_lower for kw in ['problème', 'panne', 'fuite', 'cassé']): priority = 'haute' # Vérifier si demande d'intervention is_request = is_intervention_request(subject + ' ' + body_text) detection_label = '🔒 Demande d\'intervention détectée' if is_request else 'ℹ️ Information sans action requise' # Retirer le préfixe "Tr : " du titre clean_subject = subject if clean_subject.startswith('Tr : '): clean_subject = clean_subject[5:] # Créer l'intervention interv = Intervention( title=f"[ENT] {clean_subject}", description=body_text[:500], type='curatif', priority=priority, status='brouillon', equipment_id=config.default_equipment_id, requester_name=requester, ent_message_id=msg_id, ent_sender=requester, requested_at=msg_date, ) db.session.add(interv) db.session.flush() # obtenir interv.id # Créer un commentaire avec la détection + corps du message comment_content = f"{detection_label}\n\n{body_text}" comment = InterventionComment( intervention_id=interv.id, author_id=cred.user_id, content=comment_content, ) db.session.add(comment) # Enregistrer le changement de statut initial change = StatusChange( intervention_id=interv.id, from_status=None, to_status='brouillon', changed_by_id=cred.user_id, comment=f'Création automatique depuis message ENT (ID: {msg_id})', ) db.session.add(change) # Lier le message à l'intervention ent_msg.intervention_id = interv.id created_count += 1 logger.info(f"Created intervention #{interv.id} from ENT message {msg_id}") # Marquer pour restauration unread if was_unread: unread_to_restore.append(msg_id) # Sauvegarder le dernier message traité if new_messages: cred.last_msg_id = str(new_messages[0].get('id')) cred.last_check = datetime.now(timezone.utc) db.session.commit() # Restaurer l'état non-lu if unread_to_restore: try: sess.post(TOGGLE_UNREAD_URL, json={'id': unread_to_restore, 'unread': True}, headers=headers, timeout=10) except Exception as e: logger.error(f"Error restoring unread state: {e}") return created_count def get_or_create_processed_folder(session, headers): """Récupère l'ID du dossier 'Traités' depuis la configuration.""" from app_new.outlook.processed_folder_config import ProcessedFolderConfig import logging logger = logging.getLogger(__name__) config = ProcessedFolderConfig.get_config() # Utiliser l'ID configuré if config.ent_folder_id: logger.info(f"get_or_create_processed_folder ENT: Using configured folder ID: {config.ent_folder_id}") return config.ent_folder_id # Fallback: chercher dans la liste des dossiers try: resp = session.get(FOLDERS_URL, headers=headers, timeout=10) logger.info(f"get_or_create_processed_folder ENT: GET folders -> {resp.status_code}") if resp.status_code == 200: folders = resp.json() logger.info(f"get_or_create_processed_folder ENT: {len(folders) if isinstance(folders, list) else 'dict'} folders") if isinstance(folders, list): for folder in folders: if isinstance(folder, dict): name = folder.get('name', '') folder_id = folder.get('id') if name.lower() in ['traités', 'traites', 'processed']: logger.info(f"get_or_create_processed_folder ENT: Found '{name}' -> {folder_id}") return folder_id # Créer le dossier "Traités" logger.info("get_or_create_processed_folder ENT: Creating folder") create_resp = session.post(FOLDERS_URL, headers=headers, json={'name': 'Traités'}, timeout=10) logger.info(f"get_or_create_processed_folder ENT: POST create -> {create_resp.status_code}") if create_resp.status_code in [200, 201]: folder_id = create_resp.json().get('id') logger.info(f"get_or_create_processed_folder ENT: Created -> {folder_id}") return folder_id except Exception as e: logger.error(f"Erreur récupération/création dossier Traités ENT: {e}") return None def move_ent_message_to_processed(message_id, session=None, headers=None): """Déplace un message ENT vers le dossier 'Traités'. Args: message_id: ID du message ENT session: Session requests (optionnel, sera créée si besoin) headers: Headers avec auth (optionnel) Returns: dict: {'success': bool, 'error': str (si erreur)} """ from app_new.ent.models import EntCredential from app_new.lib_ext.ent_crypto import decrypt_value from flask import current_app import logging logger = logging.getLogger(__name__) # Si pas de session fournie, en créer une if session is None: cred = EntCredential.query.filter_by(is_active=True).first() if not cred: logger.error("move_ent_message_to_processed: Aucun credential actif") return {'success': False, 'error': 'Aucun credential actif'} session = requests.Session() headers = login_ent(session, decrypt_value(cred.ent_username_encrypted, current_app._get_current_object()), decrypt_value(cred.ent_password_encrypted, current_app._get_current_object())) if not headers: logger.error("move_ent_message_to_processed: Échec de connexion ENT") return {'success': False, 'error': 'Échec de connexion ENT'} # Récupérer ou créer le dossier Traités folder_id = get_or_create_processed_folder(session, headers) if not folder_id: logger.error("move_ent_message_to_processed: Impossible de trouver/créer le dossier Traités") return {'success': False, 'error': 'Impossible de trouver/créer le dossier Traités'} logger.info(f"move_ent_message_to_processed: Dossier Traités ID: {folder_id}") # Déplacer le message - API correcte selon HAR # PUT /conversation/move/userfolder/{folder_id} avec body {"id": ["message_id"]} try: move_url = f"https://ent77.seine-et-marne.fr/conversation/move/userfolder/{folder_id}" logger.info(f"move_ent_message_to_processed: Appel API {move_url}") resp = session.put(move_url, headers=headers, json={"id": [message_id]}, timeout=10) logger.info(f"move_ent_message_to_processed: Réponse {resp.status_code}: {resp.text[:200]}") if resp.status_code in [200, 201]: return {'success': True} else: return {'success': False, 'error': f'API erreur {resp.status_code}: {resp.text[:100]}'} except Exception as e: logger.error(f"move_ent_message_to_processed: Exception {e}") return {'success': False, 'error': str(e)}