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
|
from ..models.audit import TemplateAuditMark
|
||||||
|
|
||||||
app_root = Path(current_app.root_path).resolve()
|
app_root = Path(current_app.root_path).resolve()
|
||||||
|
domain_aliases = {'admin': 'core', 'auth': 'core', 'dashboard': 'core', 'setup_wizard': 'core'}
|
||||||
files = []
|
files = []
|
||||||
for path in sorted(app_root.rglob('*.html')):
|
for path in sorted(app_root.rglob('*.html')):
|
||||||
relative = path.relative_to(app_root).as_posix()
|
relative = path.relative_to(app_root).as_posix()
|
||||||
|
|
@ -350,6 +351,15 @@ def _template_inventory():
|
||||||
'logical_key': logical_key,
|
'logical_key': logical_key,
|
||||||
'source': source,
|
'source': source,
|
||||||
'hash': digest,
|
'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 = {}
|
by_key = {}
|
||||||
|
|
@ -372,6 +382,34 @@ def _template_inventory():
|
||||||
return files, by_key
|
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')
|
@admin_bp.route('/debug/templates')
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|
@ -396,7 +434,54 @@ def template_debug():
|
||||||
groups = {}
|
groups = {}
|
||||||
for item in visible_files:
|
for item in visible_files:
|
||||||
groups.setdefault(item['logical_key'], []).append(item)
|
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'])
|
@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.
|
« Utilisé probablement » reflète l’ordre des chargeurs Jinja ; vérifiez les routes avant toute suppression manuelle.
|
||||||
</div>
|
</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">
|
<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')] %}
|
{% 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>
|
<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 %}
|
{% for logical_key, templates in groups|dictsort %}
|
||||||
<div class="card border-0 shadow-sm mb-3">
|
<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 class="card-header d-flex flex-wrap justify-content-between gap-2 align-items-center">
|
||||||
|
<div>
|
||||||
<code>{{ logical_key }}</code>
|
<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 %}
|
{% 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>
|
<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 %}
|
{% else %}<span class="badge bg-secondary">unique</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-middle mb-0">
|
<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>
|
<tbody>
|
||||||
{% for template in templates %}
|
{% for template in templates %}
|
||||||
<tr class="{% if template.mark and template.mark.status == 'obsolete' %}table-secondary{% endif %}">
|
<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">{{ 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><span class="badge bg-{{ 'primary' if template.source == 'global' else 'info' }}">{{ template.source }}</span></td>
|
||||||
<td>
|
<td>
|
||||||
{% if template.mark and template.mark.status == 'obsolete' %}<span class="badge bg-secondary">Obsolète</span>
|
{% 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 %}
|
{% else %}<span class="badge bg-warning text-dark">Masqué probablement</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td><code title="{{ template.hash }}">{{ template.hash[:10] }}</code></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>
|
<td>
|
||||||
<form method="post" action="{{ url_for('admin.template_debug_mark') }}" class="d-flex gap-1">
|
<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="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')
|
response = authenticated_client.get('/admin/debug/templates?filter=all')
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert 'Audit des templates' in response.get_data(as_text=True)
|
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={
|
response = authenticated_client.post('/admin/debug/templates/mark', data={
|
||||||
'path': template_path,
|
'path': template_path,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue