gmao/app_new/core/routes/admin.py

969 lines
40 KiB
Python
Raw Normal View History

2026-08-14 18:02:25 +02:00
"""
Core Routes - Administration
GMAO Collège
"""
from flask import Blueprint, render_template, redirect, url_for, request, flash
from pathlib import Path
import hashlib
import secrets
from werkzeug.security import generate_password_hash
2026-08-14 18:02:25 +02:00
from flask_login import login_required, current_user
from ...extensions import db
from ..models.user import User, Staff
from ..models.college import College, Building, Room, RoomType
from ..models.company import Company, Service
from app_new.constants import INTERVENTION_STATUSES, EQUIPMENT_STATUSES, ROLES
from ..authorization import PERMISSIONS_BY_ROLE, ROLE_LABELS, canonical_role
2026-08-14 18:02:25 +02:00
admin_bp = Blueprint('admin', __name__)
def admin_required(f):
"""Décorateur pour vérifier les droits admin."""
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin():
flash('Accès réservé aux administrateurs.', 'danger')
return redirect(url_for('dashboard.index'))
return f(*args, **kwargs)
return decorated_function
@admin_bp.route('/')
@login_required
@admin_required
def index():
"""Page d'administration."""
stats = {
'users_count': User.query.count(),
'staff_count': Staff.query.count(),
'colleges_count': College.query.count(),
'buildings_count': Building.query.count(),
'rooms_count': Room.query.count(),
'services_count': Service.query.count(),
'companies_count': Company.query.count(),
}
return render_template('admin/index.html', stats=stats)
@admin_bp.route('/users')
@login_required
@admin_required
def users():
"""Liste des utilisateurs."""
page = request.args.get('page', 1, type=int)
users = User.query.order_by(User.created_at.desc()).paginate(page=page, per_page=20)
return render_template('admin/users.html', users=users)
@admin_bp.route('/users/new', methods=['GET', 'POST'])
@login_required
@admin_required
def user_new():
"""Créer un utilisateur."""
if request.method == 'POST':
username = (request.form.get('username') or '').strip()
email = (request.form.get('email') or '').strip()
password = request.form.get('password') or ''
password_confirm = request.form.get('password_confirm') or ''
role = request.form.get('role', 'demandeur')
2026-08-14 18:02:25 +02:00
if len(username) < 3:
flash('Le nom dutilisateur doit contenir au moins 3 caractères.', 'danger')
elif not email:
flash('Ladresse email est obligatoire.', 'danger')
elif len(password) < 12:
flash('Le mot de passe doit contenir au moins 12 caractères.', 'danger')
elif password != password_confirm:
flash('Les mots de passe ne correspondent pas.', 'danger')
elif role not in ROLES:
flash('Rôle utilisateur invalide.', 'danger')
elif User.query.filter_by(username=username).first():
flash('Ce nom d\'utilisateur existe déjà.', 'danger')
elif User.query.filter_by(email=email).first():
flash('Cet email existe déjà.', 'danger')
else:
user = User(username=username, email=email, role=role)
user.set_password(password)
db.session.add(user)
db.session.commit()
flash('Utilisateur créé avec succès.', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/user_form.html', roles=ROLES)
2026-08-14 18:02:25 +02:00
@admin_bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_required
def user_edit(user_id):
"""Modifier un utilisateur."""
user = User.query.get_or_404(user_id)
if request.method == 'POST':
role = request.form.get('role')
if role not in ROLES:
flash('Rôle utilisateur invalide.', 'danger')
return render_template('admin/user_form.html', user=user, roles=ROLES), 400
2026-08-14 18:02:25 +02:00
user.email = request.form.get('email')
user.role = role
2026-08-14 18:02:25 +02:00
user.is_active = request.form.get('is_active') == 'on'
new_password = request.form.get('new_password')
if new_password:
if len(new_password) < 12:
flash('Le nouveau mot de passe doit contenir au moins 12 caractères.', 'danger')
return render_template('admin/user_form.html', user=user), 400
user.set_password(new_password)
db.session.commit()
flash('Utilisateur modifié avec succès.', 'success')
return redirect(url_for('admin.users'))
return render_template('admin/user_form.html', user=user, roles=ROLES)
2026-08-14 18:02:25 +02:00
@admin_bp.route('/permissions')
@login_required
@admin_required
def permissions():
"""Liste des permissions."""
from ..models.user import User
users_by_role = {role: [] for role in ROLES}
for user in User.query.order_by(User.full_name).all():
users_by_role[canonical_role(user.role)].append(user)
return render_template(
'admin/permissions.html', roles=ROLES, role_labels=ROLE_LABELS,
users_by_role=users_by_role, permissions_by_role=PERMISSIONS_BY_ROLE,
)
2026-08-14 18:02:25 +02:00
@admin_bp.route('/audit')
@login_required
@admin_required
def audit_logs():
"""Consultation du journal immuable des écritures HTTP."""
from ..models.audit import AuditLog
page = request.args.get('page', 1, type=int)
logs = AuditLog.query.order_by(AuditLog.created_at.desc()).paginate(page=page, per_page=50)
return render_template('admin/audit_logs.html', logs=logs)
2026-08-14 18:02:25 +02:00
@admin_bp.route('/users/<int:id>/permissions', methods=['POST'])
@login_required
@admin_required
def update_user_permissions(id):
"""Change le rôle d'un utilisateur (les permissions sont définies en code)."""
2026-08-14 18:02:25 +02:00
user = User.query.get_or_404(id)
role = request.form.get('role')
if role not in ROLES:
flash('Rôle invalide.', 'danger')
return redirect(url_for('admin.permissions'))
user.role = role
db.session.commit()
flash(f'Rôle de {user.username} mis à jour.', 'success')
return redirect(url_for('admin.permissions'))
2026-08-14 18:02:25 +02:00
@admin_bp.route('/users/list')
@login_required
@admin_required
def list_users():
"""Alias pour la liste des utilisateurs."""
return redirect(url_for('admin.users'))
@admin_bp.route('/users/<int:user_id>/delete', methods=['POST'])
@login_required
@admin_required
def user_delete(user_id):
"""Supprimer un utilisateur."""
user = User.query.get_or_404(user_id)
if user.id == current_user.id:
flash('Vous ne pouvez pas supprimer votre propre compte.', 'danger')
else:
db.session.delete(user)
db.session.commit()
flash('Utilisateur supprimé.', 'success')
return redirect(url_for('admin.users'))
@admin_bp.route('/staff')
@login_required
@admin_required
def staff():
"""Liste du personnel."""
page = request.args.get('page', 1, type=int)
staff_list = Staff.query.order_by(Staff.last_name).paginate(page=page, per_page=20)
return render_template('admin/staff.html', staff_list=staff_list)
@admin_bp.route('/room-types')
@login_required
@admin_required
def room_types():
"""Liste des types de salles."""
room_types_list = RoomType.query.order_by(RoomType.name).all()
return render_template('admin/room_types.html', room_types=room_types_list)
@admin_bp.route('/room-types/new', methods=['GET', 'POST'])
@login_required
@admin_required
def room_type_new():
"""Redirige l'ancien écran vers le référentiel canonique."""
return redirect(url_for('room_types.new'), code=307 if request.method == 'POST' else 302)
2026-08-14 18:02:25 +02:00
@admin_bp.route('/services')
@login_required
@admin_required
def services():
"""Liste des services."""
services_list = Service.query.order_by(Service.name).all()
return render_template('admin/services.html', services=services_list)
@admin_bp.route('/settings')
@login_required
@admin_required
def settings():
"""Paramètres de l'application."""
college = College.query.first()
from ..models.settings import AppSettings
openrouter_key = AppSettings.get_openrouter_api_key()
return render_template('admin/settings.html', college=college, openrouter_key=openrouter_key)
@admin_bp.route('/settings/api', methods=['GET', 'POST'])
@login_required
@admin_required
def settings_api():
"""API pour les paramètres."""
from ..models.settings import AppSettings
from flask import jsonify
if request.method == 'POST':
data = request.get_json()
key = data.get('key')
value = data.get('value')
if not key:
return jsonify({'error': 'Clé manquante'}), 400
AppSettings.set(key, value)
return jsonify({'success': True, 'message': f'Paramètre {key} sauvegardé'})
# GET - retourne les paramètres
key = request.args.get('key')
if key:
value = AppSettings.get(key)
return jsonify({'key': key, 'value': value})
# Retourner tous les paramètres
settings_list = AppSettings.query.all()
return jsonify({
'settings': [{'key': s.key, 'value': s.value, 'description': s.description}
for s in settings_list]
})
@admin_bp.route('/settings/openrouter', methods=['POST'])
@login_required
@admin_required
def settings_openrouter():
"""Sauvegarde la clé API OpenRouter."""
from ..models.settings import AppSettings
from flask import jsonify
data = request.get_json()
api_key = data.get('api_key')
if not api_key:
return jsonify({'error': 'Clé API manquante'}), 400
AppSettings.set_openrouter_api_key(api_key)
return jsonify({'success': True, 'message': 'Clé API OpenRouter sauvegardée'})
@admin_bp.route('/settings/api-key', methods=['POST'])
@login_required
@admin_required
def generate_api_key():
"""Génère une clé API affichée une seule fois et stockée hachée."""
from ..models.settings import AppSettings
raw_key = f"gmao_{secrets.token_urlsafe(32)}"
AppSettings.set(
'api_key_hash', generate_password_hash(raw_key),
description='Empreinte de la clé API REST GMAO',
)
return render_template('admin/api_key_created.html', api_key=raw_key)
@admin_bp.route('/settings/api-key/revoke', methods=['POST'])
@login_required
@admin_required
def revoke_api_key():
from ..models.settings import AppSettings
AppSettings.set('api_key_hash', '', description='Clé API REST révoquée')
flash('Clé API révoquée.', 'success')
return redirect(url_for('admin.settings'))
2026-08-14 18:02:25 +02:00
@admin_bp.route('/setup-wizard')
@login_required
@admin_required
def setup_wizard_redirect():
"""Redirection vers le setup wizard."""
return redirect(url_for('setup_wizard.index'))
def _template_inventory():
"""Inventorie les templates globaux et ceux propres aux modules."""
from flask import current_app
from ..models.audit import TemplateAuditMark
app_root = Path(current_app.root_path).resolve()
domain_aliases = {'admin': 'core', 'auth': 'core', 'dashboard': 'core', 'setup_wizard': 'core'}
files = []
for path in sorted(app_root.rglob('*.html')):
relative = path.relative_to(app_root).as_posix()
parts = path.relative_to(app_root).parts
if 'templates' not in parts:
continue
template_index = parts.index('templates')
inner_parts = parts[template_index + 1:]
if template_index == 0:
logical_key = '/'.join(inner_parts)
source = 'global'
else:
module = parts[0]
logical_key = '/'.join(inner_parts)
if not logical_key.startswith(f'{module}/'):
logical_key = f'{module}/{logical_key}'
source = 'module'
digest = hashlib.sha256(path.read_bytes()).hexdigest()
files.append({
'path': relative,
'logical_key': logical_key,
'source': source,
'hash': digest,
'target_path': (
f"app_new/{parts[0]}/templates/{logical_key}"
if template_index > 0
else (
f"app_new/templates/{logical_key}"
if logical_key in {'base.html'} or logical_key.startswith('errors/') or logical_key.startswith('components/')
else f"app_new/{domain_aliases.get(logical_key.split('/', 1)[0], logical_key.split('/', 1)[0])}/templates/{logical_key}"
)
),
})
by_key = {}
for item in files:
by_key.setdefault(item['logical_key'], []).append(item)
marks = {mark.path: mark for mark in TemplateAuditMark.query.all()}
for item in files:
group = by_key[item['logical_key']]
hashes = {candidate['hash'] for candidate in group}
item['duplicate_count'] = len(group)
item['comparison'] = 'identique' if len(group) > 1 and len(hashes) == 1 else ('divergent' if len(group) > 1 else 'unique')
# Le chargeur global gagne habituellement. Le détail intervention est
# explicitement chargé depuis son module par interventions.detail.
item['probable_active'] = (
item['source'] == 'global' or len(group) == 1
)
if item['logical_key'] == 'interventions/detail.html':
item['probable_active'] = item['path'] == 'interventions/templates/detail.html'
item['mark'] = marks.get(item['path'])
return files, by_key
TEMPLATE_ARCHITECTURE = [
{
'path': 'app_new/templates/',
'label': 'Socle partagé',
'description': 'base.html, erreurs, composants et fragments réutilisables. Aucun écran métier ne doit être dupliqué ici.',
'children': ['base.html', 'errors/', 'components/'],
},
{
'path': 'app_new/<module>/templates/<module>/',
'label': 'Templates par domaine',
'description': 'Un domaine métier possède ses écrans canoniques, regroupés sous son propre préfixe.',
'children': ['core/', 'equipment/', 'buildings/', 'interventions/', 'planning/', 'lots/', 'companies/', 'training/'],
},
{
'path': 'app_new/<module>/templates/',
'label': 'Zone de transition',
'description': 'Ancienne convention actuellement présente dans le projet. À migrer progressivement vers le sous-dossier <module>/.',
'children': ['detail.html', 'index.html', 'form.html'],
},
{
'path': 'app_new/static/',
'label': 'Ressources visuelles',
'description': 'CSS, JavaScript et icônes partagés ; pas de logique métier dans les templates.',
'children': ['css/', 'js/', 'icons/'],
},
]
@admin_bp.route('/debug/templates')
@login_required
@admin_required
def template_debug():
"""Affiche les doublons et les décisions de nettoyage des templates."""
files, by_key = _template_inventory()
selected_filter = request.args.get('filter', 'duplicates')
treated = lambda item: item['mark'] and item['mark'].status == 'obsolete'
if selected_filter == 'obsolete':
visible_files = [item for item in files if item['mark'] and item['mark'].status == 'obsolete']
elif selected_filter == 'identical':
visible_files = [item for item in files if not treated(item) and item['duplicate_count'] > 1 and item['comparison'] == 'identique']
elif selected_filter == 'divergent':
visible_files = [item for item in files if not treated(item) and item['duplicate_count'] > 1 and item['comparison'] == 'divergent']
elif selected_filter == 'all':
visible_files = [item for item in files if not treated(item)]
else:
selected_filter = 'duplicates'
visible_files = [item for item in files if not treated(item) and item['duplicate_count'] > 1]
stats = {
'total': len(files),
'duplicate_groups': sum(1 for group in by_key.values() if len(group) > 1),
'identical_groups': sum(1 for group in by_key.values() if len(group) > 1 and len({item['hash'] for item in group}) == 1),
'divergent_groups': sum(1 for group in by_key.values() if len(group) > 1 and len({item['hash'] for item in group}) > 1),
'obsolete': sum(1 for item in files if item['mark'] and item['mark'].status == 'obsolete'),
}
groups = {}
for item in visible_files:
groups.setdefault(item['logical_key'], []).append(item)
return render_template('admin/template_debug.html', groups=groups, stats=stats,
selected_filter=selected_filter, template_architecture=TEMPLATE_ARCHITECTURE)
@admin_bp.route('/debug/templates/preview')
@login_required
@admin_required
def template_debug_preview():
"""Prévisualise un template autorisé avec un contexte volontairement neutre."""
from flask import current_app
from jinja2 import ChoiceLoader, FileSystemLoader, TemplateNotFound, ChainableUndefined
relative_path = (request.args.get('path') or '').strip()
demo_mode = request.args.get('demo', '1') != '0'
files, _ = _template_inventory()
item = next((candidate for candidate in files if candidate['path'] == relative_path), None)
if not item:
flash('Template introuvable dans linventaire.', 'danger')
return redirect(url_for('admin.template_debug'))
app_root = Path(current_app.root_path).resolve()
source_path = (app_root / relative_path).resolve()
source = source_path.read_text(encoding='utf-8', errors='replace')
rendered = None
render_error = None
try:
parts = Path(relative_path).parts
template_index = parts.index('templates')
inner_name = '/'.join(parts[template_index + 1:])
if item['source'] == 'module':
module_root = app_root / parts[0] / 'templates'
loader = ChoiceLoader([FileSystemLoader(str(module_root)), current_app.jinja_loader])
environment = current_app.jinja_env.overlay(loader=loader)
else:
environment = current_app.jinja_env.overlay()
# Un contexte de prévisualisation peut laisser les variables métier
# vides : elles doivent produire un rendu partiel plutôt qu'une 500.
environment.undefined = ChainableUndefined
template = environment.get_template(inner_name)
preview_context = {}
# Reproduire les variables globales Flask (current_user, config,
# request...) sans injecter d'objet métier réel dans la prévisualisation.
current_app.update_template_context(preview_context)
if demo_mode:
from types import SimpleNamespace
class DemoValue(str):
"""Valeur vide pouvant aussi être appelée par un template."""
def __call__(self, *args, **kwargs):
return False
class DemoObject(SimpleNamespace):
def __getattr__(self, name):
return DemoValue('')
demo_room = DemoObject(id=12, name='B102', code='B102', full_name='Bâtiment principal — Zone Anglais — B102')
demo_equipment = DemoObject(
id=42, name='Luminaire LED central', code='ECL-0042',
full_path='Bâtiment principal / Zone Anglais / B102 / Éclairage',
room=demo_room, status='en_service', status_label='En service',
)
preview_context.update({
'current_user': DemoObject(id=1, username='demo.admin', full_name='Administrateur démo', role='admin', role_label='Administrateur système', is_authenticated=True, is_admin=lambda: True),
'stats': DemoObject(alerts_unread=3, interventions_pending=4, equipments=128),
'unread': 3,
'alerts': [DemoObject(id=1, title='Luminaire défaillant', message='Une intervention est à traiter.', level='warning')],
'interventions': [DemoObject(id=39, title='Remplacement du luminaire', status='en_cours', status_label='En cours', room=demo_room, equipment=demo_equipment)],
'intervention': DemoObject(id=39, title='Remplacement du luminaire', type='curatif', status='en_cours', status_label='En cours', room=demo_room, equipment=demo_equipment),
'rooms': [demo_room, DemoObject(id=13, name='B103', code='B103', full_name='Bâtiment principal — Zone Anglais — B103')],
'equipments': [demo_equipment, DemoObject(id=43, name='Prise murale fond de salle', code='ELEC-0043', room=demo_room)],
'categories': [DemoObject(id=1, name='Éclairage'), DemoObject(id=2, name='Prises')],
'buildings': [DemoObject(id=1, name='Bâtiment principal')],
'zones': [DemoObject(id=1, name='Zone Anglais')],
'messages': [DemoObject(subject='Demande de réparation', sender='Vie scolaire', body_preview='Une chaise est cassée en B102.')],
'mails': [DemoObject(subject='Maintenance urgente', sender='service@exemple.fr', received_at='21/08/2026 08:15')],
'tasks': [DemoObject(id=7, name='Contrôle mensuel des luminaires', periodicity='Mensuelle', duration_minutes=30)],
'lots': [DemoObject(id=3, name='Éclairage intérieur', category=DemoObject(name='Éclairage'))],
'companies': [DemoObject(id=2, name='Entreprise Démo Maintenance')],
})
rendered = template.render(**preview_context)
except (TemplateNotFound, Exception) as exc:
render_error = f'{type(exc).__name__}: {exc}'
return render_template('admin/template_preview.html', template=item,
source=source, rendered=rendered, render_error=render_error,
demo_mode=demo_mode)
@admin_bp.route('/debug/templates/mark', methods=['POST'])
@login_required
@admin_required
def template_debug_mark():
"""Marque un template comme obsolète, ou annule ce marquage."""
from flask import current_app
from ..models.audit import TemplateAuditMark
relative_path = (request.form.get('path') or '').strip()
action = request.form.get('action')
notes = (request.form.get('notes') or '').strip() or None
app_root = Path(current_app.root_path).resolve()
candidate = (app_root / relative_path).resolve()
try:
candidate.relative_to(app_root)
except ValueError:
candidate = None
if not candidate or not candidate.is_file() or candidate.suffix != '.html':
flash('Chemin de template invalide.', 'danger')
return redirect(url_for('admin.template_debug'))
mark = TemplateAuditMark.query.filter_by(path=relative_path).first()
if action == 'obsolete':
if not mark:
mark = TemplateAuditMark(path=relative_path)
db.session.add(mark)
mark.status = 'obsolete'
mark.notes = notes
mark.updated_by_id = current_user.id
flash(f'Template marqué obsolète : {relative_path}', 'warning')
elif action == 'restore':
if mark:
db.session.delete(mark)
flash(f'Marquage retiré : {relative_path}', 'success')
else:
flash('Action inconnue.', 'danger')
return redirect(url_for('admin.template_debug'))
db.session.commit()
return redirect(url_for('admin.template_debug', filter=request.form.get('return_filter', 'duplicates')))
2026-08-14 18:02:25 +02:00
# ─── Gestion des données (cleanup) ─────────────────────────────────────
@admin_bp.route('/data')
@login_required
@admin_required
def data_management():
"""Page de gestion des données : voir et supprimer en masse."""
from ..models.maintenance import Intervention
from ..models.equipment import Equipment
from ...outlook.models import OutlookMail, OutlookMailInterpretation, OutlookAttachment
2026-08-14 18:02:25 +02:00
from ...ent.models import EntMessage
from ...ent.interpretation_models import EntMessageInterpretation
stats = {
'equipments': Equipment.query.count(),
'interventions': Intervention.query.count(),
'interventions_planifiees': Intervention.query.filter_by(status='planifiee', is_deleted=False).count(),
'interventions_terminees': Intervention.query.filter_by(status='terminee').count(),
'outlook_mails': OutlookMail.query.count(),
'outlook_interpretations': OutlookMailInterpretation.query.count(),
'ent_messages': EntMessage.query.count(),
'ent_interpretations': EntMessageInterpretation.query.count(),
}
# Recuperer les mails Outlook (pagine)
page = request.args.get('page', 1, type=int)
page_eq = request.args.get('equipments_page', 1, type=int)
page_int = request.args.get('interventions_page', 1, type=int)
missing_only = request.args.get('non_interpretes') == '1'
if missing_only:
interpreted_mail_ids = db.session.query(OutlookMailInterpretation.mail_id).distinct()
mails_query = OutlookMail.query.filter(~OutlookMail.id.in_(interpreted_mail_ids))
else:
mails_query = OutlookMail.query
# Une pagination commune est utilisée par les deux listes. Ne pas
# renvoyer 404 si la liste ENT contient moins de pages que les mails.
mails = mails_query.order_by(OutlookMail.received_at.desc()).paginate(page=page, per_page=25, error_out=False)
2026-08-14 18:02:25 +02:00
# Interventions recentes (pagine, non supprimees)
interventions = Intervention.query.filter_by(is_deleted=False).order_by(Intervention.created_at.desc()).paginate(page=page_int, per_page=25)
# Équipements (pagine, non supprimes)
equipments = Equipment.query.filter_by(is_deleted=False).order_by(Equipment.name).paginate(page=page_eq, per_page=25)
# Pour chaque mail Outlook, verifier s'il a une interpretation
outlook_interp_map = {}
for interp in OutlookMailInterpretation.query.all():
outlook_interp_map[interp.mail_id] = interp
# Recuperer les messages ENT (pagine)
if missing_only:
interpreted_ent_ids = db.session.query(EntMessageInterpretation.message_id).distinct()
ent_query = EntMessage.query.filter(~EntMessage.id.in_(interpreted_ent_ids))
else:
ent_query = EntMessage.query
ent_messages = ent_query.order_by(EntMessage.date.desc()).paginate(page=page, per_page=25, error_out=False)
2026-08-14 18:02:25 +02:00
# Pour chaque message ENT, verifier s'il a une interpretation
ent_interp_map = {}
for interp in EntMessageInterpretation.query.all():
ent_interp_map[interp.message_id] = interp
# Recuperer le contexte GMAO pour les expediteurs exclus
from ...outlook.models import GmaoContext
context = GmaoContext.get_for_user(current_user.id)
excluded_list = context.get_excluded_senders_list() if context else []
return render_template('admin/data_management.html',
stats=stats,
mails=mails,
interventions=interventions,
equipments=equipments,
outlook_interp_map=outlook_interp_map,
ent_messages=ent_messages,
ent_interp_map=ent_interp_map,
missing_only=missing_only,
2026-08-14 18:02:25 +02:00
excluded_list=excluded_list)
@admin_bp.route('/data/delete-all/equipments', methods=['POST'])
@login_required
@admin_required
def delete_all_equipments():
"""Supprime tous les equipements."""
from ..models.equipment import Equipment
confirm = request.form.get('confirm_text', '')
if confirm != 'SUPPRIMER':
flash('Confirmation requise : tapez "SUPPRIMER" dans le champ de confirmation.', 'danger')
return redirect(url_for('admin.data_management'))
count = Equipment.query.count()
# Suppression logique pour eviter les cascades
from sqlalchemy import update
db.session.execute(update(Equipment).values(is_deleted=True))
db.session.commit()
flash(f'{count} equipement(s) supprime(s) (suppression logique).', 'warning')
return redirect(url_for('admin.data_management'))
@admin_bp.route('/data/delete-all/interventions', methods=['POST'])
@login_required
@admin_required
def delete_all_interventions():
"""Supprime toutes les interventions."""
from ..models.maintenance import Intervention
confirm = request.form.get('confirm_text', '')
if confirm != 'SUPPRIMER':
flash('Confirmation requise : tapez "SUPPRIMER" dans le champ de confirmation.', 'danger')
return redirect(url_for('admin.data_management'))
count = Intervention.query.count()
from sqlalchemy import update
db.session.execute(update(Intervention).values(is_deleted=True, deleted_at=db.func.now()))
db.session.commit()
flash(f'{count} intervention(s) supprimee(s) (suppression logique).', 'warning')
return redirect(url_for('admin.data_management'))
@admin_bp.route('/data/delete-all/mails', methods=['POST'])
@login_required
@admin_required
def delete_all_mails():
"""Supprime tous les mails Outlook."""
from ...outlook.models import OutlookMail, OutlookMailInterpretation, OutlookAttachment
2026-08-14 18:02:25 +02:00
confirm = request.form.get('confirm_text', '')
if confirm != 'SUPPRIMER':
flash('Confirmation requise : tapez "SUPPRIMER" dans le champ de confirmation.', 'danger')
return redirect(url_for('admin.data_management'))
try:
# Les pièces jointes possèdent une FK vers les mails : elles doivent
# être supprimées avant leurs mails (le modèle seul ne garantit pas
# une cascade côté MariaDB).
OutlookAttachment.query.delete(synchronize_session=False)
OutlookMailInterpretation.query.delete(synchronize_session=False)
count = OutlookMail.query.count()
OutlookMail.query.delete(synchronize_session=False)
db.session.commit()
except Exception:
db.session.rollback()
raise
2026-08-14 18:02:25 +02:00
flash(f'{count} mail(s) supprime(s) definitivement.', 'warning')
return redirect(url_for('admin.data_management'))
@admin_bp.route('/data/delete-mail/<mail_id>', methods=['POST'])
@login_required
@admin_required
def delete_single_mail(mail_id):
"""Supprime un seul mail Outlook."""
from ...outlook.models import OutlookMail, OutlookMailInterpretation
mail = OutlookMail.query.get(mail_id)
if mail:
# Supprimer les interpretations associees
OutlookMailInterpretation.query.filter_by(mail_id=mail_id).delete()
db.session.delete(mail)
db.session.commit()
flash(f'Mail supprime.', 'info')
else:
flash('Mail introuvable.', 'danger')
return redirect(url_for('admin.data_management', page=request.args.get('page', 1)))
@admin_bp.route('/data/delete-all/ent-messages', methods=['POST'])
@login_required
@admin_required
def delete_all_ent_messages():
"""Supprime tous les messages ENT et leurs interpretations."""
from ...ent.models import EntMessage
from ...ent.interpretation_models import EntMessageInterpretation
confirm = request.form.get('confirm_text', '')
if confirm != 'SUPPRIMER':
flash('Confirmation requise : tapez "SUPPRIMER" dans le champ de confirmation.', 'danger')
return redirect(url_for('admin.data_management'))
# Supprimer d'abord les interpretations
EntMessageInterpretation.query.delete()
# Supprimer les messages
count = EntMessage.query.count()
EntMessage.query.delete()
db.session.commit()
flash(f'{count} message(s) ENT supprime(s) definitivement.', 'warning')
return redirect(url_for('admin.data_management'))
@admin_bp.route('/data/delete-ent-message/<msg_id>', methods=['POST'])
@login_required
@admin_required
def delete_single_ent_message(msg_id):
"""Supprime un seul message ENT."""
from ...ent.models import EntMessage
from ...ent.interpretation_models import EntMessageInterpretation
msg = EntMessage.query.get(msg_id)
if msg:
EntMessageInterpretation.query.filter_by(message_id=msg_id).delete()
db.session.delete(msg)
db.session.commit()
flash('Message ENT supprime.', 'info')
else:
flash('Message introuvable.', 'danger')
return redirect(url_for('admin.data_management', page=request.args.get('page', 1)))
@admin_bp.route('/data/ignore-sender', methods=['POST'])
@login_required
@admin_required
def ignore_sender():
"""Ajoute un expediteur a la liste des expediteurs exclus du GmaoContext."""
from ...outlook.models import GmaoContext
sender = request.form.get('sender', '').strip()
if not sender:
flash('Expediteur manquant.', 'danger')
return redirect(url_for('admin.data_management'))
context = GmaoContext.get_for_user(current_user.id)
current_list = context.excluded_senders or ''
# Verifier s'il est deja present
existing = [e.strip().lower() for e in current_list.split('\n') if e.strip()]
if sender.lower() not in existing:
if current_list and not current_list.endswith('\n'):
current_list += '\n'
current_list += sender + '\n'
context.excluded_senders = current_list
db.session.commit()
flash(f'Expediteur "{sender}" ajoute a la liste d\'exclusion.', 'success')
else:
flash(f'Expediteur "{sender}" deja exclu.', 'info')
return redirect(url_for('admin.data_management', page=request.args.get('page', 1)))
# ─── Suppressions individuelles ────────────────────────────────────────
@admin_bp.route('/data/delete-intervention/<int:interv_id>', methods=['POST'])
@login_required
@admin_required
def delete_single_intervention(interv_id):
"""Supprime une intervention."""
from ..models.maintenance import Intervention
interv = Intervention.query.get(interv_id)
if interv:
interv.is_deleted = True
interv.deleted_at = db.func.now()
db.session.commit()
flash(f'Intervention #{interv_id} supprimée (suppression logique).', 'info')
else:
flash('Intervention introuvable.', 'danger')
return redirect(url_for('admin.data_management', page=request.args.get('page', 1, type=int)))
@admin_bp.route('/data/delete-equipment/<int:equip_id>', methods=['POST'])
@login_required
@admin_required
def delete_single_equipment(equip_id):
"""Supprime un équipement."""
from ..models.equipment import Equipment
equip = Equipment.query.get(equip_id)
if equip:
equip.is_deleted = True
db.session.commit()
flash(f'Équipement "{equip.name}" supprimé (suppression logique).', 'info')
else:
flash('Équipement introuvable.', 'danger')
return redirect(url_for('admin.data_management', page=request.args.get('page', 1, type=int)))
@admin_bp.route('/data/interpret-outlook/<mail_id>', methods=['POST'])
@login_required
@admin_required
def interpret_outlook_mail(mail_id):
"""Lance l'interpretation manuelle d'un mail Outlook."""
from ...outlook.models import OutlookMail, GmaoContext
from ...outlook.auto_interpret import analyze_email_with_hermes
from ...core.models.settings import AppSettings
mail = OutlookMail.query.get(mail_id)
if not mail:
flash('Mail introuvable.', 'danger')
return redirect(url_for('admin.data_management'))
context = GmaoContext.get_for_user(current_user.id)
if not context:
flash('Contexte GMAO non configure.', 'danger')
return redirect(url_for('admin.data_management'))
result = analyze_email_with_hermes(mail, context)
if result.get('error'):
flash(f'Erreur interpretation: {result["error"]}', 'danger')
else:
flash('Mail interprete avec succes.', 'success')
return redirect(url_for('admin.data_management', page=request.args.get('page', 1)))
@admin_bp.route('/data/interpret-ent/<msg_id>', methods=['POST'])
@login_required
@admin_required
def interpret_ent_message(msg_id):
"""Lance l'interpretation manuelle d'un message ENT."""
from ...ent.models import EntMessage
from ...ent.interpretation_models import EntMessageInterpretation
from ...outlook.models import GmaoContext
from ...core.models.settings import AppSettings
import requests as http_requests
message = EntMessage.query.get(msg_id)
if not message:
flash('Message introuvable.', 'danger')
return redirect(url_for('admin.data_management'))
context = GmaoContext.get_for_user(current_user.id)
if not context:
flash('Contexte GMAO non configure.', 'danger')
return redirect(url_for('admin.data_management'))
api_key = AppSettings.get('openrouter_api_key')
if not api_key:
flash('Cle API OpenRouter non configuree.', 'danger')
return redirect(url_for('admin.data_management'))
# Construire le prompt
prompt = f"""Analyse ce message de la plateforme ENT77 et propose une action GMAO.
Contexte de l'etablissement:
{context.establishment_name or 'Non defini'}
{context.technical_context or ''}
{context.equipment_context or ''}
Message:
Sujet: {message.subject}
Expediteur: {message.sender_name}
Contenu: {message.body[:1000] if message.body else ''}
Reponds en JSON avec:
- type: curative/preventive/administrative/formation/information
- action: create/update/inform
- priority: low/medium/high/critical
- summary: resume court
- location: salle ou zone concernnee
- equipment: equipement concerne
"""
try:
model = AppSettings.get('openrouter_model', 'poolside/laguna-s-2.1:free')
response = http_requests.post(
'https://openrouter.ai/api/v1/chat/completions',
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
json={'model': model, 'messages': [{'role': 'user', 'content': prompt}], 'max_tokens': 1000},
timeout=60
)
if response.status_code == 200:
content = response.json().get('choices', [{}])[0].get('message', {}).get('content', '')
# Parser le JSON
import json
if '```json' in content:
content = content.split('```json')[1].split('```')[0]
elif '```' in content:
content = content.split('```')[1].split('```')[0]
content = content.strip()
json_start = content.find('{')
if json_start >= 0:
content = content[json_start:]
json_end = content.rfind('}')
if json_end >= 0:
content = content[:json_end+1]
analysis = json.loads(content) if content else {}
# Le modèle peut renvoyer plusieurs équipements sous forme de
# tableau alors que la colonne est volontairement textuelle.
equipment = analysis.get('equipment', '')
if isinstance(equipment, (list, tuple)):
equipment = ', '.join(str(item) for item in equipment if item)
elif equipment is None:
equipment = ''
2026-08-14 18:02:25 +02:00
# Supprimer l'ancienne interpretation si elle existe
EntMessageInterpretation.query.filter_by(message_id=msg_id).delete()
# Creer l'interpretation
interp = EntMessageInterpretation(
message_id=msg_id,
analysis_type=analysis.get('type', 'information'),
analysis_action=analysis.get('action', 'inform'),
analysis_priority=analysis.get('priority', 'medium'),
analysis_summary=analysis.get('summary', ''),
suggested_equipment=str(equipment),
2026-08-14 18:02:25 +02:00
suggested_description=analysis.get('location', ''),
status='pending',
)
db.session.add(interp)
db.session.commit()
flash('Message ENT interprete avec succes.', 'success')
else:
flash(f'Erreur API: {response.status_code}', 'danger')
except Exception as e:
db.session.rollback()
2026-08-14 18:02:25 +02:00
flash(f'Erreur interpretation: {str(e)[:200]}', 'danger')
return redirect(url_for('admin.data_management', page=request.args.get('page', 1)))