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:
17
.claude/settings.local.json
Normal file
17
.claude/settings.local.json
Normal 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
16
.env.example
Normal 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
7
.gitattributes
vendored
Normal 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
39
.gitignore
vendored
Normal 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
886
app.py
Normal 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
47
deploy/nginx.conf
Normal 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
18
deploy/opencare.service
Normal 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
63
deploy/setup_vps.sh
Normal 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
11
deploy/update.sh
Normal 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
10
gunicorn.conf.py
Normal 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
407
pipeline.py
Normal 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
405
recommendations.py
Normal 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": "1000–1200 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: 12h–20h). 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
12
requirements.txt
Normal 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
588
static/css/style.css
Normal 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
12
templates/403.html
Normal 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 %}
|
||||
56
templates/admin/_sidebar.html
Normal file
56
templates/admin/_sidebar.html
Normal 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
132
templates/admin/config.html
Normal 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 (0–23)</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 (0–59)</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.01–0.05 USD par article (GPT-4o).
|
||||
20 articles/nuit ≈ 0.20–1.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
141
templates/admin/index.html
Normal 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 %}
|
||||
89
templates/admin/library.html
Normal file
89
templates/admin/library.html
Normal 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
443
templates/admin/review.html
Normal 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é (1–10)</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 %}
|
||||
313
templates/admin/sources.html
Normal file
313
templates/admin/sources.html
Normal 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
88
templates/base.html
Normal 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
183
templates/dashboard.html
Normal 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
136
templates/index.html
Normal 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
51
templates/login.html
Normal 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
438
templates/profile.html
Normal 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
65
templates/register.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user