from flask import Flask, render_template, request, redirect, url_for, send_file, flash, session from werkzeug.security import generate_password_hash, check_password_hash from functools import wraps import sqlite3 import os from datetime import datetime, timedelta import json from io import BytesIO app = Flask(__name__) # ✅ SÉCURITÉ : Clé secrète depuis variable d'environnement app.secret_key = os.environ.get('SECRET_KEY', 'changez-moi-en-production-utilisez-env') app.config['SESSION_COOKIE_SECURE'] = False # ✅ Changé pour HTTP (mettre True avec HTTPS) app.config['SESSION_COOKIE_HTTPONLY'] = True # Protection XSS app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Protection CSRF # ✅ Configuration base de données compatible Docker DB_PATH = os.path.join(os.path.dirname(__file__), 'data', 'menu_miam.db') # ✅ CREDENTIALS depuis variables d'environnement ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME', 'admin') ADMIN_PASSWORD_HASH = generate_password_hash( os.environ.get('ADMIN_PASSWORD', 'changeme123') ) # ========== DÉCORATEUR D'AUTHENTIFICATION ========== def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): if 'logged_in' not in session: flash('⚠️ Vous devez vous connecter pour accéder à cette page', 'warning') return redirect(url_for('login', next=request.url)) return f(*args, **kwargs) return decorated_function # ========== ROUTES D'AUTHENTIFICATION ========== @app.route('/login', methods=['GET', 'POST']) def login(): # Si déjà connecté, rediriger vers le menu if 'logged_in' in session: return redirect(url_for('menu')) if request.method == 'POST': username = request.form.get('username', '').strip() password = request.form.get('password', '') if username == ADMIN_USERNAME and check_password_hash(ADMIN_PASSWORD_HASH, password): session['logged_in'] = True session['username'] = username flash('✅ Connexion réussie !', 'success') # ✅ CORRECTION : Gérer correctement la redirection next_page = request.args.get('next') # Vérifier que next_page est un chemin relatif valide if next_page and next_page.startswith('/') and not next_page.startswith('//'): return redirect(next_page) # Sinon, rediriger vers le menu par défaut return redirect(url_for('menu')) else: flash('❌ Identifiants incorrects', 'error') return render_template('login.html') @app.route('/logout') def logout(): session.clear() flash('✅ Déconnexion réussie', 'success') return redirect(url_for('login')) # ========== CONNEXION BASE DE DONNÉES ========== def get_connection(): """Connexion à la base de données avec chemin persistant""" os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn # ========== INITIALISATION BASE DE DONNÉES ========== def init_db(): """Créer le dossier data et initialiser la base de données""" os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) conn = get_connection() # ✅ Table recettes conn.execute(''' CREATE TABLE IF NOT EXISTS recettes ( id INTEGER PRIMARY KEY AUTOINCREMENT, nom TEXT NOT NULL, ingredients TEXT, instructions TEXT, lien TEXT ) ''') # ✅ Table menu conn.execute(''' CREATE TABLE IF NOT EXISTS menu ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, jour TEXT NOT NULL, repas TEXT NOT NULL, recette_id INTEGER, FOREIGN KEY (recette_id) REFERENCES recettes (id), UNIQUE(date, jour, repas) ) ''') # ✅ Table accompagnements du menu (MODIFIÉ - ajout de la colonne date) conn.execute(''' CREATE TABLE IF NOT EXISTS menu_accompagnements ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, jour TEXT NOT NULL, repas TEXT NOT NULL, accompagnement_id INTEGER NOT NULL, FOREIGN KEY (accompagnement_id) REFERENCES accompagnements (id) ON DELETE CASCADE, UNIQUE(date, jour, repas, accompagnement_id) ) ''') # ✅ Table accompagnements conn.execute(''' CREATE TABLE IF NOT EXISTS accompagnements ( id INTEGER PRIMARY KEY AUTOINCREMENT, nom TEXT NOT NULL UNIQUE, descriptif TEXT ) ''') # ✅ Table de liaison recette-accompagnements (pour suggestions par défaut) conn.execute(''' CREATE TABLE IF NOT EXISTS recette_accompagnements ( id INTEGER PRIMARY KEY AUTOINCREMENT, recette_id INTEGER NOT NULL, accompagnement_id INTEGER NOT NULL, FOREIGN KEY (recette_id) REFERENCES recettes (id) ON DELETE CASCADE, FOREIGN KEY (accompagnement_id) REFERENCES accompagnements (id) ON DELETE CASCADE, UNIQUE(recette_id, accompagnement_id) ) ''') # ✅ Ajouter des recettes d'exemple si la base est vide count = conn.execute('SELECT COUNT(*) FROM recettes').fetchone()[0] if count == 0: recettes_exemple = [ ("Pâtes Carbonara", "Pâtes (400g), Lardons (200g), Œufs (4), Parmesan (100g), Crème fraîche (20cl)", "1. Cuire les pâtes al dente\n2. Faire revenir les lardons\n3. Mélanger œufs, parmesan et crème\n4. Incorporer aux pâtes chaudes\n5. Servir immédiatement", ""), ("Salade César", "Salade romaine (1), Poulet grillé (300g), Croûtons (100g), Parmesan (50g), Sauce césar (15cl)", "1. Laver et couper la salade\n2. Couper le poulet en lamelles\n3. Ajouter les croûtons et le parmesan\n4. Arroser de sauce césar\n5. Mélanger délicatement", ""), ("Tiramisu", "Mascarpone (500g), Œufs (6), Sucre (150g), Café fort (30cl), Biscuits cuillère (400g), Cacao en poudre", "1. Préparer le café et le laisser refroidir\n2. Séparer blancs et jaunes d'œufs\n3. Mélanger jaunes avec sucre et mascarpone\n4. Monter les blancs en neige et incorporer\n5. Tremper biscuits dans le café\n6. Alterner couches de biscuits et crème\n7. Saupoudrer de cacao\n8. Réfrigérer 4h minimum", ""), ("Poulet rôti", "Poulet entier (1,5kg), Beurre (50g), Citron (1), Thym, Ail (3 gousses), Pommes de terre (1kg)", "1. Préchauffer four à 200°C\n2. Farcir le poulet avec citron, thym et ail\n3. Badigeonner de beurre\n4. Disposer pommes de terre autour\n5. Cuire 1h15, arroser régulièrement", ""), ("Quiche Lorraine", "Pâte brisée (1), Lardons (200g), Œufs (4), Crème fraîche (30cl), Gruyère râpé (100g), Noix de muscade", "1. Préchauffer four à 180°C\n2. Étaler la pâte dans un moule\n3. Faire revenir les lardons\n4. Mélanger œufs, crème et gruyère\n5. Ajouter lardons et muscade\n6. Verser sur la pâte\n7. Cuire 35-40 minutes", "") ] try: conn.executemany(''' INSERT INTO recettes (nom, ingredients, instructions, lien) VALUES (?, ?, ?, ?) ''', recettes_exemple) conn.commit() print("✅ Base de données initialisée avec succès!") print(f"✅ {len(recettes_exemple)} recettes d'exemple ajoutées!") except sqlite3.IntegrityError as e: print(f"⚠️ Certaines recettes existent déjà : {e}") else: print(f"✅ Base de données déjà initialisée ({count} recettes)") # ✅ Ajouter des accompagnements d'exemple count_acc = conn.execute('SELECT COUNT(*) FROM accompagnements').fetchone()[0] if count_acc == 0: accompagnements_exemple = [ ("Riz blanc", "Riz basmati ou thaï"), ("Pâtes", "Pâtes italiennes variées"), ("Frites", "Pommes de terre frites maison"), ("Salade verte", "Salade fraîche de saison"), ("Légumes vapeur", "Carottes, brocolis, haricots verts"), ("Purée", "Purée de pommes de terre maison"), ("Ratatouille", "Légumes du soleil mijotés") ] try: conn.executemany(''' INSERT INTO accompagnements (nom, descriptif) VALUES (?, ?) ''', accompagnements_exemple) conn.commit() print(f"✅ {len(accompagnements_exemple)} accompagnements d'exemple ajoutés!") except sqlite3.IntegrityError as e: print(f"⚠️ Certains accompagnements existent déjà : {e}") conn.close() # ✅ INITIALISATION AU DÉMARRAGE init_db() # ========== ROUTES PROTÉGÉES ========== @app.route('/menu') @app.route('/') @login_required def menu(): offset = int(request.args.get('offset', 0)) conn = get_connection() # ✅ MODIFIÉ : Calculer les dates de la semaine AVANT la requête today = datetime.now().date() + timedelta(weeks=offset) start_of_week = today - timedelta(days=today.weekday()) end_of_week = start_of_week + timedelta(days=6) # ✅ MODIFIÉ : Récupération du menu pour LA SEMAINE ACTUELLE UNIQUEMENT menu_data = conn.execute(''' SELECT m.date, m.jour, m.repas, m.recette_id, r.nom, r.ingredients, r.lien FROM menu m LEFT JOIN recettes r ON m.recette_id = r.id WHERE m.date BETWEEN ? AND ? ''', (start_of_week.isoformat(), end_of_week.isoformat())).fetchall() menu = {} for row in menu_data: key = f"{row['jour']}_{row['repas']}" menu[key] = { 'id': row['recette_id'], 'nom': row['nom'], 'ingredients': row['ingredients'], 'lien': row['lien'] } # ✅ MODIFIÉ : Charger les accompagnements en utilisant la DATE + jour + repas accompagnements = conn.execute(''' SELECT a.id, a.nom, a.descriptif FROM menu_accompagnements ma JOIN accompagnements a ON ma.accompagnement_id = a.id WHERE ma.date = ? AND ma.jour = ? AND ma.repas = ? ORDER BY a.nom ''', (row['date'], row['jour'], row['repas'])).fetchall() menu[key]['accompagnements'] = [dict(acc) for acc in accompagnements] # Calcul des dates pour l'affichage days = [] jours = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'] for i, jour in enumerate(jours): date = start_of_week + timedelta(days=i) days.append({ 'name': jour, 'date': date.strftime('%d/%m/%Y') }) # Récupération de toutes les recettes pour le dropdown recettes = conn.execute('SELECT id, nom FROM recettes ORDER BY nom').fetchall() # Récupération de tous les accompagnements pour le dropdown tous_accompagnements = conn.execute('SELECT id, nom FROM accompagnements ORDER BY nom').fetchall() start_date = start_of_week.strftime('%d/%m/%Y') end_date = end_of_week.strftime('%d/%m/%Y') conn.close() return render_template('menu.html', menu=menu, days=days, recettes=recettes, tous_accompagnements=tous_accompagnements, offset=offset, start_date=start_date, end_date=end_date) @app.route('/add_to_menu', methods=['POST']) @login_required def add_to_menu(): day = request.form['day'] meal = request.form['meal'] recette_id = request.form['recette_id'] offset = int(request.form.get('offset', 0)) anchor = request.form.get('anchor', '') # ✅ AJOUTÉ today = datetime.now().date() start_of_week = today - timedelta(days=today.weekday()) + timedelta(weeks=offset) jours_fr = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'] day_index = jours_fr.index(day) target_date = start_of_week + timedelta(days=day_index) conn = get_connection() # Supprimer l'ancienne entrée avant d'ajouter la nouvelle conn.execute(''' DELETE FROM menu WHERE date = ? AND jour = ? AND repas = ? ''', (target_date.isoformat(), day, meal)) # Ajouter la nouvelle recette conn.execute(''' INSERT INTO menu (date, jour, repas, recette_id) VALUES (?, ?, ?, ?) ''', (target_date.isoformat(), day, meal, recette_id)) conn.commit() conn.close() return redirect(url_for('menu', offset=offset, _anchor=anchor)) # ✅ MODIFIÉ @app.route('/remove_from_menu', methods=['POST']) @login_required def remove_from_menu(): day = request.form['day'] meal = request.form['meal'] offset = int(request.form.get('offset', 0)) anchor = request.form.get('anchor', '') # ✅ AJOUTÉ # Calculer la date exacte today = datetime.now().date() start_of_week = today - timedelta(days=today.weekday()) + timedelta(weeks=offset) jours_fr = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'] day_index = jours_fr.index(day) target_date = start_of_week + timedelta(days=day_index) conn = get_connection() # Supprimer la recette du menu conn.execute(''' DELETE FROM menu WHERE date = ? AND jour = ? AND repas = ? ''', (target_date.isoformat(), day, meal)) # ✅ MODIFIÉ : Supprimer aussi les accompagnements avec la DATE conn.execute(''' DELETE FROM menu_accompagnements WHERE date = ? AND jour = ? AND repas = ? ''', (target_date.isoformat(), day, meal)) conn.commit() conn.close() return redirect(url_for('menu', offset=offset, _anchor=anchor)) # ✅ MODIFIÉ # ✅ MODIFIÉ : Ajouter un accompagnement au menu avec la DATE @app.route('/add_accompagnement_to_menu', methods=['POST']) @login_required def add_accompagnement_to_menu(): day = request.form['day'] meal = request.form['meal'] accompagnement_id = request.form['accompagnement_id'] offset = int(request.form.get('offset', 0)) anchor = request.form.get('anchor', '') # ✅ AJOUTÉ # Calculer la date exacte today = datetime.now().date() start_of_week = today - timedelta(days=today.weekday()) + timedelta(weeks=offset) jours_fr = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'] day_index = jours_fr.index(day) target_date = start_of_week + timedelta(days=day_index) conn = get_connection() # Ajouter l'accompagnement pour CE jour/repas/date spécifique conn.execute(''' INSERT OR IGNORE INTO menu_accompagnements (date, jour, repas, accompagnement_id) VALUES (?, ?, ?, ?) ''', (target_date.isoformat(), day, meal, accompagnement_id)) conn.commit() conn.close() return redirect(url_for('menu', offset=offset, _anchor=anchor)) # ✅ MODIFIÉ # ✅ MODIFIÉ : Retirer un accompagnement du menu avec la DATE @app.route('/remove_accompagnement_from_menu', methods=['POST']) @login_required def remove_accompagnement_from_menu(): day = request.form['day'] meal = request.form['meal'] accompagnement_id = request.form['accompagnement_id'] offset = int(request.form.get('offset', 0)) anchor = request.form.get('anchor', '') # ✅ AJOUTÉ # Calculer la date exacte today = datetime.now().date() start_of_week = today - timedelta(days=today.weekday()) + timedelta(weeks=offset) jours_fr = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'] day_index = jours_fr.index(day) target_date = start_of_week + timedelta(days=day_index) conn = get_connection() # Retirer l'accompagnement pour CE jour/repas/date spécifique conn.execute(''' DELETE FROM menu_accompagnements WHERE date = ? AND jour = ? AND repas = ? AND accompagnement_id = ? ''', (target_date.isoformat(), day, meal, accompagnement_id)) conn.commit() conn.close() return redirect(url_for('menu', offset=offset, _anchor=anchor)) # ✅ MODIFIÉ @app.route('/change_week', methods=['POST']) @login_required def change_week(): offset = int(request.form['offset']) return redirect(url_for('menu', offset=offset)) # ========== ✅ NOUVEAU : ROUTE STATISTIQUES ========== @app.route('/stats') @login_required def stats(): conn = get_connection() # 📊 Top 10 recettes les plus utilisées top_recettes = conn.execute(''' SELECT r.nom, COUNT(*) as count FROM menu m JOIN recettes r ON m.recette_id = r.id GROUP BY r.nom ORDER BY count DESC LIMIT 10 ''').fetchall() # 📊 Nombre total de recettes planifiées total_planifiees = conn.execute('SELECT COUNT(*) as count FROM menu').fetchone()['count'] # 📊 Nombre total de recettes disponibles total_recettes = conn.execute('SELECT COUNT(*) as count FROM recettes').fetchone()['count'] # 📊 Recettes jamais utilisées jamais_utilisees = conn.execute(''' SELECT r.nom FROM recettes r LEFT JOIN menu m ON r.id = m.recette_id WHERE m.id IS NULL ORDER BY r.nom ''').fetchall() # 📊 Accompagnements les plus utilisés top_accompagnements = conn.execute(''' SELECT a.nom, COUNT(*) as count FROM menu_accompagnements ma JOIN accompagnements a ON ma.accompagnement_id = a.id GROUP BY a.nom ORDER BY count DESC LIMIT 10 ''').fetchall() conn.close() return render_template('stats.html', top_recettes=top_recettes, total_planifiees=total_planifiees, total_recettes=total_recettes, jamais_utilisees=jamais_utilisees, top_accompagnements=top_accompagnements) # ========== FIN NOUVEAU ========== @app.route('/recettes') @login_required def recettes(): conn = get_connection() search = request.args.get('search', '').strip() if search: recettes = conn.execute(''' SELECT * FROM recettes WHERE nom LIKE ? OR ingredients LIKE ? ORDER BY nom ''', (f'%{search}%', f'%{search}%')).fetchall() else: recettes = conn.execute('SELECT * FROM recettes ORDER BY nom').fetchall() conn.close() return render_template('recettes.html', recettes=recettes, search=search) @app.route('/recettes/add', methods=['GET', 'POST']) @login_required def add_recette(): if request.method == 'POST': nom = request.form['nom'] ingredients = request.form.get('ingredients', '') instructions = request.form.get('instructions', '') lien = request.form.get('lien', '') conn = get_connection() conn.execute(''' INSERT INTO recettes (nom, ingredients, instructions, lien) VALUES (?, ?, ?, ?) ''', (nom, ingredients, instructions, lien)) conn.commit() conn.close() return redirect(url_for('recettes')) return render_template('add_recette.html') @app.route('/recettes/edit/', methods=['GET', 'POST']) @login_required def edit_recette(id): conn = get_connection() if request.method == 'POST': nom = request.form['nom'] ingredients = request.form.get('ingredients', '') instructions = request.form.get('instructions', '') lien = request.form.get('lien', '') # Mise à jour de la recette conn.execute(''' UPDATE recettes SET nom = ?, ingredients = ?, instructions = ?, lien = ? WHERE id = ? ''', (nom, ingredients, instructions, lien, id)) # Gestion des accompagnements (suggestions par défaut pour cette recette) # 1. Supprimer les anciennes associations conn.execute('DELETE FROM recette_accompagnements WHERE recette_id = ?', (id,)) # 2. Ajouter les nouvelles associations accompagnements_ids = request.form.getlist('accompagnements') for acc_id in accompagnements_ids: conn.execute(''' INSERT INTO recette_accompagnements (recette_id, accompagnement_id) VALUES (?, ?) ''', (id, acc_id)) conn.commit() conn.close() flash('✅ Recette modifiée avec succès !', 'success') return redirect(url_for('recettes')) # Récupération des données pour l'affichage recette = conn.execute('SELECT * FROM recettes WHERE id = ?', (id,)).fetchone() accompagnements = conn.execute('SELECT * FROM accompagnements ORDER BY nom').fetchall() # Récupérer les accompagnements sélectionnés selected = conn.execute(''' SELECT accompagnement_id FROM recette_accompagnements WHERE recette_id = ? ''', (id,)).fetchall() selected_accompagnements = [row['accompagnement_id'] for row in selected] conn.close() return render_template('edit_recette.html', recette=recette, accompagnements=accompagnements, selected_accompagnements=selected_accompagnements) @app.route('/recettes/delete/', methods=['POST']) @login_required def delete_recette(id): conn = get_connection() conn.execute('DELETE FROM recettes WHERE id = ?', (id,)) conn.commit() conn.close() return redirect(url_for('recettes')) @app.route('/export_recettes') @login_required def export_recettes(): """Exporter toutes les recettes en JSON""" conn = get_connection() recettes = conn.execute('SELECT nom, ingredients, instructions, lien FROM recettes ORDER BY nom').fetchall() conn.close() recettes_list = [dict(row) for row in recettes] json_data = json.dumps(recettes_list, ensure_ascii=False, indent=2) buffer = BytesIO() buffer.write(json_data.encode('utf-8')) buffer.seek(0) filename = f"recettes_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" return send_file( buffer, mimetype='application/json', as_attachment=True, download_name=filename ) @app.route('/import_recettes', methods=['POST']) @login_required def import_recettes(): """Importer des recettes depuis un fichier JSON""" if 'file' not in request.files: flash('Aucun fichier sélectionné', 'error') return redirect(url_for('recettes')) file = request.files['file'] if file.filename == '': flash('Aucun fichier sélectionné', 'error') return redirect(url_for('recettes')) if not file.filename.endswith('.json'): flash('Le fichier doit être au format JSON', 'error') return redirect(url_for('recettes')) try: data = json.load(file) if not isinstance(data, list): flash('Format JSON invalide (doit être une liste)', 'error') return redirect(url_for('recettes')) conn = get_connection() imported = 0 updated = 0 for recette in data: nom = recette.get('nom', '').strip() if not nom: continue ingredients = recette.get('ingredients', '') instructions = recette.get('instructions', '') lien = recette.get('lien', '') existing = conn.execute('SELECT id FROM recettes WHERE nom = ?', (nom,)).fetchone() if existing: conn.execute(''' UPDATE recettes SET ingredients=?, instructions=?, lien=? WHERE nom=? ''', (ingredients, instructions, lien, nom)) updated += 1 else: conn.execute(''' INSERT INTO recettes (nom, ingredients, instructions, lien) VALUES (?, ?, ?, ?) ''', (nom, ingredients, instructions, lien)) imported += 1 conn.commit() conn.close() flash(f'✅ Import réussi ! {imported} recette(s) ajoutée(s), {updated} mise(s) à jour', 'success') except json.JSONDecodeError: flash('❌ Erreur : fichier JSON invalide', 'error') except Exception as e: flash(f'❌ Erreur lors de l\'import : {str(e)}', 'error') return redirect(url_for('recettes')) # ========== ROUTES ACCOMPAGNEMENTS ========== @app.route('/accompagnements') @login_required def accompagnements(): conn = get_connection() accompagnements = conn.execute('SELECT * FROM accompagnements ORDER BY nom').fetchall() conn.close() return render_template('accompagnements.html', accompagnements=accompagnements) @app.route('/accompagnements/add', methods=['GET', 'POST']) @login_required def add_accompagnement(): if request.method == 'POST': nom = request.form['nom'].strip() descriptif = request.form.get('descriptif', '').strip() conn = get_connection() conn.execute(''' INSERT INTO accompagnements (nom, descriptif) VALUES (?, ?) ''', (nom, descriptif)) conn.commit() conn.close() flash('✅ Accompagnement ajouté avec succès !', 'success') return redirect(url_for('accompagnements')) return render_template('add_accompagnement.html') @app.route('/accompagnements/edit/', methods=['GET', 'POST']) @login_required def edit_accompagnement(id): conn = get_connection() if request.method == 'POST': nom = request.form['nom'].strip() descriptif = request.form.get('descriptif', '').strip() conn.execute(''' UPDATE accompagnements SET nom = ?, descriptif = ? WHERE id = ? ''', (nom, descriptif, id)) conn.commit() conn.close() flash('✅ Accompagnement modifié avec succès !', 'success') return redirect(url_for('accompagnements')) accompagnement = conn.execute('SELECT * FROM accompagnements WHERE id = ?', (id,)).fetchone() conn.close() return render_template('edit_accompagnement.html', accompagnement=accompagnement) @app.route('/accompagnements/delete/', methods=['POST']) @login_required def delete_accompagnement(id): conn = get_connection() conn.execute('DELETE FROM accompagnements WHERE id = ?', (id,)) conn.commit() conn.close() flash('✅ Accompagnement supprimé avec succès !', 'success') return redirect(url_for('accompagnements')) if __name__ == '__main__': app.run(debug=False, host='0.0.0.0')