Ajouter architecture et aperçu des 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
098690a13f
commit
04f85905eb
4 changed files with 167 additions and 3 deletions
|
|
@ -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 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)
|
||||
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'])
|
||||
|
|
|
|||
|
|
@ -15,6 +15,28 @@
|
|||
« Utilisé probablement » reflète l’ordre 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 }}">
|
||||
|
|
|
|||
45
app_new/templates/admin/template_preview.html
Normal file
45
app_new/templates/admin/template_preview.html
Normal 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 à l’audit</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 %}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue