Spaces:
Running
Running
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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 | |
| 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) | |
| 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]) | |
| 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]) | |
| 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 | |
| def admin_home(): | |
| return render_template('admin_home.html') | |
| def admin_articles(): | |
| articles = Article.query.all() | |
| return render_template('admin_articles.html', articles=articles) | |
| 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) | |
| 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) | |
| 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')) | |
| def admin_subjects(): | |
| subjects = Subject.query.all() | |
| return render_template('admin_subjects.html', subjects=subjects) | |
| 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') | |
| 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) | |
| 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')) | |
| def admin_categories(): | |
| categories = Category.query.all() | |
| return render_template('admin_categories.html', categories=categories) | |
| 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) | |
| 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) | |
| 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="<p>Les équations linéaires sont de la forme ax + b = 0.</p>", category=algebra) | |
| Article(title="Triangles", content="<p>Un triangle a trois côtés.</p>", category=geometry) | |
| Article(title="Lois de Newton", content="<p>La première loi de Newton...</p>", 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 | |
| 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"\[(?P<tag>youtube|image):(?P<url>[^\]|]+)(?:\|(?P<opts>[^\]]+))?\]") | |
| 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'<iframe {attrs} src="{embed}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>' | |
| 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'<img src="{url}" alt=""{class_attr}{style_attr} {attrs} />' | |
| return m.group(0) | |
| return pattern.sub(_repl, content) | |
| def render_embeds_filter(content): | |
| return render_embeds(content) | |
| 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}) | |
| 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}) | |
| 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}) | |
| 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 | |
| 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 | |
| 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) |