Améliorer la localisation et auditer les templates
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
Some checks are pending
CI - Tests et Syntax / lint-and-test (push) Waiting to run
This commit is contained in:
parent
a81b3ed4fa
commit
098690a13f
9 changed files with 372 additions and 45 deletions
|
|
@ -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',
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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('/<int:id>/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')
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
{% block content %}
|
||||
<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 %}
|
||||
<div class="alert alert-danger mb-3">
|
||||
|
|
@ -37,13 +38,64 @@
|
|||
</h2>
|
||||
<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 class="d-flex gap-2 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 class="text-md-end">
|
||||
<div class="fw-semibold mb-1">
|
||||
<i class="bi bi-geo-alt text-success"></i>
|
||||
{{ concerned_room.name if concerned_room else 'Salle non renseignée' }}
|
||||
<span class="text-muted mx-1">—</span>
|
||||
{{ intervention.equipment.name if intervention.equipment else 'Équipement non renseigné' }}
|
||||
</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>
|
||||
|
||||
|
|
@ -298,39 +350,6 @@
|
|||
</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 -->
|
||||
{% if intervention.is_group_intervention %}
|
||||
{% set affected_units = intervention.get_affected_units() %}
|
||||
|
|
@ -592,11 +611,34 @@
|
|||
|
||||
<script>
|
||||
(() => {
|
||||
const building = document.getElementById('intervention-building');
|
||||
const zone = document.getElementById('intervention-zone');
|
||||
const room = document.getElementById('intervention-room');
|
||||
const category = document.getElementById('intervention-category');
|
||||
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 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 roomId = room.value;
|
||||
const presentCategories = new Set(
|
||||
|
|
@ -627,8 +669,12 @@
|
|||
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(); });
|
||||
category.addEventListener('change', filter);
|
||||
filterZones();
|
||||
filterRooms();
|
||||
filterCategories();
|
||||
filter();
|
||||
})();
|
||||
|
|
|
|||
77
app_new/templates/admin/template_debug.html
Normal file
77
app_new/templates/admin/template_debug.html
Normal 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 n’est supprimé et le fonctionnement du site n’est pas modifié.
|
||||
« Utilisé probablement » reflète l’ordre 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 %}
|
||||
|
|
@ -265,6 +265,7 @@
|
|||
<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('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.index') }}"><i class="bi bi-magic"></i> Setup Wizard</a></li>
|
||||
</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.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('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('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>
|
||||
|
|
|
|||
29
migrations/versions/a07b8c9d0e2f_add_template_audit_marks.py
Normal file
29
migrations/versions/a07b8c9d0e2f_add_template_audit_marks.py
Normal 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")
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue