Spaces:
Running
Running
| # --- START OF index.py --- | |
| import os | |
| import logging | |
| import json | |
| from flask import Flask, Blueprint, render_template, request, redirect, url_for, flash, jsonify, session, current_app, send_file, Response | |
| from flask_sqlalchemy import SQLAlchemy | |
| from sqlalchemy.orm import DeclarativeBase | |
| from werkzeug.utils import secure_filename | |
| from werkzeug.security import check_password_hash, generate_password_hash | |
| from datetime import datetime | |
| from functools import wraps | |
| import requests | |
| from io import BytesIO | |
| import base64 | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO) | |
| # --------------------------------------------------------------------------- | |
| # Configuration (Hardcoded as requested) | |
| # --------------------------------------------------------------------------- | |
| # Database configuration | |
| SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') | |
| # Session secret key | |
| SECRET_KEY = os.environ.get('SESSION_SECRET', 'dev_secret_key_change_in_production') | |
| if SECRET_KEY == 'dev_secret_key_change_in_production': | |
| print("WARNING: Using default SECRET_KEY. Set SESSION_SECRET environment variable for production.") | |
| # Admin login credentials (simple authentication for single admin) | |
| ADMIN_USERNAME = os.environ.get('ADMIN_USERNAME', 'admin') | |
| ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', 'password') | |
| # Telegram configuration for feedback | |
| TELEGRAM_BOT_TOKEN = "7126991043:AAEzeKswNo6eO7oJA49Hxn_bsbzgzUoJ-6A" | |
| TELEGRAM_CHAT_ID = "-1002081124539" | |
| # Application host/port/debug | |
| DEBUG = os.environ.get('DEBUG', 'False') == 'True' | |
| HOST = '0.0.0.0' | |
| PORT = int(os.environ.get('PORT', 5000)) | |
| # --------------------------------------------------------------------------- | |
| # Flask App Initialization and SQLAlchemy Setup | |
| # --------------------------------------------------------------------------- | |
| # Create base class for SQLAlchemy models | |
| class Base(DeclarativeBase): | |
| pass | |
| # Initialize SQLAlchemy with the Base class | |
| db = SQLAlchemy(model_class=Base) | |
| # Create Flask application | |
| app = Flask(__name__) | |
| # Apply configuration | |
| app.config['SQLALCHEMY_DATABASE_URI'] = SQLALCHEMY_DATABASE_URI | |
| app.config['SECRET_KEY'] = SECRET_KEY | |
| app.config['ADMIN_USERNAME'] = ADMIN_USERNAME | |
| app.config['ADMIN_PASSWORD'] = ADMIN_PASSWORD | |
| app.config['TELEGRAM_BOT_TOKEN'] = TELEGRAM_BOT_TOKEN | |
| app.config['TELEGRAM_CHAT_ID'] = TELEGRAM_CHAT_ID | |
| app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False | |
| app.config["SQLALCHEMY_ENGINE_OPTIONS"] = { | |
| "pool_recycle": 300, | |
| "pool_pre_ping": True, | |
| } | |
| app.config['HOST'] = HOST | |
| app.config['PORT'] = PORT | |
| app.config['DEBUG'] = DEBUG | |
| app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max upload | |
| # Initialize the app with SQLAlchemy | |
| db.init_app(app) | |
| # --------------------------------------------------------------------------- | |
| # Database Models | |
| # --------------------------------------------------------------------------- | |
| # Matiere (Subject) Model | |
| class Matiere(db.Model): | |
| id = db.Column(db.Integer, primary_key=True) | |
| nom = db.Column(db.String(100), unique=True, nullable=False) | |
| color_code = db.Column(db.String(7), nullable=False, default="#3498db") # Add color code field for subject | |
| # Relationships | |
| sous_categories = db.relationship('SousCategorie', backref='matiere', lazy=True, cascade="all, delete-orphan") | |
| def __repr__(self): | |
| return f'<Matiere {self.nom}>' | |
| # SousCategorie (SubCategory) Model | |
| class SousCategorie(db.Model): | |
| id = db.Column(db.Integer, primary_key=True) | |
| nom = db.Column(db.String(100), nullable=False) | |
| matiere_id = db.Column(db.Integer, db.ForeignKey('matiere.id'), nullable=False) | |
| # Enforce unique constraint on name within same matiere | |
| __table_args__ = (db.UniqueConstraint('nom', 'matiere_id', name='_nom_matiere_uc'),) | |
| # Relationships | |
| textes = db.relationship('Texte', backref='sous_categorie', lazy=True, cascade="all, delete-orphan") | |
| def __repr__(self): | |
| return f'<SousCategorie {self.nom} (Matiere ID: {self.matiere_id})>' | |
| # ContentBlock model for the new block-based content | |
| class ContentBlock(db.Model): | |
| id = db.Column(db.Integer, primary_key=True) | |
| texte_id = db.Column(db.Integer, db.ForeignKey('texte.id'), nullable=False) | |
| title = db.Column(db.String(200), nullable=True) | |
| content = db.Column(db.Text, nullable=False) | |
| order = db.Column(db.Integer, nullable=False, default=0) | |
| image_id = db.Column(db.Integer, db.ForeignKey('image.id'), nullable=True) | |
| image_position = db.Column(db.String(10), nullable=True, default='left') # 'left', 'right', 'top', 'bottom' | |
| # Relationship | |
| image = db.relationship('Image', foreign_keys=[image_id]) | |
| def __repr__(self): | |
| return f'<ContentBlock {self.id} (Texte ID: {self.texte_id})>' | |
| # Texte (Text content) Model | |
| class Texte(db.Model): | |
| id = db.Column(db.Integer, primary_key=True) | |
| titre = db.Column(db.String(200), nullable=False) | |
| contenu = db.Column(db.Text, nullable=False) | |
| sous_categorie_id = db.Column(db.Integer, db.ForeignKey('sous_categorie.id'), nullable=False) | |
| created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) | |
| updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| # Relationships | |
| historiques = db.relationship('TexteHistorique', backref='texte', lazy=True, cascade="all, delete-orphan") | |
| content_blocks = db.relationship('ContentBlock', backref='texte', lazy=True, cascade="all, delete-orphan", | |
| order_by="ContentBlock.order") | |
| def __repr__(self): | |
| return f'<Texte {self.titre} (SousCategorie ID: {self.sous_categorie_id})>' | |
| # Image Model for storing content images | |
| class Image(db.Model): | |
| id = db.Column(db.Integer, primary_key=True) | |
| nom_fichier = db.Column(db.String(255)) | |
| mime_type = db.Column(db.String(100), nullable=False) | |
| data = db.Column(db.LargeBinary, nullable=False) # BLOB/BYTEA for image data | |
| uploaded_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) | |
| # Additional fields for image management | |
| description = db.Column(db.String(255), nullable=True) | |
| alt_text = db.Column(db.String(255), nullable=True) | |
| def __repr__(self): | |
| return f'<Image {self.id} ({self.nom_fichier})>' | |
| # TexteHistorique (Text History) Model | |
| class TexteHistorique(db.Model): | |
| id = db.Column(db.Integer, primary_key=True) | |
| texte_id = db.Column(db.Integer, db.ForeignKey('texte.id'), nullable=False) | |
| contenu_precedent = db.Column(db.Text, nullable=False) | |
| date_modification = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) | |
| def __repr__(self): | |
| return f'<TexteHistorique {self.id} (Texte ID: {self.texte_id})>' | |
| # UserPreference Model to store user theme preferences | |
| class UserPreference(db.Model): | |
| id = db.Column(db.Integer, primary_key=True) | |
| user_id = db.Column(db.String(50), unique=True, nullable=False) # Use session ID or similar for anonymous users | |
| theme = db.Column(db.String(10), nullable=False, default='light') # 'light' or 'dark' | |
| created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) | |
| updated_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow, onupdate=datetime.utcnow) | |
| def __repr__(self): | |
| return f'<UserPreference {self.id} (User ID: {self.user_id})>' | |
| # --------------------------------------------------------------------------- | |
| # Utility Functions | |
| # --------------------------------------------------------------------------- | |
| # Admin authentication decorator | |
| def admin_required(f): | |
| def decorated_function(*args, **kwargs): | |
| if not session.get('admin_logged_in'): | |
| flash('Veuillez vous connecter pour accéder à cette page.', 'warning') | |
| return redirect(url_for('admin_bp.login')) | |
| return f(*args, **kwargs) | |
| return decorated_function | |
| # Admin login check | |
| def check_admin_credentials(username, password): | |
| # Access config via current_app proxy | |
| admin_username = current_app.config['ADMIN_USERNAME'] | |
| admin_password = current_app.config['ADMIN_PASSWORD'] | |
| return username == admin_username and password == admin_password | |
| # Send feedback to Telegram | |
| def send_telegram_feedback(message): | |
| token = current_app.config.get('TELEGRAM_BOT_TOKEN') | |
| chat_id = current_app.config.get('TELEGRAM_CHAT_ID') | |
| if not token or not chat_id: | |
| current_app.logger.error("Telegram bot token or chat ID not configured") | |
| return False | |
| api_url = f"https://api.telegram.org/bot{token}/sendMessage" | |
| payload = { | |
| "chat_id": chat_id, | |
| "text": f"📝 Nouveau feedback:\n\n{message}", | |
| "parse_mode": "HTML" | |
| } | |
| try: | |
| response = requests.post(api_url, data=payload, timeout=10) | |
| response.raise_for_status() | |
| current_app.logger.info("Feedback sent to Telegram successfully") | |
| return True | |
| except requests.exceptions.RequestException as e: | |
| current_app.logger.error(f"Error sending feedback to Telegram: {str(e)}") | |
| if hasattr(e, 'response') and e.response is not None: | |
| current_app.logger.error(f"Telegram API Response: {e.response.text}") | |
| return False | |
| except Exception as e: | |
| current_app.logger.error(f"Unexpected exception while sending feedback to Telegram: {str(e)}") | |
| return False | |
| # Get or create user preferences | |
| def get_user_preferences(): | |
| user_id = session.get('user_id') | |
| if not user_id: | |
| # Generate a unique ID for new users | |
| user_id = str(datetime.utcnow().timestamp()) | |
| session['user_id'] = user_id | |
| user_pref = UserPreference.query.filter_by(user_id=user_id).first() | |
| if not user_pref: | |
| user_pref = UserPreference(user_id=user_id) | |
| db.session.add(user_pref) | |
| db.session.commit() | |
| return user_pref | |
| # Parse text content into blocks | |
| def parse_content_to_blocks(text_content): | |
| # Simple parser that creates blocks based on paragraphs or headings | |
| blocks = [] | |
| current_block = {"title": None, "content": ""} | |
| for line in text_content.split('\n'): | |
| line = line.strip() | |
| if not line: | |
| # Empty line might indicate a block break | |
| if current_block["content"]: | |
| blocks.append(current_block) | |
| current_block = {"title": None, "content": ""} | |
| continue | |
| # Check if line might be a heading (simplistic approach) | |
| if len(line) < 100 and not current_block["content"]: | |
| current_block["title"] = line | |
| else: | |
| if current_block["content"]: | |
| current_block["content"] += "\n" + line | |
| else: | |
| current_block["content"] = line | |
| # Add the last block if not empty | |
| if current_block["content"]: | |
| blocks.append(current_block) | |
| # If no blocks were created, create one with all content | |
| if not blocks: | |
| blocks.append({"title": None, "content": text_content}) | |
| return blocks | |
| # --------------------------------------------------------------------------- | |
| # Blueprints Definition | |
| # --------------------------------------------------------------------------- | |
| main_bp = Blueprint('main_bp', __name__) | |
| admin_bp = Blueprint('admin_bp', __name__, url_prefix='/gestion') | |
| # --------------------------------------------------------------------------- | |
| # Main Routes | |
| # --------------------------------------------------------------------------- | |
| def index(): | |
| # Get user theme preference | |
| user_pref = get_user_preferences() | |
| # Fetch all subjects (matieres) | |
| matieres = Matiere.query.all() | |
| return render_template('index.html', matieres=matieres, theme=user_pref.theme) | |
| def get_sous_categories(matiere_id): | |
| sous_categories = SousCategorie.query.filter_by(matiere_id=matiere_id).all() | |
| return jsonify([{'id': sc.id, 'nom': sc.nom} for sc in sous_categories]) | |
| def get_textes(sous_categorie_id): | |
| textes = Texte.query.filter_by(sous_categorie_id=sous_categorie_id).all() | |
| return jsonify([{'id': t.id, 'titre': t.titre} for t in textes]) | |
| def get_texte(texte_id): | |
| texte = Texte.query.get_or_404(texte_id) | |
| # Get the subject color for theming | |
| matiere = Matiere.query.join(SousCategorie).filter(SousCategorie.id == texte.sous_categorie_id).first() | |
| color_code = matiere.color_code if matiere else "#3498db" | |
| # Check if the texte has content blocks | |
| if texte.content_blocks: | |
| blocks = [] | |
| for block in texte.content_blocks: | |
| block_data = { | |
| 'id': block.id, | |
| 'title': block.title, | |
| 'content': block.content, | |
| 'order': block.order, | |
| 'image_position': block.image_position, | |
| 'image': None | |
| } | |
| # Add image data if available | |
| if block.image: | |
| image_data = base64.b64encode(block.image.data).decode('utf-8') | |
| block_data['image'] = { | |
| 'id': block.image.id, | |
| 'src': f"data:{block.image.mime_type};base64,{image_data}", | |
| 'alt': block.image.alt_text or block.title or "Image illustration" | |
| } | |
| blocks.append(block_data) | |
| else: | |
| # If no blocks exist yet, parse the content to create blocks | |
| # This is useful for existing content migration | |
| parsed_blocks = parse_content_to_blocks(texte.contenu) | |
| blocks = [] | |
| for i, block_data in enumerate(parsed_blocks): | |
| blocks.append({ | |
| 'id': None, | |
| 'title': block_data['title'], | |
| 'content': block_data['content'], | |
| 'order': i, | |
| 'image_position': 'left', | |
| 'image': None | |
| }) | |
| return jsonify({ | |
| 'id': texte.id, | |
| 'titre': texte.titre, | |
| 'contenu': texte.contenu, | |
| 'blocks': blocks, | |
| 'color_code': color_code | |
| }) | |
| def submit_feedback(): | |
| message = request.form.get('message', '').strip() | |
| if not message: | |
| flash("Le message ne peut pas être vide.", "error") | |
| return redirect(url_for('main_bp.index')) | |
| success = send_telegram_feedback(message) | |
| if success: | |
| flash("Merci pour votre feedback!", "success") | |
| else: | |
| flash("Une erreur s'est produite lors de l'envoi de votre feedback. Veuillez réessayer plus tard.", "error") | |
| return redirect(url_for('main_bp.index')) | |
| def set_theme(): | |
| theme = request.form.get('theme', 'light') | |
| if theme not in ['light', 'dark']: | |
| theme = 'light' | |
| user_pref = get_user_preferences() | |
| user_pref.theme = theme | |
| db.session.commit() | |
| return jsonify({'success': True, 'theme': theme}) | |
| def get_image(image_id): | |
| image = Image.query.get_or_404(image_id) | |
| return Response(image.data, mimetype=image.mime_type) | |
| # --------------------------------------------------------------------------- | |
| # Admin Routes | |
| # --------------------------------------------------------------------------- | |
| def login(): | |
| if request.method == 'POST': | |
| username = request.form.get('username') | |
| password = request.form.get('password') | |
| if check_admin_credentials(username, password): | |
| session['admin_logged_in'] = True | |
| flash('Connexion réussie !', 'success') | |
| return redirect(url_for('admin_bp.dashboard')) | |
| else: | |
| flash('Nom d\'utilisateur ou mot de passe incorrect.', 'danger') | |
| return render_template('admin/login.html') | |
| def logout(): | |
| session.pop('admin_logged_in', None) | |
| flash('Vous avez été déconnecté.', 'info') | |
| return redirect(url_for('admin_bp.login')) | |
| def dashboard(): | |
| # Count of each entity type for dashboard stats | |
| stats = { | |
| 'matieres': Matiere.query.count(), | |
| 'sous_categories': SousCategorie.query.count(), | |
| 'textes': Texte.query.count(), | |
| 'images': Image.query.count() | |
| } | |
| # Get recent texts for dashboard | |
| recent_textes = Texte.query.order_by(Texte.updated_at.desc()).limit(5).all() | |
| return render_template('admin/dashboard.html', stats=stats, recent_textes=recent_textes) | |
| # Matières (Subjects) Management | |
| def matieres(): | |
| if request.method == 'POST': | |
| action = request.form.get('action') | |
| if action == 'add': | |
| nom = request.form.get('nom', '').strip() | |
| color_code = request.form.get('color_code', '#3498db') | |
| if not nom: | |
| flash('Le nom de la matière est requis.', 'danger') | |
| else: | |
| matiere = Matiere.query.filter_by(nom=nom).first() | |
| if matiere: | |
| flash(f'La matière "{nom}" existe déjà.', 'warning') | |
| else: | |
| new_matiere = Matiere(nom=nom, color_code=color_code) | |
| db.session.add(new_matiere) | |
| db.session.commit() | |
| flash(f'Matière "{nom}" ajoutée avec succès !', 'success') | |
| elif action == 'edit': | |
| matiere_id = request.form.get('matiere_id') | |
| nom = request.form.get('nom', '').strip() | |
| color_code = request.form.get('color_code', '#3498db') | |
| if not matiere_id or not nom: | |
| flash('Informations incomplètes pour la modification.', 'danger') | |
| else: | |
| matiere = Matiere.query.get(matiere_id) | |
| if not matiere: | |
| flash('Matière non trouvée.', 'danger') | |
| else: | |
| existing = Matiere.query.filter_by(nom=nom).first() | |
| if existing and existing.id != int(matiere_id): | |
| flash(f'Une autre matière avec le nom "{nom}" existe déjà.', 'warning') | |
| else: | |
| matiere.nom = nom | |
| matiere.color_code = color_code | |
| db.session.commit() | |
| flash(f'Matière "{nom}" modifiée avec succès !', 'success') | |
| elif action == 'delete': | |
| matiere_id = request.form.get('matiere_id') | |
| if not matiere_id: | |
| flash('ID de matière manquant pour la suppression.', 'danger') | |
| else: | |
| matiere = Matiere.query.get(matiere_id) | |
| if not matiere: | |
| flash('Matière non trouvée.', 'danger') | |
| else: | |
| nom = matiere.nom | |
| db.session.delete(matiere) | |
| db.session.commit() | |
| flash(f'Matière "{nom}" supprimée avec succès !', 'success') | |
| matieres = Matiere.query.all() | |
| return render_template('admin/matieres.html', matieres=matieres) | |
| # Sous-Catégories (Subcategories) Management | |
| def sous_categories(): | |
| if request.method == 'POST': | |
| action = request.form.get('action') | |
| if action == 'add': | |
| nom = request.form.get('nom', '').strip() | |
| matiere_id = request.form.get('matiere_id') | |
| if not nom or not matiere_id: | |
| flash('Le nom et la matière sont requis.', 'danger') | |
| else: | |
| sous_categorie = SousCategorie.query.filter_by(nom=nom, matiere_id=matiere_id).first() | |
| if sous_categorie: | |
| flash(f'La sous-catégorie "{nom}" existe déjà pour cette matière.', 'warning') | |
| else: | |
| new_sous_categorie = SousCategorie(nom=nom, matiere_id=matiere_id) | |
| db.session.add(new_sous_categorie) | |
| db.session.commit() | |
| flash(f'Sous-catégorie "{nom}" ajoutée avec succès !', 'success') | |
| elif action == 'edit': | |
| sous_categorie_id = request.form.get('sous_categorie_id') | |
| nom = request.form.get('nom', '').strip() | |
| matiere_id = request.form.get('matiere_id') | |
| if not sous_categorie_id or not nom or not matiere_id: | |
| flash('Informations incomplètes pour la modification.', 'danger') | |
| else: | |
| sous_categorie = SousCategorie.query.get(sous_categorie_id) | |
| if not sous_categorie: | |
| flash('Sous-catégorie non trouvée.', 'danger') | |
| else: | |
| existing = SousCategorie.query.filter_by(nom=nom, matiere_id=matiere_id).first() | |
| if existing and existing.id != int(sous_categorie_id): | |
| flash(f'Une sous-catégorie avec le nom "{nom}" existe déjà pour cette matière.', 'warning') | |
| else: | |
| sous_categorie.nom = nom | |
| sous_categorie.matiere_id = matiere_id | |
| db.session.commit() | |
| flash(f'Sous-catégorie "{nom}" modifiée avec succès !', 'success') | |
| elif action == 'delete': | |
| sous_categorie_id = request.form.get('sous_categorie_id') | |
| if not sous_categorie_id: | |
| flash('ID de sous-catégorie manquant pour la suppression.', 'danger') | |
| else: | |
| sous_categorie = SousCategorie.query.get(sous_categorie_id) | |
| if not sous_categorie: | |
| flash('Sous-catégorie non trouvée.', 'danger') | |
| else: | |
| nom = sous_categorie.nom | |
| db.session.delete(sous_categorie) | |
| db.session.commit() | |
| flash(f'Sous-catégorie "{nom}" supprimée avec succès !', 'success') | |
| sous_categories = SousCategorie.query.join(Matiere).all() | |
| matieres = Matiere.query.all() | |
| return render_template('admin/sous_categories.html', sous_categories=sous_categories, matieres=matieres) | |
| # Textes (Content) Management | |
| def textes(): | |
| if request.method == 'POST': | |
| action = request.form.get('action') | |
| if action == 'add': | |
| titre = request.form.get('titre', '').strip() | |
| sous_categorie_id = request.form.get('sous_categorie_id') | |
| contenu = request.form.get('contenu', '').strip() | |
| if not titre or not sous_categorie_id or not contenu: | |
| flash('Tous les champs sont requis.', 'danger') | |
| else: | |
| new_texte = Texte( | |
| titre=titre, | |
| sous_categorie_id=sous_categorie_id, | |
| contenu=contenu | |
| ) | |
| db.session.add(new_texte) | |
| db.session.commit() | |
| # Parse content into blocks | |
| blocks = parse_content_to_blocks(contenu) | |
| for i, block_data in enumerate(blocks): | |
| new_block = ContentBlock( | |
| texte_id=new_texte.id, | |
| title=block_data['title'], | |
| content=block_data['content'], | |
| order=i | |
| ) | |
| db.session.add(new_block) | |
| db.session.commit() | |
| flash(f'Texte "{titre}" ajouté avec succès !', 'success') | |
| return redirect(url_for('admin_bp.edit_texte', texte_id=new_texte.id)) | |
| elif action == 'delete': | |
| texte_id = request.form.get('texte_id') | |
| if not texte_id: | |
| flash('ID de texte manquant pour la suppression.', 'danger') | |
| else: | |
| texte = Texte.query.get(texte_id) | |
| if not texte: | |
| flash('Texte non trouvé.', 'danger') | |
| else: | |
| titre = texte.titre | |
| db.session.delete(texte) | |
| db.session.commit() | |
| flash(f'Texte "{titre}" supprimé avec succès !', 'success') | |
| matieres = Matiere.query.all() | |
| textes = Texte.query.join(SousCategorie).join(Matiere).order_by(Matiere.nom, SousCategorie.nom, Texte.titre).all() | |
| # Group texts by matiere and sous_categorie for easier display | |
| grouped_textes = {} | |
| for texte in textes: | |
| matiere_id = texte.sous_categorie.matiere.id | |
| if matiere_id not in grouped_textes: | |
| grouped_textes[matiere_id] = { | |
| 'nom': texte.sous_categorie.matiere.nom, | |
| 'color': texte.sous_categorie.matiere.color_code, | |
| 'sous_categories': {} | |
| } | |
| sous_cat_id = texte.sous_categorie.id | |
| if sous_cat_id not in grouped_textes[matiere_id]['sous_categories']: | |
| grouped_textes[matiere_id]['sous_categories'][sous_cat_id] = { | |
| 'nom': texte.sous_categorie.nom, | |
| 'textes': [] | |
| } | |
| grouped_textes[matiere_id]['sous_categories'][sous_cat_id]['textes'].append({ | |
| 'id': texte.id, | |
| 'titre': texte.titre, | |
| 'updated_at': texte.updated_at | |
| }) | |
| sous_categories = SousCategorie.query.all() | |
| return render_template('admin/textes.html', grouped_textes=grouped_textes, matieres=matieres, sous_categories=sous_categories) | |
| def edit_texte(texte_id): | |
| texte = Texte.query.get_or_404(texte_id) | |
| if request.method == 'POST': | |
| action = request.form.get('action') | |
| if action == 'update_basic': | |
| # Basic text info update | |
| titre = request.form.get('titre', '').strip() | |
| sous_categorie_id = request.form.get('sous_categorie_id') | |
| if not titre or not sous_categorie_id: | |
| flash('Le titre et la sous-catégorie sont requis.', 'danger') | |
| else: | |
| # Save previous content for history | |
| historique = TexteHistorique( | |
| texte_id=texte.id, | |
| contenu_precedent=texte.contenu | |
| ) | |
| db.session.add(historique) | |
| texte.titre = titre | |
| texte.sous_categorie_id = sous_categorie_id | |
| db.session.commit() | |
| flash('Informations de base mises à jour avec succès !', 'success') | |
| elif action == 'update_blocks': | |
| # Update content blocks | |
| blocks_data = json.loads(request.form.get('blocks_data', '[]')) | |
| # Save previous content for history if changes were made | |
| historique = TexteHistorique( | |
| texte_id=texte.id, | |
| contenu_precedent=texte.contenu | |
| ) | |
| db.session.add(historique) | |
| # Update all blocks | |
| # First, remove existing blocks | |
| for block in texte.content_blocks: | |
| db.session.delete(block) | |
| # Then create new blocks from the submitted data | |
| new_content = [] | |
| for i, block_data in enumerate(blocks_data): | |
| new_block = ContentBlock( | |
| texte_id=texte.id, | |
| title=block_data.get('title'), | |
| content=block_data.get('content', ''), | |
| order=i, | |
| image_position=block_data.get('image_position', 'left') | |
| ) | |
| # Set image if provided | |
| image_id = block_data.get('image_id') | |
| if image_id and image_id != 'null' and image_id != 'undefined': | |
| new_block.image_id = image_id | |
| db.session.add(new_block) | |
| # Append to full content for the main text field | |
| if new_block.title: | |
| new_content.append(new_block.title) | |
| new_content.append(new_block.content) | |
| # Update the main content field to match block content | |
| texte.contenu = "\n\n".join(new_content) | |
| db.session.commit() | |
| flash('Contenu mis à jour avec succès !', 'success') | |
| elif action == 'upload_image': | |
| if 'image' not in request.files: | |
| return jsonify({'success': False, 'error': 'Aucun fichier trouvé'}) | |
| file = request.files['image'] | |
| if file.filename == '': | |
| return jsonify({'success': False, 'error': 'Aucun fichier sélectionné'}) | |
| # Check file type (accept only images) | |
| allowed_mimetypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'] | |
| if file.mimetype not in allowed_mimetypes: | |
| return jsonify({'success': False, 'error': 'Type de fichier non autorisé'}) | |
| # Read file data | |
| file_data = file.read() | |
| if not file_data: | |
| return jsonify({'success': False, 'error': 'Fichier vide'}) | |
| # Create new image record | |
| new_image = Image( | |
| nom_fichier=secure_filename(file.filename), | |
| mime_type=file.mimetype, | |
| data=file_data, | |
| alt_text=request.form.get('alt_text', '') | |
| ) | |
| db.session.add(new_image) | |
| db.session.commit() | |
| # Return image details | |
| image_data = base64.b64encode(new_image.data).decode('utf-8') | |
| return jsonify({ | |
| 'success': True, | |
| 'image': { | |
| 'id': new_image.id, | |
| 'filename': new_image.nom_fichier, | |
| 'src': f"data:{new_image.mime_type};base64,{image_data}", | |
| 'alt': new_image.alt_text or "Image illustration" | |
| } | |
| }) | |
| # Get existing content blocks or create them if none exist | |
| if not texte.content_blocks: | |
| # Parse content into blocks | |
| blocks = parse_content_to_blocks(texte.contenu) | |
| for i, block_data in enumerate(blocks): | |
| new_block = ContentBlock( | |
| texte_id=texte.id, | |
| title=block_data['title'], | |
| content=block_data['content'], | |
| order=i | |
| ) | |
| db.session.add(new_block) | |
| db.session.commit() | |
| # Reload the texte to get the new blocks | |
| texte = Texte.query.get(texte_id) | |
| # Prepare block data for template | |
| blocks = [] | |
| for block in texte.content_blocks: | |
| block_data = { | |
| 'id': block.id, | |
| 'title': block.title, | |
| 'content': block.content, | |
| 'order': block.order, | |
| 'image_position': block.image_position, | |
| 'image': None | |
| } | |
| # Add image data if available | |
| if block.image: | |
| image_data = base64.b64encode(block.image.data).decode('utf-8') | |
| block_data['image'] = { | |
| 'id': block.image.id, | |
| 'src': f"data:{block.image.mime_type};base64,{image_data}", | |
| 'alt': block.image.alt_text or block.title or "Image illustration" | |
| } | |
| blocks.append(block_data) | |
| # Get all images for selection | |
| images = Image.query.order_by(Image.uploaded_at.desc()).all() | |
| images_data = [] | |
| for image in images: | |
| image_data = base64.b64encode(image.data).decode('utf-8') | |
| images_data.append({ | |
| 'id': image.id, | |
| 'filename': image.nom_fichier, | |
| 'src': f"data:{image.mime_type};base64,{image_data}", | |
| 'alt': image.alt_text or "Image illustration" | |
| }) | |
| sous_categories = SousCategorie.query.all() | |
| return render_template('admin/edit_texte.html', | |
| texte=texte, | |
| blocks=blocks, | |
| images=images_data, | |
| sous_categories=sous_categories) | |
| def historique(texte_id): | |
| texte = Texte.query.get_or_404(texte_id) | |
| historiques = TexteHistorique.query.filter_by(texte_id=texte_id).order_by(TexteHistorique.date_modification.desc()).all() | |
| return render_template('admin/historique.html', texte=texte, historiques=historiques) | |
| def images(): | |
| if request.method == 'POST': | |
| action = request.form.get('action') | |
| if action == 'upload': | |
| if 'image' not in request.files: | |
| flash('Aucun fichier trouvé.', 'danger') | |
| return redirect(request.url) | |
| file = request.files['image'] | |
| if file.filename == '': | |
| flash('Aucun fichier sélectionné.', 'danger') | |
| return redirect(request.url) | |
| # Check file type (accept only images) | |
| allowed_mimetypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'] | |
| if file.mimetype not in allowed_mimetypes: | |
| flash('Type de fichier non autorisé.', 'danger') | |
| return redirect(request.url) | |
| # Read file data | |
| file_data = file.read() | |
| if not file_data: | |
| flash('Fichier vide.', 'danger') | |
| return redirect(request.url) | |
| # Create new image record | |
| alt_text = request.form.get('alt_text', '') | |
| description = request.form.get('description', '') | |
| new_image = Image( | |
| nom_fichier=secure_filename(file.filename), | |
| mime_type=file.mimetype, | |
| data=file_data, | |
| alt_text=alt_text, | |
| description=description | |
| ) | |
| db.session.add(new_image) | |
| db.session.commit() | |
| flash('Image téléchargée avec succès !', 'success') | |
| elif action == 'delete': | |
| image_id = request.form.get('image_id') | |
| if image_id: | |
| image = Image.query.get(image_id) | |
| if image: | |
| # Check if image is used in any content block | |
| usage_count = ContentBlock.query.filter_by(image_id=image.id).count() | |
| if usage_count > 0: | |
| flash(f'Cette image est utilisée dans {usage_count} blocs de contenu. Veuillez les modifier avant de supprimer l\'image.', 'warning') | |
| else: | |
| db.session.delete(image) | |
| db.session.commit() | |
| flash('Image supprimée avec succès !', 'success') | |
| else: | |
| flash('Image non trouvée.', 'danger') | |
| else: | |
| flash('ID d\'image manquant.', 'danger') | |
| elif action == 'update': | |
| image_id = request.form.get('image_id') | |
| alt_text = request.form.get('alt_text', '') | |
| description = request.form.get('description', '') | |
| if image_id: | |
| image = Image.query.get(image_id) | |
| if image: | |
| image.alt_text = alt_text | |
| image.description = description | |
| db.session.commit() | |
| flash('Informations de l\'image mises à jour avec succès !', 'success') | |
| else: | |
| flash('Image non trouvée.', 'danger') | |
| else: | |
| flash('ID d\'image manquant.', 'danger') | |
| # Get all images | |
| images = Image.query.order_by(Image.uploaded_at.desc()).all() | |
| images_data = [] | |
| for image in images: | |
| image_data = base64.b64encode(image.data).decode('utf-8') | |
| images_data.append({ | |
| 'id': image.id, | |
| 'filename': image.nom_fichier, | |
| 'description': image.description, | |
| 'alt_text': image.alt_text, | |
| 'uploaded_at': image.uploaded_at, | |
| 'src': f"data:{image.mime_type};base64,{image_data}" | |
| }) | |
| return render_template('admin/images.html', images=images_data) | |
| # --------------------------------------------------------------------------- | |
| # Register blueprints and setup database | |
| # --------------------------------------------------------------------------- | |
| app.register_blueprint(main_bp) | |
| app.register_blueprint(admin_bp) | |
| # Create tables if they don't exist | |
| with app.app_context(): | |
| db.create_all() | |
| # Application entry point | |
| def index(): | |
| return redirect(url_for('main_bp.index')) | |
| # --------------------------------------------------------------------------- | |
| # Serve the app - This is only used when running locally | |
| # --------------------------------------------------------------------------- | |
| if __name__ == '__main__': | |
| app.run(host=HOST, port=PORT, debug=DEBUG) | |
| # For Vercel serverless function | |
| def app_handler(environ, start_response): | |
| return app(environ, start_response) | |