978 lines
38 KiB
Python
978 lines
38 KiB
Python
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', '📌'),
|
|
}
|
|
|
|
_CONDITION_LABELS = {
|
|
'hypertension': 'Hypertension',
|
|
'diabetes_risk': 'Risque diabète',
|
|
'cardiovascular_risk': 'Risque cardio-vasculaire',
|
|
'anxiety': 'Anxiété',
|
|
'digestive_issues': 'Troubles digestifs',
|
|
'back_pain': 'Douleurs dorsales',
|
|
'osteoporosis_risk': 'Risque ostéoporose',
|
|
'metabolic_syndrome': 'Syndrome métabolique',
|
|
'thyroid': 'Thyroïde',
|
|
'sleep_apnea': 'Apnée du sommeil',
|
|
}
|
|
_GOAL_LABELS = {
|
|
'weight_loss': 'Perte de poids',
|
|
'muscle_gain': 'Prise de muscle',
|
|
'longevity': 'Longévité',
|
|
'energy': 'Énergie',
|
|
'better_sleep': 'Améliorer le sommeil',
|
|
'stress_reduction': 'Réduction du stress',
|
|
'metabolic_health': 'Santé métabolique',
|
|
'cardiovascular_health': 'Santé cardio-vasculaire',
|
|
'cognitive_health': 'Santé cognitive',
|
|
}
|
|
_DIET_LABELS = {
|
|
'omnivore': 'Omnivore',
|
|
'flexitarian': 'Flexitarien',
|
|
'vegetarian': 'Végétarien',
|
|
'vegan': 'Végétalien',
|
|
'paleo': 'Paléo',
|
|
'keto': 'Kéto',
|
|
'other': 'Autre',
|
|
}
|
|
_ACTIVITY_LABELS = {
|
|
'sedentary': 'Sédentaire',
|
|
'light': 'Légère activité',
|
|
'moderate': 'Activité modérée',
|
|
'intense': 'Activité intense',
|
|
'athlete': 'Athlète',
|
|
}
|
|
|
|
|
|
# ── Models ─────────────────────────────────────────────────────────────────────
|
|
|
|
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():
|
|
from pipeline import _CONDITIONS, _HEALTH_GOALS, _DIET_TYPES, _ACTIVITY_LEVELS
|
|
|
|
recs = PendingRecommendation.query.filter_by(status='approved') \
|
|
.order_by(PendingRecommendation.domain, PendingRecommendation.priority.desc()).all()
|
|
|
|
tree = {}
|
|
ev_total = {'A': 0, 'B': 0, 'C': 0}
|
|
for rec in recs:
|
|
d = rec.domain
|
|
ev = rec.evidence_level if rec.evidence_level in ('A', 'B', 'C') else 'C'
|
|
ev_total[ev] += 1
|
|
if d not in tree:
|
|
tree[d] = {'recs': [], 'tags': {}, 'ev': {'A': 0, 'B': 0, 'C': 0}}
|
|
tree[d]['recs'].append(rec)
|
|
tree[d]['ev'][ev] += 1
|
|
for tag in (rec.tags or []):
|
|
tag = tag.strip().lower()
|
|
if not tag:
|
|
continue
|
|
if tag not in tree[d]['tags']:
|
|
tree[d]['tags'][tag] = {'recs': [], 'ev': {'A': 0, 'B': 0, 'C': 0}}
|
|
tree[d]['tags'][tag]['recs'].append(rec)
|
|
tree[d]['tags'][tag]['ev'][ev] += 1
|
|
|
|
return render_template('admin/library.html',
|
|
recs=recs, tree=tree,
|
|
domain_labels=_DOMAIN_LABELS, ev_total=ev_total,
|
|
conditions=_CONDITIONS, health_goals=_HEALTH_GOALS,
|
|
diet_types=_DIET_TYPES, activity_levels=_ACTIVITY_LEVELS,
|
|
condition_labels=_CONDITION_LABELS, goal_labels=_GOAL_LABELS,
|
|
diet_labels=_DIET_LABELS, activity_labels=_ACTIVITY_LABELS)
|
|
|
|
|
|
@app.route('/admin/library/<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/library/<int:rid>/update', methods=['POST'])
|
|
@login_required
|
|
@admin_required
|
|
def admin_library_update(rid):
|
|
rec = db.get_or_404(PendingRecommendation, rid)
|
|
f = request.form
|
|
rec.target_sex = f.get('target_sex', 'all')
|
|
rec.target_age_min = int(f['target_age_min']) if f.get('target_age_min') else None
|
|
rec.target_age_max = int(f['target_age_max']) if f.get('target_age_max') else None
|
|
rec.not_for_under_18 = 'not_for_under_18' in f
|
|
rec.not_for_pregnant = 'not_for_pregnant' in f
|
|
rec.required_conditions = f.getlist('required_conditions')
|
|
rec.excluded_conditions = f.getlist('excluded_conditions')
|
|
rec.required_health_goals = f.getlist('required_health_goals')
|
|
rec.required_diet_types = f.getlist('required_diet_types')
|
|
rec.required_activity_levels = f.getlist('required_activity_levels')
|
|
rec.priority = int(f.get('priority', rec.priority))
|
|
rec.admin_notes = f.get('admin_notes', '').strip()
|
|
db.session.commit()
|
|
flash(f'Ciblage de « {rec.title} » mis à jour.', 'success')
|
|
return redirect(url_for('admin_library'))
|
|
|
|
|
|
@app.route('/admin/config', methods=['GET', 'POST'])
|
|
@login_required
|
|
@admin_required
|
|
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)
|