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//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//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//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//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//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//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//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)