Initial production-ready release of OpenCare v1

- Flask/SQLAlchemy app with profile, dashboard, recommendations
- AI crawl pipeline (GPT-4o) with admin review workflow
- 14 curated health sources (RSS + index crawling)
- Production config: env vars, Gunicorn, systemd, nginx
- deploy/ scripts for VPS setup and updates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-05 21:38:03 +02:00
commit efd775706b
28 changed files with 4682 additions and 0 deletions

886
app.py Normal file
View File

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