gmao/app_new/lib_ext/ent_service.py

459 lines
18 KiB
Python
Raw Normal View History

2026-08-14 18:02:25 +02:00
"""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 <email>" 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)}