Initial production-ready release of OpenCare v1

- Flask/SQLAlchemy app with profile, dashboard, recommendations
- AI crawl pipeline (GPT-4o) with admin review workflow
- 14 curated health sources (RSS + index crawling)
- Production config: env vars, Gunicorn, systemd, nginx
- deploy/ scripts for VPS setup and updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 21:38:03 +02:00
commit efd775706b
28 changed files with 4682 additions and 0 deletions

12
templates/403.html Normal file
View File

@@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block title %}Accès refusé — OpenCare{% endblock %}
{% block content %}
<div class="container py-5 text-center">
<div class="empty-state mx-auto">
<div class="empty-icon mb-3">🚫</div>
<h2 class="fw-bold">Accès refusé</h2>
<p class="text-muted">Vous n'avez pas les droits pour accéder à cette page.</p>
<a href="{{ url_for('dashboard') }}" class="btn btn-primary mt-2">Retour au tableau de bord</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,56 @@
<nav class="admin-sidebar">
<div class="admin-sidebar-brand">
<i class="fas fa-shield-alt me-2"></i>Administration
</div>
<ul class="admin-nav">
<li>
<a href="{{ url_for('admin_index') }}"
class="admin-nav-link {% if request.endpoint == 'admin_index' %}active{% endif %}">
<i class="fas fa-chart-pie"></i>
<span>Tableau de bord</span>
</a>
</li>
<li>
<a href="{{ url_for('admin_sources') }}"
class="admin-nav-link {% if request.endpoint in ('admin_sources',) %}active{% endif %}">
<i class="fas fa-rss"></i>
<span>Sources</span>
<span class="admin-badge ms-auto">{{ CrawlSource.query.filter_by(active=True).count() }}</span>
</a>
</li>
<li>
<a href="{{ url_for('admin_review', status='pending') }}"
class="admin-nav-link {% if request.endpoint == 'admin_review' %}active{% endif %}">
<i class="fas fa-inbox"></i>
<span>File d'attente</span>
{% set pending_count = PendingRecommendation.query.filter_by(status='pending').count() %}
{% if pending_count > 0 %}
<span class="admin-badge admin-badge-danger ms-auto">{{ pending_count }}</span>
{% endif %}
</a>
</li>
<li>
<a href="{{ url_for('admin_library') }}"
class="admin-nav-link {% if request.endpoint == 'admin_library' %}active{% endif %}">
<i class="fas fa-book-medical"></i>
<span>Bibliothèque</span>
<span class="admin-badge ms-auto">{{ PendingRecommendation.query.filter_by(status='approved').count() }}</span>
</a>
</li>
<li>
<a href="{{ url_for('admin_config') }}"
class="admin-nav-link {% if request.endpoint == 'admin_config' %}active{% endif %}">
<i class="fas fa-cog"></i>
<span>Configuration</span>
</a>
</li>
</ul>
<div class="admin-sidebar-footer">
<a href="{{ url_for('dashboard') }}" class="admin-nav-link">
<i class="fas fa-arrow-left"></i>
<span>Retour à l'app</span>
</a>
</div>
</nav>

132
templates/admin/config.html Normal file
View File

@@ -0,0 +1,132 @@
{% extends 'base.html' %}
{% block title %}Configuration — Admin OpenCare{% endblock %}
{% block content %}
<div class="admin-layout">
{% include 'admin/_sidebar.html' %}
<div class="admin-main">
<div class="admin-page-header">
<h1 class="admin-page-title">
<i class="fas fa-cog me-2 text-accent"></i>Configuration
</h1>
<p class="text-muted mb-0">Paramètres du pipeline de veille</p>
</div>
<form method="post" action="{{ url_for('admin_config') }}">
<div class="row g-4">
<!-- OpenAI -->
<div class="col-12">
<div class="card border-0 shadow-sm rounded-3">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">
<i class="fas fa-robot me-2 text-accent"></i>OpenAI
</h5>
<div class="row g-3">
<div class="col-md-8">
<label class="form-label fw-medium">Clé API OpenAI</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-key"></i></span>
<input type="password" name="openai_api_key" class="form-control"
placeholder="{% if config.openai_api_key %}••••••••••••••••••••••••{% else %}sk-...{% endif %}"
autocomplete="off">
</div>
<div class="form-text">
{% if config.openai_api_key %}
<span class="text-success"><i class="fas fa-check-circle me-1"></i>Clé configurée.</span>
Laissez vide pour conserver la clé actuelle.
{% else %}
<span class="text-danger"><i class="fas fa-exclamation-circle me-1"></i>Aucune clé configurée — le crawl ne peut pas s'exécuter.</span>
{% endif %}
</div>
</div>
<div class="col-md-4">
<label class="form-label fw-medium">Modèle</label>
<select name="openai_model" class="form-select">
<option value="gpt-4o" {% if config.openai_model == 'gpt-4o' %}selected{% endif %}>
GPT-4o (recommandé)
</option>
<option value="gpt-4o-mini" {% if config.openai_model == 'gpt-4o-mini' %}selected{% endif %}>
GPT-4o mini (économique)
</option>
<option value="gpt-4-turbo" {% if config.openai_model == 'gpt-4-turbo' %}selected{% endif %}>
GPT-4 Turbo
</option>
</select>
<div class="form-text">GPT-4o offre la meilleure extraction structurée.</div>
</div>
</div>
</div>
</div>
</div>
<!-- Scheduler -->
<div class="col-12">
<div class="card border-0 shadow-sm rounded-3">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">
<i class="fas fa-clock me-2 text-accent"></i>Planification du crawl
</h5>
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label fw-medium">Heure (023)</label>
<input type="number" name="crawl_hour" class="form-control"
min="0" max="23" value="{{ config.crawl_hour }}">
<div class="form-text">Heure locale du serveur</div>
</div>
<div class="col-md-3">
<label class="form-label fw-medium">Minute (059)</label>
<input type="number" name="crawl_minute" class="form-control"
min="0" max="59" value="{{ config.crawl_minute }}">
</div>
<div class="col-md-4">
<div class="info-box">
<i class="fas fa-info-circle me-1 text-primary"></i>
Crawl automatique chaque nuit à
<strong>{{ config.crawl_hour }}h{{ '%02d'|format(config.crawl_minute|int) }}</strong>.
Les résultats apparaissent en file d'attente le lendemain matin.
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Limits -->
<div class="col-12">
<div class="card border-0 shadow-sm rounded-3">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">
<i class="fas fa-sliders-h me-2 text-accent"></i>Limites
</h5>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label fw-medium">Articles max par crawl</label>
<input type="number" name="max_articles_per_run" class="form-control"
min="1" max="200" value="{{ config.max_articles_per_run }}">
<div class="form-text">Limite le nombre d'appels API OpenAI par nuit.</div>
</div>
<div class="col-md-8">
<div class="info-box mt-4">
<i class="fas fa-coins me-1 text-primary"></i>
<strong>Coût estimé :</strong> ~0.010.05 USD par article (GPT-4o).
20 articles/nuit ≈ 0.201.00 USD/nuit.
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-primary px-4">
<i class="fas fa-save me-2"></i>Enregistrer la configuration
</button>
<a href="{{ url_for('admin_index') }}" class="btn btn-outline-secondary">Annuler</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}

141
templates/admin/index.html Normal file
View File

@@ -0,0 +1,141 @@
{% extends 'base.html' %}
{% block title %}Admin — OpenCare{% endblock %}
{% block content %}
<div class="admin-layout">
{% include 'admin/_sidebar.html' %}
<div class="admin-main">
<div class="admin-page-header">
<h1 class="admin-page-title">
<i class="fas fa-chart-pie me-2 text-accent"></i>Tableau de bord
</h1>
<p class="text-muted mb-0">Vue d'ensemble du pipeline de veille</p>
</div>
{% if not api_key_set %}
<div class="alert alert-warning d-flex align-items-center gap-2 rounded-3 mb-4">
<i class="fas fa-exclamation-triangle fa-lg"></i>
<div>
<strong>Clé API OpenAI manquante.</strong>
Le crawl ne peut pas s'exécuter.
<a href="{{ url_for('admin_config') }}" class="alert-link ms-1">Configurer →</a>
</div>
</div>
{% endif %}
<!-- Stats cards -->
<div class="row g-3 mb-4">
<div class="col-sm-6 col-lg-3">
<div class="admin-stat-card">
<div class="admin-stat-icon" style="background:#eff6ff;color:#2563eb">
<i class="fas fa-rss"></i>
</div>
<div>
<div class="admin-stat-num">{{ stats.sources_active }}<span class="admin-stat-total">/{{ stats.sources }}</span></div>
<div class="admin-stat-lbl">Sources actives</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="admin-stat-card">
<div class="admin-stat-icon" style="background:#fff7ed;color:#c2410c">
<i class="fas fa-file-alt"></i>
</div>
<div>
<div class="admin-stat-num">{{ stats.articles }}</div>
<div class="admin-stat-lbl">Articles crawlés</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="admin-stat-card border-warning-subtle">
<div class="admin-stat-icon" style="background:#fefce8;color:#854d0e">
<i class="fas fa-hourglass-half"></i>
</div>
<div>
<div class="admin-stat-num">{{ stats.pending }}</div>
<div class="admin-stat-lbl">En attente de validation</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="admin-stat-card">
<div class="admin-stat-icon" style="background:#f0fdf4;color:#166534">
<i class="fas fa-check-circle"></i>
</div>
<div>
<div class="admin-stat-num">{{ stats.approved }}</div>
<div class="admin-stat-lbl">Recommandations actives</div>
</div>
</div>
</div>
</div>
<div class="row g-3">
<!-- Quick actions -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm rounded-3 h-100">
<div class="card-body p-4">
<h5 class="fw-bold mb-3"><i class="fas fa-bolt me-2 text-accent"></i>Actions rapides</h5>
<div class="d-grid gap-2">
<a href="{{ url_for('admin_review', status='pending') }}" class="btn btn-primary">
<i class="fas fa-inbox me-2"></i>
Réviser la file d'attente
{% if stats.pending > 0 %}
<span class="badge bg-warning text-dark ms-1">{{ stats.pending }}</span>
{% endif %}
</a>
<a href="{{ url_for('admin_sources') }}" class="btn btn-outline-primary">
<i class="fas fa-plus me-2"></i>Ajouter une source
</a>
<form action="{{ url_for('admin_crawl_run') }}" method="post" class="d-grid">
<button type="submit" class="btn btn-outline-secondary">
<i class="fas fa-sync me-2"></i>Lancer un crawl maintenant
</button>
</form>
</div>
</div>
</div>
</div>
<!-- Status -->
<div class="col-lg-6">
<div class="card border-0 shadow-sm rounded-3 h-100">
<div class="card-body p-4">
<h5 class="fw-bold mb-3"><i class="fas fa-info-circle me-2 text-accent"></i>État du pipeline</h5>
<ul class="list-unstyled mb-0 admin-status-list">
<li>
<span class="text-muted">Dernier crawl</span>
<strong>
{% if last_crawl %}
{{ last_crawl.strftime('%d/%m/%Y à %H:%M') }}
{% else %}
Jamais exécuté
{% endif %}
</strong>
</li>
<li>
<span class="text-muted">Clé OpenAI</span>
{% if api_key_set %}
<span class="badge bg-success">Configurée</span>
{% else %}
<span class="badge bg-danger">Manquante</span>
{% endif %}
</li>
<li>
<span class="text-muted">Crawl planifié</span>
<strong>{{ CrawlConfig.get('crawl_hour', '2') }}h{{ CrawlConfig.get('crawl_minute', '00') }} (heure locale)</strong>
</li>
<li>
<span class="text-muted">Articles rejetés</span>
<strong>{{ stats.rejected }}</strong>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,89 @@
{% extends 'base.html' %}
{% block title %}Bibliothèque — Admin OpenCare{% endblock %}
{% block content %}
<div class="admin-layout">
{% include 'admin/_sidebar.html' %}
<div class="admin-main">
<div class="admin-page-header">
<div>
<h1 class="admin-page-title">
<i class="fas fa-book-medical me-2 text-accent"></i>Bibliothèque
</h1>
<p class="text-muted mb-0">{{ recs|length }} recommandation(s) approuvée(s) et active(s)</p>
</div>
</div>
{% if recs %}
{% set current_domain = namespace(val='') %}
{% for rec in recs %}
{% if rec.domain != current_domain.val %}
{% set current_domain.val = rec.domain %}
<div class="admin-domain-divider">
{{ domain_labels.get(rec.domain, (rec.domain, ''))[1] }}
{{ domain_labels.get(rec.domain, (rec.domain, ''))[0] }}
<span class="text-muted ms-1 fw-normal small">
({{ recs|selectattr('domain', 'equalto', rec.domain)|list|length }})
</span>
</div>
{% endif %}
<div class="card border-0 shadow-sm rounded-3 mb-2">
<div class="card-body p-3">
<div class="d-flex align-items-start gap-3">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1 flex-wrap">
<span class="evidence-badge ev-{{ rec.evidence_level }}">Niveau {{ rec.evidence_level }}</span>
<span class="badge bg-light text-muted border small">Priorité {{ rec.priority }}</span>
<span class="text-muted small">{{ rec.reviewed_at.strftime('%d/%m/%Y') if rec.reviewed_at else '—' }}</span>
</div>
<strong>{{ rec.title }}</strong>
<p class="text-muted small mb-1 mt-1">{{ rec.summary }}</p>
{% if rec.source_title %}
<div class="small text-muted">
<i class="fas fa-book me-1"></i>{{ rec.source_title }}
{% if rec.source_doi %} — {{ rec.source_doi }}{% endif %}
</div>
{% endif %}
<!-- Targeting pills -->
<div class="d-flex flex-wrap gap-1 mt-2">
{% if rec.target_sex and rec.target_sex != 'all' %}
<span class="tag-pill">{{ 'Hommes' if rec.target_sex == 'male' else 'Femmes' }}</span>
{% endif %}
{% if rec.target_age_min or rec.target_age_max %}
<span class="tag-pill">{{ rec.target_age_min or '?' }}{{ rec.target_age_max or '∞' }} ans</span>
{% endif %}
{% for c in rec.required_conditions %}
<span class="tag-pill tag-pill-blue">Si: {{ c.replace('_',' ') }}</span>
{% endfor %}
{% for c in rec.excluded_conditions %}
<span class="tag-pill tag-pill-red">Exclu: {{ c.replace('_',' ') }}</span>
{% endfor %}
{% if not rec.required_conditions and not rec.required_health_goals and not rec.required_diet_types and not rec.required_activity_levels %}
<span class="tag-pill tag-pill-green">Universel</span>
{% endif %}
</div>
</div>
<form action="{{ url_for('admin_library_delete', rid=rec.id) }}" method="post"
onsubmit="return confirm('Retirer « {{ rec.title }} » de la bibliothèque ?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Retirer">
<i class="fas fa-archive"></i>
</button>
</form>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="fas fa-book-medical fa-3x mb-3 opacity-25"></i>
<p class="mb-0">Aucune recommandation approuvée. Allez dans la file d'attente pour en valider.</p>
<a href="{{ url_for('admin_review', status='pending') }}" class="btn btn-primary mt-3">
<i class="fas fa-inbox me-1"></i>Aller à la file d'attente
</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

443
templates/admin/review.html Normal file
View File

@@ -0,0 +1,443 @@
{% extends 'base.html' %}
{% block title %}Révision — Admin OpenCare{% endblock %}
{% block content %}
<div class="admin-layout">
{% include 'admin/_sidebar.html' %}
<div class="admin-main">
<div class="admin-page-header">
<div>
<h1 class="admin-page-title">
<i class="fas fa-inbox me-2 text-accent"></i>File de révision
</h1>
<p class="text-muted mb-0">Validez les recommandations extraites par l'IA avant publication</p>
</div>
</div>
<!-- Status filter tabs -->
<ul class="nav nav-pills mb-4 gap-1">
<li class="nav-item">
<a class="nav-link {% if status_filter == 'pending' %}active{% endif %}"
href="{{ url_for('admin_review', status='pending') }}">
En attente <span class="badge bg-warning text-dark ms-1">{{ counts.pending }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'approved' %}active{% endif %}"
href="{{ url_for('admin_review', status='approved') }}">
Approuvées <span class="badge bg-success ms-1">{{ counts.approved }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'rejected' %}active{% endif %}"
href="{{ url_for('admin_review', status='rejected') }}">
Rejetées <span class="badge bg-secondary ms-1">{{ counts.rejected }}</span>
</a>
</li>
</ul>
{% if recs %}
<div class="d-flex flex-column gap-3">
{% for rec in recs %}
<div class="card border-0 shadow-sm rounded-3 review-card">
<div class="card-body p-4">
<div class="d-flex align-items-start gap-3 flex-wrap">
<!-- Left: content -->
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
<span class="badge admin-domain-badge">
{{ domain_labels.get(rec.domain, (rec.domain, ''))[0] }}
</span>
<span class="evidence-badge ev-{{ rec.evidence_level }}">Niveau {{ rec.evidence_level }}</span>
{% if rec.study_type %}
<span class="badge bg-light text-muted border">{{ rec.study_type }}</span>
{% endif %}
<span class="text-muted small ms-auto">{{ rec.created_at.strftime('%d/%m/%Y') }}</span>
</div>
<h5 class="fw-bold mb-1">{{ rec.title }}</h5>
<p class="text-muted mb-2">{{ rec.summary }}</p>
{% if rec.details %}
<p class="small mb-2 text-secondary">{{ rec.details[:200] }}{% if rec.details|length > 200 %}…{% endif %}</p>
{% endif %}
<!-- Source -->
{% if rec.source_url or rec.source_title %}
<div class="source-box mb-2 small">
<i class="fas fa-book me-1"></i>
{% if rec.source_title %}<strong>{{ rec.source_title }}</strong>{% endif %}
{% if rec.source_doi %} — DOI: {{ rec.source_doi }}{% endif %}
{% if rec.source_url %}
<a href="{{ rec.source_url }}" target="_blank" rel="noopener" class="ms-1">
<i class="fas fa-external-link-alt"></i>
</a>
{% endif %}
</div>
{% endif %}
<!-- Targeting summary -->
<div class="d-flex flex-wrap gap-1 mt-2">
{% if rec.target_age_min or rec.target_age_max %}
<span class="tag-pill">
<i class="fas fa-user me-1"></i>
{{ rec.target_age_min or '?' }}{{ rec.target_age_max or '∞' }} ans
</span>
{% endif %}
{% if rec.target_sex and rec.target_sex != 'all' %}
<span class="tag-pill">{{ 'Homme' if rec.target_sex == 'male' else 'Femme' }}</span>
{% endif %}
{% if rec.not_for_under_18 %}
<span class="tag-pill tag-pill-warn">18+</span>
{% endif %}
{% if rec.not_for_pregnant %}
<span class="tag-pill tag-pill-warn">Pas pour grossesse</span>
{% endif %}
{% for c in rec.required_conditions %}
<span class="tag-pill tag-pill-blue">Si: {{ c }}</span>
{% endfor %}
{% for c in rec.excluded_conditions %}
<span class="tag-pill tag-pill-red">Exclu si: {{ c }}</span>
{% endfor %}
{% for g in rec.required_health_goals %}
<span class="tag-pill tag-pill-green">Objectif: {{ g }}</span>
{% endfor %}
</div>
</div>
<!-- Right: action buttons -->
<div class="d-flex flex-column gap-2" style="min-width:140px">
{% if status_filter == 'pending' %}
<button type="button" class="btn btn-primary btn-sm"
onclick="openEditModal({{ rec.to_edit_dict()|tojson }})">
<i class="fas fa-edit me-1"></i>Modifier
</button>
<form action="{{ url_for('admin_review_approve', rid=rec.id) }}" method="post">
<button type="submit" class="btn btn-success btn-sm w-100">
<i class="fas fa-check me-1"></i>Approuver
</button>
</form>
<button type="button" class="btn btn-outline-danger btn-sm"
data-reject-id="{{ rec.id }}"
data-reject-title="{{ rec.title | e }}"
onclick="openRejectModal(this.dataset.rejectId, this.dataset.rejectTitle)">
<i class="fas fa-times me-1"></i>Rejeter
</button>
{% elif status_filter == 'approved' %}
<button type="button" class="btn btn-outline-primary btn-sm"
onclick="openEditModal({{ rec.to_edit_dict()|tojson }})">
<i class="fas fa-edit me-1"></i>Modifier
</button>
{% endif %}
{% if rec.admin_notes %}
<div class="admin-note small text-muted mt-1">
<i class="fas fa-sticky-note me-1"></i>{{ rec.admin_notes }}
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="fas fa-inbox fa-3x mb-3 opacity-25"></i>
<p class="mb-0">
{% if status_filter == 'pending' %}
Aucune recommandation en attente. Lancez un crawl pour en générer.
{% elif status_filter == 'approved' %}
Aucune recommandation approuvée.
{% else %}
Aucune recommandation rejetée.
{% endif %}
</p>
</div>
{% endif %}
</div>
</div>
<!-- ── Edit Modal ───────────────────────────────────────────────────────────── -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header border-0 pb-0">
<h5 class="modal-title fw-bold">
<i class="fas fa-edit me-2 text-accent"></i>Modifier la recommandation
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="editForm" method="post">
<div class="modal-body px-4">
<div class="row g-3">
<!-- Domain & Evidence -->
<div class="col-md-4">
<label class="form-label fw-medium">Domaine</label>
<select name="domain" id="edit_domain" class="form-select">
{% for key, (label, icon) in domain_labels.items() %}
<option value="{{ key }}">{{ icon }} {{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-medium">Niveau de preuve</label>
<select name="evidence_level" id="edit_evidence_level" class="form-select">
<option value="A">A — Méta-analyse / RCT solide</option>
<option value="B">B — Observationnel / RCT limité</option>
<option value="C">C — Expert / Anecdote</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-medium">Type d'étude</label>
<select name="study_type" id="edit_study_type" class="form-select">
<option value="meta-analysis">Méta-analyse</option>
<option value="RCT">RCT</option>
<option value="observational">Observationnel</option>
<option value="expert_opinion">Avis expert</option>
<option value="anecdote">Anecdote</option>
</select>
</div>
<!-- Title -->
<div class="col-12">
<label class="form-label fw-medium">Titre</label>
<input type="text" name="title" id="edit_title" class="form-control" maxlength="256" required>
</div>
<!-- Summary -->
<div class="col-12">
<label class="form-label fw-medium">Résumé</label>
<textarea name="summary" id="edit_summary" class="form-control" rows="2"></textarea>
</div>
<!-- Details -->
<div class="col-12">
<label class="form-label fw-medium">Détails</label>
<textarea name="details" id="edit_details" class="form-control" rows="3"></textarea>
</div>
<!-- Intervention / Effect -->
<div class="col-md-6">
<label class="form-label fw-medium">Intervention concrète</label>
<input type="text" name="intervention" id="edit_intervention" class="form-control">
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Effet attendu</label>
<input type="text" name="effect" id="edit_effect" class="form-control">
</div>
<!-- Source -->
<div class="col-md-6">
<label class="form-label fw-medium">Titre de la source</label>
<input type="text" name="source_title" id="edit_source_title" class="form-control">
</div>
<div class="col-md-3">
<label class="form-label fw-medium">DOI</label>
<input type="text" name="source_doi" id="edit_source_doi" class="form-control" placeholder="10.xxx/...">
</div>
<div class="col-md-3">
<label class="form-label fw-medium">Priorité (110)</label>
<input type="number" name="priority" id="edit_priority" class="form-control" min="1" max="10">
</div>
<div class="col-12">
<label class="form-label fw-medium">URL source</label>
<input type="url" name="source_url" id="edit_source_url" class="form-control">
</div>
<div class="col-12"><hr class="my-1"></div>
<!-- Targeting -->
<div class="col-md-3">
<label class="form-label fw-medium">Sexe cible</label>
<select name="target_sex" id="edit_target_sex" class="form-select">
<option value="all">Tous</option>
<option value="male">Hommes</option>
<option value="female">Femmes</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label fw-medium">Âge min</label>
<input type="number" name="target_age_min" id="edit_target_age_min" class="form-control" placeholder="—">
</div>
<div class="col-md-2">
<label class="form-label fw-medium">Âge max</label>
<input type="number" name="target_age_max" id="edit_target_age_max" class="form-control" placeholder="—">
</div>
<div class="col-md-5 d-flex align-items-end gap-3 pb-1">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="not_for_under_18" id="edit_under18">
<label class="form-check-label" for="edit_under18">Déconseillé -18 ans</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="not_for_pregnant" id="edit_pregnant">
<label class="form-check-label" for="edit_pregnant">Déconseillé grossesse</label>
</div>
</div>
<!-- Conditions -->
<div class="col-md-6">
<label class="form-label fw-medium">Déclenché si l'utilisateur a une de ces conditions</label>
<div class="d-flex flex-wrap gap-1" id="edit_required_conditions">
{% for c in ['hypertension','diabetes_risk','cardiovascular_risk','anxiety','digestive_issues','back_pain','osteoporosis_risk','metabolic_syndrome','thyroid','sleep_apnea'] %}
<label class="admin-check-pill">
<input type="checkbox" name="required_conditions" value="{{ c }}"> {{ c.replace('_', ' ') }}
</label>
{% endfor %}
</div>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Ne pas afficher si l'utilisateur a</label>
<div class="d-flex flex-wrap gap-1" id="edit_excluded_conditions">
{% for c in ['hypertension','diabetes_risk','cardiovascular_risk','anxiety','digestive_issues','back_pain','osteoporosis_risk','metabolic_syndrome','thyroid','sleep_apnea'] %}
<label class="admin-check-pill">
<input type="checkbox" name="excluded_conditions" value="{{ c }}"> {{ c.replace('_', ' ') }}
</label>
{% endfor %}
</div>
</div>
<!-- Goals -->
<div class="col-md-6">
<label class="form-label fw-medium">Objectifs de santé associés</label>
<div class="d-flex flex-wrap gap-1">
{% for g in ['weight_loss','muscle_gain','longevity','energy','better_sleep','stress_reduction','metabolic_health','cardiovascular_health','cognitive_health'] %}
<label class="admin-check-pill">
<input type="checkbox" name="required_health_goals" value="{{ g }}" class="edit_goals"> {{ g.replace('_', ' ') }}
</label>
{% endfor %}
</div>
</div>
<!-- Diet & Activity -->
<div class="col-md-3">
<label class="form-label fw-medium">Types de régime ciblés</label>
<div class="d-flex flex-wrap gap-1">
{% for d in ['omnivore','flexitarian','vegetarian','vegan','paleo','keto','other'] %}
<label class="admin-check-pill">
<input type="checkbox" name="required_diet_types" value="{{ d }}" class="edit_diets"> {{ d }}
</label>
{% endfor %}
</div>
</div>
<div class="col-md-3">
<label class="form-label fw-medium">Niveaux d'activité ciblés</label>
<div class="d-flex flex-wrap gap-1">
{% for a in ['sedentary','light','moderate','intense','athlete'] %}
<label class="admin-check-pill">
<input type="checkbox" name="required_activity_levels" value="{{ a }}" class="edit_activity"> {{ a }}
</label>
{% endfor %}
</div>
</div>
<!-- Notes -->
<div class="col-12">
<label class="form-label fw-medium">Notes admin (privées)</label>
<input type="text" name="admin_notes" id="edit_admin_notes" class="form-control" placeholder="Remarques internes…">
</div>
</div>
</div>
<div class="modal-footer border-0 pt-0 gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="submit" name="action" value="save" class="btn btn-outline-primary">
<i class="fas fa-save me-1"></i>Enregistrer
</button>
<button type="submit" name="action" value="approve" class="btn btn-success">
<i class="fas fa-check me-1"></i>Enregistrer & Approuver
</button>
</div>
</form>
</div>
</div>
</div>
<!-- ── Reject Modal ─────────────────────────────────────────────────────────── -->
<div class="modal fade" id="rejectModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header border-0">
<h5 class="modal-title fw-bold">
<i class="fas fa-times-circle me-2 text-danger"></i>Rejeter la recommandation
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="rejectForm" method="post">
<div class="modal-body">
<p class="text-muted mb-3" id="rejectTitle"></p>
<label class="form-label">Motif (optionnel)</label>
<textarea name="notes" class="form-control" rows="2" placeholder="Sources insuffisantes, hors sujet…"></textarea>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="submit" class="btn btn-danger">
<i class="fas fa-times me-1"></i>Confirmer le rejet
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function openEditModal(rec) {
const f = document.getElementById('editForm');
f.action = '/admin/review/' + rec.id + '/update';
document.getElementById('edit_domain').value = rec.domain || 'other';
document.getElementById('edit_evidence_level').value = rec.evidence_level || 'C';
document.getElementById('edit_study_type').value = rec.study_type || 'observational';
document.getElementById('edit_title').value = rec.title || '';
document.getElementById('edit_summary').value = rec.summary || '';
document.getElementById('edit_details').value = rec.details || '';
document.getElementById('edit_intervention').value = rec.intervention || '';
document.getElementById('edit_effect').value = rec.effect || '';
document.getElementById('edit_source_title').value = rec.source_title || '';
document.getElementById('edit_source_doi').value = rec.source_doi || '';
document.getElementById('edit_source_url').value = rec.source_url || '';
document.getElementById('edit_priority').value = rec.priority || 5;
document.getElementById('edit_target_sex').value = rec.target_sex || 'all';
document.getElementById('edit_target_age_min').value = rec.target_age_min || '';
document.getElementById('edit_target_age_max').value = rec.target_age_max || '';
document.getElementById('edit_under18').checked = !!rec.not_for_under_18;
document.getElementById('edit_pregnant').checked = !!rec.not_for_pregnant;
document.getElementById('edit_admin_notes').value = rec.admin_notes || '';
// Reset all checkboxes first
f.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = false);
// Re-check required_conditions
(rec._required_conditions ? JSON.parse(rec._required_conditions) : []).forEach(v => {
const cb = f.querySelector(`input[name=required_conditions][value="${v}"]`);
if (cb) cb.checked = true;
});
(rec._excluded_conditions ? JSON.parse(rec._excluded_conditions) : []).forEach(v => {
const cb = f.querySelector(`input[name=excluded_conditions][value="${v}"]`);
if (cb) cb.checked = true;
});
(rec._required_health_goals ? JSON.parse(rec._required_health_goals) : []).forEach(v => {
const cb = f.querySelector(`input[name=required_health_goals][value="${v}"]`);
if (cb) cb.checked = true;
});
(rec._required_diet_types ? JSON.parse(rec._required_diet_types) : []).forEach(v => {
const cb = f.querySelector(`input[name=required_diet_types][value="${v}"]`);
if (cb) cb.checked = true;
});
(rec._required_activity_levels ? JSON.parse(rec._required_activity_levels) : []).forEach(v => {
const cb = f.querySelector(`input[name=required_activity_levels][value="${v}"]`);
if (cb) cb.checked = true;
});
new bootstrap.Modal(document.getElementById('editModal')).show();
}
function openRejectModal(id, title) {
document.getElementById('rejectForm').action = '/admin/review/' + id + '/reject';
document.getElementById('rejectTitle').textContent = '« ' + title + ' »';
new bootstrap.Modal(document.getElementById('rejectModal')).show();
}
</script>
{% endblock %}

View File

@@ -0,0 +1,313 @@
{% extends 'base.html' %}
{% block title %}Sources — Admin OpenCare{% endblock %}
{% block content %}
<div class="admin-layout">
{% include 'admin/_sidebar.html' %}
<div class="admin-main">
<div class="admin-page-header">
<div>
<h1 class="admin-page-title">
<i class="fas fa-rss me-2 text-accent"></i>Sources de crawl
</h1>
<p class="text-muted mb-0">Gérez les URLs surveillées quotidiennement</p>
</div>
<button type="button" class="btn btn-outline-primary btn-sm" onclick="triggerCrawl(null)">
<i class="fas fa-sync me-1"></i>Crawler toutes les sources
</button>
</div>
<!-- ── Crawl log panel ─────────────────────────────────────────── -->
<div id="crawlStatusPanel" class="card border-0 shadow-sm rounded-3 mb-4" style="display:none">
<div class="card-body p-4">
<div class="d-flex align-items-center gap-3 mb-3">
<div id="crawlStatusIcon" class="admin-stat-icon" style="background:#eff6ff;color:#2563eb">
<i class="fas fa-sync fa-spin"></i>
</div>
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap">
<strong id="crawlStatusTitle">Crawl en cours…</strong>
<span id="crawlStatusBadge" class="badge bg-primary">En cours</span>
</div>
<div class="text-muted small" id="crawlStatusMeta"></div>
</div>
<div id="crawlStatsCounts" class="d-flex gap-3 text-center"></div>
</div>
<div id="crawlLog" class="crawl-log-box">
<span class="text-muted" style="font-size:.78rem;font-style:italic">En attente de logs…</span>
</div>
</div>
</div>
<!-- ── Add source form ─────────────────────────────────────────── -->
<div class="card border-0 shadow-sm rounded-3 mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3"><i class="fas fa-plus-circle me-2 text-accent"></i>Ajouter une source</h5>
<form method="post" action="{{ url_for('admin_sources') }}">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label fw-medium">Nom</label>
<input type="text" name="name" class="form-control" placeholder="Examine.com Nutrition" required>
</div>
<div class="col-md-4">
<label class="form-label fw-medium">URL</label>
<input type="url" name="url" class="form-control" placeholder="https://..." required>
</div>
<div class="col-md-2">
<label class="form-label fw-medium">Type</label>
<select name="source_type" class="form-select">
<option value="rss">Flux RSS</option>
<option value="index">Index (liens)</option>
<option value="webpage">Page web</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label fw-medium">Catégorie</label>
<select name="category" class="form-select">
<option value="advice">Conseils</option>
<option value="scientific">Scientifique</option>
</select>
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-primary w-100"><i class="fas fa-plus"></i></button>
</div>
</div>
</form>
</div>
</div>
<!-- ── Sources list ────────────────────────────────────────────── -->
{% if sources %}
<div class="card border-0 shadow-sm rounded-3">
<div class="table-responsive">
<table class="table table-hover mb-0 admin-table">
<thead>
<tr>
<th>Nom</th>
<th>URL</th>
<th>Type</th>
<th>Catégorie</th>
<th>Dernier crawl</th>
<th>Articles</th>
<th>Statut</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for src in sources %}
<tr class="{% if not src.active %}table-secondary opacity-60{% endif %}">
<td class="fw-medium">{{ src.name }}</td>
<td>
<a href="{{ src.url }}" target="_blank" rel="noopener"
class="text-muted small text-truncate d-inline-block" style="max-width:260px">
{{ src.url }}<i class="fas fa-external-link-alt ms-1 opacity-50" style="font-size:.65rem"></i>
</a>
</td>
<td>
{% if src.source_type == 'rss' %}
<span class="badge bg-info text-dark"><i class="fas fa-rss me-1"></i>RSS</span>
{% elif src.source_type == 'index' %}
<span class="badge bg-warning text-dark"><i class="fas fa-sitemap me-1"></i>Index</span>
{% else %}
<span class="badge bg-secondary"><i class="fas fa-globe me-1"></i>Webpage</span>
{% endif %}
</td>
<td>
{% if src.category == 'scientific' %}
<span class="badge bg-primary"><i class="fas fa-flask me-1"></i>Scientifique</span>
{% else %}
<span class="badge bg-success"><i class="fas fa-lightbulb me-1"></i>Conseils</span>
{% endif %}
</td>
<td class="text-muted small" id="lastCrawl_{{ src.id }}">
{{ src.last_crawled_at.strftime('%d/%m/%Y %H:%M') if src.last_crawled_at else '—' }}
</td>
<td class="text-muted">{{ src.articles_found or 0 }}</td>
<td>
{% if src.active %}
<span class="badge bg-success">Actif</span>
{% else %}
<span class="badge bg-secondary">Inactif</span>
{% endif %}
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<!-- AJAX crawl button -->
<button type="button" class="btn btn-sm btn-outline-secondary"
title="Crawler maintenant" onclick="triggerCrawl({{ src.id }})">
<i class="fas fa-sync"></i>
</button>
<form action="{{ url_for('admin_source_toggle', sid=src.id) }}" method="post" class="d-inline">
<button type="submit"
class="btn btn-sm {% if src.active %}btn-outline-warning{% else %}btn-outline-success{% endif %}"
title="{% if src.active %}Désactiver{% else %}Activer{% endif %}">
<i class="fas fa-{% if src.active %}pause{% else %}play{% endif %}"></i>
</button>
</form>
<form action="{{ url_for('admin_source_delete', sid=src.id) }}" method="post" class="d-inline"
onsubmit="return confirm('Supprimer « {{ src.name }} » ?')">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Supprimer">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="fas fa-rss fa-3x mb-3 opacity-25"></i>
<p class="mb-0">Aucune source configurée. Ajoutez une URL ci-dessus.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const CRAWL_RUN_URL = '{{ url_for("admin_crawl_run") }}';
const CRAWL_STAT_URL = '{{ url_for("admin_crawl_status") }}';
const panel = document.getElementById('crawlStatusPanel');
const logBox = document.getElementById('crawlLog');
const icon = document.getElementById('crawlStatusIcon');
const ttl = document.getElementById('crawlStatusTitle');
const badge = document.getElementById('crawlStatusBadge');
const meta = document.getElementById('crawlStatusMeta');
const counts = document.getElementById('crawlStatsCounts');
let pollTimer = null;
let lastLogLen = 0;
let triggeredAt = 0; // timestamp of last crawl trigger
// ── Trigger crawl via AJAX (no page reload) ───────────────────────
async function triggerCrawl(sourceId) {
triggeredAt = Date.now();
lastLogLen = 0;
logBox.innerHTML = '<span style="color:#64748b;font-style:italic">Démarrage du crawl…</span>';
showRunning('Démarrage…');
panel.style.display = 'block';
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const body = new FormData();
if (sourceId) body.append('source_id', sourceId);
try {
await fetch(CRAWL_RUN_URL, {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body,
});
} catch(e) {
appendLog('❌ Impossible de contacter le serveur : ' + e, 'log-err');
return;
}
clearTimeout(pollTimer);
schedulePoll();
}
// ── Polling ───────────────────────────────────────────────────────
function schedulePoll() {
// Grace window: poll every 400ms for 15s after trigger (thread may not have started yet)
const inGrace = (Date.now() - triggeredAt) < 15000;
const delay = inGrace ? 400 : 1500;
pollTimer = setTimeout(poll, delay);
}
async function poll() {
try {
const r = await fetch(CRAWL_STAT_URL);
const data = await r.json();
renderStatus(data);
const inGrace = (Date.now() - triggeredAt) < 15000;
if (data.status === 'running' || (data.status === 'idle' && inGrace)) {
schedulePoll();
} else if (data.status !== 'idle') {
// done or error — one final render, stop polling
pollTimer = null;
} else {
pollTimer = null;
}
} catch(e) {
pollTimer = setTimeout(poll, 3000);
}
}
// ── Render helpers ────────────────────────────────────────────────
function showRunning(text) {
icon.innerHTML = '<i class="fas fa-sync fa-spin"></i>';
icon.style.cssText = 'background:#eff6ff;color:#2563eb';
ttl.textContent = text;
badge.textContent = 'En cours'; badge.className = 'badge bg-primary';
}
function renderStatus(data) {
if (!data || data.status === 'idle') return;
panel.style.display = 'block';
if (data.status === 'running') {
showRunning('Crawl en cours…');
} else if (data.status === 'done') {
icon.innerHTML = '<i class="fas fa-check-circle"></i>';
icon.style.cssText = 'background:#f0fdf4;color:#166534';
ttl.textContent = 'Crawl terminé';
badge.textContent = 'Terminé'; badge.className = 'badge bg-success';
} else {
icon.innerHTML = '<i class="fas fa-exclamation-triangle"></i>';
icon.style.cssText = 'background:#fef2f2;color:#991b1b';
ttl.textContent = 'Erreur lors du crawl';
badge.textContent = 'Erreur'; badge.className = 'badge bg-danger';
}
let metaTxt = '';
if (data.started_at) metaTxt = 'Démarré à ' + data.started_at;
if (data.ended_at) metaTxt += ' · Terminé à ' + data.ended_at;
meta.textContent = metaTxt;
if (data.articles_processed > 0 || data.status !== 'running') {
counts.innerHTML =
`<div><div class="fw-bold fs-5">${data.articles_processed}</div><div class="small text-muted">articles</div></div>` +
`<div><div class="fw-bold fs-5">${data.recs_generated}</div><div class="small text-muted">recs</div></div>`;
}
// Append only new log lines
const lines = data.log || [];
if (lines.length > lastLogLen) {
if (lastLogLen === 0) logBox.innerHTML = '';
lines.slice(lastLogLen).forEach(line => appendLog(line));
lastLogLen = lines.length;
logBox.scrollTop = logBox.scrollHeight;
}
}
function appendLog(line, extraClass) {
const div = document.createElement('div');
div.className = 'crawl-log-line';
if (extraClass) div.classList.add(extraClass);
else if (/✅|✓|🏁/.test(line)) div.classList.add('log-ok');
else if (/⚠|❌|💥/.test(line)) div.classList.add('log-err');
else if (/🤖/.test(line)) div.classList.add('log-ai');
div.textContent = line;
logBox.appendChild(div);
}
// ── On page load: restore last run if any ────────────────────────
(async () => {
const r = await fetch(CRAWL_STAT_URL);
const data = await r.json();
if (data.status && data.status !== 'idle') {
renderStatus(data);
if (data.status === 'running') poll();
}
})();
</script>
{% endblock %}

88
templates/base.html Normal file
View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}OpenCare{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css" rel="stylesheet">
<link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark oc-nav">
<div class="container">
<a class="navbar-brand fw-bold fs-4" href="{{ url_for('index') }}">
<i class="fas fa-heartbeat me-2 text-accent"></i>Open<span class="text-accent">Care</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="nav">
<ul class="navbar-nav ms-auto align-items-lg-center gap-1">
{% if current_user.is_authenticated %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('dashboard') }}">
<i class="fas fa-chart-line me-1"></i>Tableau de bord
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('profile') }}">
<i class="fas fa-user-edit me-1"></i>Mon profil
</a>
</li>
{% if current_user.is_admin %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin_index') }}">
<i class="fas fa-shield-alt me-1"></i>Admin
</a>
</li>
{% endif %}
<li class="nav-item ms-2">
<a class="nav-link btn btn-outline-light btn-sm px-3" href="{{ url_for('logout') }}">
<i class="fas fa-sign-out-alt me-1"></i>Déconnexion
</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('login') }}">Connexion</a>
</li>
<li class="nav-item ms-2">
<a class="nav-link btn btn-accent btn-sm px-3" href="{{ url_for('register') }}">
Créer un compte
</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<main>
<div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for cat, msg in messages %}
<div class="alert alert-{{ cat }} alert-dismissible fade show rounded-3 shadow-sm" role="alert">
<i class="fas fa-{% if cat == 'success' %}check-circle{% elif cat == 'danger' %}exclamation-circle{% elif cat == 'warning' %}exclamation-triangle{% else %}info-circle{% endif %} me-2"></i>
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
</div>
{% block content %}{% endblock %}
</main>
<footer class="oc-footer mt-5 py-4">
<div class="container text-center">
<p class="mb-1 fw-semibold"><i class="fas fa-heartbeat me-2 text-accent"></i>OpenCare — Santé personnalisée basée sur les preuves</p>
<small class="text-muted">Les informations sont à titre éducatif uniquement et ne remplacent pas un avis médical professionnel.</small>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

183
templates/dashboard.html Normal file
View File

@@ -0,0 +1,183 @@
{% extends "base.html" %}
{% block title %}Tableau de bord — OpenCare{% endblock %}
{% block content %}
<div class="container py-4">
<!-- Header -->
<div class="row align-items-center mb-4">
<div class="col">
<h1 class="fw-bold h3 mb-1">
<i class="fas fa-chart-line text-primary me-2"></i>
Bonjour {% if profile.first_name %}<span class="text-primary">{{ profile.first_name }}</span>{% else %}!{% endif %}
</h1>
<p class="text-muted mb-0">{{ total }} recommandations personnalisées pour votre profil</p>
</div>
<div class="col-auto">
<a href="{{ url_for('profile') }}" class="btn btn-outline-primary">
<i class="fas fa-user-edit me-2"></i>Modifier le profil
</a>
</div>
</div>
<!-- Profile summary bar -->
<div class="profile-summary-bar card border-0 shadow-sm mb-4">
<div class="card-body py-3 px-4">
<div class="row align-items-center g-3">
<div class="col-md-3">
<div class="d-flex align-items-center gap-3">
<div class="avatar-circle">
{{ (profile.first_name or 'U')[0].upper() }}
</div>
<div>
<div class="fw-semibold">{{ profile.first_name or '' }} {{ profile.last_name or '' }}</div>
<div class="text-muted small">{{ current_user.email }}</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="d-flex flex-wrap gap-2">
{% if profile.age %}
<span class="profile-badge"><i class="fas fa-birthday-cake me-1"></i>{{ profile.age }} ans</span>
{% endif %}
{% if profile.bmi %}
<span class="profile-badge profile-badge-{{ profile.bmi_category[1] }}">
<i class="fas fa-weight me-1"></i>IMC {{ profile.bmi }} — {{ profile.bmi_category[0] }}
</span>
{% endif %}
{% if profile.blood_type %}
<span class="profile-badge"><i class="fas fa-tint me-1"></i>Groupe {{ profile.blood_type }}</span>
{% endif %}
{% if profile.diet_type %}
<span class="profile-badge">
{% set diet_icons = {'omnivore':'🥩','flexitarian':'🥗','vegetarian':'🌿','vegan':'🌱','keto':'🥑','paleo':'🍖','mediterranean':'🫒'} %}
{{ diet_icons.get(profile.diet_type, '🍽️') }}
{{ {'omnivore':'Omnivore','flexitarian':'Flexitarien','vegetarian':'Végétarien','vegan':'Vegan','keto':'Keto','paleo':'Paléo','mediterranean':'Méditerranéen'}.get(profile.diet_type, profile.diet_type) }}
</span>
{% endif %}
{% if profile.activity_level %}
<span class="profile-badge">
{% set act_icons = {'sedentary':'🪑','light':'🚶','moderate':'🚴','intense':'🏋️','athlete':'🏆'} %}
{{ act_icons.get(profile.activity_level, '🏃') }}
{{ {'sedentary':'Sédentaire','light':'Actif léger','moderate':'Actif modéré','intense':'Très actif','athlete':'Athlète'}.get(profile.activity_level, profile.activity_level) }}
</span>
{% endif %}
</div>
</div>
<div class="col-md-3">
<div class="completeness-wrap">
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Profil complété</span>
<span class="fw-semibold text-primary">{{ completeness }}%</span>
</div>
<div class="progress completeness-bar">
<div class="progress-bar bg-primary" style="width: {{ completeness }}%"></div>
</div>
{% if completeness < 70 %}
<a href="{{ url_for('profile') }}" class="small text-primary mt-1 d-inline-block">
<i class="fas fa-plus me-1"></i>Compléter pour plus de recommandations
</a>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Legend -->
<div class="d-flex flex-wrap gap-3 mb-4 align-items-center">
<span class="text-muted small fw-semibold">Niveau de preuve :</span>
<span class="evidence-badge ev-A">A — Fort (méta-analyse / RCT)</span>
<span class="evidence-badge ev-B">B — Modéré (observationnel)</span>
<span class="evidence-badge ev-C">C — Expert consensus</span>
</div>
<!-- Recommendation domains -->
{% for domain_key, (domain_label, domain_icon), recs in ordered_domains %}
<div class="domain-section mb-5">
<div class="domain-header d-flex align-items-center mb-3">
<span class="domain-icon-lg me-3">{{ domain_icon }}</span>
<div>
<h4 class="fw-bold mb-0">{{ domain_label }}</h4>
<span class="text-muted small">{{ recs|length }} recommandation{{ 's' if recs|length > 1 else '' }}</span>
</div>
</div>
<div class="row g-3">
{% for rec in recs %}
<div class="col-md-6 col-lg-4">
<div class="rec-card card h-100 border-0 shadow-sm" data-bs-toggle="modal" data-bs-target="#modal-{{ rec.id }}">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<span class="rec-icon">{{ rec.icon }}</span>
<span class="evidence-badge ev-{{ rec.evidence_level }}">{{ rec.evidence_level }}</span>
</div>
<h6 class="fw-bold mb-2">{{ rec.title }}</h6>
<p class="text-muted small mb-3 rec-summary">{{ rec.summary }}</p>
<div class="d-flex flex-wrap gap-1">
{% for tag in rec.tags[:3] %}
<span class="tag-pill">{{ tag }}</span>
{% endfor %}
</div>
</div>
<div class="card-footer bg-transparent border-0 pt-0 px-3 pb-3">
<button class="btn btn-sm btn-outline-primary w-100">
<i class="fas fa-book-open me-1"></i>Voir les détails
</button>
</div>
</div>
</div>
<!-- Modal for this rec -->
<div class="modal fade" id="modal-{{ rec.id }}" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center gap-3">
<span class="rec-icon-lg">{{ rec.icon }}</span>
<div>
<h5 class="modal-title fw-bold mb-1">{{ rec.title }}</h5>
<span class="evidence-badge ev-{{ rec.evidence_level }} me-2">Niveau {{ rec.evidence_level }}</span>
<span class="badge bg-light text-dark">{{ rec.domain_label }}</span>
</div>
</div>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pt-3">
<div class="rec-detail-box mb-3">
<p class="mb-0">{{ rec.details }}</p>
</div>
<div class="source-box">
<i class="fas fa-graduation-cap text-primary me-2"></i>
<strong>Source :</strong> {{ rec.source }}
</div>
<div class="mt-3 d-flex flex-wrap gap-2">
{% for tag in rec.tags %}
<span class="tag-pill tag-pill-lg">{{ tag }}</span>
{% endfor %}
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Fermer</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% if not ordered_domains %}
<div class="empty-state text-center py-5">
<div class="empty-icon mb-4">🔍</div>
<h4 class="fw-bold">Aucune recommandation pour l'instant</h4>
<p class="text-muted mb-4">Complétez votre profil de santé pour recevoir des conseils personnalisés.</p>
<a href="{{ url_for('profile') }}" class="btn btn-primary btn-lg px-5">
<i class="fas fa-user-edit me-2"></i>Compléter mon profil
</a>
</div>
{% endif %}
</div>
{% endblock %}

136
templates/index.html Normal file
View File

@@ -0,0 +1,136 @@
{% extends "base.html" %}
{% block title %}OpenCare — Santé personnalisée basée sur les preuves{% endblock %}
{% block content %}
<section class="hero-section py-5">
<div class="container py-3">
<div class="row align-items-center gy-5">
<div class="col-lg-6">
<span class="badge rounded-pill bg-accent-light text-accent mb-3 px-3 py-2">
<i class="fas fa-microscope me-1"></i>Evidence-based medicine
</span>
<h1 class="display-4 fw-bold lh-sm mb-4">
Votre santé,<br><span class="text-gradient">vraiment personnalisée</span>
</h1>
<p class="lead text-muted mb-4">
OpenCare synthétise des milliers d'études scientifiques et les traduit en recommandations concrètes, adaptées à <strong>votre profil unique</strong> : âge, génétique, mode de vie et objectifs.
</p>
<div class="d-flex flex-wrap gap-3">
<a href="{{ url_for('register') }}" class="btn btn-primary btn-lg px-4 shadow">
<i class="fas fa-rocket me-2"></i>Commencer gratuitement
</a>
<a href="#domains" class="btn btn-outline-secondary btn-lg px-4">
Découvrir les domaines
</a>
</div>
<div class="mt-4 d-flex flex-wrap gap-4 text-muted small">
<span><i class="fas fa-lock me-1 text-success"></i>Données locales</span>
<span><i class="fas fa-flask me-1 text-primary"></i>Sources PubMed</span>
<span><i class="fas fa-user-md me-1 text-warning"></i>Pas de diagnostic</span>
</div>
</div>
<div class="col-lg-6">
<div class="hero-visual d-flex flex-wrap gap-3 justify-content-center">
{% for item in [
('🧬', 'Génétique & origines'),
('🥗', 'Nutrition ciblée'),
('⏱️', 'Jeûne intermittent'),
('💪', 'Exercice optimal'),
('😴', 'Sommeil réparateur'),
('🦠', 'Microbiote'),
] %}
<div class="hero-pill">
<span class="hero-pill-icon">{{ item[0] }}</span>
<span class="hero-pill-text">{{ item[1] }}</span>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</section>
<section class="stats-bar py-4 bg-white border-top border-bottom">
<div class="container">
<div class="row text-center g-4">
<div class="col-4">
<div class="stat-item">
<div class="stat-num text-primary">25+</div>
<div class="stat-lbl">Recommandations</div>
</div>
</div>
<div class="col-4">
<div class="stat-item">
<div class="stat-num text-primary">6</div>
<div class="stat-lbl">Domaines couverts</div>
</div>
</div>
<div class="col-4">
<div class="stat-item">
<div class="stat-num text-primary">100%</div>
<div class="stat-lbl">Sources citées</div>
</div>
</div>
</div>
</div>
</section>
<section id="domains" class="py-5">
<div class="container">
<div class="text-center mb-5">
<h2 class="fw-bold">Domaines de santé couverts</h2>
<p class="text-muted">Chaque recommandation est filtrée selon votre profil et sourcée scientifiquement.</p>
</div>
<div class="row g-4">
{% for icon, title, desc, color in [
('🥗', 'Alimentation', 'Régimes, macronutriments, aliments fonctionnels, intolérances alimentaires.', 'green'),
('⏱️', 'Jeûne intermittent', 'Protocoles 14/10, 16/8, 5:2 — adaptés à votre activité et objectifs.', 'teal'),
('💪', 'Exercice', 'Cardio zone 2, musculation, HIIT, mobilité — selon votre niveau actuel.', 'blue'),
('😴', 'Sommeil', 'Durée, hygiène du sommeil, chronotype, gestion du stress nocturne.', 'indigo'),
('💚', 'Gestion du stress', 'Cohérence cardiaque, méditation MBSR, techniques validées cliniquement.', 'emerald'),
('💊', 'Suppléments', 'Vitamine D, oméga-3, magnésium, probiotiques — selon vos déficits réels.', 'amber'),
] %}
<div class="col-md-4">
<div class="domain-card card h-100 border-0 shadow-sm">
<div class="card-body p-4">
<div class="domain-icon mb-3">{{ icon }}</div>
<h5 class="fw-bold mb-2">{{ title }}</h5>
<p class="text-muted small mb-0">{{ desc }}</p>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</section>
<section class="how-it-works py-5 bg-light">
<div class="container">
<div class="text-center mb-5">
<h2 class="fw-bold">Comment ça fonctionne</h2>
</div>
<div class="row g-4 text-center">
{% for n, icon, title, desc in [
(1, 'fas fa-user-plus', 'Créez votre compte', 'Inscription simple et rapide, données stockées localement.'),
(2, 'fas fa-clipboard-list', 'Remplissez votre profil', 'Âge, poids, antécédents, mode de vie, objectifs — 7 sections.'),
(3, 'fas fa-brain', 'Recevez vos recommandations', 'Le moteur croise vos données avec les publications scientifiques.'),
] %}
<div class="col-md-4">
<div class="step-card">
<div class="step-num">{{ n }}</div>
<i class="{{ icon }} fa-2x text-primary mb-3"></i>
<h5 class="fw-bold">{{ title }}</h5>
<p class="text-muted small">{{ desc }}</p>
</div>
</div>
{% endfor %}
</div>
<div class="text-center mt-5">
<a href="{{ url_for('register') }}" class="btn btn-primary btn-lg px-5">
<i class="fas fa-arrow-right me-2"></i>Commencer maintenant
</a>
</div>
</div>
</section>
{% endblock %}

51
templates/login.html Normal file
View File

@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Connexion — OpenCare{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<div class="auth-card card border-0 shadow">
<div class="card-body p-4 p-md-5">
<div class="text-center mb-4">
<div class="auth-icon-wrap mb-3">
<i class="fas fa-heartbeat text-primary fa-2x"></i>
</div>
<h2 class="h4 fw-bold">Connexion</h2>
<p class="text-muted small">Accédez à vos recommandations personnalisées</p>
</div>
<form method="POST" novalidate>
<div class="mb-3">
<label class="form-label fw-semibold">Email</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="fas fa-envelope text-muted"></i>
</span>
<input type="email" name="email" class="form-control border-start-0 ps-0"
placeholder="votre@email.com" required autocomplete="email">
</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Mot de passe</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="fas fa-lock text-muted"></i>
</span>
<input type="password" name="password" class="form-control border-start-0 ps-0"
placeholder="••••••••" required autocomplete="current-password">
</div>
</div>
<button type="submit" class="btn btn-primary w-100 btn-lg fw-semibold">
<i class="fas fa-sign-in-alt me-2"></i>Se connecter
</button>
</form>
<hr class="my-4">
<p class="text-center text-muted small mb-0">
Pas encore de compte ?
<a href="{{ url_for('register') }}" class="fw-semibold">Créer un compte</a>
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

438
templates/profile.html Normal file
View File

@@ -0,0 +1,438 @@
{% extends "base.html" %}
{% block title %}Mon profil de santé — OpenCare{% endblock %}
{% block content %}
<div class="container py-4">
<div class="row mb-4">
<div class="col">
<h1 class="fw-bold h3 mb-1"><i class="fas fa-user-edit text-primary me-2"></i>Mon profil de santé</h1>
<p class="text-muted">Plus votre profil est complet, plus les recommandations seront précises.</p>
</div>
</div>
<form method="POST" id="profileForm">
<!-- Tab Navigation -->
<ul class="nav nav-pills profile-tabs mb-4 flex-wrap gap-1" id="profileTabs" role="tablist">
{% for tab_id, icon, label in [
('identite', 'fas fa-id-card', 'Identité'),
('origine', 'fas fa-globe', 'Origine & Biologie'),
('sante', 'fas fa-notes-medical', 'Antécédents'),
('lifestyle', 'fas fa-sun', 'Mode de vie'),
('alimentation', 'fas fa-utensils', 'Alimentation'),
('activite', 'fas fa-running', 'Activité physique'),
('objectifs', 'fas fa-bullseye', 'Objectifs'),
] %}
<li class="nav-item" role="presentation">
<button class="nav-link {% if loop.first %}active{% endif %} profile-tab-btn"
id="tab-{{ tab_id }}" data-bs-toggle="tab"
data-bs-target="#pane-{{ tab_id }}" type="button">
<i class="{{ icon }} me-1"></i>{{ label }}
</button>
</li>
{% endfor %}
</ul>
<div class="tab-content profile-tab-content">
<!-- ① IDENTITÉ -->
<div class="tab-pane fade show active" id="pane-identite">
<div class="profile-section card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="section-title"><i class="fas fa-id-card me-2 text-primary"></i>Informations personnelles</h5>
<div class="row g-3 mt-1">
<div class="col-md-6">
<label class="form-label fw-semibold">Prénom</label>
<input type="text" name="first_name" class="form-control"
value="{{ profile.first_name or '' }}" placeholder="Votre prénom">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Nom</label>
<input type="text" name="last_name" class="form-control"
value="{{ profile.last_name or '' }}" placeholder="Votre nom">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Année de naissance</label>
<input type="number" name="birth_year" class="form-control"
min="1920" max="2015"
value="{{ profile.birth_year or '' }}" placeholder="Ex: 1985">
{% if profile.age %}
<div class="form-text text-success"><i class="fas fa-check me-1"></i>{{ profile.age }} ans</div>
{% endif %}
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Sexe biologique</label>
<select name="sex" class="form-select">
<option value="">— Sélectionner —</option>
{% for val, lbl in [('male','Homme'),('female','Femme'),('other','Autre / Non binaire')] %}
<option value="{{ val }}" {% if profile.sex == val %}selected{% endif %}>{{ lbl }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Taille (cm)</label>
<input type="number" name="height_cm" class="form-control"
min="100" max="250" step="0.5"
value="{{ profile.height_cm or '' }}" placeholder="Ex: 175"
id="heightInput">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Poids (kg)</label>
<input type="number" name="weight_kg" class="form-control"
min="30" max="300" step="0.1"
value="{{ profile.weight_kg or '' }}" placeholder="Ex: 70"
id="weightInput">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">IMC calculé</label>
<div class="form-control bg-light fw-semibold" id="bmiDisplay">
{% if profile.bmi %}
<span class="text-{{ profile.bmi_category[1] }}">{{ profile.bmi }} — {{ profile.bmi_category[0] }}</span>
{% else %}
<span class="text-muted">— (remplissez taille & poids)</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ② ORIGINE & BIOLOGIE -->
<div class="tab-pane fade" id="pane-origine">
<div class="profile-section card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="section-title"><i class="fas fa-globe me-2 text-primary"></i>Origine & Biologie</h5>
<p class="text-muted small mb-4">Ces données permettent d'adapter les recommandations aux prédispositions génétiques et culturelles associées aux origines.</p>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Origine ethnique</label>
<select name="ethnicity" class="form-select">
<option value="">— Sélectionner —</option>
{% for val, lbl in [
('european', 'Européen / Caucasien'),
('african', 'Africain / Afro-descendant'),
('asian_east', 'Asiatique de l\'Est (Chine, Japon, Corée)'),
('asian_south', 'Asiatique du Sud (Inde, Pakistan)'),
('latin', 'Latino-américain'),
('middle_eastern', 'Moyen-Oriental / Maghrébin'),
('mixed', 'Métis / Origines multiples'),
('other', 'Autre'),
] %}
<option value="{{ val }}" {% if profile.ethnicity == val %}selected{% endif %}>{{ lbl }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Pays d'origine</label>
<input type="text" name="country_origin" class="form-control"
value="{{ profile.country_origin or '' }}" placeholder="Ex: France, Sénégal, Maroc...">
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">Groupe sanguin</label>
<select name="blood_type" class="form-select">
<option value="">— Inconnu —</option>
{% for bt in ['A+','A-','B+','B-','AB+','AB-','O+','O-'] %}
<option value="{{ bt }}" {% if profile.blood_type == bt %}selected{% endif %}>{{ bt }}</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<div class="info-box mt-2">
<i class="fas fa-info-circle text-primary me-2"></i>
<strong>Pourquoi ces données ?</strong> Certaines populations présentent des prédispositions documentées : intolérance au lactose plus fréquente en Asie de l'Est (~90%), risque de diabète T2 plus élevé en Asie du Sud à IMC plus bas, etc.
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ③ ANTÉCÉDENTS -->
<div class="tab-pane fade" id="pane-sante">
<div class="profile-section card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="section-title"><i class="fas fa-notes-medical me-2 text-primary"></i>Antécédents de santé</h5>
<p class="text-muted small mb-4">Cochez les conditions qui vous concernent ou qui ont des antécédents familiaux directs.</p>
<div class="row g-2 mb-4">
{% for val, lbl in [
('hypertension', '🫀 Hypertension artérielle'),
('diabetes_risk', '🍬 Risque / prédiabète T2'),
('cardiovascular_risk', '❤️ Risque cardiovasculaire'),
('anxiety', '🧠 Anxiété / dépression'),
('digestive_issues', '🫃 Troubles digestifs / SII'),
('back_pain', '🦵 Douleurs dorsales / lombalgies'),
('osteoporosis_risk', '🦴 Risque d\'ostéoporose'),
('metabolic_syndrome', '⚖️ Syndrome métabolique'),
('thyroid_issues', '🦋 Troubles thyroïdiens'),
('sleep_apnea', '😮 Apnée du sommeil'),
] %}
<div class="col-md-6">
<div class="form-check condition-check">
<input class="form-check-input" type="checkbox" name="conditions"
value="{{ val }}" id="cond_{{ val }}"
{% if val in (profile.conditions or []) %}checked{% endif %}>
<label class="form-check-label" for="cond_{{ val }}">{{ lbl }}</label>
</div>
</div>
{% endfor %}
</div>
<div>
<label class="form-label fw-semibold">Allergies / médicaments (optionnel)</label>
<textarea name="allergies_text" class="form-control" rows="3"
placeholder="Ex: allergie aux arachides, prise de metformine, antihypertenseurs...">{{ profile._allergies_text or '' }}</textarea>
<div class="form-text">Ces informations restent locales et ne sont pas transmises.</div>
</div>
</div>
</div>
</div>
<!-- ④ MODE DE VIE -->
<div class="tab-pane fade" id="pane-lifestyle">
<div class="profile-section card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="section-title"><i class="fas fa-sun me-2 text-primary"></i>Mode de vie</h5>
<div class="row g-4 mt-1">
<div class="col-md-6">
<label class="form-label fw-semibold">
Durée de sommeil moyenne
<span class="badge bg-light text-dark ms-2" id="sleepVal">
{{ '%.1f'|format(profile.sleep_hours) if profile.sleep_hours else '7.0' }}h
</span>
</label>
<input type="range" name="sleep_hours" class="form-range" min="3" max="12" step="0.5"
value="{{ profile.sleep_hours or 7 }}"
oninput="document.getElementById('sleepVal').textContent = this.value + 'h'">
<div class="d-flex justify-content-between text-muted small">
<span>3h</span><span>7.5h (optimal)</span><span>12h</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">
Niveau de stress
<span class="badge bg-light text-dark ms-2" id="stressVal">
{{ profile.stress_level or 5 }}/10
</span>
</label>
<input type="range" name="stress_level" class="form-range stress-range" min="1" max="10"
value="{{ profile.stress_level or 5 }}"
oninput="document.getElementById('stressVal').textContent = this.value + '/10'">
<div class="d-flex justify-content-between text-muted small">
<span>😌 Faible</span><span>😰 Élevé</span>
</div>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Tabac</label>
<select name="smoking" class="form-select">
<option value="">— Sélectionner —</option>
{% for val, lbl in [('never','Non-fumeur'),('former','Ex-fumeur'),('occasional','Fumeur occasionnel'),('current','Fumeur régulier')] %}
<option value="{{ val }}" {% if profile.smoking == val %}selected{% endif %}>{{ lbl }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Consommation d'alcool</label>
<select name="alcohol_freq" class="form-select">
<option value="">— Sélectionner —</option>
{% for val, lbl in [('never','Jamais'),('occasional','Occasionnel (<1x/sem)'),('moderate','Modéré (1-3x/sem)'),('frequent','Fréquent (>3x/sem)')] %}
<option value="{{ val }}" {% if profile.alcohol_freq == val %}selected{% endif %}>{{ lbl }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
</div>
<!-- ⑤ ALIMENTATION -->
<div class="tab-pane fade" id="pane-alimentation">
<div class="profile-section card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="section-title"><i class="fas fa-utensils me-2 text-primary"></i>Alimentation</h5>
<div class="row g-4 mt-1">
<div class="col-md-6">
<label class="form-label fw-semibold">Type de régime alimentaire</label>
<select name="diet_type" class="form-select">
<option value="">— Sélectionner —</option>
{% for val, lbl in [
('omnivore', '🥩 Omnivore'),
('flexitarian', '🥗 Flexitarien'),
('vegetarian', '🌿 Végétarien'),
('vegan', '🌱 Végétalien / Vegan'),
('keto', '🥑 Cétogène / Low-carb'),
('paleo', '🍖 Paléo'),
('mediterranean', '🫒 Méditerranéen'),
] %}
<option value="{{ val }}" {% if profile.diet_type == val %}selected{% endif %}>{{ lbl }}</option>
{% endfor %}
</select>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Intolérances / exclusions alimentaires</label>
<div class="row g-2 mt-1">
{% for val, lbl in [
('gluten', '🌾 Gluten'),
('lactose', '🥛 Lactose'),
('fructose', '🍎 Fructose'),
('nuts', '🥜 Fruits à coque'),
('shellfish', '🦐 Crustacés'),
('eggs', '🥚 Œufs'),
('soy', '🌿 Soja'),
('nightshades', '🍅 Solanacées'),
] %}
<div class="col-md-3 col-6">
<div class="form-check intol-check">
<input class="form-check-input" type="checkbox" name="food_intolerances"
value="{{ val }}" id="intol_{{ val }}"
{% if val in (profile.food_intolerances or []) %}checked{% endif %}>
<label class="form-check-label" for="intol_{{ val }}">{{ lbl }}</label>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ⑥ ACTIVITÉ PHYSIQUE -->
<div class="tab-pane fade" id="pane-activite">
<div class="profile-section card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="section-title"><i class="fas fa-running me-2 text-primary"></i>Activité physique</h5>
<div class="row g-4 mt-1">
<div class="col-12">
<label class="form-label fw-semibold">Niveau d'activité global</label>
<div class="row g-2 mt-1">
{% for val, icon, lbl, desc in [
('sedentary', '🪑', 'Sédentaire', 'Peu ou pas d\'exercice, travail de bureau'),
('light', '🚶', 'Léger', '1-3 fois/semaine, activité légère'),
('moderate', '🚴', 'Modéré', '3-5 fois/semaine, effort modéré'),
('intense', '🏋️', 'Intense', '5-6 fois/semaine, effort soutenu'),
('athlete', '🏆', 'Athlète', 'Entraînement quotidien ou compétition'),
] %}
<div class="col-md-4 col-6">
<label class="activity-level-card {% if profile.activity_level == val %}selected{% endif %}">
<input type="radio" name="activity_level" value="{{ val }}"
{% if profile.activity_level == val %}checked{% endif %}>
<div class="act-icon">{{ icon }}</div>
<div class="act-label fw-semibold">{{ lbl }}</div>
<div class="act-desc text-muted small">{{ desc }}</div>
</label>
</div>
{% endfor %}
</div>
</div>
<div class="col-12">
<label class="form-label fw-semibold">Types d'activités pratiquées</label>
<div class="row g-2 mt-1">
{% for val, lbl in [
('running', '🏃 Course à pied'),
('cycling', '🚴 Vélo'),
('swimming', '🏊 Natation'),
('gym', '🏋️ Musculation/Salle'),
('yoga', '🧘 Yoga / Pilates'),
('team_sports', '⚽ Sports collectifs'),
('martial_arts', '🥋 Arts martiaux'),
('hiking', '🥾 Randonnée'),
('crossfit', '🔥 CrossFit / Functional'),
] %}
<div class="col-md-4 col-6">
<div class="form-check activity-check">
<input class="form-check-input" type="checkbox" name="activity_types"
value="{{ val }}" id="act_{{ val }}"
{% if val in (profile.activity_types or []) %}checked{% endif %}>
<label class="form-check-label" for="act_{{ val }}">{{ lbl }}</label>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ⑦ OBJECTIFS -->
<div class="tab-pane fade" id="pane-objectifs">
<div class="profile-section card border-0 shadow-sm">
<div class="card-body p-4">
<h5 class="section-title"><i class="fas fa-bullseye me-2 text-primary"></i>Objectifs de santé</h5>
<p class="text-muted small mb-4">Sélectionnez vos objectifs principaux. Les recommandations seront filtrées et priorisées en conséquence.</p>
<div class="row g-3">
{% for val, icon, lbl, desc in [
('weight_loss', '⚖️', 'Perte de poids', 'Réduire la masse grasse de façon durable'),
('muscle_gain', '💪', 'Prise de muscle', 'Augmenter la masse musculaire'),
('longevity', '🌿', 'Longévité', 'Vieillir en bonne santé'),
('energy', '⚡', 'Plus d\'énergie', 'Réduire la fatigue chronique'),
('sleep_quality', '😴', 'Sommeil', 'Améliorer la qualité et durée'),
('stress_reduction', '🧘', 'Réduire le stress', 'Anxiété, bien-être mental'),
('metabolic_health', '🔬', 'Santé métabolique', 'Glycémie, insuline, lipides'),
('cardiovascular_health', '❤️', 'Santé cardio', 'Réduire les facteurs de risque'),
('cognitive_performance', '🧠', 'Performance cognitive', 'Mémoire, concentration, focus'),
] %}
<div class="col-md-4">
<label class="goal-card {% if val in (profile.health_goals or []) %}selected{% endif %}">
<input type="checkbox" name="health_goals" value="{{ val }}"
{% if val in (profile.health_goals or []) %}checked{% endif %}>
<div class="goal-icon">{{ icon }}</div>
<div class="goal-title fw-semibold">{{ lbl }}</div>
<div class="goal-desc text-muted small">{{ desc }}</div>
</label>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div><!-- end tab-content -->
<div class="d-flex justify-content-between align-items-center mt-4">
<a href="{{ url_for('dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-2"></i>Retour au tableau de bord
</a>
<button type="submit" class="btn btn-primary btn-lg px-5 fw-semibold">
<i class="fas fa-save me-2"></i>Sauvegarder mon profil
</button>
</div>
</form>
</div>
{% endblock %}
{% block scripts %}
<script>
// BMI auto-calculation
function calcBMI() {
const h = parseFloat(document.getElementById('heightInput').value);
const w = parseFloat(document.getElementById('weightInput').value);
const el = document.getElementById('bmiDisplay');
if (h > 0 && w > 0) {
const bmi = (w / Math.pow(h/100, 2)).toFixed(1);
let cat, cls;
if (bmi < 18.5) { cat = 'Insuffisance pondérale'; cls = 'text-info'; }
else if (bmi < 25) { cat = 'Poids normal'; cls = 'text-success'; }
else if (bmi < 30) { cat = 'Surpoids'; cls = 'text-warning'; }
else { cat = 'Obésité'; cls = 'text-danger'; }
el.innerHTML = `<span class="${cls}">${bmi}${cat}</span>`;
}
}
document.getElementById('heightInput').addEventListener('input', calcBMI);
document.getElementById('weightInput').addEventListener('input', calcBMI);
// Activity level card selection
document.querySelectorAll('.activity-level-card input').forEach(inp => {
inp.addEventListener('change', () => {
document.querySelectorAll('.activity-level-card').forEach(c => c.classList.remove('selected'));
if (inp.checked) inp.closest('.activity-level-card').classList.add('selected');
});
});
// Goal card selection
document.querySelectorAll('.goal-card input').forEach(inp => {
inp.addEventListener('change', () => {
inp.closest('.goal-card').classList.toggle('selected', inp.checked);
});
});
</script>
{% endblock %}

65
templates/register.html Normal file
View File

@@ -0,0 +1,65 @@
{% extends "base.html" %}
{% block title %}Créer un compte — OpenCare{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-5 col-lg-4">
<div class="auth-card card border-0 shadow">
<div class="card-body p-4 p-md-5">
<div class="text-center mb-4">
<div class="auth-icon-wrap mb-3">
<i class="fas fa-user-plus text-primary fa-2x"></i>
</div>
<h2 class="h4 fw-bold">Créer un compte</h2>
<p class="text-muted small">Commencez à recevoir des conseils personnalisés</p>
</div>
<form method="POST" novalidate>
<div class="mb-3">
<label class="form-label fw-semibold">Email</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="fas fa-envelope text-muted"></i>
</span>
<input type="email" name="email" class="form-control border-start-0 ps-0"
placeholder="votre@email.com" required autocomplete="email">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Mot de passe</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="fas fa-lock text-muted"></i>
</span>
<input type="password" name="password" class="form-control border-start-0 ps-0"
placeholder="Min. 6 caractères" required autocomplete="new-password">
</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">Confirmer le mot de passe</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0">
<i class="fas fa-lock text-muted"></i>
</span>
<input type="password" name="confirm_password" class="form-control border-start-0 ps-0"
placeholder="Répétez le mot de passe" required autocomplete="new-password">
</div>
</div>
<button type="submit" class="btn btn-primary w-100 btn-lg fw-semibold">
<i class="fas fa-rocket me-2"></i>Créer mon compte
</button>
</form>
<div class="mt-3 p-3 bg-light rounded-3 small text-muted text-center">
<i class="fas fa-shield-alt me-1 text-success"></i>
Vos données sont stockées uniquement en local.
</div>
<hr class="my-3">
<p class="text-center text-muted small mb-0">
Déjà un compte ?
<a href="{{ url_for('login') }}" class="fw-semibold">Se connecter</a>
</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}