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

View File

@@ -0,0 +1,17 @@
{
"permissions": {
"allow": [
"Bash(mkdir \"c:\\\\DataS\\\\OpenCare\\\\static\\\\css\")",
"Bash(mkdir \"c:\\\\DataS\\\\OpenCare\\\\templates\")",
"PowerShell(Get-Command python3 -ErrorAction SilentlyContinue)",
"PowerShell(Get-Command python -ErrorAction SilentlyContinue)",
"PowerShell(py --version 2>&1)",
"Bash(py seed_sources.py)",
"Bash(python -c \"import sys; sys.stdout.buffer.write\\(sys.stdin.buffer.read\\(\\)\\)\")",
"Bash(py -c \"import sqlite3; conn=sqlite3.connect\\('instance/opencare.db'\\); rows=conn.execute\\('SELECT id,name,source_type,category,active FROM crawl_source ORDER BY id'\\).fetchall\\(\\); [print\\(r\\) for r in rows]; conn.close\\(\\)\")",
"Bash(py -c \"import sqlite3; conn=sqlite3.connect\\('instance/opencare.db'\\); cols=[r[1] for r in conn.execute\\('PRAGMA table_info\\(crawl_source\\)'\\).fetchall\\(\\)]; print\\('Colonnes:', cols\\); conn.close\\(\\)\")",
"Bash(git init *)",
"Bash(git add *)"
]
}
}

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Copier ce fichier en .env et remplir les valeurs
# Ne jamais committer .env dans git
# Clé secrète Flask — générer avec : python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change-me-with-a-strong-random-key
# Base de données (SQLite par défaut, PostgreSQL possible)
# SQLite : sqlite:///opencare.db
# Postgres: postgresql://user:password@localhost/opencare
DATABASE_URL=sqlite:///opencare.db
# Environnement
FLASK_ENV=production
# Port d'écoute (optionnel, défaut 5000)
PORT=5000

7
.gitattributes vendored Normal file
View File

@@ -0,0 +1,7 @@
* text=auto eol=lf
*.sh text eol=lf
*.py text eol=lf
*.html text eol=lf
*.css text eol=lf
*.conf text eol=lf
*.service text eol=lf

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Environnement
.env
*.env
# Base de données
instance/
*.db
*.sqlite3
# Python
__pycache__/
*.py[cod]
*.pyo
.Python
venv/
env/
.venv/
*.egg-info/
dist/
build/
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
*.swp
# Fichiers sensibles
seed_sources.py
# Sous-dossier non lié au projet
Chimical/

886
app.py Normal file
View File

@@ -0,0 +1,886 @@
from flask import Flask, render_template, redirect, url_for, request, flash, abort, jsonify
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import date, datetime
from functools import wraps
import json
import os
from dotenv import load_dotenv
load_dotenv()
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ.get('SECRET_KEY', 'opencare-dev-secret-change-in-prod'),
SQLALCHEMY_DATABASE_URI=os.environ.get('DATABASE_URL', 'sqlite:///opencare.db'),
SQLALCHEMY_TRACK_MODIFICATIONS=False,
)
db = SQLAlchemy(app)
login_manager = LoginManager(app)
login_manager.login_view = 'login'
login_manager.login_message = 'Veuillez vous connecter.'
login_manager.login_message_category = 'warning'
# ── Helpers ────────────────────────────────────────────────────────────────────
def admin_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
abort(403)
return f(*args, **kwargs)
return decorated
_DOMAIN_LABELS = {
'nutrition': ('Alimentation', '🥗'),
'fasting': ('Jeûne intermittent', '⏱️'),
'exercise': ('Exercice', '💪'),
'sleep': ('Sommeil', '😴'),
'stress': ('Gestion du stress', '💚'),
'supplements': ('Suppléments', '💊'),
'mental_health': ('Santé mentale', '🧠'),
'other': ('Autre', '📌'),
}
# ── Models ─────────────────────────────────────────────────────────────────────
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=db.func.now())
profile = db.relationship('Profile', backref='user', uselist=False, cascade='all, delete-orphan')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
class Profile(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
first_name = db.Column(db.String(64))
last_name = db.Column(db.String(64))
birth_year = db.Column(db.Integer)
sex = db.Column(db.String(16))
height_cm = db.Column(db.Float)
weight_kg = db.Column(db.Float)
ethnicity = db.Column(db.String(64))
country_origin = db.Column(db.String(64))
blood_type = db.Column(db.String(8))
_conditions = db.Column('conditions', db.Text, default='[]')
_allergies_text = db.Column('allergies_text', db.Text, default='')
sleep_hours = db.Column(db.Float)
stress_level = db.Column(db.Integer)
smoking = db.Column(db.String(16))
alcohol_freq = db.Column(db.String(16))
diet_type = db.Column(db.String(32))
_food_intolerances = db.Column('food_intolerances', db.Text, default='[]')
activity_level = db.Column(db.String(16))
_activity_types = db.Column('activity_types', db.Text, default='[]')
_health_goals = db.Column('health_goals', db.Text, default='[]')
updated_at = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())
def _get_json(self, col):
return json.loads(getattr(self, col) or '[]')
def _set_json(self, col, val):
setattr(self, col, json.dumps(val))
@property
def conditions(self): return self._get_json('_conditions')
@conditions.setter
def conditions(self, v): self._set_json('_conditions', v)
@property
def food_intolerances(self): return self._get_json('_food_intolerances')
@food_intolerances.setter
def food_intolerances(self, v): self._set_json('_food_intolerances', v)
@property
def activity_types(self): return self._get_json('_activity_types')
@activity_types.setter
def activity_types(self, v): self._set_json('_activity_types', v)
@property
def health_goals(self): return self._get_json('_health_goals')
@health_goals.setter
def health_goals(self, v): self._set_json('_health_goals', v)
@property
def age(self):
if self.birth_year:
return date.today().year - self.birth_year
return None
@property
def bmi(self):
if self.height_cm and self.weight_kg and self.height_cm > 0:
h = self.height_cm / 100
return round(self.weight_kg / (h * h), 1)
return None
@property
def bmi_category(self):
b = self.bmi
if b is None: return None
if b < 18.5: return ('Insuffisance pondérale', 'info')
if b < 25: return ('Poids normal', 'success')
if b < 30: return ('Surpoids', 'warning')
return ('Obésité', 'danger')
def to_dict(self):
return {
'first_name': self.first_name, 'age': self.age,
'birth_year': self.birth_year, 'sex': self.sex,
'height_cm': self.height_cm, 'weight_kg': self.weight_kg,
'bmi': self.bmi, 'ethnicity': self.ethnicity,
'country_origin': self.country_origin, 'blood_type': self.blood_type,
'conditions': self.conditions, 'sleep_hours': self.sleep_hours,
'stress_level': self.stress_level, 'smoking': self.smoking,
'alcohol_freq': self.alcohol_freq, 'diet_type': self.diet_type,
'food_intolerances': self.food_intolerances,
'activity_level': self.activity_level,
'activity_types': self.activity_types,
'health_goals': self.health_goals,
}
# ── Crawl / Admin Models ────────────────────────────────────────────────────────
class CrawlSource(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(128), nullable=False)
url = db.Column(db.String(512), nullable=False)
source_type = db.Column(db.String(16), default='webpage') # webpage | rss
active = db.Column(db.Boolean, default=True)
last_crawled_at = db.Column(db.DateTime)
articles_found = db.Column(db.Integer, default=0)
created_at = db.Column(db.DateTime, default=db.func.now())
category = db.Column(db.String(16), default='advice') # 'advice' | 'scientific'
articles = db.relationship('RawArticle', backref='source', lazy='dynamic')
class RawArticle(db.Model):
id = db.Column(db.Integer, primary_key=True)
source_id = db.Column(db.Integer, db.ForeignKey('crawl_source.id'), nullable=True)
url = db.Column(db.String(512), unique=True, nullable=False)
title = db.Column(db.String(512))
content = db.Column(db.Text)
crawled_at = db.Column(db.DateTime, default=db.func.now())
analyzed = db.Column(db.Boolean, default=False)
recommendations = db.relationship('PendingRecommendation', backref='article', lazy='dynamic')
class PendingRecommendation(db.Model):
id = db.Column(db.Integer, primary_key=True)
article_id = db.Column(db.Integer, db.ForeignKey('raw_article.id'), nullable=True)
domain = db.Column(db.String(32), nullable=False, default='other')
title = db.Column(db.String(256), nullable=False)
summary = db.Column(db.Text)
details = db.Column(db.Text)
evidence_level = db.Column(db.String(4), default='C')
study_type = db.Column(db.String(32))
intervention = db.Column(db.String(512))
effect = db.Column(db.String(512))
source_title = db.Column(db.String(512))
source_doi = db.Column(db.String(256))
source_url = db.Column(db.String(512))
_tags = db.Column('tags', db.Text, default='[]')
_contraindications = db.Column('contraindications', db.Text, default='[]')
_required_conditions = db.Column('required_conditions', db.Text, default='[]')
_excluded_conditions = db.Column('excluded_conditions', db.Text, default='[]')
_required_health_goals = db.Column('required_health_goals', db.Text, default='[]')
_required_diet_types = db.Column('required_diet_types', db.Text, default='[]')
_required_activity_levels = db.Column('required_activity_levels', db.Text, default='[]')
target_age_min = db.Column(db.Integer)
target_age_max = db.Column(db.Integer)
target_sex = db.Column(db.String(16), default='all')
min_stress_level = db.Column(db.Integer)
min_bmi = db.Column(db.Float)
not_for_under_18 = db.Column(db.Boolean, default=False)
not_for_pregnant = db.Column(db.Boolean, default=False)
priority = db.Column(db.Integer, default=5)
status = db.Column(db.String(16), default='pending') # pending | approved | rejected
admin_notes = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=db.func.now())
reviewed_at = db.Column(db.DateTime)
def _get_json(self, col):
try:
return json.loads(getattr(self, col) or '[]')
except Exception:
return []
def _set_json(self, col, val):
setattr(self, col, json.dumps(val if val is not None else []))
@property
def tags(self): return self._get_json('_tags')
@tags.setter
def tags(self, v): self._set_json('_tags', v)
@property
def contraindications(self): return self._get_json('_contraindications')
@contraindications.setter
def contraindications(self, v): self._set_json('_contraindications', v)
@property
def required_conditions(self): return self._get_json('_required_conditions')
@required_conditions.setter
def required_conditions(self, v): self._set_json('_required_conditions', v)
@property
def excluded_conditions(self): return self._get_json('_excluded_conditions')
@excluded_conditions.setter
def excluded_conditions(self, v): self._set_json('_excluded_conditions', v)
@property
def required_health_goals(self): return self._get_json('_required_health_goals')
@required_health_goals.setter
def required_health_goals(self, v): self._set_json('_required_health_goals', v)
@property
def required_diet_types(self): return self._get_json('_required_diet_types')
@required_diet_types.setter
def required_diet_types(self, v): self._set_json('_required_diet_types', v)
@property
def required_activity_levels(self): return self._get_json('_required_activity_levels')
@required_activity_levels.setter
def required_activity_levels(self, v): self._set_json('_required_activity_levels', v)
def matches_profile(self, profile: dict) -> bool:
age = profile.get('age') or 0
sex = profile.get('sex') or ''
conditions = profile.get('conditions') or []
health_goals = profile.get('health_goals') or []
diet_type = profile.get('diet_type') or ''
activity_level = profile.get('activity_level') or ''
bmi = profile.get('bmi') or 0
stress = profile.get('stress_level') or 0
# Hard gates
if self.not_for_under_18 and age and age < 18:
return False
if self.target_age_min and age and age < self.target_age_min:
return False
if self.target_age_max and age and age > self.target_age_max:
return False
if self.target_sex and self.target_sex != 'all' and sex and sex != self.target_sex:
return False
if self.min_bmi and bmi and bmi < self.min_bmi:
return False
if self.min_stress_level and stress and stress < self.min_stress_level:
return False
for cond in self.excluded_conditions:
if cond in conditions:
return False
# Soft criteria — OR across groups
criteria = []
if self.required_conditions:
criteria.append(any(c in conditions for c in self.required_conditions))
if self.required_health_goals:
criteria.append(any(g in health_goals for g in self.required_health_goals))
if self.required_diet_types:
criteria.append(diet_type in self.required_diet_types)
if self.required_activity_levels:
criteria.append(activity_level in self.required_activity_levels)
if not criteria:
return True # Universal recommendation
return any(criteria)
def to_edit_dict(self) -> dict:
return {
'id': self.id,
'domain': self.domain,
'evidence_level': self.evidence_level,
'study_type': self.study_type or '',
'title': self.title,
'summary': self.summary or '',
'details': self.details or '',
'intervention': self.intervention or '',
'effect': self.effect or '',
'source_title': self.source_title or '',
'source_doi': self.source_doi or '',
'source_url': self.source_url or '',
'priority': self.priority,
'target_sex': self.target_sex or 'all',
'target_age_min': self.target_age_min,
'target_age_max': self.target_age_max,
'not_for_under_18': bool(self.not_for_under_18),
'not_for_pregnant': bool(self.not_for_pregnant),
'admin_notes': self.admin_notes or '',
'_required_conditions': self._required_conditions or '[]',
'_excluded_conditions': self._excluded_conditions or '[]',
'_required_health_goals': self._required_health_goals or '[]',
'_required_diet_types': self._required_diet_types or '[]',
'_required_activity_levels': self._required_activity_levels or '[]',
}
def to_rec_dict(self) -> dict:
label, icon = _DOMAIN_LABELS.get(self.domain, (self.domain, '📌'))
src = self.source_title or ''
if self.source_doi:
src += f' — DOI: {self.source_doi}'
elif self.source_url:
src += f'{self.source_url}'
return {
'id': f'db_{self.id}',
'domain': self.domain,
'domain_label': label,
'icon': icon,
'title': self.title,
'summary': self.summary,
'details': self.details,
'evidence_level': self.evidence_level,
'source': src,
'tags': self.tags,
'priority': self.priority,
}
class CrawlRun(db.Model):
id = db.Column(db.Integer, primary_key=True)
started_at = db.Column(db.DateTime, default=db.func.now())
ended_at = db.Column(db.DateTime)
status = db.Column(db.String(16), default='running') # running | done | error
articles_processed = db.Column(db.Integer, default=0)
recs_generated = db.Column(db.Integer, default=0)
_log_lines = db.Column('log_lines', db.Text, default='[]')
@property
def log_lines(self):
try:
return json.loads(self._log_lines or '[]')
except Exception:
return []
def append_log(self, msg):
ts = datetime.utcnow().strftime('%H:%M:%S')
lines = self.log_lines
lines.append(f"[{ts}] {msg}")
self._log_lines = json.dumps(lines[-150:])
db.session.add(self)
db.session.commit()
def to_dict(self):
return {
'status': self.status,
'run_id': self.id,
'started_at': self.started_at.strftime('%d/%m/%Y %H:%M:%S') if self.started_at else None,
'ended_at': self.ended_at.strftime('%d/%m/%Y %H:%M:%S') if self.ended_at else None,
'articles_processed': self.articles_processed or 0,
'recs_generated': self.recs_generated or 0,
'log': self.log_lines,
}
class CrawlConfig(db.Model):
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(64), unique=True, nullable=False)
value = db.Column(db.Text)
@classmethod
def get(cls, key, default=None):
r = cls.query.filter_by(key=key).first()
return r.value if r else default
@classmethod
def set(cls, key, value):
r = cls.query.filter_by(key=key).first()
v = str(value) if value is not None else ''
if r:
r.value = v
else:
db.session.add(cls(key=key, value=v))
# ── Scheduler ──────────────────────────────────────────────────────────────────
scheduler = None
def _build_scheduler():
from apscheduler.schedulers.background import BackgroundScheduler
s = BackgroundScheduler(daemon=True)
return s
def _scheduled_crawl():
from pipeline import run_pipeline
with app.app_context():
api_key = CrawlConfig.get('openai_api_key', '')
model = CrawlConfig.get('openai_model', 'gpt-4o')
max_art = int(CrawlConfig.get('max_articles_per_run', '20'))
run_pipeline(db.engine, api_key=api_key, model=model, max_articles=max_art)
def start_scheduler():
global scheduler
if scheduler and scheduler.running:
return
scheduler = _build_scheduler()
hour = int(CrawlConfig.get('crawl_hour', '2'))
minute = int(CrawlConfig.get('crawl_minute', '0'))
scheduler.add_job(_scheduled_crawl, 'cron', hour=hour, minute=minute, id='nightly_crawl')
scheduler.start()
def reschedule():
if scheduler and scheduler.running:
hour = int(CrawlConfig.get('crawl_hour', '2'))
minute = int(CrawlConfig.get('crawl_minute', '0'))
scheduler.reschedule_job('nightly_crawl', trigger='cron', hour=hour, minute=minute)
# ── Login manager ──────────────────────────────────────────────────────────────
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
# ── Auth routes ────────────────────────────────────────────────────────────────
@app.route('/')
def index():
return render_template('index.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('dashboard'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
confirm = request.form.get('confirm_password', '')
if not email or not password:
flash('Email et mot de passe requis.', 'danger')
elif len(password) < 6:
flash('Le mot de passe doit contenir au moins 6 caractères.', 'danger')
elif password != confirm:
flash('Les mots de passe ne correspondent pas.', 'danger')
elif User.query.filter_by(email=email).first():
flash('Cet email est déjà utilisé.', 'danger')
else:
is_first = User.query.count() == 0
user = User(email=email, is_admin=is_first)
user.set_password(password)
db.session.add(user)
db.session.commit()
login_user(user)
if is_first:
flash('Compte admin créé ! Configurez le crawl dans Administration.', 'success')
else:
flash('Compte créé ! Complétez votre profil pour recevoir vos recommandations.', 'success')
return redirect(url_for('profile'))
return render_template('register.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('dashboard'))
if request.method == 'POST':
email = request.form.get('email', '').strip().lower()
password = request.form.get('password', '')
user = User.query.filter_by(email=email).first()
if user and user.check_password(password):
login_user(user, remember=True)
return redirect(url_for('dashboard'))
flash('Email ou mot de passe incorrect.', 'danger')
return render_template('login.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
flash('Vous êtes déconnecté.', 'info')
return redirect(url_for('index'))
# ── Profile ────────────────────────────────────────────────────────────────────
@app.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
prof = current_user.profile
if prof is None:
prof = Profile(user_id=current_user.id)
db.session.add(prof)
if request.method == 'POST':
f = request.form
prof.first_name = f.get('first_name', '').strip() or None
prof.last_name = f.get('last_name', '').strip() or None
prof.birth_year = int(f['birth_year']) if f.get('birth_year') else None
prof.sex = f.get('sex') or None
prof.height_cm = float(f['height_cm']) if f.get('height_cm') else None
prof.weight_kg = float(f['weight_kg']) if f.get('weight_kg') else None
prof.ethnicity = f.get('ethnicity') or None
prof.country_origin = f.get('country_origin', '').strip() or None
prof.blood_type = f.get('blood_type') or None
prof.conditions = request.form.getlist('conditions')
prof._allergies_text = f.get('allergies_text', '').strip()
prof.sleep_hours = float(f['sleep_hours']) if f.get('sleep_hours') else None
prof.stress_level = int(f['stress_level']) if f.get('stress_level') else None
prof.smoking = f.get('smoking') or None
prof.alcohol_freq = f.get('alcohol_freq') or None
prof.diet_type = f.get('diet_type') or None
prof.food_intolerances = request.form.getlist('food_intolerances')
prof.activity_level = f.get('activity_level') or None
prof.activity_types = request.form.getlist('activity_types')
prof.health_goals = request.form.getlist('health_goals')
db.session.commit()
flash('Profil mis à jour avec succès.', 'success')
return redirect(url_for('dashboard'))
return render_template('profile.html', profile=prof)
# ── Dashboard ──────────────────────────────────────────────────────────────────
@app.route('/dashboard')
@login_required
def dashboard():
from recommendations import get_recommendations, profile_completeness
prof = current_user.profile
if not prof:
flash('Complétez votre profil pour recevoir des recommandations personnalisées.', 'info')
return redirect(url_for('profile'))
prof_dict = prof.to_dict()
static_recs = get_recommendations(prof_dict)
approved = PendingRecommendation.query.filter_by(status='approved').all()
db_recs = [r.to_rec_dict() for r in approved if r.matches_profile(prof_dict)]
all_recs = static_recs + db_recs
all_recs.sort(key=lambda r: -r.get('priority', 0))
domain_order = ['nutrition', 'fasting', 'exercise', 'sleep', 'stress', 'supplements', 'mental_health', 'other']
domains = {}
for r in all_recs:
domains.setdefault(r['domain'], []).append(r)
ordered_domains = [
(d, _DOMAIN_LABELS.get(d, (d, '📌')), domains[d])
for d in domain_order if d in domains
]
completeness = profile_completeness(prof)
return render_template(
'dashboard.html',
profile=prof,
ordered_domains=ordered_domains,
total=len(all_recs),
completeness=completeness,
)
# ── Admin routes ────────────────────────────────────────────────────────────────
@app.route('/admin')
@login_required
@admin_required
def admin_index():
stats = {
'sources': CrawlSource.query.count(),
'sources_active': CrawlSource.query.filter_by(active=True).count(),
'articles': RawArticle.query.count(),
'pending': PendingRecommendation.query.filter_by(status='pending').count(),
'approved': PendingRecommendation.query.filter_by(status='approved').count(),
'rejected': PendingRecommendation.query.filter_by(status='rejected').count(),
}
last_article = RawArticle.query.order_by(RawArticle.crawled_at.desc()).first()
last_crawl = last_article.crawled_at if last_article else None
api_key_set = bool(CrawlConfig.get('openai_api_key'))
return render_template('admin/index.html', stats=stats, last_crawl=last_crawl, api_key_set=api_key_set)
@app.route('/admin/sources', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_sources():
if request.method == 'POST':
name = request.form.get('name', '').strip()
url = request.form.get('url', '').strip()
stype = request.form.get('source_type', 'rss')
category = request.form.get('category', 'advice')
if not name or not url:
flash('Nom et URL requis.', 'danger')
else:
db.session.add(CrawlSource(name=name, url=url, source_type=stype, category=category))
db.session.commit()
flash(f'Source « {name} » ajoutée.', 'success')
return redirect(url_for('admin_sources'))
sources = CrawlSource.query.order_by(CrawlSource.created_at.desc()).all()
return render_template('admin/sources.html', sources=sources)
@app.route('/admin/sources/<int:sid>/toggle', methods=['POST'])
@login_required
@admin_required
def admin_source_toggle(sid):
src = db.get_or_404(CrawlSource, sid)
src.active = not src.active
db.session.commit()
return redirect(url_for('admin_sources'))
@app.route('/admin/sources/<int:sid>/delete', methods=['POST'])
@login_required
@admin_required
def admin_source_delete(sid):
src = db.get_or_404(CrawlSource, sid)
db.session.delete(src)
db.session.commit()
flash(f'Source « {src.name} » supprimée.', 'info')
return redirect(url_for('admin_sources'))
@app.route('/admin/crawl/run', methods=['POST'])
@login_required
@admin_required
def admin_crawl_run():
import threading
from pipeline import run_pipeline
source_id = request.form.get('source_id', type=int)
# Fetch everything we need while we're still in the Flask/SQLAlchemy context
engine = db.engine
api_key = CrawlConfig.get('openai_api_key', '')
model = CrawlConfig.get('openai_model', 'gpt-4o')
max_art = int(CrawlConfig.get('max_articles_per_run', '20'))
def do_crawl():
# Background thread: no Flask context, plain SQLAlchemy session
run_pipeline(engine, source_id=source_id,
api_key=api_key, model=model, max_articles=max_art)
threading.Thread(target=do_crawl, daemon=True).start()
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({'status': 'starting'})
flash('Crawl lancé en arrière-plan.', 'info')
return redirect(url_for('admin_sources'))
@app.route('/admin/crawl/status')
@login_required
@admin_required
def admin_crawl_status():
run = CrawlRun.query.order_by(CrawlRun.started_at.desc()).first()
if not run:
return jsonify({'status': 'idle', 'log': [], 'run_id': None})
return jsonify(run.to_dict())
@app.route('/admin/review')
@login_required
@admin_required
def admin_review():
status_filter = request.args.get('status', 'pending')
query = PendingRecommendation.query
if status_filter in ('pending', 'approved', 'rejected'):
query = query.filter_by(status=status_filter)
recs = query.order_by(PendingRecommendation.created_at.desc()).all()
counts = {
'pending': PendingRecommendation.query.filter_by(status='pending').count(),
'approved': PendingRecommendation.query.filter_by(status='approved').count(),
'rejected': PendingRecommendation.query.filter_by(status='rejected').count(),
}
domains = list(_DOMAIN_LABELS.keys())
return render_template('admin/review.html', recs=recs, counts=counts,
status_filter=status_filter, domains=domains,
domain_labels=_DOMAIN_LABELS)
@app.route('/admin/review/<int:rid>/approve', methods=['POST'])
@login_required
@admin_required
def admin_review_approve(rid):
rec = db.get_or_404(PendingRecommendation, rid)
rec.status = 'approved'
rec.reviewed_at = datetime.utcnow()
db.session.commit()
flash(f'« {rec.title} » approuvée et active.', 'success')
return redirect(url_for('admin_review', status='pending'))
@app.route('/admin/review/<int:rid>/reject', methods=['POST'])
@login_required
@admin_required
def admin_review_reject(rid):
rec = db.get_or_404(PendingRecommendation, rid)
rec.status = 'rejected'
rec.admin_notes = request.form.get('notes', '')
rec.reviewed_at = datetime.utcnow()
db.session.commit()
flash(f'« {rec.title} » rejetée.', 'info')
return redirect(url_for('admin_review', status='pending'))
@app.route('/admin/review/<int:rid>/update', methods=['POST'])
@login_required
@admin_required
def admin_review_update(rid):
rec = db.get_or_404(PendingRecommendation, rid)
f = request.form
rec.domain = f.get('domain', rec.domain)
rec.title = f.get('title', rec.title).strip()[:256]
rec.summary = f.get('summary', rec.summary).strip()
rec.details = f.get('details', rec.details).strip()
rec.evidence_level = f.get('evidence_level', rec.evidence_level)
rec.study_type = f.get('study_type', rec.study_type)
rec.intervention = f.get('intervention', rec.intervention or '').strip()
rec.effect = f.get('effect', rec.effect or '').strip()
rec.source_title = f.get('source_title', rec.source_title or '').strip()
rec.source_doi = f.get('source_doi', rec.source_doi or '').strip()
rec.source_url = f.get('source_url', rec.source_url or '').strip()
rec.priority = int(f.get('priority', rec.priority))
rec.target_sex = f.get('target_sex', rec.target_sex)
rec.target_age_min = int(f['target_age_min']) if f.get('target_age_min') else None
rec.target_age_max = int(f['target_age_max']) if f.get('target_age_max') else None
rec.not_for_under_18 = 'not_for_under_18' in f
rec.not_for_pregnant = 'not_for_pregnant' in f
rec.required_conditions = f.getlist('required_conditions')
rec.excluded_conditions = f.getlist('excluded_conditions')
rec.required_health_goals = f.getlist('required_health_goals')
rec.required_diet_types = f.getlist('required_diet_types')
rec.required_activity_levels = f.getlist('required_activity_levels')
rec.admin_notes = f.get('admin_notes', '').strip()
action = f.get('action', 'save')
if action == 'approve':
rec.status = 'approved'
rec.reviewed_at = datetime.utcnow()
flash(f'« {rec.title} » modifiée et approuvée.', 'success')
else:
flash(f'« {rec.title} » mise à jour.', 'info')
db.session.commit()
return redirect(url_for('admin_review', status=rec.status if action != 'approve' else 'pending'))
@app.route('/admin/library')
@login_required
@admin_required
def admin_library():
recs = PendingRecommendation.query.filter_by(status='approved') \
.order_by(PendingRecommendation.domain, PendingRecommendation.priority.desc()).all()
return render_template('admin/library.html', recs=recs, domain_labels=_DOMAIN_LABELS)
@app.route('/admin/library/<int:rid>/delete', methods=['POST'])
@login_required
@admin_required
def admin_library_delete(rid):
rec = db.get_or_404(PendingRecommendation, rid)
title = rec.title
rec.status = 'rejected'
rec.reviewed_at = datetime.utcnow()
db.session.commit()
flash(f'« {title} » retirée de la bibliothèque.', 'info')
return redirect(url_for('admin_library'))
@app.route('/admin/config', methods=['GET', 'POST'])
@login_required
@admin_required
def admin_config():
if request.method == 'POST':
f = request.form
if f.get('openai_api_key'):
CrawlConfig.set('openai_api_key', f['openai_api_key'].strip())
CrawlConfig.set('openai_model', f.get('openai_model', 'gpt-4o'))
CrawlConfig.set('crawl_hour', f.get('crawl_hour', '2'))
CrawlConfig.set('crawl_minute', f.get('crawl_minute', '0'))
CrawlConfig.set('max_articles_per_run', f.get('max_articles_per_run', '20'))
db.session.commit()
reschedule()
flash('Configuration enregistrée.', 'success')
return redirect(url_for('admin_config'))
config = {
'openai_api_key': CrawlConfig.get('openai_api_key', ''),
'openai_model': CrawlConfig.get('openai_model', 'gpt-4o'),
'crawl_hour': CrawlConfig.get('crawl_hour', '2'),
'crawl_minute': CrawlConfig.get('crawl_minute', '0'),
'max_articles_per_run': CrawlConfig.get('max_articles_per_run', '20'),
}
return render_template('admin/config.html', config=config)
# ── Template globals ───────────────────────────────────────────────────────────
@app.context_processor
def inject_admin_globals():
return dict(
CrawlSource=CrawlSource,
PendingRecommendation=PendingRecommendation,
CrawlConfig=CrawlConfig,
CrawlRun=CrawlRun,
)
# ── Error handlers ─────────────────────────────────────────────────────────────
@app.errorhandler(403)
def forbidden(e):
return render_template('403.html'), 403
# ── Startup ────────────────────────────────────────────────────────────────────
def _init_db():
db.create_all()
from sqlalchemy import text as _sql_text
with db.engine.connect() as _c:
try:
_c.execute(_sql_text(
"ALTER TABLE crawl_source ADD COLUMN category VARCHAR(16) DEFAULT 'advice'"
))
_c.commit()
except Exception:
pass
if __name__ == '__main__':
with app.app_context():
_init_db()
if not os.environ.get('WERKZEUG_RUN_MAIN'):
with app.app_context():
start_scheduler()
debug = os.environ.get('FLASK_ENV') != 'production'
port = int(os.environ.get('PORT', 5000))
app.run(debug=debug, host='0.0.0.0', port=port)

47
deploy/nginx.conf Normal file
View File

@@ -0,0 +1,47 @@
server {
listen 80;
server_name opencare.open-squared.tech; # adapter selon votre domaine
# Redirection HTTPS (décommenter après avoir configuré le certificat)
# return 301 https://$host$request_uri;
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# Fichiers statiques servis directement par nginx
location /static/ {
alias /opt/opencare/static/;
expires 7d;
add_header Cache-Control "public, immutable";
}
}
# Bloc HTTPS (générer le certificat avec : certbot --nginx -d opencare.open-squared.tech)
# server {
# listen 443 ssl;
# server_name opencare.open-squared.tech;
# ssl_certificate /etc/letsencrypt/live/opencare.open-squared.tech/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/opencare.open-squared.tech/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
#
# location / {
# proxy_pass http://127.0.0.1:5000;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_read_timeout 120s;
# }
#
# location /static/ {
# alias /opt/opencare/static/;
# expires 7d;
# }
# }

18
deploy/opencare.service Normal file
View File

@@ -0,0 +1,18 @@
[Unit]
Description=OpenCare Web App
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/opencare
EnvironmentFile=/opt/opencare/.env
ExecStart=/opt/opencare/venv/bin/gunicorn -c gunicorn.conf.py wsgi:app
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

63
deploy/setup_vps.sh Normal file
View File

@@ -0,0 +1,63 @@
#!/bin/bash
# Script d'installation OpenCare sur VPS Ubuntu/Debian
# Usage : sudo bash setup_vps.sh
set -e
APP_DIR=/opt/opencare
APP_USER=www-data
REPO=https://gitea.open-squared.tech/admin/opencare.git
echo "=== Installation OpenCare ==="
# 1. Dépendances système
apt-get update -qq
apt-get install -y python3 python3-pip python3-venv nginx git
# 2. Cloner / mettre à jour le repo
if [ -d "$APP_DIR/.git" ]; then
echo "--- Mise à jour du code ---"
cd "$APP_DIR" && git pull
else
echo "--- Clonage du repo ---"
git clone "$REPO" "$APP_DIR"
fi
cd "$APP_DIR"
# 3. Environnement virtuel Python
if [ ! -d "$APP_DIR/venv" ]; then
python3 -m venv venv
fi
venv/bin/pip install -q --upgrade pip
venv/bin/pip install -q -r requirements.txt
# 4. Fichier .env (créer si absent)
if [ ! -f "$APP_DIR/.env" ]; then
cp .env.example .env
SECRET=$(python3 -c "import secrets; print(secrets.token_hex(32))")
sed -i "s/change-me-with-a-strong-random-key/$SECRET/" .env
echo "ATTENTION : .env créé avec une clé secrète aléatoire."
echo "Editez /opt/opencare/.env pour configurer DATABASE_URL et autres."
fi
# 5. Dossier instance (SQLite)
mkdir -p "$APP_DIR/instance"
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
# 6. Service systemd
cp deploy/opencare.service /etc/systemd/system/opencare.service
systemctl daemon-reload
systemctl enable opencare
systemctl restart opencare
# 7. Nginx
cp deploy/nginx.conf /etc/nginx/sites-available/opencare
ln -sf /etc/nginx/sites-available/opencare /etc/nginx/sites-enabled/opencare
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
echo ""
echo "=== Déploiement terminé ==="
echo "L'application tourne sur http://$(hostname -I | awk '{print $1}')"
echo "Consultez les logs : journalctl -u opencare -f"

11
deploy/update.sh Normal file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
# Mise à jour rapide après git pull
# Usage (depuis /opt/opencare) : bash deploy/update.sh
set -e
cd /opt/opencare
git pull
venv/bin/pip install -q -r requirements.txt
systemctl restart opencare
echo "Mise à jour terminée — $(systemctl is-active opencare)"

10
gunicorn.conf.py Normal file
View File

@@ -0,0 +1,10 @@
import os
bind = f"0.0.0.0:{os.environ.get('PORT', '5000')}"
workers = 1 # 1 worker = APScheduler tourne une seule fois (SQLite-safe)
threads = 4 # Multi-thread dans le worker unique
preload_app = True # Charge l'app avant de forker
timeout = 120
accesslog = "-"
errorlog = "-"
loglevel = "info"

407
pipeline.py Normal file
View File

@@ -0,0 +1,407 @@
"""
Pipeline de crawl — fonctionne dans un thread background sans contexte Flask.
Reçoit un sqlalchemy.engine.Engine + la config, crée sa propre Session native.
"""
import json
import logging
import requests
from bs4 import BeautifulSoup
from datetime import datetime
from sqlalchemy.orm import Session as SASession
logger = logging.getLogger(__name__)
_CONDITIONS = [
'hypertension', 'diabetes_risk', 'cardiovascular_risk', 'anxiety',
'digestive_issues', 'back_pain', 'osteoporosis_risk', 'metabolic_syndrome',
'thyroid', 'sleep_apnea',
]
_HEALTH_GOALS = [
'weight_loss', 'muscle_gain', 'longevity', 'energy', 'better_sleep',
'stress_reduction', 'metabolic_health', 'cardiovascular_health', 'cognitive_health',
]
_DIET_TYPES = ['omnivore', 'flexitarian', 'vegetarian', 'vegan', 'paleo', 'keto', 'other']
_ACTIVITY_LEVELS = ['sedentary', 'light', 'moderate', 'intense', 'athlete']
SYSTEM_PROMPT = f"""Tu es un expert en médecine factuelle (evidence-based medicine).
Analyse le contenu santé fourni et extrait les recommandations médicales/santé qu'il contient.
Retourne UNIQUEMENT du JSON valide.
Si le contenu ne contient pas de recommandation santé pertinente, retourne: {{"relevant": false}}
Format attendu:
{{
"relevant": true,
"recommendations": [
{{
"domain": "nutrition|fasting|exercise|sleep|stress|supplements|mental_health|other",
"title": "Titre court (< 60 caractères)",
"summary": "1-2 phrases résumant l'action recommandée",
"details": "2-4 phrases avec mécanisme d'action et données quantitatives si disponibles",
"evidence_level": "A (méta-analyse/RCT solide) | B (observationnel/RCT limité) | C (expert/anecdote)",
"study_type": "meta-analysis|RCT|observational|expert_opinion|anecdote",
"intervention": "Action concrète à mettre en oeuvre",
"effect": "Résultat attendu avec chiffres si disponibles",
"source_title": "Titre de l'étude ou article source",
"source_doi": "DOI si disponible sinon null",
"source_url": "URL de la source",
"tags": ["tag1", "tag2", "tag3"],
"contraindications": ["contre-indication 1"],
"target_age_min": null,
"target_age_max": null,
"target_sex": "all|male|female",
"required_conditions": [],
"excluded_conditions": [],
"required_health_goals": [],
"required_diet_types": [],
"required_activity_levels": [],
"not_for_under_18": false,
"not_for_pregnant": false,
"priority": 5
}}
]
}}
Valeurs acceptées pour required_conditions / excluded_conditions: {_CONDITIONS}
Valeurs acceptées pour required_health_goals: {_HEALTH_GOALS}
Valeurs acceptées pour required_diet_types: {_DIET_TYPES}
Valeurs acceptées pour required_activity_levels: {_ACTIVITY_LEVELS}
Pour required_conditions: conditions qui DÉCLENCHENT cette rec.
Pour excluded_conditions: conditions INCOMPATIBLES avec cette rec.
Si la recommandation est universelle, laisser toutes les listes vides.
RÈGLE ABSOLUE — Citation scientifique:
- Identifier TOUJOURS l'étude primaire sous-jacente (PubMed, Cochrane, NEJM, Lancet, BMJ…)
- source_doi: DOI de l'étude originale (ex: 10.1056/NEJMoa1200303), PAS l'URL de l'article source
- source_url: URL PubMed ou DOI de la source primaire (https://doi.org/...), pas le site référant
- evidence_level A UNIQUEMENT si méta-analyse ou RCT avec ≥ 100 participants
- Ne jamais inventer de référence: si incertain, mettre source_doi null et evidence_level C
- study_type doit refléter le niveau de preuve réel de l'étude citée
"""
# ── DB helpers (session-aware, no Flask context) ───────────────────────────────
def _log(session: SASession, run, msg: str) -> None:
ts = datetime.utcnow().strftime('%H:%M:%S')
lines = run.log_lines
lines.append(f"[{ts}] {msg}")
run._log_lines = json.dumps(lines[-150:])
session.add(run)
session.commit()
logger.info(msg)
# ── Web fetchers ───────────────────────────────────────────────────────────────
def _fetch_webpage(url: str) -> tuple:
headers = {'User-Agent': 'Mozilla/5.0 (compatible; OpenCare-Bot/1.0)'}
r = requests.get(url, headers=headers, timeout=20)
r.raise_for_status()
soup = BeautifulSoup(r.content, 'html.parser')
for tag in soup(['script', 'style', 'nav', 'footer', 'header', 'aside', 'iframe', 'form']):
tag.decompose()
title_tag = soup.find('title')
title = title_tag.get_text(strip=True) if title_tag else url
main = (
soup.find('article') or soup.find('main') or
soup.find(id='content') or soup.find(class_='content') or
soup.find('body')
)
text = main.get_text(separator='\n', strip=True) if main else soup.get_text()
return title, text[:6000]
def _fetch_rss(url: str) -> list:
import feedparser
feed = feedparser.parse(url)
results = []
for entry in feed.entries[:30]:
link = entry.get('link', '')
title = entry.get('title', '')
content = entry.get('summary') or entry.get('description') or ''
if content:
soup = BeautifulSoup(content, 'html.parser')
content = soup.get_text(strip=True)
results.append((link, title, content[:4000]))
return results
def _fetch_index_page(url: str) -> list:
"""Crawl an index/listing page and return (article_url, title) pairs found on it."""
from urllib.parse import urlparse, urljoin
_SKIP = (
'/tag/', '/category/', '/author/', '/page/', '/feed', '/wp-content',
'login', 'search', 'contact', 'about', 'privacy', 'terms',
'twitter.com', 'facebook.com', 'instagram.com', 'youtube.com',
)
headers = {'User-Agent': 'Mozilla/5.0 (compatible; OpenCare-Bot/1.0)'}
r = requests.get(url, headers=headers, timeout=20)
r.raise_for_status()
soup = BeautifulSoup(r.content, 'html.parser')
base = urlparse(url)
seen = set()
links = []
for a in soup.find_all('a', href=True):
href = a['href'].strip()
if not href or href.startswith(('#', 'mailto:', 'tel:', 'javascript:')):
continue
full = urljoin(url, href)
p = urlparse(full)
if p.netloc != base.netloc:
continue
path = p.path.rstrip('/')
if not path or path == base.path.rstrip('/'):
continue
if full in seen:
continue
if any(s in full.lower() for s in _SKIP):
continue
title = a.get_text(strip=True)
if len(title) < 10 or len(title) > 200:
continue
seen.add(full)
links.append((full, title))
return links[:60]
# ── OpenAI analysis ────────────────────────────────────────────────────────────
def _analyze(text: str, title: str, api_key: str, model: str = 'gpt-4o') -> list:
from openai import OpenAI
client = OpenAI(api_key=api_key)
user_msg = f"Titre: {title}\n\nContenu:\n{text}"
resp = client.chat.completions.create(
model=model,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
temperature=0.2,
)
data = json.loads(resp.choices[0].message.content)
if not data.get('relevant', False):
return []
return data.get('recommendations', [])
def _store_recs(session: SASession, pending_model, article_id: int,
recs: list, fallback_url: str) -> int:
count = 0
for rec in recs:
p = pending_model(
article_id=article_id,
domain=rec.get('domain', 'other'),
title=(rec.get('title') or '')[:256],
summary=rec.get('summary', ''),
details=rec.get('details', ''),
evidence_level=rec.get('evidence_level', 'C'),
study_type=rec.get('study_type', ''),
intervention=(rec.get('intervention') or '')[:512],
effect=(rec.get('effect') or '')[:512],
source_title=(rec.get('source_title') or '')[:512],
source_doi=(rec.get('source_doi') or '')[:256],
source_url=(rec.get('source_url') or fallback_url or '')[:512],
target_age_min=rec.get('target_age_min'),
target_age_max=rec.get('target_age_max'),
target_sex=rec.get('target_sex', 'all'),
not_for_under_18=bool(rec.get('not_for_under_18', False)),
not_for_pregnant=bool(rec.get('not_for_pregnant', False)),
priority=int(rec.get('priority', 5)),
)
p.tags = rec.get('tags') or []
p.contraindications = rec.get('contraindications') or []
p.required_conditions = rec.get('required_conditions') or []
p.excluded_conditions = rec.get('excluded_conditions') or []
p.required_health_goals = rec.get('required_health_goals') or []
p.required_diet_types = rec.get('required_diet_types') or []
p.required_activity_levels = rec.get('required_activity_levels') or []
session.add(p)
count += 1
return count
# ── Main entry point ───────────────────────────────────────────────────────────
def run_pipeline(engine, source_id: int = None,
api_key: str = '', model: str = 'gpt-4o', max_articles: int = 20):
"""
Runs entirely in a background thread with no Flask context.
Uses a plain SQLAlchemy session bound to the passed engine.
"""
# Import models lazily (avoiding circular imports at module load time)
from app import CrawlRun, CrawlSource, RawArticle, PendingRecommendation
with SASession(engine) as session:
# Create run record — immediately visible to the polling endpoint
run = CrawlRun()
session.add(run)
session.commit()
try:
if not api_key:
run.status = 'error'
_log(session, run, '❌ Clé API OpenAI manquante — configurez-la dans Administration > Configuration')
return 0
query = session.query(CrawlSource).filter_by(active=True)
if source_id:
query = query.filter_by(id=source_id)
sources = query.all()
if not sources:
run.status = 'done'
run.ended_at = datetime.utcnow()
_log(session, run, ' Aucune source active à crawler')
return 0
_log(session, run, f'🚀 Démarrage — {len(sources)} source(s), modèle: {model}')
total_articles = 0
total_recs = 0
for source in sources:
if total_articles >= max_articles:
_log(session, run, f'⏹ Limite de {max_articles} articles atteinte — arrêt')
break
_log(session, run, f'🔍 {source.name}{source.url[:70]}')
try:
if source.source_type == 'rss':
items = _fetch_rss(source.url)
_log(session, run, f'{len(items)} entrée(s) dans le flux RSS')
src_articles = 0
src_recs = 0
for item_url, item_title, item_content in items:
if total_articles >= max_articles:
break
if not item_url:
continue
if session.query(RawArticle).filter_by(url=item_url).first():
continue
article = RawArticle(
source_id=source.id, url=item_url,
title=item_title, content=item_content,
)
session.add(article)
session.flush()
try:
_log(session, run, f' 🤖 Analyse : « {(item_title or item_url)[:55]} »')
recs = _analyze(item_content, item_title, api_key, model)
n = _store_recs(session, PendingRecommendation, article.id, recs, item_url)
session.commit()
article.analyzed = True
src_articles += 1
src_recs += n
total_articles += 1
total_recs += n
if recs:
_log(session, run, f'{n} recommandation(s) extraite(s)')
else:
_log(session, run, f' — Aucune recommandation pertinente')
except Exception as e:
_log(session, run, f' ⚠ Erreur analyse IA : {str(e)[:80]}')
session.rollback()
_log(session, run, f'{source.name}{src_articles} article(s), {src_recs} rec(s)')
elif source.source_type == 'index':
links = _fetch_index_page(source.url)
_log(session, run, f'{len(links)} lien(s) repérés sur la page index')
src_articles = 0
src_recs = 0
for link_url, link_title in links:
if total_articles >= max_articles:
break
if session.query(RawArticle).filter_by(url=link_url).first():
continue
try:
title, text_content = _fetch_webpage(link_url)
title = title or link_title
article = RawArticle(
source_id=source.id, url=link_url,
title=title, content=text_content,
)
session.add(article)
session.flush()
_log(session, run, f' 🤖 Analyse : « {(title or link_url)[:55]} »')
recs = _analyze(text_content, title, api_key, model)
n = _store_recs(session, PendingRecommendation, article.id, recs, link_url)
session.commit()
article.analyzed = True
src_articles += 1
src_recs += n
total_articles += 1
total_recs += n
if recs:
_log(session, run, f'{n} recommandation(s) extraite(s)')
else:
_log(session, run, f' — Aucune recommandation pertinente')
except Exception as e:
_log(session, run, f'{link_url[:50]}: {str(e)[:60]}')
session.rollback()
_log(session, run, f'{source.name}{src_articles} article(s), {src_recs} rec(s)')
else: # webpage
if session.query(RawArticle).filter_by(url=source.url).first():
_log(session, run, f' → Déjà crawlé (ignoré)')
else:
title, text = _fetch_webpage(source.url)
article = RawArticle(
source_id=source.id, url=source.url,
title=title, content=text,
)
session.add(article)
session.flush()
_log(session, run, f' 🤖 Analyse : « {title[:60]} »')
try:
recs = _analyze(text, title, api_key, model)
n = _store_recs(session, PendingRecommendation, article.id, recs, source.url)
session.commit()
article.analyzed = True
total_articles += 1
total_recs += n
if recs:
_log(session, run, f'{source.name}{n} recommandation(s) extraite(s)')
else:
_log(session, run, f'{source.name} — Aucune recommandation pertinente')
except Exception as e:
_log(session, run, f'{source.name} — Erreur IA : {str(e)[:80]}')
session.rollback()
source.last_crawled_at = datetime.utcnow()
session.commit()
except requests.exceptions.Timeout:
_log(session, run, f'{source.name} — Timeout réseau (>20s)')
session.rollback()
except requests.exceptions.ConnectionError as e:
_log(session, run, f'{source.name} — Erreur connexion : {str(e)[:80]}')
session.rollback()
except Exception as e:
_log(session, run, f'{source.name} — Erreur : {str(e)[:80]}')
session.rollback()
run.status = 'done'
run.ended_at = datetime.utcnow()
run.articles_processed = total_articles
run.recs_generated = total_recs
_log(session, run,
f"🏁 Terminé — {total_articles} article(s), {total_recs} rec(s) en file d'attente")
return total_articles
except Exception as e:
run.status = 'error'
run.ended_at = datetime.utcnow()
_log(session, run, f'💥 Erreur fatale : {str(e)[:120]}')
logger.exception("Pipeline fatal error")
return 0

405
recommendations.py Normal file
View File

@@ -0,0 +1,405 @@
# Evidence levels: A = meta-analyse/RCT solide, B = observationnel/RCT limité, C = consensus expert
RECS = [
# ── NUTRITION ─────────────────────────────────────────────────────────────
{
"id": "med_diet",
"domain": "nutrition", "domain_label": "Alimentation", "icon": "🥗",
"title": "Régime méditerranéen",
"summary": "Huile d'olive, légumes, légumineuses, poissons gras, noix. Limiter viandes rouges et ultra-transformés.",
"details": "Réduit de 30% le risque cardiovasculaire (étude PREDIMED, 7447 participants). Protège contre le diabète T2, l'Alzheimer et certains cancers. Riche en antioxydants et acides gras mono-insaturés.",
"evidence_level": "A",
"source": "Estruch R. et al., NEJM 2013 (PREDIMED) — Sofi F. et al., BMJ 2008",
"tags": ["cœur", "longévité", "anti-inflammatoire"],
"priority": 10,
"condition": lambda p: p.get('diet_type') in [None, 'omnivore', 'flexitarian'],
},
{
"id": "low_gi",
"domain": "nutrition", "domain_label": "Alimentation", "icon": "📊",
"title": "Alimentation à faible indice glycémique",
"summary": "Privilégier glucides complexes et fibres pour stabiliser la glycémie et l'énergie.",
"details": "Favoriser légumineuses, céréales complètes, légumes non-féculents. Éviter sucres ajoutés, pain blanc, produits industriels. Réduit de 20% le risque de diabète T2 et améliore le contrôle glycémique existant.",
"evidence_level": "A",
"source": "Jenkins D.J. et al., Diabetes Care 2008 — Livesey G. et al., Am J Clin Nutr 2019",
"tags": ["glycémie", "diabète", "énergie"],
"priority": 9,
"condition": lambda p: (
(p.get('bmi') or 0) > 25 or
'diabetes_risk' in (p.get('conditions') or []) or
'metabolic_syndrome' in (p.get('conditions') or [])
),
},
{
"id": "omega3",
"domain": "nutrition", "domain_label": "Alimentation", "icon": "🐟",
"title": "Acides gras oméga-3 (EPA/DHA)",
"summary": "2-3 portions/semaine de poissons gras ou supplémentation 1g/jour d'EPA+DHA.",
"details": "Réduisent les triglycérides de 25-30%, l'inflammation (CRP) et le risque d'arythmie. Action anti-agrégante plaquettaire. Pour les végétaliens : DHA d'algues recommandé (même source que les poissons).",
"evidence_level": "A",
"source": "Mozaffarian D. & Wu J.H.Y., JACC 2011 — GISSI trial, Lancet 1999",
"tags": ["cœur", "cerveau", "anti-inflammatoire"],
"priority": 9,
"condition": lambda p: (
'cardiovascular_risk' in (p.get('conditions') or []) or
p.get('diet_type') in ['omnivore', 'flexitarian', 'vegan']
),
},
{
"id": "hydration",
"domain": "nutrition", "domain_label": "Alimentation", "icon": "💧",
"title": "Hydratation optimale",
"summary": "1.5 à 2.5L d'eau par jour. La soif est un signal tardif de déshydratation.",
"details": "Même une déshydratation légère (1-2%) dégrade les fonctions cognitives et l'humeur. L'eau plate reste la meilleure boisson. Tisanes et infusions comptent. Limiter sodas, jus industriels et alcool.",
"evidence_level": "B",
"source": "EFSA Panel on Dietetic Products, EFSA Journal 2010",
"tags": ["base", "énergie", "cognition"],
"priority": 7,
"condition": lambda p: True,
},
{
"id": "iron_women",
"domain": "nutrition", "domain_label": "Alimentation", "icon": "🩸",
"title": "Apport en fer — femmes en âge de procréer",
"summary": "Besoins 2x supérieurs (18mg/j vs 8mg/j). Viandes rouges maigres, légumineuses, épinards.",
"details": "Associer aliments riches en fer avec vitamine C pour multiplier par 3 l'absorption non-héminique. Éviter thé et café pendant les repas riches en fer (tanins inhibiteurs). Le fer héminique (viandes) est absorbé à 25% vs 5% pour le fer végétal.",
"evidence_level": "A",
"source": "OMS — Iron Deficiency Anaemia guidelines 2001 — Hallberg L., Eur J Clin Nutr 2001",
"tags": ["fer", "fatigue", "femmes"],
"priority": 8,
"condition": lambda p: p.get('sex') == 'female' and (p.get('age') or 0) < 50,
},
{
"id": "calcium_bones",
"domain": "nutrition", "domain_label": "Alimentation", "icon": "🦴",
"title": "Calcium et santé osseuse",
"summary": "10001200 mg/j via produits laitiers, sardines avec arêtes, amandes, légumes verts.",
"details": "La densité osseuse (pic) est atteinte à 30 ans, puis décline. Le calcium seul est insuffisant : associer vitamine D et activité physique portante. Éviter suppléments >500mg/prise (risque cardiovasculaire si fortes doses).",
"evidence_level": "A",
"source": "Weaver C.M. et al., Osteoporosis International 2016 — EFSA 2015",
"tags": ["os", "vieillissement", "femmes"],
"priority": 7,
"condition": lambda p: (
(p.get('age') or 0) > 40 or
p.get('sex') == 'female' or
'osteoporosis_risk' in (p.get('conditions') or [])
),
},
{
"id": "plant_protein",
"domain": "nutrition", "domain_label": "Alimentation", "icon": "🌱",
"title": "Protéines végétales complètes",
"summary": "Combiner légumineuses + céréales (ex: lentilles + riz) pour tous les acides aminés essentiels.",
"details": "Substituer 3% des calories des protéines animales par des végétales réduit la mortalité de 10%. Tofu, tempeh, quinoa (protéine complète), edamame sont d'excellentes sources. La complémentation n'a pas besoin d'être au même repas.",
"evidence_level": "B",
"source": "Song M. et al., JAMA Internal Medicine 2016 — Willett W. et al., Lancet 2019",
"tags": ["protéines", "cœur", "longévité"],
"priority": 6,
"condition": lambda p: p.get('diet_type') in ['vegetarian', 'vegan', 'flexitarian'],
},
# ── JEÛNE INTERMITTENT ─────────────────────────────────────────────────────
{
"id": "fasting_14_10",
"domain": "fasting", "domain_label": "Jeûne intermittent", "icon": "",
"title": "Protocole 14/10 — démarrage progressif",
"summary": "Jeûner 14h, fenêtre alimentaire de 10h. Idéal pour débuter sans contrainte excessive.",
"details": "Plus facile à tenir que le 16/8, tout en activant la cétose légère et l'autophagie débutante après 12-14h. Recommandé aux débutants, femmes et +50 ans. Dernier repas avant 20h améliore la qualité du sommeil et réduit le cortisol nocturne.",
"evidence_level": "B",
"source": "Sutton E.F. et al., Cell Metabolism 2018 — Wilkinson M.J. et al., Cell Metabolism 2020",
"tags": ["débutant", "métabolisme", "sommeil"],
"priority": 8,
"condition": lambda p: (
p.get('activity_level') in ['sedentary', 'light', 'moderate'] or
p.get('sex') == 'female' or
(p.get('age') or 0) > 50
),
},
{
"id": "fasting_16_8",
"domain": "fasting", "domain_label": "Jeûne intermittent", "icon": "⏱️",
"title": "Protocole 16/8",
"summary": "Jeûner 16h, fenêtre alimentaire de 8h (ex: 12h20h). Standard pour sportifs actifs.",
"details": "Améliore la sensibilité à l'insuline de 25%, réduit l'IL-6 et le TNF-α (marqueurs inflammatoires). La perte de poids est comparable à la restriction calorique continue avec meilleure adhérence à long terme. Ne pas pratiquer les jours d'entraînement intensif sans adaptation.",
"evidence_level": "B",
"source": "Lowe D.A. et al., JAMA Internal Medicine 2020 — Cioffi I. et al., Nutrients 2018",
"tags": ["métabolisme", "poids", "insuline"],
"priority": 9,
"condition": lambda p: (
p.get('activity_level') in ['moderate', 'intense', 'athlete'] and
(
'weight_loss' in (p.get('health_goals') or []) or
'metabolic_health' in (p.get('health_goals') or [])
)
),
},
{
"id": "fasting_5_2",
"domain": "fasting", "domain_label": "Jeûne intermittent", "icon": "📅",
"title": "Protocole 5:2",
"summary": "5 jours normaux, 2 jours à 500-600 kcal. Flexibilité maximale.",
"details": "Efficace pour perte de poids (-5-8% en 3 mois) avec bonne adhérence. Les jours de restriction peuvent être consécutifs (plus d'effet) ou non (plus facile). Éviter les jours de sport intense pour les jours restreints.",
"evidence_level": "B",
"source": "Harvie M.N. et al., Int Journal of Obesity 2011 — Harris L. et al., Obesity Reviews 2018",
"tags": ["poids", "flexibilité", "calories"],
"priority": 7,
"condition": lambda p: 'weight_loss' in (p.get('health_goals') or []),
},
{
"id": "fasting_autophagy",
"domain": "fasting", "domain_label": "Jeûne intermittent", "icon": "🔬",
"title": "Jeûne et autophagie cellulaire",
"summary": "À partir de 16-18h de jeûne, l'autophagie s'active et recycle les protéines endommagées.",
"details": "L'autophagie (Prix Nobel 2016, Yoshinori Ohsumi) est le mécanisme de nettoyage cellulaire. Activée par la déplétion en nutriments (mTOR inhibé). Potentiellement protecteur contre cancers, Alzheimer et vieillissement cellulaire. Encore peu d'études humaines directes.",
"evidence_level": "B",
"source": "Levine B. & Kroemer G., Cell 2019 — Nobel Prize 2016 Commentary",
"tags": ["longévité", "cellules", "vieillissement"],
"priority": 7,
"condition": lambda p: 'longevity' in (p.get('health_goals') or []),
},
# ── EXERCICE ──────────────────────────────────────────────────────────────
{
"id": "zone2_cardio",
"domain": "exercise", "domain_label": "Exercice", "icon": "🏃",
"title": "Cardio zone 2 — 150 min/semaine",
"summary": "Marche rapide, vélo, natation à effort modéré. Réduction de 35% de la mortalité toutes causes.",
"details": "Zone 2 = 60-70% FCmax, conversation possible. Optimise la biogenèse mitochondriale et le métabolisme des graisses. L'OMS recommande 150 min/semaine minimum. Même 11 min/jour montrent des bénéfices significatifs.",
"evidence_level": "A",
"source": "OMS Physical Activity Guidelines 2020 — Wen C.P. et al., Lancet 2011",
"tags": ["cœur", "métabolisme", "base"],
"priority": 10,
"condition": lambda p: True,
},
{
"id": "strength_training",
"domain": "exercise", "domain_label": "Exercice", "icon": "💪",
"title": "Musculation 2-3x/semaine",
"summary": "Préserve la masse musculaire, la densité osseuse et le métabolisme de base après 35 ans.",
"details": "La sarcopénie débute à 30 ans (-3-5%/décennie sans exercice). La musculation augmente la sensibilité à l'insuline, améliore la composition corporelle et réduit le risque de fractures. Priorité absolue après 40 ans, même avec poids légers.",
"evidence_level": "A",
"source": "Westcott W.L., Current Sports Medicine Reports 2012 — Liu C.J. & Latham N., Cochrane 2009",
"tags": ["muscle", "métabolisme", "os", "vieillissement"],
"priority": 9,
"condition": lambda p: (
'muscle_gain' in (p.get('health_goals') or []) or
(p.get('age') or 0) > 35 or
p.get('activity_level') in ['moderate', 'intense', 'athlete']
),
},
{
"id": "hiit",
"domain": "exercise", "domain_label": "Exercice", "icon": "",
"title": "HIIT — Entraînement par intervalles",
"summary": "20-30 min de HIIT 2x/semaine. +15% VO2max en 8 semaines, combustion graisses prolongée.",
"details": "Le HIIT génère un effet EPOC (Excess Post-exercise Oxygen Consumption) : combustion accrue jusqu'à 24h post-effort. Améliore la VO2max et la sensibilité à l'insuline plus efficacement que le cardio continu par unité de temps. Ne pas faire plus de 2-3 sessions/semaine.",
"evidence_level": "A",
"source": "Gibala M.J. et al., Journal of Physiology 2012 — Weston K.S. et al., Br J Sports Med 2014",
"tags": ["VO2max", "graisses", "efficacité"],
"priority": 8,
"condition": lambda p: p.get('activity_level') in ['moderate', 'intense', 'athlete'],
},
{
"id": "core_training",
"domain": "exercise", "domain_label": "Exercice", "icon": "🎯",
"title": "Gainage et renforcement du core",
"summary": "Planche, dead bug, bird dog : 3 séries de 30-60s, 3x/semaine. Prévient les lombalgies.",
"details": "Le renforcement du core (abdominaux profonds, lombaires, plancher pelvien) réduit les lombalgies chroniques de 50%. La planche frontale active plus efficacement le transverse de l'abdomen que les crunchs classiques, sans stress vertébral.",
"evidence_level": "B",
"source": "McGill S.M., J Orthopedic & Sports PT 2010 — Coulombe B.J. et al., Sports Health 2017",
"tags": ["posture", "dos", "performance"],
"priority": 7,
"condition": lambda p: (
'back_pain' in (p.get('conditions') or []) or
p.get('activity_level') in ['moderate', 'intense', 'athlete'] or
'muscle_gain' in (p.get('health_goals') or [])
),
},
{
"id": "mobility",
"domain": "exercise", "domain_label": "Exercice", "icon": "🧘",
"title": "Mobilité et flexibilité",
"summary": "10-15 min d'étirements dynamiques ou yoga quotidien. Prévient blessures et douleurs chroniques.",
"details": "La mobilité articulaire décline de 30% entre 30 et 70 ans sans entretien. Le yoga combine mobilité, force et réduction du stress (cortisol -20%). Privilégier étirements dynamiques avant effort, statiques après.",
"evidence_level": "B",
"source": "Cramer H. et al., Cochrane Database 2013 — Behm D.G. et al., Applied Physiology 2016",
"tags": ["souplesse", "blessures", "stress"],
"priority": 6,
"condition": lambda p: (
p.get('activity_level') in ['sedentary', 'light'] or
(p.get('age') or 0) > 50 or
'stress_reduction' in (p.get('health_goals') or [])
),
},
# ── SOMMEIL ───────────────────────────────────────────────────────────────
{
"id": "sleep_duration",
"domain": "sleep", "domain_label": "Sommeil", "icon": "😴",
"title": "Optimiser la durée du sommeil (7-9h)",
"summary": "Dormir moins de 6h double le risque d'obésité, de diabète T2 et de maladies cardiovasculaires.",
"details": "La dette de sommeil chronique élève le cortisol, la ghréline (appétit) et diminue la leptine (satiété). La consolidation mémorielle et la réparation cellulaire se font principalement en sommeil profond (ondes lentes). Récupérer le week-end ne compense pas.",
"evidence_level": "A",
"source": "Cappuccio F.P. et al., Sleep 2010 — Walker M., Why We Sleep 2017",
"tags": ["récupération", "hormones", "poids"],
"priority": 10,
"condition": lambda p: (p.get('sleep_hours') or 8) < 7,
},
{
"id": "sleep_hygiene",
"domain": "sleep", "domain_label": "Sommeil", "icon": "🌙",
"title": "Hygiène du sommeil",
"summary": "Chambre à 18°C, obscurité totale, horaires réguliers, pas d'écrans 1h avant.",
"details": "La mélatonine est inhibée par la lumière bleue (LED, écrans). La régularité des horaires synchronise l'horloge circadienne. Une chambre froide (18°C) facilite la chute de la température corporelle nécessaire à l'endormissement. Caféine à éviter après 14h (demi-vie 5-7h).",
"evidence_level": "B",
"source": "Gradisar M. et al., Sleep Medicine Reviews 2013 — Chang A.M. et al., PNAS 2015",
"tags": ["mélatonine", "circadien", "qualité"],
"priority": 8,
"condition": lambda p: True,
},
{
"id": "sleep_stress",
"domain": "sleep", "domain_label": "Sommeil", "icon": "🧠",
"title": "Protocole anti-stress pré-sommeil",
"summary": "Cohérence cardiaque 5 min + journaling : réduit le cortisol et facilite l'endormissement.",
"details": "Le cortisol élevé en soirée (lié au stress chronique) fragmente le sommeil et réduit le sommeil profond. La respiration 5-5 (5s inspire / 5s expire) active le système parasympathique en 3-4 cycles. Le journaling externalise les pensées ruminatives.",
"evidence_level": "B",
"source": "Ong J.C. et al., Sleep 2014 — Lehrer P.M., Applied Psychophysiology 2013",
"tags": ["cortisol", "stress", "relaxation"],
"priority": 9,
"condition": lambda p: (p.get('stress_level') or 0) >= 6,
},
# ── GESTION DU STRESS ─────────────────────────────────────────────────────
{
"id": "coherence_cardiaque",
"domain": "stress", "domain_label": "Gestion du stress", "icon": "💚",
"title": "Cohérence cardiaque (méthode 365)",
"summary": "3 fois/jour, 6 respirations/min, 5 min. Réduit le cortisol de 20% en 4 semaines.",
"details": "La méthode 365 (3x/j, 6 resp/min, 5 min) synchronise la HRV (variabilité de fréquence cardiaque). Effets mesurables sur l'anxiété et la pression artérielle. Apps : RespiRelax+, Kardia. Idéalement : matin au réveil, avant déjeuner, soir.",
"evidence_level": "B",
"source": "McCraty R. et al., HeartMath Institute 2015 — Lehrer P.M., Biofeedback 2013",
"tags": ["cortisol", "HRV", "anxiété"],
"priority": 9,
"condition": lambda p: (p.get('stress_level') or 0) >= 5,
},
{
"id": "meditation",
"domain": "stress", "domain_label": "Gestion du stress", "icon": "🧘",
"title": "Méditation pleine conscience (MBSR)",
"summary": "10-20 min/jour réduit l'anxiété de 38% et modifie structurellement l'amygdale en 8 semaines.",
"details": "Le programme MBSR (Mindfulness-Based Stress Reduction) de Jon Kabat-Zinn est le plus étudié (47 essais contrôlés en méta-analyse). L'IRM montre une réduction du volume de l'amygdale et une augmentation du cortex préfrontal après 8 semaines. Apps : Petit Bambou, Headspace.",
"evidence_level": "A",
"source": "Goyal M. et al., JAMA Internal Medicine 2014 — Hölzel B.K. et al., Psychiatry Research 2011",
"tags": ["anxiété", "dépression", "focus"],
"priority": 8,
"condition": lambda p: (
(p.get('stress_level') or 0) >= 6 or
'anxiety' in (p.get('conditions') or []) or
'stress_reduction' in (p.get('health_goals') or [])
),
},
# ── SUPPLÉMENTS ───────────────────────────────────────────────────────────
{
"id": "vitamin_d",
"domain": "supplements", "domain_label": "Suppléments", "icon": "☀️",
"title": "Vitamine D3 (cholécalciférol)",
"summary": "80% des Européens déficients. 1000-2000 UI/jour d'oct. à mars, avec un repas gras.",
"details": "La vitamine D est essentielle à l'immunité (T-cells), la santé osseuse, la régulation de l'humeur et la prévention de certains cancers. La D3 est 87% plus efficace que la D2. Taux optimal : 40-60 ng/mL. Vérifier par bilan sanguin (25-OH-D).",
"evidence_level": "A",
"source": "Holick M.F., NEJM 2007 — Martineau A.R. et al., BMJ 2017",
"tags": ["immunité", "os", "humeur"],
"priority": 9,
"condition": lambda p: True,
},
{
"id": "magnesium",
"domain": "supplements", "domain_label": "Suppléments", "icon": "⚗️",
"title": "Magnésium (bisglycinate ou malate)",
"summary": "300-400 mg/jour au coucher. Améliore sommeil, gestion du stress et contractilité musculaire.",
"details": "Le magnésium est cofacteur de 300+ réactions enzymatiques. 70% de la population est déficiente (stress, alcool, sport intensif l'épuisent). Forme bisglycinate = meilleure biodisponibilité + effets sur le sommeil. Forme malate = énergie/musculation. Éviter l'oxyde (très faible absorption).",
"evidence_level": "B",
"source": "Abbasi B. et al., J Research in Medical Sciences 2012 — Boyle N.B. et al., Nutrients 2017",
"tags": ["sommeil", "stress", "muscles"],
"priority": 8,
"condition": lambda p: (
(p.get('stress_level') or 0) >= 5 or
(p.get('sleep_hours') or 8) < 7 or
p.get('activity_level') in ['intense', 'athlete']
),
},
{
"id": "probiotics",
"domain": "supplements", "domain_label": "Suppléments", "icon": "🦠",
"title": "Probiotiques et santé du microbiote",
"summary": "Lactobacillus + Bifidobacterium multi-souches. Kéfir, yaourt, kimchi, kombucha en natif.",
"details": "Le microbiote intestinal influence l'immunité (70% des cellules immunitaires), l'humeur (axe intestin-cerveau via nerf vague) et le métabolisme. Probiotiques efficaces si au moins 10^9 UFC/dose. Les sources fermentées naturelles sont préférables aux suppléments.",
"evidence_level": "B",
"source": "Hill C. et al., Nature Reviews Gastroenterology 2014 — Cryan J.F. et al., Nature Reviews 2019",
"tags": ["immunité", "digestion", "humeur"],
"priority": 7,
"condition": lambda p: (
'digestive_issues' in (p.get('conditions') or []) or
p.get('diet_type') in ['vegan', 'vegetarian'] or
(p.get('stress_level') or 0) >= 6
),
},
{
"id": "creatine",
"domain": "supplements", "domain_label": "Suppléments", "icon": "💊",
"title": "Créatine monohydrate",
"summary": "3-5g/jour. Supplément le plus étudié au monde. Force +8%, récupération et mémoire.",
"details": "La créatine augmente les réserves de phosphocréatine musculaire. Plus de 1000 études cliniques. Bénéfices cognitifs émergents (mémoire de travail +14% en carence). Particulièrement utile pour végétariens (faibles réserves endogènes). Phase de charge inutile.",
"evidence_level": "A",
"source": "Lanhers C. et al., European J Sport Science 2017 — Rawson E.S. & Venezia A.C., J Nutrition 2011",
"tags": ["muscle", "force", "cognition"],
"priority": 7,
"condition": lambda p: (
'muscle_gain' in (p.get('health_goals') or []) or
p.get('activity_level') in ['intense', 'athlete'] or
p.get('diet_type') in ['vegetarian', 'vegan']
),
},
{
"id": "b12_vegan",
"domain": "supplements", "domain_label": "Suppléments", "icon": "🔴",
"title": "Vitamine B12 — essentielle pour végétaliens",
"summary": "500-1000 µg/semaine ou 25-250 µg/jour. Seul supplément non-négociable en régime vegan.",
"details": "La B12 n'existe pas dans les végétaux (algues et levures nutritionnelles insuffisantes). Carence irréversible en neurologie à long terme. Méthylcobalamine ou cyanocobalamine. Tester le taux sanguin (optimal >300 pmol/L) tous les 1-2 ans.",
"evidence_level": "A",
"source": "Watanabe F. et al., Nutrients 2014 — Pawlak R. et al., Nutrition Reviews 2013",
"tags": ["vegan", "neurologie", "énergie"],
"priority": 10,
"condition": lambda p: p.get('diet_type') == 'vegan',
},
]
def get_recommendations(profile: dict) -> list:
if not profile:
return []
results = []
for rec in RECS:
try:
if rec['condition'](profile):
results.append({k: v for k, v in rec.items() if k != 'condition'})
except Exception:
pass
results.sort(key=lambda r: -r.get('priority', 0))
return results
def profile_completeness(prof) -> int:
if not prof:
return 0
scalar_fields = [
'first_name', 'birth_year', 'sex', 'height_cm', 'weight_kg',
'blood_type', 'sleep_hours', 'stress_level', 'smoking',
'alcohol_freq', 'diet_type', 'activity_level', 'ethnicity',
]
list_fields = ['conditions', 'health_goals', 'activity_types']
filled = sum(1 for f in scalar_fields if getattr(prof, f, None) is not None)
filled += sum(1 for f in list_fields if getattr(prof, f, []))
total = len(scalar_fields) + len(list_fields)
return int(filled / total * 100)

12
requirements.txt Normal file
View File

@@ -0,0 +1,12 @@
Flask==3.0.3
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.3
Werkzeug==3.0.3
openai>=1.30.0
requests>=2.31.0
beautifulsoup4>=4.12.0
APScheduler>=3.10.0
feedparser>=6.0.11
gunicorn>=22.0.0
python-dotenv>=1.0.0
lxml>=5.0.0

588
static/css/style.css Normal file
View File

@@ -0,0 +1,588 @@
/* ── Variables ────────────────────────────────────────────────── */
:root {
--primary: #2563eb;
--primary-dark: #1d4ed8;
--accent: #10b981;
--accent-light: #d1fae5;
--bg: #f8fafc;
--card: #ffffff;
--text: #1e293b;
--muted: #64748b;
--border: #e2e8f0;
--shadow-sm: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
--shadow: 0 4px 16px rgba(0,0,0,.10);
--shadow-lg: 0 10px 40px rgba(0,0,0,.12);
--radius: 12px;
--radius-sm: 8px;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.95rem;
}
/* ── Navbar ───────────────────────────────────────────────────── */
.oc-nav {
background: var(--text);
box-shadow: 0 2px 8px rgba(0,0,0,.2);
padding: 0.6rem 0;
}
.text-accent { color: var(--accent) !important; }
.btn-accent {
background: var(--accent);
color: #fff;
border: none;
}
.btn-accent:hover { background: #059669; color: #fff; }
/* ── Footer ───────────────────────────────────────────────────── */
.oc-footer {
background: var(--text);
color: #94a3b8;
}
/* ── Hero ─────────────────────────────────────────────────────── */
.hero-section {
background: linear-gradient(135deg, #eff6ff 0%, #ecfdf5 100%);
border-bottom: 1px solid var(--border);
}
.text-gradient {
background: linear-gradient(135deg, var(--primary), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.bg-accent-light { background: var(--accent-light); }
.hero-visual {
padding: 1.5rem;
}
.hero-pill {
display: flex;
align-items: center;
gap: 0.5rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 50px;
padding: 0.5rem 1rem;
box-shadow: var(--shadow-sm);
font-size: 0.88rem;
font-weight: 500;
animation: floatIn 0.4s ease forwards;
}
.hero-pill-icon { font-size: 1.2rem; }
@keyframes floatIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Stats bar ────────────────────────────────────────────────── */
.stat-num {
font-size: 2rem;
font-weight: 800;
line-height: 1;
}
.stat-lbl {
font-size: 0.8rem;
color: var(--muted);
margin-top: 0.25rem;
}
/* ── Domain cards (landing) ───────────────────────────────────── */
.domain-card {
border-radius: var(--radius) !important;
transition: transform 0.2s, box-shadow 0.2s;
cursor: default;
}
.domain-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow) !important;
}
.domain-icon { font-size: 2.5rem; }
/* ── How it works steps ───────────────────────────────────────── */
.step-card { padding: 1.5rem; }
.step-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background: var(--primary);
color: #fff;
border-radius: 50%;
font-weight: 700;
font-size: 1.1rem;
margin: 0 auto 1rem;
}
/* ── Auth cards ───────────────────────────────────────────────── */
.auth-card {
border-radius: var(--radius) !important;
}
.auth-icon-wrap {
width: 3.5rem;
height: 3.5rem;
background: #eff6ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
}
/* ── Profile tabs ─────────────────────────────────────────────── */
.profile-tabs .nav-link {
color: var(--muted);
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius-sm) !important;
padding: 0.5rem 0.9rem;
font-size: 0.85rem;
font-weight: 500;
transition: all 0.15s;
}
.profile-tabs .nav-link.active,
.profile-tabs .nav-link:hover {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
.profile-section {
border-radius: var(--radius) !important;
}
.section-title {
font-weight: 700;
font-size: 1rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--border);
margin-bottom: 0 !important;
}
.info-box {
background: #eff6ff;
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
font-size: 0.85rem;
}
/* Condition / intolerance checkboxes */
.condition-check, .intol-check, .activity-check {
background: #f8fafc;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 0.6rem 0.8rem;
margin: 0;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.condition-check:has(.form-check-input:checked),
.intol-check:has(.form-check-input:checked),
.activity-check:has(.form-check-input:checked) {
background: #eff6ff;
border-color: var(--primary);
}
.condition-check .form-check-input,
.intol-check .form-check-input,
.activity-check .form-check-input {
margin-top: 0.1rem;
}
.condition-check label, .intol-check label, .activity-check label {
cursor: pointer;
font-size: 0.875rem;
}
/* Activity level radio cards */
.activity-level-card {
display: block;
text-align: center;
padding: 1rem 0.75rem;
border: 2px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.15s;
background: #fff;
user-select: none;
}
.activity-level-card input { display: none; }
.activity-level-card:hover { border-color: var(--primary); background: #eff6ff; }
.activity-level-card.selected { border-color: var(--primary); background: #eff6ff; }
.act-icon { font-size: 1.75rem; margin-bottom: 0.25rem; }
.act-label { font-size: 0.875rem; }
.act-desc { font-size: 0.75rem; }
/* Goal cards */
.goal-card {
display: block;
padding: 1rem;
border: 2px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.15s;
background: #fff;
user-select: none;
text-align: center;
}
.goal-card input { display: none; }
.goal-card:hover { border-color: var(--accent); background: #f0fdf4; }
.goal-card.selected { border-color: var(--accent); background: #f0fdf4; }
.goal-icon { font-size: 1.75rem; margin-bottom: 0.3rem; }
.goal-title { font-size: 0.875rem; }
.goal-desc { font-size: 0.75rem; }
/* ── Dashboard ────────────────────────────────────────────────── */
.profile-summary-bar {
border-radius: var(--radius) !important;
}
.avatar-circle {
width: 2.75rem;
height: 2.75rem;
background: var(--primary);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 1.1rem;
flex-shrink: 0;
}
.profile-badge {
display: inline-flex;
align-items: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 50px;
padding: 0.25rem 0.7rem;
font-size: 0.8rem;
font-weight: 500;
}
.profile-badge-success { border-color: #86efac; background: #f0fdf4; color: #166534; }
.profile-badge-warning { border-color: #fcd34d; background: #fffbeb; color: #92400e; }
.profile-badge-danger { border-color: #fca5a5; background: #fef2f2; color: #991b1b; }
.profile-badge-info { border-color: #93c5fd; background: #eff6ff; color: #1e40af; }
.completeness-bar { height: 6px; border-radius: 3px; }
/* Evidence badges */
.evidence-badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 50px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.03em;
}
.ev-A { background: #dcfce7; color: #166534; }
.ev-B { background: #fef9c3; color: #854d0e; }
.ev-C { background: #ffedd5; color: #9a3412; }
/* Domain section header */
.domain-header { border-bottom: 2px solid var(--border); padding-bottom: 0.75rem; }
.domain-icon-lg { font-size: 2rem; }
/* Recommendation cards */
.rec-card {
border-radius: var(--radius) !important;
cursor: pointer;
transition: transform 0.18s, box-shadow 0.18s;
}
.rec-card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow) !important;
}
.rec-icon { font-size: 1.5rem; }
.rec-icon-lg { font-size: 2.5rem; }
.rec-summary {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-clamp: 3;
overflow: hidden;
}
/* Tag pills */
.tag-pill {
display: inline-block;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 50px;
padding: 0.15rem 0.55rem;
font-size: 0.7rem;
color: var(--muted);
}
.tag-pill-lg { font-size: 0.8rem; padding: 0.25rem 0.7rem; }
/* Modal */
.rec-detail-box {
background: #f8fafc;
border-radius: var(--radius-sm);
padding: 1rem;
line-height: 1.7;
}
.source-box {
background: #eff6ff;
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
font-size: 0.85rem;
}
/* Empty state */
.empty-state { max-width: 400px; margin: 0 auto; }
.empty-icon { font-size: 4rem; }
/* ── Forms ────────────────────────────────────────────────────── */
.form-control, .form-select {
border-radius: var(--radius-sm);
border-color: var(--border);
font-size: 0.9rem;
}
.form-control:focus, .form-select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37,99,235,.15);
}
.input-group-text {
border-radius: var(--radius-sm) 0 0 var(--radius-sm) !important;
}
.input-group .form-control {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0 !important;
}
/* ── Buttons ──────────────────────────────────────────────────── */
.btn-primary {
background: var(--primary);
border-color: var(--primary);
border-radius: var(--radius-sm);
}
.btn-primary:hover {
background: var(--primary-dark);
border-color: var(--primary-dark);
}
.btn-outline-primary {
color: var(--primary);
border-color: var(--primary);
border-radius: var(--radius-sm);
}
.btn-outline-primary:hover {
background: var(--primary);
border-color: var(--primary);
}
/* ── Admin layout ─────────────────────────────────────────────── */
.admin-layout {
display: flex;
min-height: calc(100vh - 120px);
gap: 0;
}
.admin-sidebar {
width: 220px;
flex-shrink: 0;
background: var(--text);
padding: 1.25rem 0;
display: flex;
flex-direction: column;
border-right: 1px solid rgba(255,255,255,.08);
}
.admin-sidebar-brand {
color: #94a3b8;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
padding: 0 1.25rem 1rem;
border-bottom: 1px solid rgba(255,255,255,.08);
margin-bottom: .5rem;
}
.admin-nav { list-style: none; padding: 0; margin: 0; flex: 1; }
.admin-nav li { padding: 0 .5rem; }
.admin-nav-link {
display: flex;
align-items: center;
gap: .7rem;
color: #94a3b8;
text-decoration: none;
padding: .6rem .75rem;
border-radius: var(--radius-sm);
font-size: .875rem;
font-weight: 500;
transition: background .15s, color .15s;
}
.admin-nav-link:hover { background: rgba(255,255,255,.08); color: #e2e8f0; }
.admin-nav-link.active { background: var(--primary); color: #fff; }
.admin-nav-link i { width: 1.1rem; text-align: center; opacity: .8; }
.admin-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4rem;
height: 1.4rem;
padding: 0 .35rem;
border-radius: 50px;
font-size: .7rem;
font-weight: 700;
background: rgba(255,255,255,.15);
color: #e2e8f0;
}
.admin-badge-danger { background: #dc2626; color: #fff; }
.admin-sidebar-footer {
padding: .5rem;
border-top: 1px solid rgba(255,255,255,.08);
margin-top: .5rem;
}
.admin-main {
flex: 1;
padding: 1.75rem 2rem;
overflow-x: auto;
background: var(--bg);
}
.admin-page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1.75rem;
gap: 1rem;
flex-wrap: wrap;
}
.admin-page-title { font-size: 1.5rem; font-weight: 800; margin: 0; }
/* Admin stat cards */
.admin-stat-card {
display: flex;
align-items: center;
gap: 1rem;
background: #fff;
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow-sm);
}
.admin-stat-icon {
width: 2.75rem;
height: 2.75rem;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
flex-shrink: 0;
}
.admin-stat-num {
font-size: 1.75rem;
font-weight: 800;
line-height: 1;
}
.admin-stat-total { font-size: 1rem; color: var(--muted); font-weight: 400; }
.admin-stat-lbl { font-size: .78rem; color: var(--muted); margin-top: .2rem; }
/* Status list */
.admin-status-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: .5rem 0;
border-bottom: 1px solid var(--border);
font-size: .875rem;
}
.admin-status-list li:last-child { border-bottom: none; }
/* Tables */
.admin-table th {
font-size: .78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--muted);
border-bottom: 2px solid var(--border);
padding: .75rem 1rem;
}
.admin-table td { padding: .75rem 1rem; vertical-align: middle; }
/* Domain badge in review */
.admin-domain-badge {
background: #eff6ff;
color: var(--primary);
font-size: .75rem;
font-weight: 600;
}
/* Review card */
.review-card { transition: box-shadow .15s; }
.review-card:hover { box-shadow: var(--shadow) !important; }
/* Admin check pills (checkboxes styled as tags) */
.admin-check-pill {
display: inline-flex;
align-items: center;
gap: .3rem;
background: #f8fafc;
border: 1px solid var(--border);
border-radius: 50px;
padding: .2rem .6rem;
font-size: .75rem;
cursor: pointer;
transition: background .12s, border-color .12s;
user-select: none;
}
.admin-check-pill:has(input:checked) {
background: #eff6ff;
border-color: var(--primary);
color: var(--primary);
}
.admin-check-pill input { display: none; }
/* Tag pill variants */
.tag-pill-warn { background: #fff7ed; border-color: #fed7aa; color: #9a3412; }
.tag-pill-blue { background: #eff6ff; border-color: #bfdbfe; color: #1e40af; }
.tag-pill-red { background: #fef2f2; border-color: #fecaca; color: #991b1b; }
.tag-pill-green { background: #f0fdf4; border-color: #bbf7d0; color: #166534; }
/* Domain divider in library */
.admin-domain-divider {
font-size: .9rem;
font-weight: 700;
color: var(--muted);
padding: 1rem 0 .4rem;
border-bottom: 2px solid var(--border);
margin-bottom: .5rem;
text-transform: uppercase;
letter-spacing: .04em;
}
/* Admin note */
.admin-note {
background: #fffbeb;
border-radius: var(--radius-sm);
padding: .35rem .6rem;
border: 1px solid #fde68a;
}
/* ── Crawl log terminal ───────────────────────────────────────── */
.crawl-log-box {
background: #0f172a;
border-radius: var(--radius-sm);
padding: .75rem 1rem;
font-family: 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
font-size: .78rem;
max-height: 280px;
overflow-y: auto;
line-height: 1.6;
}
.crawl-log-line { color: #94a3b8; }
.crawl-log-line.log-ok { color: #4ade80; }
.crawl-log-line.log-err { color: #f87171; }
.crawl-log-line.log-ai { color: #a78bfa; }
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 768px) {
.profile-tabs { gap: 0.25rem !important; }
.profile-tabs .nav-link { font-size: 0.78rem; padding: 0.4rem 0.65rem; }
.hero-section { padding: 2rem 0; }
.display-4 { font-size: 2rem; }
}

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 %}

9
wsgi.py Normal file
View File

@@ -0,0 +1,9 @@
"""
Point d'entrée Gunicorn pour la production.
Usage : gunicorn -c gunicorn.conf.py wsgi:app
"""
from app import app, _init_db, start_scheduler
with app.app_context():
_init_db()
start_scheduler()