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
2026-08-21 13:08:30 +02:00
from pathlib import Path
import hashlib
2026-08-15 00:56:36 +02:00
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
2026-08-14 20:53:03 +02:00
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 ' '
2026-08-14 20:53:03 +02:00
role = request . form . get ( ' role ' , ' demandeur ' )
2026-08-14 18:02:25 +02:00
if len ( username ) < 3 :
flash ( ' Le nom d’ utilisateur doit contenir au moins 3 caractères. ' , ' danger ' )
elif not email :
flash ( ' L’ adresse 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 ' ) )
2026-08-14 20:53:03 +02:00
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 ' :
2026-08-14 20:53:03 +02:00
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 ' )
2026-08-14 20:53:03 +02:00
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 ' ) )
2026-08-14 20:53:03 +02:00
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
2026-08-14 20:53:03 +02:00
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
2026-08-14 21:01:11 +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 ) :
2026-08-14 20:53:03 +02:00
""" 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 )
2026-08-14 20:53:03 +02:00
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 ( ) :
2026-08-15 00:47:55 +02:00
""" 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 ' } )
2026-08-15 00:56:36 +02:00
@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 ' ) )
2026-08-21 13:08:30 +02:00
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 ( )
2026-08-21 13:26:32 +02:00
domain_aliases = { ' admin ' : ' core ' , ' auth ' : ' core ' , ' dashboard ' : ' core ' , ' setup_wizard ' : ' core ' }
2026-08-21 13:08:30 +02:00
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 ,
2026-08-21 13:26:32 +02:00
' 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 } "
)
) ,
2026-08-21 13:08:30 +02:00
} )
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
2026-08-21 13:26:32 +02:00
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/ ' ] ,
} ,
]
2026-08-21 13:08:30 +02:00
@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 ' )
2026-08-21 13:47:58 +02:00
treated = lambda item : item [ ' mark ' ] and item [ ' mark ' ] . status == ' obsolete '
2026-08-21 13:08:30 +02:00
if selected_filter == ' obsolete ' :
visible_files = [ item for item in files if item [ ' mark ' ] and item [ ' mark ' ] . status == ' obsolete ' ]
2026-08-21 13:47:58 +02:00
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 ' ]
2026-08-21 13:08:30 +02:00
elif selected_filter == ' all ' :
2026-08-21 13:47:58 +02:00
visible_files = [ item for item in files if not treated ( item ) ]
2026-08-21 13:08:30 +02:00
else :
selected_filter = ' duplicates '
2026-08-21 13:47:58 +02:00
visible_files = [ item for item in files if not treated ( item ) and item [ ' duplicate_count ' ] > 1 ]
2026-08-21 13:08:30 +02:00
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 )
2026-08-21 13:26:32 +02:00
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 ( )
2026-08-21 14:15:10 +02:00
demo_mode = request . args . get ( ' demo ' , ' 1 ' ) != ' 0 '
2026-08-21 13:26:32 +02:00
files , _ = _template_inventory ( )
item = next ( ( candidate for candidate in files if candidate [ ' path ' ] == relative_path ) , None )
if not item :
flash ( ' Template introuvable dans l’ inventaire. ' , ' 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 )
2026-08-21 14:15:10 +02:00
if demo_mode :
from types import SimpleNamespace
2026-08-21 14:18:49 +02:00
class DemoValue ( str ) :
""" Valeur vide pouvant aussi être appelée par un template. """
def __call__ ( self , * args , * * kwargs ) :
return False
2026-08-21 14:15:10 +02:00
class DemoObject ( SimpleNamespace ) :
def __getattr__ ( self , name ) :
2026-08-21 14:18:49 +02:00
return DemoValue ( ' ' )
2026-08-21 14:15:10 +02:00
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 ( {
2026-08-21 14:18:49 +02:00
' 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 ) ,
2026-08-21 14:15:10 +02:00
' 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 ' ) ] ,
} )
2026-08-21 13:26:32 +02:00
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 ,
2026-08-21 14:15:10 +02:00
source = source , rendered = rendered , render_error = render_error ,
demo_mode = demo_mode )
2026-08-21 13:08:30 +02:00
@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
2026-08-16 23:32:04 +02:00
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 )
2026-08-16 15:32:55 +02:00
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
2026-08-16 23:22:52 +02:00
# 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)
2026-08-16 15:32:55 +02:00
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
2026-08-16 23:22:52 +02:00
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 ,
2026-08-16 15:32:55 +02:00
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. """
2026-08-16 23:32:04 +02:00
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 ' ) )
2026-08-16 23:32:04 +02:00
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 { }
2026-08-16 15:40:57 +02:00
# 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 ' , ' ' ) ,
2026-08-16 15:40:57 +02:00
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 :
2026-08-16 15:40:57 +02:00
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 ) ) )