From 0bca327b80af28d6ed080987e787858bf5b99c9d Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Thu, 7 May 2026 20:47:49 +0200 Subject: [PATCH] New cockpit --- .claude/settings.json | 3 +- app.py | 93 ++++++- static/css/style.css | 106 ++++++++ templates/admin/library.html | 514 +++++++++++++++++++++++++++++++---- 4 files changed, 656 insertions(+), 60 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 329bb03..ee6a578 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,7 +4,8 @@ "Bash(git remote *)", "Bash(git push *)", "Bash(git commit -m 'Add Dockerfile for Docker production deployment *)", - "Bash(git commit -m 'Auto-seed default sources on first startup *)" + "Bash(git commit -m 'Auto-seed default sources on first startup *)", + "Bash(py -c \"from app import app; print\\('OK'\\)\")" ] } } diff --git a/app.py b/app.py index 9f500df..6867cc7 100644 --- a/app.py +++ b/app.py @@ -46,6 +46,46 @@ _DOMAIN_LABELS = { 'other': ('Autre', 'πŸ“Œ'), } +_CONDITION_LABELS = { + 'hypertension': 'Hypertension', + 'diabetes_risk': 'Risque diabΓ¨te', + 'cardiovascular_risk': 'Risque cardio-vasculaire', + 'anxiety': 'AnxiΓ©tΓ©', + 'digestive_issues': 'Troubles digestifs', + 'back_pain': 'Douleurs dorsales', + 'osteoporosis_risk': 'Risque ostΓ©oporose', + 'metabolic_syndrome': 'Syndrome mΓ©tabolique', + 'thyroid': 'ThyroΓ―de', + 'sleep_apnea': 'ApnΓ©e du sommeil', +} +_GOAL_LABELS = { + 'weight_loss': 'Perte de poids', + 'muscle_gain': 'Prise de muscle', + 'longevity': 'LongΓ©vitΓ©', + 'energy': 'Γ‰nergie', + 'better_sleep': 'AmΓ©liorer le sommeil', + 'stress_reduction': 'RΓ©duction du stress', + 'metabolic_health': 'SantΓ© mΓ©tabolique', + 'cardiovascular_health': 'SantΓ© cardio-vasculaire', + 'cognitive_health': 'SantΓ© cognitive', +} +_DIET_LABELS = { + 'omnivore': 'Omnivore', + 'flexitarian': 'Flexitarien', + 'vegetarian': 'VΓ©gΓ©tarien', + 'vegan': 'VΓ©gΓ©talien', + 'paleo': 'PalΓ©o', + 'keto': 'KΓ©to', + 'other': 'Autre', +} +_ACTIVITY_LABELS = { + 'sedentary': 'SΓ©dentaire', + 'light': 'LΓ©gΓ¨re activitΓ©', + 'moderate': 'ActivitΓ© modΓ©rΓ©e', + 'intense': 'ActivitΓ© intense', + 'athlete': 'AthlΓ¨te', +} + # ── Models ───────────────────────────────────────────────────────────────────── @@ -796,9 +836,37 @@ def admin_review_update(rid): @login_required @admin_required def admin_library(): + from pipeline import _CONDITIONS, _HEALTH_GOALS, _DIET_TYPES, _ACTIVITY_LEVELS + recs = PendingRecommendation.query.filter_by(status='approved') \ .order_by(PendingRecommendation.domain, PendingRecommendation.priority.desc()).all() - return render_template('admin/library.html', recs=recs, domain_labels=_DOMAIN_LABELS) + + tree = {} + ev_total = {'A': 0, 'B': 0, 'C': 0} + for rec in recs: + d = rec.domain + ev = rec.evidence_level if rec.evidence_level in ('A', 'B', 'C') else 'C' + ev_total[ev] += 1 + if d not in tree: + tree[d] = {'recs': [], 'tags': {}, 'ev': {'A': 0, 'B': 0, 'C': 0}} + tree[d]['recs'].append(rec) + tree[d]['ev'][ev] += 1 + for tag in (rec.tags or []): + tag = tag.strip().lower() + if not tag: + continue + if tag not in tree[d]['tags']: + tree[d]['tags'][tag] = {'recs': [], 'ev': {'A': 0, 'B': 0, 'C': 0}} + tree[d]['tags'][tag]['recs'].append(rec) + tree[d]['tags'][tag]['ev'][ev] += 1 + + return render_template('admin/library.html', + recs=recs, tree=tree, + domain_labels=_DOMAIN_LABELS, ev_total=ev_total, + conditions=_CONDITIONS, health_goals=_HEALTH_GOALS, + diet_types=_DIET_TYPES, activity_levels=_ACTIVITY_LEVELS, + condition_labels=_CONDITION_LABELS, goal_labels=_GOAL_LABELS, + diet_labels=_DIET_LABELS, activity_labels=_ACTIVITY_LABELS) @app.route('/admin/library//delete', methods=['POST']) @@ -814,6 +882,29 @@ def admin_library_delete(rid): return redirect(url_for('admin_library')) +@app.route('/admin/library//update', methods=['POST']) +@login_required +@admin_required +def admin_library_update(rid): + rec = db.get_or_404(PendingRecommendation, rid) + f = request.form + rec.target_sex = f.get('target_sex', 'all') + rec.target_age_min = int(f['target_age_min']) if f.get('target_age_min') else None + rec.target_age_max = int(f['target_age_max']) if f.get('target_age_max') else None + rec.not_for_under_18 = 'not_for_under_18' in f + rec.not_for_pregnant = 'not_for_pregnant' in f + rec.required_conditions = f.getlist('required_conditions') + rec.excluded_conditions = f.getlist('excluded_conditions') + rec.required_health_goals = f.getlist('required_health_goals') + rec.required_diet_types = f.getlist('required_diet_types') + rec.required_activity_levels = f.getlist('required_activity_levels') + rec.priority = int(f.get('priority', rec.priority)) + rec.admin_notes = f.get('admin_notes', '').strip() + db.session.commit() + flash(f'Ciblage de Β« {rec.title} Β» mis Γ  jour.', 'success') + return redirect(url_for('admin_library')) + + @app.route('/admin/config', methods=['GET', 'POST']) @login_required @admin_required diff --git a/static/css/style.css b/static/css/style.css index dfb5764..b383a01 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -563,6 +563,112 @@ body { border: 1px solid #fde68a; } +/* ── Library cockpit ──────────────────────────────────────────── */ +.admin-main--cockpit { + padding: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.lib-header { + flex-shrink: 0; +} +.lib-cockpit { + flex: 1; + display: flex; + overflow: hidden; + min-height: 0; +} +.lib-tree { + width: 240px; + flex-shrink: 0; + overflow-y: auto; + background: #fff; + border-right: 1px solid var(--border); +} +.lib-cards { + flex: 1; + overflow-y: auto; + padding: 1rem 1.5rem; + background: var(--bg); +} +.lib-tree-node { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.4rem 0.6rem; + border-radius: var(--radius-sm); + border: none; + background: none; + text-align: left; + font-size: 0.82rem; + font-weight: 500; + color: var(--muted); + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.lib-tree-node:hover { background: #f1f5f9; color: var(--text); } +.lib-tree-node.active { background: #eff6ff; color: var(--primary); font-weight: 600; } +.lib-tree-tag { padding-left: 1.4rem; font-size: 0.79rem; } +.lib-tree-ev { + display: inline-block; + min-width: 1.1rem; + text-align: center; + font-size: 0.63rem; + font-weight: 700; + border-radius: 3px; + padding: 0.05rem 0.2rem; +} +.lib-tree-ev.ev-A { background: #dcfce7; color: #166534; } +.lib-tree-ev.ev-B { background: #fef9c3; color: #854d0e; } +.lib-tree-ev.ev-C { background: #ffedd5; color: #9a3412; } +.lib-card { + background: #fff; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-sm); + overflow: hidden; + transition: box-shadow 0.15s; +} +.lib-card:hover { box-shadow: var(--shadow); } +.lib-card-head { + cursor: pointer; + user-select: none; + transition: background 0.12s; +} +.lib-card-head:hover { background: #f8fafc; } +.lib-card-head[aria-expanded="true"] { background: #f8fafc; } +.lib-card-head[aria-expanded="true"] .lib-chevron { transform: rotate(180deg); } +.lib-chevron { transition: transform 0.2s; } +.targeting-panel { + background: #f8fafc; + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.multi-check-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.25rem; +} +.multi-check-item { + background: #f8fafc; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.3rem 0.5rem 0.3rem 1.75rem; + margin: 0; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; + font-size: 0.79rem; + line-height: 1.3; +} +.multi-check-item:has(input:checked) { + background: #eff6ff; + border-color: var(--primary); + color: var(--primary); +} + /* ── Crawl log terminal ───────────────────────────────────────── */ .crawl-log-box { background: #0f172a; diff --git a/templates/admin/library.html b/templates/admin/library.html index 206afde..dfbd1f3 100644 --- a/templates/admin/library.html +++ b/templates/admin/library.html @@ -5,85 +5,483 @@
{% include 'admin/_sidebar.html' %} -
-
+
+ + {# ── Header ─────────────────────────────────────────────────────── #} +
-

+

Bibliothèque

-

{{ recs|length }} recommandation(s) approuvΓ©e(s) et active(s)

+

{{ recs|length }} recommandation(s) approuvΓ©e(s) et active(s)

+
+
+
+ A {{ ev_total.A }} + B {{ ev_total.B }} + C {{ ev_total.C }} +
+ + File d'attente +
{% if recs %} - {% set current_domain = namespace(val='') %} - {% for rec in recs %} - {% if rec.domain != current_domain.val %} - {% set current_domain.val = rec.domain %} -
- {{ domain_labels.get(rec.domain, (rec.domain, ''))[1] }} - {{ domain_labels.get(rec.domain, (rec.domain, ''))[0] }} - - ({{ recs|selectattr('domain', 'equalto', rec.domain)|list|length }}) - -
- {% endif %} + {# ── Cockpit body ────────────────────────────────────────────────── #} +
-
-
-
-
-
- Niveau {{ rec.evidence_level }} - PrioritΓ© {{ rec.priority }} - {{ rec.reviewed_at.strftime('%d/%m/%Y') if rec.reviewed_at else 'β€”' }} -
- {{ rec.title }} -

{{ rec.summary }}

- {% if rec.source_title %} -
- {{ rec.source_title }} - {% if rec.source_doi %} β€” {{ rec.source_doi }}{% endif %} -
- {% endif %} - -
- {% if rec.target_sex and rec.target_sex != 'all' %} - {{ 'Hommes' if rec.target_sex == 'male' else 'Femmes' }} - {% endif %} - {% if rec.target_age_min or rec.target_age_max %} - {{ rec.target_age_min or '?' }}–{{ rec.target_age_max or '∞' }} ans - {% endif %} - {% for c in rec.required_conditions %} - Si: {{ c.replace('_',' ') }} - {% endfor %} - {% for c in rec.excluded_conditions %} - Exclu: {{ c.replace('_',' ') }} - {% endfor %} - {% if not rec.required_conditions and not rec.required_health_goals and not rec.required_diet_types and not rec.required_activity_levels %} - Universel - {% endif %} -
+ {# ── Left tree nav ──────────────────────────────────────────────── #} +
+
+ + + +
+ + {% for domain_key, label in domain_labels.items() %} + {% if domain_key in tree %} + {% set node = tree[domain_key] %} +
+ + + {% if node.tags %} +
+ {% for tag, tnode in node.tags.items()|sort %} + - + {% endfor %} +
+ {% endif %}
+ {% endif %} + {% endfor %} +
- {% endfor %} + + {# ── Right cards panel ──────────────────────────────────────────── #} +
+ +
+ +

Aucune recommandation dans cette catΓ©gorie.

+
+ + {% for rec in recs %} + {% set is_universal = not rec.required_conditions and not rec.required_health_goals and not rec.required_diet_types and not rec.required_activity_levels %} + {% set has_gates = rec.not_for_under_18 or rec.not_for_pregnant or (rec.target_sex and rec.target_sex != 'all') or rec.target_age_min or rec.target_age_max or rec.excluded_conditions %} + +
+ + {# Card header β€” click to expand #} + + + {# Card body β€” collapsible #} +
+
+ + {% if rec.summary %} +

{{ rec.summary }}

+ {% endif %} + {% if rec.details %} +

{{ rec.details }}

+ {% endif %} + + {# Intervention + Effect #} + {% if rec.intervention or rec.effect %} +
+ {% if rec.intervention %} +
+
+
Intervention
+
{{ rec.intervention }}
+
+
+ {% endif %} + {% if rec.effect %} +
+
+
Effet attendu
+
{{ rec.effect }}
+
+
+ {% endif %} +
+ {% endif %} + + {# Source citation #} + {% if rec.source_title %} +
+ + {{ rec.source_title }} + {% if rec.source_doi %} β€” {{ rec.source_doi }}{% endif %} + {% if rec.study_type %}{{ rec.study_type.replace('_',' ') }}{% endif %} +
+ {% endif %} + + {# ── Targeting breakdown ─────────────────────────────────── #} +
+
+ Ciblage profil + {% if is_universal %} + 🌍 Universel β€” affichΓ© Γ  tous + {% else %} + 🎯 Conditionnel + {% endif %} +
+ + {% if has_gates %} +
+
+ Exclusions (portes dures β€” toutes doivent passer) +
+
+ {% if rec.not_for_under_18 %}βœ— Pas < 18 ans{% endif %} + {% if rec.not_for_pregnant %}βœ— Pas grossesse{% endif %} + {% if rec.target_sex == 'male' %}β™‚ Hommes uniquement{% endif %} + {% if rec.target_sex == 'female' %}♀ Femmes uniquement{% endif %} + {% if rec.target_age_min %}β‰₯ {{ rec.target_age_min }} ans{% endif %} + {% if rec.target_age_max %}≀ {{ rec.target_age_max }} ans{% endif %} + {% for c in rec.excluded_conditions %} + Exclu si: {{ condition_labels.get(c, c) }} + {% endfor %} +
+
+ {% endif %} + + {% if not is_universal %} +
+
+ DΓ©clencheurs β€” logique OU (un groupe suffit) +
+
+ {% for c in rec.required_conditions %} + Si: {{ condition_labels.get(c, c) }} + {% endfor %} + {% for g in rec.required_health_goals %} + Objectif: {{ goal_labels.get(g, g) }} + {% endfor %} + {% for d in rec.required_diet_types %} + RΓ©gime: {{ diet_labels.get(d, d) }} + {% endfor %} + {% for a in rec.required_activity_levels %} + ActivitΓ©: {{ activity_labels.get(a, a) }} + {% endfor %} +
+
+ {% else %} +

+ Aucun critère de déclenchement — affichée à tous les utilisateurs (sous réserve des exclusions ci-dessus si définies). +

+ {% endif %} +
+ + {# Actions #} +
+ +
+ +
+
+ +
+
+
+ + {# ── Edit targeting modal ───────────────────────────────────── #} + + + {% endfor %} +
+
+ {% else %}

Aucune recommandation approuvΓ©e. Allez dans la file d'attente pour en valider.

- Aller Γ  la file d'attente + File d'attente
{% endif %} +
{% endblock %} + +{% block scripts %} + +{% endblock %}