- 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>
439 lines
22 KiB
HTML
439 lines
22 KiB
HTML
{% 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 %}
|