Ajouter architecture et aperçu des 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:26:32 +00:00
parent 098690a13f
commit 04f85905eb
4 changed files with 167 additions and 3 deletions

View file

@ -327,6 +327,7 @@ def _template_inventory():
from ..models.audit import TemplateAuditMark
app_root = Path(current_app.root_path).resolve()
domain_aliases = {'admin': 'core', 'auth': 'core', 'dashboard': 'core', 'setup_wizard': 'core'}
files = []
for path in sorted(app_root.rglob('*.html')):
relative = path.relative_to(app_root).as_posix()
@ -350,6 +351,15 @@ def _template_inventory():
'logical_key': logical_key,
'source': source,
'hash': digest,
'target_path': (
f"app_new/{parts[0]}/templates/{logical_key}"
if template_index > 0
else (
f"app_new/templates/{logical_key}"
if logical_key in {'base.html'} or logical_key.startswith('errors/') or logical_key.startswith('components/')
else f"app_new/{domain_aliases.get(logical_key.split('/', 1)[0], logical_key.split('/', 1)[0])}/templates/{logical_key}"
)
),
})
by_key = {}
@ -372,6 +382,34 @@ def _template_inventory():
return files, by_key
TEMPLATE_ARCHITECTURE = [
{
'path': 'app_new/templates/',
'label': 'Socle partagé',
'description': 'base.html, erreurs, composants et fragments réutilisables. Aucun écran métier ne doit être dupliqué ici.',
'children': ['base.html', 'errors/', 'components/'],
},
{
'path': 'app_new/<module>/templates/<module>/',
'label': 'Templates par domaine',
'description': 'Un domaine métier possède ses écrans canoniques, regroupés sous son propre préfixe.',
'children': ['core/', 'equipment/', 'buildings/', 'interventions/', 'planning/', 'lots/', 'companies/', 'training/'],
},
{
'path': 'app_new/<module>/templates/',
'label': 'Zone de transition',
'description': 'Ancienne convention actuellement présente dans le projet. À migrer progressivement vers le sous-dossier <module>/.',
'children': ['detail.html', 'index.html', 'form.html'],
},
{
'path': 'app_new/static/',
'label': 'Ressources visuelles',
'description': 'CSS, JavaScript et icônes partagés ; pas de logique métier dans les templates.',
'children': ['css/', 'js/', 'icons/'],
},
]
@admin_bp.route('/debug/templates')
@login_required
@admin_required
@ -396,7 +434,54 @@ def template_debug():
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)
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()
files, _ = _template_inventory()
item = next((candidate for candidate in files if candidate['path'] == relative_path), None)
if not item:
flash('Template introuvable dans linventaire.', 'danger')
return redirect(url_for('admin.template_debug'))
app_root = Path(current_app.root_path).resolve()
source_path = (app_root / relative_path).resolve()
source = source_path.read_text(encoding='utf-8', errors='replace')
rendered = None
render_error = None
try:
parts = Path(relative_path).parts
template_index = parts.index('templates')
inner_name = '/'.join(parts[template_index + 1:])
if item['source'] == 'module':
module_root = app_root / parts[0] / 'templates'
loader = ChoiceLoader([FileSystemLoader(str(module_root)), current_app.jinja_loader])
environment = current_app.jinja_env.overlay(loader=loader)
else:
environment = current_app.jinja_env.overlay()
# Un contexte de prévisualisation peut laisser les variables métier
# vides : elles doivent produire un rendu partiel plutôt qu'une 500.
environment.undefined = ChainableUndefined
template = environment.get_template(inner_name)
preview_context = {}
# Reproduire les variables globales Flask (current_user, config,
# request...) sans injecter d'objet métier réel dans la prévisualisation.
current_app.update_template_context(preview_context)
rendered = template.render(**preview_context)
except (TemplateNotFound, Exception) as exc:
render_error = f'{type(exc).__name__}: {exc}'
return render_template('admin/template_preview.html', template=item,
source=source, rendered=rendered, render_error=render_error)
@admin_bp.route('/debug/templates/mark', methods=['POST'])

View file

@ -15,6 +15,28 @@
« Utilisé probablement » reflète lordre des chargeurs Jinja ; vérifiez les routes avant toute suppression manuelle.
</div>
<div class="card border-0 shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-diagram-3"></i> Architecture cible des templates</strong>
<span class="badge bg-primary">Convention recommandée</span>
</div>
<div class="card-body">
<p class="small text-muted">Le domaine métier est propriétaire de ses écrans. Le dossier global reste réservé au socle partagé ; une seule copie canonique doit être conservée par écran.</p>
<div class="row g-2">
{% for node in template_architecture %}
<div class="col-md-6 col-xl-3">
<div class="border rounded h-100 p-2 {% if 'transition' in node.label|lower %}bg-warning-subtle{% else %}bg-light{% endif %}">
<code class="d-block text-break">{{ node.path }}</code>
<strong class="small">{{ node.label }}</strong>
<p class="small text-muted mb-1">{{ node.description }}</p>
<div class="small">{% for child in node.children %}<span class="badge text-bg-secondary me-1 mb-1">{{ child }}</span>{% endfor %}</div>
</div>
</div>
{% endfor %}
</div>
</div>
</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>
@ -31,18 +53,22 @@
{% 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">
<div>
<code>{{ logical_key }}</code>
<span class="text-muted small ms-2">Cible : <code>{{ templates[0].target_path }}</code></span>
</div>
{% 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>
<thead><tr><th>Fichier</th><th>Architecture cible</th><th>Source</th><th>État estimé</th><th>Empreinte</th><th>Actions</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><code class="text-break small">{{ template.target_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>
@ -50,6 +76,7 @@
{% else %}<span class="badge bg-warning text-dark">Masqué probablement</span>{% endif %}
</td>
<td><code title="{{ template.hash }}">{{ template.hash[:10] }}</code></td>
<td><a class="btn btn-outline-primary btn-sm" href="{{ url_for('admin.template_debug_preview', path=template.path) }}" target="_blank" rel="noopener"><i class="bi bi-eye"></i> Prévisualiser</a></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 }}">

View file

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Prévisualisation {{ template.path }} — Administration{% endblock %}
{% block content %}
<div class="container-fluid py-3">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-2 align-items-lg-center mb-3">
<div>
<h1 class="h4 mb-1"><i class="bi bi-eye"></i> Prévisualisation du template</h1>
<code class="text-break">{{ template.path }}</code>
</div>
<a href="{{ url_for('admin.template_debug', filter='all') }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left"></i> Retour à laudit</a>
</div>
{% if render_error %}
<div class="alert alert-warning">
<strong><i class="bi bi-exclamation-triangle"></i> Rendu incomplet</strong>
<p class="mb-0 small">Ce template attend probablement un contexte métier (objet, identifiant ou endpoint). Le fichier reste consultable ci-dessous.</p>
<code class="d-block mt-2 text-break">{{ render_error }}</code>
</div>
{% endif %}
<div class="row g-3">
<div class="col-xl-8">
<div class="card border-0 shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-window"></i> Rendu visuel</strong>
<span class="badge bg-info text-dark">sandboxé</span>
</div>
<div class="card-body p-0">
{% if rendered is not none %}
<iframe title="Rendu du template" sandbox="" srcdoc="{{ rendered|e }}" style="width:100%;height:70vh;border:0;background:#f8f9fa;"></iframe>
{% else %}
<div class="p-4 text-muted">Aucun rendu exploitable sans contexte métier.</div>
{% endif %}
</div>
</div>
</div>
<div class="col-xl-4">
<div class="card border-0 shadow-sm">
<div class="card-header"><strong><i class="bi bi-code-slash"></i> Source</strong></div>
<div class="card-body p-0"><pre class="m-0 p-3" style="max-height:70vh;overflow:auto;font-size:.78rem;white-space:pre-wrap;"><code>{{ source }}</code></pre></div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -5,6 +5,13 @@ def test_template_audit_can_mark_and_restore_without_deleting(authenticated_clie
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)
assert 'Architecture cible des templates' in response.get_data(as_text=True)
assert 'app_new/chatbot/templates/chatbot/index.html' in response.get_data(as_text=True)
response = authenticated_client.get('/admin/debug/templates/preview?path=templates%2Fadmin%2Ftemplate_debug.html')
assert response.status_code == 200
assert 'Prévisualisation du template' in response.get_data(as_text=True)
assert 'sandbox' in response.get_data(as_text=True)
response = authenticated_client.post('/admin/debug/templates/mark', data={
'path': template_path,