From 098690a13f645335d79b8356fe6b1353380b9464 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 21 Aug 2026 11:08:30 +0000 Subject: [PATCH] =?UTF-8?q?Am=C3=A9liorer=20la=20localisation=20et=20audit?= =?UTF-8?q?er=20les=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app_new/core/models/__init__.py | 4 +- app_new/core/models/audit.py | 15 ++ app_new/core/routes/admin.py | 121 +++++++++++++++++ app_new/interventions/crud.py | 14 +- app_new/interventions/templates/detail.html | 128 ++++++++++++------ app_new/templates/admin/template_debug.html | 77 +++++++++++ app_new/templates/base.html | 2 + .../a07b8c9d0e2f_add_template_audit_marks.py | 29 ++++ ...ntervention_location_and_template_audit.py | 27 ++++ 9 files changed, 372 insertions(+), 45 deletions(-) create mode 100644 app_new/templates/admin/template_debug.html create mode 100644 migrations/versions/a07b8c9d0e2f_add_template_audit_marks.py create mode 100644 tests/integration/test_intervention_location_and_template_audit.py diff --git a/app_new/core/models/__init__.py b/app_new/core/models/__init__.py index 4fc7185..4e691d1 100644 --- a/app_new/core/models/__init__.py +++ b/app_new/core/models/__init__.py @@ -17,7 +17,7 @@ from .planning import ( TechnicianAvailability, AdminTask, ZoneAccessRule ) from .settings import AppSettings -from .audit import AuditLog +from .audit import AuditLog, TemplateAuditMark from .prevention import PreventionWorkLog, StaffAuthorization, RiskAssessment, PreventionAction, SafetyRegisterEntry __all__ = [ @@ -31,6 +31,6 @@ __all__ = [ 'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable', 'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule', - 'AppSettings', 'AuditLog', + 'AppSettings', 'AuditLog', 'TemplateAuditMark', 'PreventionWorkLog', 'StaffAuthorization', 'RiskAssessment', 'PreventionAction', 'SafetyRegisterEntry', ] diff --git a/app_new/core/models/audit.py b/app_new/core/models/audit.py index 1f8e454..06a0558 100644 --- a/app_new/core/models/audit.py +++ b/app_new/core/models/audit.py @@ -23,3 +23,18 @@ class AuditLog(db.Model): default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), ) + +class TemplateAuditMark(db.Model): + """Décision administrative associée à un fichier template détecté.""" + __tablename__ = "template_audit_marks" + + id = db.Column(db.Integer, primary_key=True) + path = db.Column(db.String(500), nullable=False, unique=True) + status = db.Column(db.String(20), nullable=False, default="obsolete", index=True) + notes = db.Column(db.Text, nullable=True) + updated_by_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + updated_at = db.Column( + db.DateTime, nullable=False, + default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), + onupdate=lambda: datetime.now(timezone.utc).replace(tzinfo=None), + ) diff --git a/app_new/core/routes/admin.py b/app_new/core/routes/admin.py index a49a1ec..855a149 100644 --- a/app_new/core/routes/admin.py +++ b/app_new/core/routes/admin.py @@ -3,6 +3,8 @@ 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 from flask_login import login_required, current_user @@ -319,6 +321,125 @@ def setup_wizard_redirect(): 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() + 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, + }) + + 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 + + +@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') + if selected_filter == 'obsolete': + visible_files = [item for item in files if item['mark'] and item['mark'].status == 'obsolete'] + elif selected_filter == 'all': + visible_files = files + else: + selected_filter = 'duplicates' + visible_files = [item for item in files if 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) + + +@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'))) + + # ─── Gestion des données (cleanup) ───────────────────────────────────── @admin_bp.route('/data') diff --git a/app_new/interventions/crud.py b/app_new/interventions/crud.py index 21ff3e2..df326b9 100644 --- a/app_new/interventions/crud.py +++ b/app_new/interventions/crud.py @@ -20,7 +20,7 @@ from app_new.extensions import db from ..core.models.maintenance import Intervention, StatusChange, InterventionComment, Lot from ..core.models.planning import ScheduledTask from ..core.models.equipment import Equipment -from ..core.models.college import Room +from ..core.models.college import Building, Zone, Room from ..core.models.company import Service, Company from app_new.constants import INTERVENTION_STATUSES, INTERVENTION_TRANSITIONS, PRIORITIES, WORKFLOW_TYPES, WORKFLOW_STATUS_ORDERS @@ -412,6 +412,8 @@ def detail(id): transitions=transitions, history=history, workflow_status_order=WORKFLOW_STATUS_ORDERS.get(intervention.workflow_type, WORKFLOW_STATUS_ORDERS['corrective']), + buildings=Building.query.order_by(Building.name).all(), + zones=Zone.query.order_by(Zone.name).all(), rooms=Room.query.order_by(Room.name).all(), categories=EquipmentCategory.query.order_by(EquipmentCategory.name).all(), equipment_choices=Equipment.query.filter_by(is_deleted=False).order_by(Equipment.name).all(), @@ -453,8 +455,10 @@ def save_work_details(id): @interventions_bp.route('//location', methods=['POST']) @login_required def update_location(id): - """Modifie la localisation en cascade salle -> catégorie -> équipement.""" + """Modifie la localisation en cascade bâtiment -> zone -> salle -> équipement.""" intervention = Intervention.query.get_or_404(id) + building_id = request.form.get('building_id', type=int) + zone_id = request.form.get('zone_id', type=int) room_id = request.form.get('room_id', type=int) category_id = request.form.get('category_id', type=int) equipment_id = request.form.get('equipment_id', type=int) @@ -464,6 +468,12 @@ def update_location(id): if room_id and not room: flash('Salle invalide.', 'danger') return redirect(url_for('interventions.detail', id=id)) + if room and building_id and room.building_id != building_id: + flash("La salle ne correspond pas au bâtiment sélectionné.", 'danger') + return redirect(url_for('interventions.detail', id=id)) + if room and zone_id and room.zone_id != zone_id: + flash("La salle ne correspond pas à la zone sélectionnée.", 'danger') + return redirect(url_for('interventions.detail', id=id)) if equipment: if room_id and equipment.room_id != room_id: flash("L'équipement choisi n'est pas dans la salle sélectionnée.", 'danger') diff --git a/app_new/interventions/templates/detail.html b/app_new/interventions/templates/detail.html index e640590..f867cdc 100644 --- a/app_new/interventions/templates/detail.html +++ b/app_new/interventions/templates/detail.html @@ -4,6 +4,7 @@ {% block content %}
+ {% set concerned_room = intervention.room if intervention.room else (intervention.equipment.room if intervention.equipment and intervention.equipment.room else none) %} {% if intervention.is_deleted %}
@@ -37,13 +38,64 @@ Intervention #{{ intervention.id }} — {{ intervention.created_at|datetime_fmt if intervention.created_at else '—' }}
-
- - {{ intervention.status_label }} - - - {{ intervention.priority_label }} - +
+
+ + {{ concerned_room.name if concerned_room else 'Salle non renseignée' }} + + {{ intervention.equipment.name if intervention.equipment else 'Équipement non renseigné' }} +
+
+ {{ intervention.status_label }} + {{ intervention.priority_label }} +
+
+
+ + +
+
+
Salle et équipement concernés
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
@@ -298,39 +350,6 @@
- -
-
-
Salle et équipement concernés
-
-
-
- - -
-
- - -
-
- - -
-
- -
-
-
- {% if intervention.is_group_intervention %} {% set affected_units = intervention.get_affected_units() %} @@ -592,11 +611,34 @@