from flask import Flask, jsonify, render_template, request, redirect, url_for from flask_sqlalchemy import SQLAlchemy import os from werkzeug.utils import secure_filename app = Flask(__name__) app.config['SQLALCHEMY_DATABASE_URI'] = "postgresql://neondb_owner:npg_vz5FLSXfj2sp@ep-late-block-adp1o88t-pooler.c-2.us-east-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require" app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_ENGINE_OPTIONS'] = { "pool_pre_ping": True, "pool_recycle": 240, } # --------------------------- UPLOAD_FOLDER = 'static/uploads' app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER if not os.path.exists(UPLOAD_FOLDER): os.makedirs(UPLOAD_FOLDER) db = SQLAlchemy(app) class Subject(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) icon_data = db.Column(db.LargeBinary, nullable=True) # Image data stored in DB icon_filename = db.Column(db.String(200), nullable=True) # Original filename icon_mimetype = db.Column(db.String(100), nullable=True) # MIME type categories = db.relationship('Category', backref='subject', lazy=True) class Category(db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) subject_id = db.Column(db.Integer, db.ForeignKey('subject.id'), nullable=False) articles = db.relationship('Article', backref='category', lazy=True) class Article(db.Model): id = db.Column(db.Integer, primary_key=True) title = db.Column(db.String(200), nullable=False) content = db.Column(db.Text, nullable=False) category_id = db.Column(db.Integer, db.ForeignKey('category.id'), nullable=False) icon_data = db.Column(db.LargeBinary, nullable=True) # Image data stored in DB icon_filename = db.Column(db.String(200), nullable=True) # Original filename icon_mimetype = db.Column(db.String(100), nullable=True) # MIME type youtube_url = db.Column(db.String(300), nullable=True) # Optional YouTube URL class Attachment(db.Model): id = db.Column(db.Integer, primary_key=True) data = db.Column(db.LargeBinary, nullable=False) filename = db.Column(db.String(200), nullable=False) mimetype = db.Column(db.String(100), nullable=False) @app.route('/') def home(): subjects = Subject.query.all() # Add icon_url to each subject for template use for subject in subjects: if subject.icon_data: subject.icon_url = url_for('get_subject_icon', subject_id=subject.id) else: subject.icon_url = None return render_template('home.html', subjects=subjects) @app.route('/subjects//categories') def categories(subject_id): subject = Subject.query.get_or_404(subject_id) categories = Category.query.filter_by(subject_id=subject_id).all() return render_template('categories.html', subject=subject, categories=categories) @app.route('/categories//articles') def articles(category_id): category = Category.query.get_or_404(category_id) articles = Article.query.filter_by(category_id=category_id).all() return render_template('articles.html', category=category, articles=articles) @app.route('/articles/') def article(article_id): article = Article.query.get_or_404(article_id) return render_template('article.html', article=article) # API endpoints for JSON if needed @app.route('/api/subjects') def api_subjects(): subjects = Subject.query.all() subject_list = [] for s in subjects: icon_url = url_for('get_subject_icon', subject_id=s.id) if s.icon_data else None subject_list.append({'id': s.id, 'name': s.name, 'icon': icon_url}) return jsonify(subject_list) @app.route('/api/subjects//categories') def api_categories(subject_id): categories = Category.query.filter_by(subject_id=subject_id).all() return jsonify([{'id': c.id, 'name': c.name} for c in categories]) @app.route('/api/categories//articles') def api_articles(category_id): articles = Article.query.filter_by(category_id=category_id).all() return jsonify([{'id': a.id, 'title': a.title} for a in articles]) @app.route('/api/articles/') def api_article(article_id): article = Article.query.get_or_404(article_id) return jsonify({'id': article.id, 'title': article.title, 'content': article.content}) # Admin routes @app.route('/admin') def admin_home(): return render_template('admin_home.html') @app.route('/admin/articles') def admin_articles(): articles = Article.query.all() return render_template('admin_articles.html', articles=articles) @app.route('/admin/articles/edit/', methods=['GET', 'POST']) def admin_edit_article(article_id): article = Article.query.get_or_404(article_id) if request.method == 'POST': article.title = request.form['title'] article.content = request.form['content'] # store optional youtube url article.youtube_url = request.form.get('youtube_url') or None # Handle file upload if provided if 'icon' in request.files and request.files['icon'].filename: file = request.files['icon'] file_data = file.read() filename = secure_filename(file.filename) article.icon_data = file_data article.icon_filename = filename article.icon_mimetype = file.mimetype db.session.commit() return redirect(url_for('admin_articles')) return render_template('admin_edit_article.html', article=article) @app.route('/admin/articles/new', methods=['GET', 'POST']) def admin_new_article(): if request.method == 'POST': title = request.form['title'] content = request.form['content'] category_id = request.form['category_id'] new_article = Article(title=title, content=content, category_id=category_id, youtube_url=(request.form.get('youtube_url') or None)) db.session.add(new_article) db.session.commit() # Handle file upload if provided if 'icon' in request.files and request.files['icon'].filename: file = request.files['icon'] file_data = file.read() filename = secure_filename(file.filename) new_article.icon_data = file_data new_article.icon_filename = filename new_article.icon_mimetype = file.mimetype db.session.commit() return redirect(url_for('admin_articles')) categories = Category.query.all() return render_template('admin_new_article.html', categories=categories) @app.route('/admin/articles/delete/', methods=['POST']) def admin_delete_article(article_id): article = Article.query.get_or_404(article_id) db.session.delete(article) db.session.commit() return redirect(url_for('admin_articles')) @app.route('/admin/subjects') def admin_subjects(): subjects = Subject.query.all() return render_template('admin_subjects.html', subjects=subjects) @app.route('/admin/subjects/new', methods=['GET', 'POST']) def admin_new_subject(): if request.method == 'POST': name = request.form['name'] new_subject = Subject(name=name) db.session.add(new_subject) db.session.commit() # Handle file upload if provided if 'icon' in request.files and request.files['icon'].filename: file = request.files['icon'] file_data = file.read() filename = secure_filename(file.filename) new_subject.icon_data = file_data new_subject.icon_filename = filename new_subject.icon_mimetype = file.mimetype db.session.commit() return redirect(url_for('admin_subjects')) return render_template('admin_new_subject.html') @app.route('/admin/subjects/edit/', methods=['GET', 'POST']) def admin_edit_subject(subject_id): subject = Subject.query.get_or_404(subject_id) if request.method == 'POST': subject.name = request.form['name'] # Handle file upload if provided if 'icon' in request.files and request.files['icon'].filename: file = request.files['icon'] file_data = file.read() filename = secure_filename(file.filename) subject.icon_data = file_data subject.icon_filename = filename subject.icon_mimetype = file.mimetype db.session.commit() return redirect(url_for('admin_subjects')) return render_template('admin_edit_subject.html', subject=subject) @app.route('/admin/subjects/delete/', methods=['POST']) def admin_delete_subject(subject_id): subject = Subject.query.get_or_404(subject_id) # Supprimer toutes les catégories et articles associés au sujet categories = Category.query.filter_by(subject_id=subject_id).all() for category in categories: Article.query.filter_by(category_id=category.id).delete() db.session.delete(category) db.session.delete(subject) db.session.commit() return redirect(url_for('admin_subjects')) @app.route('/admin/categories') def admin_categories(): categories = Category.query.all() return render_template('admin_categories.html', categories=categories) @app.route('/admin/categories/new', methods=['GET', 'POST']) def admin_new_category(): if request.method == 'POST': name = request.form['name'] subject_id = request.form['subject_id'] new_category = Category(name=name, subject_id=subject_id) db.session.add(new_category) db.session.commit() return redirect(url_for('admin_categories')) subjects = Subject.query.all() return render_template('admin_new_category.html', subjects=subjects) @app.route('/admin/categories/edit/', methods=['GET', 'POST']) def admin_edit_category(category_id): category = Category.query.get_or_404(category_id) if request.method == 'POST': category.name = request.form['name'] category.subject_id = request.form['subject_id'] db.session.commit() return redirect(url_for('admin_categories')) subjects = Subject.query.all() return render_template('admin_edit_category.html', category=category, subjects=subjects) @app.route('/admin/categories/delete/', methods=['POST']) def admin_delete_category(category_id): category = Category.query.get_or_404(category_id) # Supprimer tous les articles de cette catégorie d'abord Article.query.filter_by(category_id=category_id).delete() db.session.delete(category) db.session.commit() return redirect(url_for('admin_categories')) def add_sample_data(): if Subject.query.count() == 0: math = Subject(name="Mathématiques") physics = Subject(name="Physique") db.session.add(math) db.session.add(physics) db.session.commit() algebra = Category(name="Algèbre", subject=math) geometry = Category(name="Géométrie", subject=math) mechanics = Category(name="Mécanique", subject=physics) db.session.add(algebra) db.session.add(geometry) db.session.add(mechanics) db.session.commit() Article(title="Équations linéaires", content="

Les équations linéaires sont de la forme ax + b = 0.

", category=algebra) Article(title="Triangles", content="

Un triangle a trois côtés.

", category=geometry) Article(title="Lois de Newton", content="

La première loi de Newton...

", category=mechanics) db.session.commit() # Helper to convert a standard YouTube URL into an embeddable URL from urllib.parse import urlparse, parse_qs import re def get_youtube_embed(url): if not url: return None try: parsed = urlparse(url) hostname = parsed.hostname or '' video_id = None if hostname.endswith('youtu.be'): video_id = parsed.path.lstrip('/') elif 'youtube' in hostname: # check query param v qs = parse_qs(parsed.query) video_id = qs.get('v', [None])[0] if not video_id: # maybe already an embed path parts = parsed.path.split('/') if 'embed' in parts: idx = parts.index('embed') if len(parts) > idx + 1: video_id = parts[idx + 1] if not video_id: return None return f'https://www.youtube.com/embed/{video_id}' except Exception: return None @app.template_filter('youtube_embed') def youtube_embed_filter(url): return get_youtube_embed(url) def render_embeds(content): if not content: return content # replace shortcodes like [youtube:URL] and [image:URL|opts] pattern = re.compile(r"\[(?Pyoutube|image):(?P[^\]|]+)(?:\|(?P[^\]]+))?\]") def _repl(m): tag = m.group('tag') url = m.group('url').strip() opts = m.group('opts') # parse options like "left", "right", "width=300", "height=200" opt_map = {} if opts: parts = re.split(r"[\|,]", opts) for p in parts: p = p.strip() if not p: continue if '=' in p: k, v = p.split('=', 1) opt_map[k.strip().lower()] = v.strip() else: # shorthand for alignment if p.lower() in ('left', 'right', 'center'): opt_map['align'] = p.lower() elif p.endswith('%') or p.isdigit(): opt_map['width'] = p else: # unknown token; ignore pass if tag == 'youtube': embed = get_youtube_embed(url) if embed: attrs = 'width="100%" height="480"' return f'' return m.group(0) if tag == 'image': # build img tag with optional attributes classes = [] styles = [] attr_list = [] if 'align' in opt_map: if opt_map['align'] == 'left': classes.append('float-left') elif opt_map['align'] == 'right': classes.append('float-right') elif opt_map['align'] == 'center': styles.append('display:block;margin-left:auto;margin-right:auto') if 'width' in opt_map: w = opt_map['width'] # allow percent or number if w.endswith('%'): styles.append(f'width:{w}') else: # assume pixels if w.isdigit(): attr_list.append(f'width="{w}"') if 'height' in opt_map: h = opt_map['height'] if h.isdigit(): attr_list.append(f'height="{h}"') class_attr = f' class="{" ".join(classes)}"' if classes else '' style_attr = f' style="{";".join(styles)}"' if styles else '' attrs = ' '.join(attr_list) return f'' return m.group(0) return pattern.sub(_repl, content) @app.template_filter('render_embeds') def render_embeds_filter(content): return render_embeds(content) @app.route('/upload/subject/', methods=['POST']) def upload_subject_icon(subject_id): subject = Subject.query.get_or_404(subject_id) if 'file' not in request.files: return jsonify({'error': 'No file'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'error': 'No selected file'}), 400 # Read file data file_data = file.read() filename = secure_filename(file.filename) # Store in database subject.icon_data = file_data subject.icon_filename = filename subject.icon_mimetype = file.mimetype db.session.commit() return jsonify({'success': True, 'filename': filename}) @app.route('/upload/article/', methods=['POST']) def upload_article_image(article_id): article = Article.query.get_or_404(article_id) if 'file' not in request.files: return jsonify({'error': 'No file'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'error': 'No selected file'}), 400 # Read file data file_data = file.read() filename = secure_filename(file.filename) # Store in database article.icon_data = file_data article.icon_filename = filename article.icon_mimetype = file.mimetype db.session.commit() return jsonify({'success': True, 'filename': filename}) @app.route('/upload', methods=['POST']) def upload_attachment(): if 'file' not in request.files: return jsonify({'error': 'No file'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'error': 'No selected file'}), 400 # Read file data file_data = file.read() filename = secure_filename(file.filename) # Store in database attachment = Attachment(data=file_data, filename=filename, mimetype=file.mimetype) db.session.add(attachment) db.session.commit() url = url_for('get_attachment', attachment_id=attachment.id) return jsonify({'url': url}) @app.route('/subject//icon') def get_subject_icon(subject_id): subject = Subject.query.get_or_404(subject_id) if subject.icon_data: return subject.icon_data, 200, {'Content-Type': subject.icon_mimetype or 'image/png'} else: return '', 404 @app.route('/article//image') def get_article_image(article_id): article = Article.query.get_or_404(article_id) if article.icon_data: return article.icon_data, 200, {'Content-Type': article.icon_mimetype or 'image/png'} else: return '', 404 @app.route('/attachment/') def get_attachment(attachment_id): attachment = Attachment.query.get_or_404(attachment_id) return attachment.data, 200, {'Content-Type': attachment.mimetype} if __name__ == '__main__': with app.app_context(): # create tables if they don't exist db.create_all() # Attempt a tiny, safe migration for SQLite: if the youtube_url column # is missing on the articles table, add it. This avoids OperationalError # when the DB was created before the model change. try: from sqlalchemy import inspect, text inspector = inspect(db.engine) table_name = Article.__table__.name existing_cols = [c['name'] for c in inspector.get_columns(table_name)] if 'youtube_url' not in existing_cols: with db.engine.connect() as conn: # SQLite allows simple ALTER TABLE ADD COLUMN conn.execute(text("ALTER TABLE %s ADD COLUMN youtube_url VARCHAR(300)" % table_name)) conn.commit() print('Migration: added youtube_url column to', table_name) except Exception as e: # If anything goes wrong, log and continue; user can perform a manual migration print('Migration skipped or failed:', e) app.run(debug=True)