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:
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)
|
||||
Reference in New Issue
Block a user