Améliorer la localisation et auditer les templates
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run

This commit is contained in:
root 2026-08-21 11:08:30 +00:00
parent a81b3ed4fa
commit 098690a13f
9 changed files with 372 additions and 45 deletions

View file

@ -17,7 +17,7 @@ from .planning import (
TechnicianAvailability, AdminTask, ZoneAccessRule TechnicianAvailability, AdminTask, ZoneAccessRule
) )
from .settings import AppSettings from .settings import AppSettings
from .audit import AuditLog from .audit import AuditLog, TemplateAuditMark
from .prevention import PreventionWorkLog, StaffAuthorization, RiskAssessment, PreventionAction, SafetyRegisterEntry from .prevention import PreventionWorkLog, StaffAuthorization, RiskAssessment, PreventionAction, SafetyRegisterEntry
__all__ = [ __all__ = [
@ -31,6 +31,6 @@ __all__ = [
'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable', 'Meter', 'MeterReading', 'Consumable', 'ConsumableUsage', 'EquipmentConsumable',
'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask', 'PreventiveTask', 'PreventiveTaskConsumable', 'ScheduledTask',
'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule', 'TechnicianAvailability', 'AdminTask', 'ZoneAccessRule',
'AppSettings', 'AuditLog', 'AppSettings', 'AuditLog', 'TemplateAuditMark',
'PreventionWorkLog', 'StaffAuthorization', 'RiskAssessment', 'PreventionAction', 'SafetyRegisterEntry', 'PreventionWorkLog', 'StaffAuthorization', 'RiskAssessment', 'PreventionAction', 'SafetyRegisterEntry',
] ]

View file

@ -23,3 +23,18 @@ class AuditLog(db.Model):
default=lambda: datetime.now(timezone.utc).replace(tzinfo=None), 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),
)

View file

@ -3,6 +3,8 @@ Core Routes - Administration
GMAO Collège GMAO Collège
""" """
from flask import Blueprint, render_template, redirect, url_for, request, flash from flask import Blueprint, render_template, redirect, url_for, request, flash
from pathlib import Path
import hashlib
import secrets import secrets
from werkzeug.security import generate_password_hash from werkzeug.security import generate_password_hash
from flask_login import login_required, current_user from flask_login import login_required, current_user
@ -319,6 +321,125 @@ def setup_wizard_redirect():
return redirect(url_for('setup_wizard.index')) 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) ───────────────────────────────────── # ─── Gestion des données (cleanup) ─────────────────────────────────────
@admin_bp.route('/data') @admin_bp.route('/data')

View file

@ -20,7 +20,7 @@ from app_new.extensions import db
from ..core.models.maintenance import Intervention, StatusChange, InterventionComment, Lot from ..core.models.maintenance import Intervention, StatusChange, InterventionComment, Lot
from ..core.models.planning import ScheduledTask from ..core.models.planning import ScheduledTask
from ..core.models.equipment import Equipment 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 ..core.models.company import Service, Company
from app_new.constants import INTERVENTION_STATUSES, INTERVENTION_TRANSITIONS, PRIORITIES, WORKFLOW_TYPES, WORKFLOW_STATUS_ORDERS from app_new.constants import INTERVENTION_STATUSES, INTERVENTION_TRANSITIONS, PRIORITIES, WORKFLOW_TYPES, WORKFLOW_STATUS_ORDERS
@ -412,6 +412,8 @@ def detail(id):
transitions=transitions, transitions=transitions,
history=history, history=history,
workflow_status_order=WORKFLOW_STATUS_ORDERS.get(intervention.workflow_type, WORKFLOW_STATUS_ORDERS['corrective']), 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(), rooms=Room.query.order_by(Room.name).all(),
categories=EquipmentCategory.query.order_by(EquipmentCategory.name).all(), categories=EquipmentCategory.query.order_by(EquipmentCategory.name).all(),
equipment_choices=Equipment.query.filter_by(is_deleted=False).order_by(Equipment.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('/<int:id>/location', methods=['POST']) @interventions_bp.route('/<int:id>/location', methods=['POST'])
@login_required @login_required
def update_location(id): 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) 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) room_id = request.form.get('room_id', type=int)
category_id = request.form.get('category_id', type=int) category_id = request.form.get('category_id', type=int)
equipment_id = request.form.get('equipment_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: if room_id and not room:
flash('Salle invalide.', 'danger') flash('Salle invalide.', 'danger')
return redirect(url_for('interventions.detail', id=id)) 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 equipment:
if room_id and equipment.room_id != room_id: if room_id and equipment.room_id != room_id:
flash("L'équipement choisi n'est pas dans la salle sélectionnée.", 'danger') flash("L'équipement choisi n'est pas dans la salle sélectionnée.", 'danger')

View file

@ -4,6 +4,7 @@
{% block content %} {% block content %}
<div class="py-2 py-md-4"> <div class="py-2 py-md-4">
{% 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 %} {% if intervention.is_deleted %}
<div class="alert alert-danger mb-3"> <div class="alert alert-danger mb-3">
@ -37,13 +38,64 @@
</h2> </h2>
<span class="text-muted" style="font-size:0.85rem;">Intervention #{{ intervention.id }} — {{ intervention.created_at|datetime_fmt if intervention.created_at else '—' }}</span> <span class="text-muted" style="font-size:0.85rem;">Intervention #{{ intervention.id }} — {{ intervention.created_at|datetime_fmt if intervention.created_at else '—' }}</span>
</div> </div>
<div class="d-flex gap-2 align-items-center"> <div class="text-md-end">
<span class="badge bg-{{ intervention.status_color }}" style="font-size:1rem;"> <div class="fw-semibold mb-1">
{{ intervention.status_label }} <i class="bi bi-geo-alt text-success"></i>
</span> {{ concerned_room.name if concerned_room else 'Salle non renseignée' }}
<span class="badge bg-{{ intervention.priority_color }}"> <span class="text-muted mx-1"></span>
{{ intervention.priority_label }} {{ intervention.equipment.name if intervention.equipment else 'Équipement non renseigné' }}
</span> </div>
<div class="d-flex gap-2 justify-content-md-end align-items-center">
<span class="badge bg-{{ intervention.status_color }}" style="font-size:1rem;">{{ intervention.status_label }}</span>
<span class="badge bg-{{ intervention.priority_color }}">{{ intervention.priority_label }}</span>
</div>
</div>
</div>
<!-- Localisation modifiable en cascade : volontairement placée en tête de fiche -->
<div class="card border-0 shadow-sm mb-3 border-start border-success border-4">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-geo-alt text-success"></i> Salle et équipement concernés</h6>
<form method="POST" action="{{ url_for('interventions.update_location', id=intervention.id) }}" id="intervention-location-form">
<div class="row g-2">
<div class="col-sm-6 col-lg-2">
<label class="form-label small">1. Bâtiment</label>
<select name="building_id" id="intervention-building" class="form-select form-select-sm">
<option value="">— Bâtiment —</option>
{% for building in buildings %}<option value="{{ building.id }}" {% if concerned_room and concerned_room.building_id == building.id %}selected{% endif %}>{{ building.name }}</option>{% endfor %}
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">2. Zone</label>
<select name="zone_id" id="intervention-zone" class="form-select form-select-sm">
<option value="">— Zone —</option>
{% for zone in zones %}<option value="{{ zone.id }}" data-building="{{ zone.building_id }}" {% if concerned_room and concerned_room.zone_id == zone.id %}selected{% endif %}>{{ zone.name }}</option>{% endfor %}
</select>
</div>
<div class="col-sm-6 col-lg-3">
<label class="form-label small">3. Salle</label>
<select name="room_id" id="intervention-room" class="form-select form-select-sm">
<option value="">— Salle —</option>
{% for room in rooms %}<option value="{{ room.id }}" data-building="{{ room.building_id }}" data-zone="{{ room.zone_id or '' }}" {% if concerned_room and concerned_room.id == room.id %}selected{% endif %}>{{ room.name }}</option>{% endfor %}
</select>
</div>
<div class="col-sm-6 col-lg-2">
<label class="form-label small">4. Type déquipement</label>
<select name="category_id" id="intervention-category" class="form-select form-select-sm">
<option value="">— Type —</option>
{% for category in categories %}<option value="{{ category.id }}" {% if intervention.equipment and intervention.equipment.effective_category_id == category.id %}selected{% endif %}>{{ category.name }}</option>{% endfor %}
</select>
</div>
<div class="col-sm-6 col-lg-3">
<label class="form-label small">5. Équipement précis</label>
<select name="equipment_id" id="intervention-equipment" class="form-select form-select-sm">
<option value="">— Aucun équipement précis —</option>
{% for equipment in equipment_choices %}<option value="{{ equipment.id }}" data-room="{{ equipment.room_id or '' }}" data-category="{{ equipment.effective_category_id or '' }}" {% if intervention.equipment_id == equipment.id %}selected{% endif %}>{{ equipment.name }}{% if equipment.code %} ({{ equipment.code }}){% endif %}</option>{% endfor %}
</select>
</div>
</div>
<button type="submit" class="btn btn-outline-success btn-sm mt-3"><i class="bi bi-save"></i> Enregistrer la localisation</button>
</form>
</div> </div>
</div> </div>
@ -298,39 +350,6 @@
</div> </div>
</div> </div>
<!-- Localisation modifiable en cascade -->
<div class="card border-0 shadow-sm mb-3 border-start border-success border-4">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-geo-alt text-success"></i> Salle et équipement concernés</h6>
<form method="POST" action="{{ url_for('interventions.update_location', id=intervention.id) }}" id="intervention-location-form">
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small">1. Salle</label>
<select name="room_id" id="intervention-room" class="form-select form-select-sm">
<option value="">— Aucune salle —</option>
{% for room in rooms %}<option value="{{ room.id }}" {% if intervention.room_id == room.id %}selected{% endif %}>{{ room.full_name }}</option>{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label small">2. Type déquipement</label>
<select name="category_id" id="intervention-category" class="form-select form-select-sm">
<option value="">— Toutes les catégories —</option>
{% for category in categories %}<option value="{{ category.id }}" {% if intervention.equipment and intervention.equipment.effective_category_id == category.id %}selected{% endif %}>{{ category.name }}</option>{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label small">3. Équipement précis</label>
<select name="equipment_id" id="intervention-equipment" class="form-select form-select-sm">
<option value="">— Aucun équipement précis —</option>
{% for equipment in equipment_choices %}<option value="{{ equipment.id }}" data-room="{{ equipment.room_id or '' }}" data-category="{{ equipment.effective_category_id or '' }}" {% if intervention.equipment_id == equipment.id %}selected{% endif %}>{{ equipment.name }}{% if equipment.code %} ({{ equipment.code }}){% endif %}</option>{% endfor %}
</select>
</div>
</div>
<button type="submit" class="btn btn-outline-success btn-sm mt-3"><i class="bi bi-save"></i> Enregistrer la localisation</button>
</form>
</div>
</div>
<!-- Éléments du groupe concernés --> <!-- Éléments du groupe concernés -->
{% if intervention.is_group_intervention %} {% if intervention.is_group_intervention %}
{% set affected_units = intervention.get_affected_units() %} {% set affected_units = intervention.get_affected_units() %}
@ -592,11 +611,34 @@
<script> <script>
(() => { (() => {
const building = document.getElementById('intervention-building');
const zone = document.getElementById('intervention-zone');
const room = document.getElementById('intervention-room'); const room = document.getElementById('intervention-room');
const category = document.getElementById('intervention-category'); const category = document.getElementById('intervention-category');
const equipment = document.getElementById('intervention-equipment'); const equipment = document.getElementById('intervention-equipment');
if (!room || !category || !equipment) return; if (!building || !zone || !room || !category || !equipment) return;
const equipmentOptions = [...equipment.options].filter(option => option.value); const equipmentOptions = [...equipment.options].filter(option => option.value);
const filterZones = () => {
const buildingId = building.value;
[...zone.options].forEach(option => {
if (!option.value) { option.hidden = false; return; }
option.hidden = !buildingId || option.dataset.building !== buildingId;
if (option.hidden && option.selected) zone.value = '';
});
zone.disabled = !buildingId;
};
const filterRooms = () => {
const buildingId = building.value;
const zoneId = zone.value;
[...room.options].forEach(option => {
if (!option.value) { option.hidden = false; return; }
const matchesBuilding = buildingId && option.dataset.building === buildingId;
const matchesZone = !zoneId || option.dataset.zone === zoneId;
option.hidden = !(matchesBuilding && matchesZone);
if (option.hidden && option.selected) room.value = '';
});
room.disabled = !buildingId;
};
const filterCategories = () => { const filterCategories = () => {
const roomId = room.value; const roomId = room.value;
const presentCategories = new Set( const presentCategories = new Set(
@ -627,8 +669,12 @@
if (option.hidden && option.selected) equipment.value = ''; if (option.hidden && option.selected) equipment.value = '';
}); });
}; };
building.addEventListener('change', () => { filterZones(); filterRooms(); filterCategories(); filter(); });
zone.addEventListener('change', () => { filterRooms(); filterCategories(); filter(); });
room.addEventListener('change', () => { filterCategories(); filter(); }); room.addEventListener('change', () => { filterCategories(); filter(); });
category.addEventListener('change', filter); category.addEventListener('change', filter);
filterZones();
filterRooms();
filterCategories(); filterCategories();
filter(); filter();
})(); })();

View file

@ -0,0 +1,77 @@
{% extends "base.html" %}
{% block title %}Audit des templates — Administration{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-2 mb-3">
<div>
<h1 class="h3 mb-1"><i class="bi bi-files"></i> Audit des templates</h1>
<p class="text-muted mb-0">Repérez les copies identiques ou divergentes et marquez celles qui ne doivent plus être utilisées.</p>
</div>
<a href="{{ url_for('admin.data_management') }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left"></i> Gestion des données</a>
</div>
<div class="alert alert-info py-2">
<i class="bi bi-info-circle"></i> Le marquage <strong>obsolète</strong> est documentaire : aucun fichier nest supprimé et le fonctionnement du site nest pas modifié.
« Utilisé probablement » reflète lordre des chargeurs Jinja ; vérifiez les routes avant toute suppression manuelle.
</div>
<div class="row g-2 mb-3">
{% for label, value, color in [('Templates', stats.total, 'primary'), ('Groupes doublons', stats.duplicate_groups, 'warning'), ('Identiques', stats.identical_groups, 'success'), ('Divergents', stats.divergent_groups, 'danger'), ('Marqués obsolètes', stats.obsolete, 'secondary')] %}
<div class="col-6 col-md"><div class="card border-0 shadow-sm h-100"><div class="card-body py-2"><div class="small text-muted">{{ label }}</div><div class="h4 text-{{ color }} mb-0">{{ value }}</div></div></div></div>
{% endfor %}
</div>
<div class="btn-group btn-group-sm mb-3" role="group">
<a class="btn {{ 'btn-primary' if selected_filter == 'duplicates' else 'btn-outline-primary' }}" href="{{ url_for('admin.template_debug', filter='duplicates') }}">Doublons</a>
<a class="btn {{ 'btn-primary' if selected_filter == 'obsolete' else 'btn-outline-primary' }}" href="{{ url_for('admin.template_debug', filter='obsolete') }}">Obsolètes</a>
<a class="btn {{ 'btn-primary' if selected_filter == 'all' else 'btn-outline-primary' }}" href="{{ url_for('admin.template_debug', filter='all') }}">Tous</a>
</div>
{% if groups %}
{% for logical_key, templates in groups|dictsort %}
<div class="card border-0 shadow-sm mb-3">
<div class="card-header d-flex flex-wrap justify-content-between gap-2 align-items-center">
<code>{{ logical_key }}</code>
{% if templates[0].duplicate_count > 1 %}
<span class="badge bg-{{ 'success' if templates[0].comparison == 'identique' else 'danger' }}">{{ templates[0].duplicate_count }} copies — {{ templates[0].comparison }}</span>
{% else %}<span class="badge bg-secondary">unique</span>{% endif %}
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Fichier</th><th>Source</th><th>État estimé</th><th>Empreinte</th><th style="min-width:300px">Décision</th></tr></thead>
<tbody>
{% for template in templates %}
<tr class="{% if template.mark and template.mark.status == 'obsolete' %}table-secondary{% endif %}">
<td><code class="text-break">{{ template.path }}</code></td>
<td><span class="badge bg-{{ 'primary' if template.source == 'global' else 'info' }}">{{ template.source }}</span></td>
<td>
{% if template.mark and template.mark.status == 'obsolete' %}<span class="badge bg-secondary">Obsolète</span>
{% elif template.probable_active %}<span class="badge bg-success">Utilisé probablement</span>
{% else %}<span class="badge bg-warning text-dark">Masqué probablement</span>{% endif %}
</td>
<td><code title="{{ template.hash }}">{{ template.hash[:10] }}</code></td>
<td>
<form method="post" action="{{ url_for('admin.template_debug_mark') }}" class="d-flex gap-1">
<input type="hidden" name="path" value="{{ template.path }}">
<input type="hidden" name="return_filter" value="{{ selected_filter }}">
{% if template.mark and template.mark.status == 'obsolete' %}
<span class="small text-muted flex-grow-1">{{ template.mark.notes or 'Aucune note' }}</span>
<button class="btn btn-outline-success btn-sm" name="action" value="restore"><i class="bi bi-arrow-counterclockwise"></i> Réactiver</button>
{% else %}
<input class="form-control form-control-sm" name="notes" placeholder="Raison / remplacement conseillé">
<button class="btn btn-outline-danger btn-sm text-nowrap" name="action" value="obsolete"><i class="bi bi-archive"></i> Obsolète</button>
{% endif %}
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
{% else %}
<div class="alert alert-secondary">Aucun template ne correspond à ce filtre.</div>
{% endif %}
</div>
{% endblock %}

View file

@ -265,6 +265,7 @@
<li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li> <li><hr class="dropdown-divider" style="border-color:rgba(255,255,255,0.15)"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.list_users') }}"><i class="bi bi-people"></i> Utilisateurs</a></li> <li><a class="dropdown-item" href="{{ url_for('auth.list_users') }}"><i class="bi bi-people"></i> Utilisateurs</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.data_management') }}"><i class="bi bi-database-exclamation text-danger"></i> Gestion des données</a></li> <li><a class="dropdown-item" href="{{ url_for('admin.data_management') }}"><i class="bi bi-database-exclamation text-danger"></i> Gestion des données</a></li>
<li><a class="dropdown-item" href="{{ url_for('admin.template_debug') }}"><i class="bi bi-files"></i> Audit des templates</a></li>
<li><a class="dropdown-item" href="{{ url_for('setup_wizard.admin_panel') }}"><i class="bi bi-database"></i> Gestion bases</a></li> <li><a class="dropdown-item" href="{{ url_for('setup_wizard.admin_panel') }}"><i class="bi bi-database"></i> Gestion bases</a></li>
<li><a class="dropdown-item" href="{{ url_for('setup_wizard.index') }}"><i class="bi bi-magic"></i> Setup Wizard</a></li> <li><a class="dropdown-item" href="{{ url_for('setup_wizard.index') }}"><i class="bi bi-magic"></i> Setup Wizard</a></li>
</ul> </ul>
@ -283,6 +284,7 @@
<li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('yeastar.index') }}"><i class="bi bi-telephone"></i> Téléphone</a></li> <li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('yeastar.index') }}"><i class="bi bi-telephone"></i> Téléphone</a></li>
<li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('yeastar.config_page') }}"><i class="bi bi-telephone-gear"></i> Config téléphone</a></li> <li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('yeastar.config_page') }}"><i class="bi bi-telephone-gear"></i> Config téléphone</a></li>
<li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('yeastar.dnd_config_page') }}"><i class="bi bi-bell-slash"></i> Watchdog DND</a></li> <li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('yeastar.dnd_config_page') }}"><i class="bi bi-bell-slash"></i> Watchdog DND</a></li>
<li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('admin.template_debug') }}"><i class="bi bi-files"></i> Audit des templates</a></li>
<li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('status.index') }}"><i class="bi bi-activity"></i> Status</a></li> <li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('status.index') }}"><i class="bi bi-activity"></i> Status</a></li>
<li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('logs.index') }}"><i class="bi bi-journal-text"></i> Logs</a></li> <li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('logs.index') }}"><i class="bi bi-journal-text"></i> Logs</a></li>
<li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('auth.list_users') }}"><i class="bi bi-people"></i> Utilisateurs</a></li> <li class="nav-item d-lg-none"><a class="nav-link" href="{{ url_for('auth.list_users') }}"><i class="bi bi-people"></i> Utilisateurs</a></li>

View file

@ -0,0 +1,29 @@
"""Ajoute les décisions d'audit des templates."""
from alembic import op
import sqlalchemy as sa
revision = "a07b8c9d0e2f"
down_revision = "fg7b8c9d0e1f"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"template_audit_marks",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("path", sa.String(length=500), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False, server_default="obsolete"),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("updated_by_id", sa.Integer(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(["updated_by_id"], ["users.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("path"),
)
op.create_index("ix_template_audit_marks_status", "template_audit_marks", ["status"], unique=False)
def downgrade():
op.drop_index("ix_template_audit_marks_status", table_name="template_audit_marks")
op.drop_table("template_audit_marks")

View file

@ -0,0 +1,27 @@
def test_template_audit_can_mark_and_restore_without_deleting(authenticated_client, app):
from app_new.core.models.audit import TemplateAuditMark
template_path = 'templates/admin/template_debug.html'
response = authenticated_client.get('/admin/debug/templates?filter=all')
assert response.status_code == 200
assert 'Audit des templates' in response.get_data(as_text=True)
response = authenticated_client.post('/admin/debug/templates/mark', data={
'path': template_path,
'action': 'obsolete',
'notes': 'Marquage de test',
'return_filter': 'obsolete',
})
assert response.status_code == 302
with app.app_context():
mark = TemplateAuditMark.query.filter_by(path=template_path).one()
assert mark.notes == 'Marquage de test'
response = authenticated_client.post('/admin/debug/templates/mark', data={
'path': template_path,
'action': 'restore',
'return_filter': 'all',
})
assert response.status_code == 302
with app.app_context():
assert TemplateAuditMark.query.filter_by(path=template_path).first() is None