459 lines
No EOL
18 KiB
Python
459 lines
No EOL
18 KiB
Python
"""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)} |