commit 674ad5f06f5550fe8e16cb43d9cd352bfe1a8198 Author: Aguay Date: Tue Dec 2 14:07:54 2025 +0100 first commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1beef04 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.10-slim + +WORKDIR /app + +COPY app.py schema.sql requirements.txt ./ +COPY templates ./templates + +RUN pip install --no-cache-dir -r requirements.txt + +EXPOSE 5000 + +CMD ["python", "app.py"] + diff --git a/app.py b/app.py new file mode 100644 index 0000000..acd7f41 --- /dev/null +++ b/app.py @@ -0,0 +1,374 @@ +import os +import math +import csv +import sqlite3 +import random +from datetime import date +from io import StringIO +from flask import ( + Flask, g, render_template, redirect, url_for, flash, + request, session +) +from functools import wraps +from werkzeug.utils import secure_filename + +DATABASE = os.path.join(os.path.dirname(__file__), "avent.db") + +# Configuration upload images +UPLOAD_FOLDER = os.path.join(os.path.dirname(__file__), "static", "uploads") +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif"} + +app = Flask(__name__) +app.secret_key = "change-me-super-secret-key-2025" +app.config["UPLOAD_FOLDER"] = UPLOAD_FOLDER +app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 # 5 Mo max + +def allowed_file(filename): + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +def get_db(): + if 'db' not in g: + g.db = sqlite3.connect(DATABASE) + g.db.row_factory = sqlite3.Row + return g.db + +@app.teardown_appcontext +def close_db(error=None): + db = g.pop('db', None) + if db is not None: + db.close() + +def init_db(): + db = get_db() + with open(os.path.join(os.path.dirname(__file__), "schema.sql")) as f: + db.executescript(f.read()) + + cur = db.execute("SELECT COUNT(*) AS c FROM user") + if cur.fetchone()['c'] == 0: + db.execute("INSERT INTO user (username, password) VALUES (?, ?)", ("admin", "admin")) + + cur = db.execute("SELECT COUNT(*) AS c FROM project") + if cur.fetchone()['c'] == 0: + project_id = db.execute( + "INSERT INTO project (name, description, image_url, total_days) VALUES (?, ?, ?, ?)", + ( + "Calendrier de l'Avent 2025", + "Le calendrier de l'Avent officiel avec tirage aléatoire et gestion des utilisateurs.", + "https://cdn-icons-png.flaticon.com/512/616/616408.png", + 24 + ) + ).lastrowid + people_list = ["Valentin", "Nicolas", "Victor", "Julie", "Louis", "Alexandre", "David", "Raphaël"] + for name in people_list: + db.execute("INSERT INTO people (project_id, name, draws, max_draws) VALUES (?, ?, 0, 0)", (project_id, name)) + recalc_max_draws_for_project(project_id) + + db.commit() + +def get_user_by_username(username): + db = get_db() + cur = db.execute("SELECT * FROM user WHERE username = ?", (username,)) + return cur.fetchone() + +def check_login(username, password): + user = get_user_by_username(username) + if user and user["password"] == password: + return user + return None + +def login_required(fn): + @wraps(fn) + def wrapped(*args, **kwargs): + if 'user_id' not in session: + return redirect(url_for('login')) + return fn(*args, **kwargs) + return wrapped + +def recalc_max_draws_for_project(project_id): + db = get_db() + cur = db.execute("SELECT total_days FROM project WHERE id = ?", (project_id,)) + project = cur.fetchone() + if not project: + return + total_days = project["total_days"] + cur = db.execute("SELECT COUNT(*) AS c FROM people WHERE project_id = ?", (project_id,)) + count = cur.fetchone()["c"] + if count == 0: + return + max_draws = math.ceil(total_days / count) + db.execute("UPDATE people SET max_draws = ? WHERE project_id = ?", (max_draws, project_id)) + db.commit() + +def get_project(project_id=None): + db = get_db() + if project_id: + cur = db.execute("SELECT * FROM project WHERE id = ?", (project_id,)) + return cur.fetchone() + cur = db.execute("SELECT * FROM project ORDER BY id ASC") + return cur.fetchall() + +def get_people_stats(project_id): + db = get_db() + cur = db.execute("SELECT * FROM people WHERE project_id = ? ORDER BY name ASC", (project_id,)) + return cur.fetchall() + +def get_draw_for_project_day(project_id, day): + db = get_db() + cur = db.execute(""" + SELECT draws.day, people.name, draws.draw_time + FROM draws JOIN people ON people.id = draws.person_id + WHERE draws.project_id = ? AND draws.day = ? + """, (project_id, day)) + return cur.fetchone() + +def get_all_draws_for_project(project_id): + db = get_db() + cur = db.execute(""" + SELECT draws.day, people.name, draws.draw_time + FROM draws JOIN people ON people.id = draws.person_id + WHERE draws.project_id = ? + ORDER BY draws.day ASC + """, (project_id,)) + return cur.fetchall() + +def draw_for_project_day(project_id, day): + db = get_db() + existing = get_draw_for_project_day(project_id, day) + if existing: + return existing["name"], False + + cur = db.execute(""" + SELECT id, name, draws, max_draws + FROM people WHERE project_id = ? AND draws < max_draws + """, (project_id,)) + candidates = cur.fetchall() + if not candidates: + return None, False + + chosen = random.choice(candidates) + db.execute("UPDATE people SET draws = draws + 1 WHERE id = ?", (chosen["id"],)) + db.execute("INSERT INTO draws (day, person_id, project_id) VALUES (?, ?, ?)", + (day, chosen["id"], project_id)) + db.commit() + return chosen["name"], True + +def catchup_draws(project_id): + db = get_db() + project = get_project(project_id) + if not project: + return 0 + + today = date.today() + today_day = today.day if today.month == 12 else 1 + if today_day > project["total_days"]: + today_day = project["total_days"] + + new_draws = 0 + for day in range(1, today_day + 1): + existing = get_draw_for_project_day(project_id, day) + if not existing: + name, success = draw_for_project_day(project_id, day) + if success: + new_draws += 1 + + db.commit() + return new_draws + +@app.route("/") +@login_required +def dashboard(): + projects = get_project() + return render_template("projects.html", projects=projects) + +@app.route("/project/") +@login_required +def project_view(project_id): + project = get_project(project_id) + if not project: + flash("Projet non trouvé.") + return redirect(url_for('dashboard')) + + today = date.today() + today_day = today.day if today.month == 12 else 1 + if today_day > project["total_days"]: + today_day = project["total_days"] + + today_draw = get_draw_for_project_day(project_id, today_day) + all_draws = get_all_draws_for_project(project_id) + stats = get_people_stats(project_id) + + return render_template("project_view.html", project=project, today_day=today_day, + today_draw=today_draw, all_draws=all_draws, stats=stats) + +@app.route("/project//draw-today") +@login_required +def draw_today(project_id): + today = date.today() + today_day = today.day if today.month == 12 else 1 + project = get_project(project_id) + if not project or today_day > project["total_days"]: + flash("Projet ou jour invalide.") + return redirect(url_for('dashboard')) + + name, is_new = draw_for_project_day(project_id, today_day) + if not name: + flash("Plus aucune personne disponible pour ce projet.") + else: + if is_new: + flash(f"Tirage du jour {today_day} pour '{project['name']}': {name}") + else: + flash(f"Le jour {today_day} a déjà été tiré: {name}") + return redirect(url_for("project_view", project_id=project_id)) + +@app.route("/project//catchup") +@login_required +def catchup_draws_route(project_id): + project = get_project(project_id) + if not project: + flash("Projet non trouvé.") + return redirect(url_for('dashboard')) + + new_draws = catchup_draws(project_id) + if new_draws > 0: + flash(f"✅ {new_draws} tirages effectués pour rattraper les jours manquants !") + else: + flash("Tous les jours ont déjà été tirés.") + + return redirect(url_for("project_view", project_id=project_id)) + +@app.route("/admin/projects", methods=["GET", "POST"]) +@login_required +def admin_projects(): + db = get_db() + if request.method == "POST": + action = request.form.get("action") + + if action in ("add", "update"): + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + total_days = int(request.form.get("total_days", 24)) + + # Gestion upload image + image_url = None + if 'image_file' in request.files: + file = request.files['image_file'] + if file and allowed_file(file.filename): + filename = secure_filename(file.filename) + filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) + file.save(filepath) + image_url = f"/static/uploads/{filename}" + + if action == "add": + if name: + db.execute( + "INSERT INTO project (name, description, image_url, total_days) VALUES (?, ?, ?, ?)", + (name, description, image_url, total_days) + ) + db.commit() + flash("Projet ajouté avec succès.") + else: # update + project_id = int(request.form.get("project_id")) + # Si pas d'image uploadée, conserver l'ancienne + if image_url is None: + cur = db.execute("SELECT image_url FROM project WHERE id = ?", (project_id,)) + row = cur.fetchone() + image_url = row["image_url"] if row else None + + db.execute( + "UPDATE project SET name = ?, description = ?, image_url = ?, total_days = ? WHERE id = ?", + (name, description, image_url, total_days, project_id) + ) + recalc_max_draws_for_project(project_id) + db.commit() + flash("Projet mis à jour avec succès.") + + elif action == "delete": + project_id = int(request.form.get("project_id")) + db.execute("DELETE FROM project WHERE id = ?", (project_id,)) + db.commit() + flash("Projet supprimé.") + + projects = get_project() + return render_template("admin_projects.html", projects=projects) + +@app.route("/admin/project//people", methods=["GET", "POST"]) +@login_required +def admin_project_people(project_id): + db = get_db() + project = get_project(project_id) + if not project: + flash("Projet non trouvé.") + return redirect(url_for('admin_projects')) + + if request.method == "POST": + action = request.form.get("action") + if action == "add": + name = request.form.get("name", "").strip() + if name: + db.execute("INSERT INTO people (project_id, name, draws, max_draws) VALUES (?, ?, 0, 0)", + (project_id, name)) + recalc_max_draws_for_project(project_id) + db.commit() + flash("Personne ajoutée.") + elif action == "import_csv": + if 'csv_file' in request.files: + csv_file = request.files['csv_file'] + if csv_file.filename: + content = csv_file.read().decode('utf-8') + reader = csv.DictReader(StringIO(content)) + count = 0 + for row in reader: + name = row.get("name", "").strip() + if name: + cur = db.execute("SELECT id FROM people WHERE name = ? AND project_id = ?", + (name, project_id)) + if not cur.fetchone(): + db.execute("INSERT INTO people (project_id, name, draws, max_draws) VALUES (?, ?, 0, 0)", + (project_id, name)) + count += 1 + recalc_max_draws_for_project(project_id) + db.commit() + flash(f"{count} personnes importées depuis le CSV.") + elif action == "delete": + person_id = int(request.form.get("person_id")) + db.execute("DELETE FROM people WHERE id = ? AND project_id = ?", (person_id, project_id)) + recalc_max_draws_for_project(project_id) + db.commit() + flash("Personne supprimée.") + elif action == "reset_draws": + db.execute("UPDATE people SET draws = 0 WHERE project_id = ?", (project_id,)) + db.execute("DELETE FROM draws WHERE project_id = ?", (project_id,)) + db.commit() + flash("Tirages remis à zéro.") + + people = get_people_stats(project_id) + return render_template("admin_project_people.html", project=project, people=people) + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + username = request.form.get("username") + password = request.form.get("password") + user = check_login(username, password) + if user: + session["user_id"] = user["id"] + session["username"] = user["username"] + flash("Connexion réussie.") + return redirect(url_for("dashboard")) + else: + flash("Identifiants invalides.") + return render_template("login.html") + +@app.route("/logout") +def logout(): + session.clear() + flash("Déconnecté.") + return redirect(url_for("login")) + +if __name__ == "__main__": + if not os.path.exists(DATABASE): + with app.app_context(): + init_db() + app.run(host="0.0.0.0", debug=True) + diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..fcf70f4 --- /dev/null +++ b/compose.yml @@ -0,0 +1,13 @@ +version: "3.8" + +services: + avent_app: + build: . + ports: + - "5000:5000" + volumes: + - ./templates:/app/templates:ro + environment: + - FLASK_ENV=development + restart: unless-stopped + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8459b23 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask==3.0.0 + diff --git a/restart.sh b/restart.sh new file mode 100755 index 0000000..e80d841 --- /dev/null +++ b/restart.sh @@ -0,0 +1,19 @@ +#!/bin/bash +echo "🛑 Arrêt des containers..." +docker-compose down -v + +echo "🔨 Reconstruction de l'image..." +docker-compose build --no-cache + +echo "🚀 Démarrage de l'application..." +docker-compose up -d + +echo "📊 Status des services :" +docker-compose ps + +echo "📋 Logs des 20 dernières lignes :" +docker-compose logs --tail=20 + +echo "✅ App accessible sur http://localhost:5000" +echo " Login admin: admin / admin" + diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..938cf77 --- /dev/null +++ b/schema.sql @@ -0,0 +1,38 @@ +DROP TABLE IF EXISTS draws; +DROP TABLE IF EXISTS people; +DROP TABLE IF EXISTS project; +DROP TABLE IF EXISTS user; + +CREATE TABLE user ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL +); + +CREATE TABLE project ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + image_url TEXT, + total_days INTEGER NOT NULL DEFAULT 24 +); + +CREATE TABLE people ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER NOT NULL, + name TEXT NOT NULL, + draws INTEGER NOT NULL DEFAULT 0, + max_draws INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE +); + +CREATE TABLE draws ( + day INTEGER NOT NULL, + person_id INTEGER NOT NULL, + project_id INTEGER NOT NULL, + draw_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (day, project_id), + FOREIGN KEY (person_id) REFERENCES people(id), + FOREIGN KEY (project_id) REFERENCES project(id) ON DELETE CASCADE +); + diff --git a/templates/admin_people.html b/templates/admin_people.html new file mode 100644 index 0000000..ea519ae --- /dev/null +++ b/templates/admin_people.html @@ -0,0 +1,136 @@ + + + + + Administration - Calendrier de l'Avent + + + + +
+
+

+ + Gestion des participants +

+
+ Connecté: {{ session.username }} + + Déconnexion + + + Calendrier + +
+
+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for msg in messages %} + {{ msg }} + {% endfor %} + +
+ {% endif %} + {% endwith %} + +
+
+
+
+
Ajouter un participant
+
+
+
+ +
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+
+
Réinitialiser
+
+
+
+ + +
+

Remet tous les compteurs à 0

+
+
+
+
+ +
+
+
+ Liste des participants ({{ people|length }}) +
+
+
+
+ + + + + + + + + + + + {% for p in people %} + + + + + + + + {% endfor %} + +
NomTiragesMaximumRestantActions
+ + {{ p.name }} + + {{ p.draws }} + {{ p.max_draws }} + + {{ p.max_draws - p.draws }} + + +
+ + + +
+
+
+
+
+
+ + + + + diff --git a/templates/admin_project_people.html b/templates/admin_project_people.html new file mode 100644 index 0000000..f49300c --- /dev/null +++ b/templates/admin_project_people.html @@ -0,0 +1,141 @@ + + + + + Gestion des utilisateurs - {{ project.name }} + + + + +
+
+

Utilisateurs pour: {{ project.name }}

+ + Retour projets + +
+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for m in messages %} +
{{ m }}
+ {% endfor %} + +
+ {% endif %} + {% endwith %} + +
+
+
+
+
Ajouter un utilisateur
+
+
+
+ +
+ +
+ +
+
+
+ +
+
+
Import CSV
+
+
+
+ +
+ + + Format: une colonne name
+ Exemple: Marie
+ Pierre +
+
+ +
+
+
+
+ +
+
+
+
Réinitialiser
+
+
+
+ + +
+
+
+
+
+ +
+
+
Liste des {{ people|length }} participants (Quota: {{ people[0].max_draws if people else 0 }} max)
+
+
+
+ + + + + + + + + + + + {% for person in people %} + + + + + + + + {% endfor %} + +
NomTiragesMaximumRestantActions
{{ person.name }}{{ person.draws }}{{ person.max_draws }} + + {{ person.max_draws - person.draws }} + + +
+ + + +
+
+
+
+
+ + +
+ + + + diff --git a/templates/admin_projects.html b/templates/admin_projects.html new file mode 100644 index 0000000..8c7544f --- /dev/null +++ b/templates/admin_projects.html @@ -0,0 +1,118 @@ + + + + + Administration des projets + + + + +
+
+

Gestion des projets

+ + Tableau de bord + +
+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for m in messages %} +
{{ m }}
+ {% endfor %} + +
+ {% endif %} + {% endwith %} + +
+
+

Ajouter un projet

+
+
+
+ +
+ +
+
+ +
+
+ + PNG, JPG, GIF (max 5 Mo) +
+
+ +
+
+ +
+
+
+
+ +

Projets existants ({{ projects|length }})

+
+ + + + + + + + + + + + + {% for proj in projects %} + + + + + + + + + + + + {% endfor %} + +
IDIllustrationNomDescriptionJoursActions
{{ proj.id }} + {% if proj.image_url %} + Illustration + {% else %} + + {% endif %} + + + + + + + + + + + + + +
+
+ + Retour au tableau de bord +
+ + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..f174342 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,348 @@ + + + + + 🎄 Calendrier de l'Avent 🎄 + + + + + + +
❄️
+
❄️
+
❄️
+
❄️
+
❄️
+
❄️
+ +
+
+

+ Calendrier de l'Avent +

+

Qui aura la chance aujourd'hui ? 🎁

+
+ + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for msg in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+ +

+ + Jour {{ today_day }} +

+ + {% if today_draw %} +
+ +
{{ today_draw["name"] }}
+ + + {{ today_draw["draw_time"] }} + +
+
+ + Déjà tiré pour aujourd'hui ! +
+ {% else %} +
+ +

Prêt pour le tirage ?

+
+ {% endif %} + + + + {% if today_draw %} + Revoir le tirage + {% else %} + Tirer pour aujourd'hui ! + {% endif %} + +
+
+
+ +
+
+
+

+ Statistiques +

+
+
+ Tirages effectués + {{ all_draws|length }} / {{ max_days }} +
+
+
+
+
+
+
+
+
+ +
+
+
+
+

+ Historique +

+
+
+ {% if all_draws %} +
+ {% for d in all_draws[-10:] %} +
+
+
+ Jour {{ d["day"] }} + {{ d["name"] }} +
+ {{ d["draw_time"][:10] }} +
+
+ {% endfor %} +
+ {% else %} +
+ +

Aucun tirage effectué

+
+ {% endif %} +
+
+
+ +
+
+
+

+ Statut des participants +

+
+
+
+ + + + + + + + + + + {% for p in stats %} + + + + + + + {% endfor %} + +
NomTiragesMaxRestant
{{ p["name"] }}{{ p["draws"] }}{{ p["max_draws"] }} + + {{ p["max_draws"] - p["draws"] }} + +
+
+
+
+
+
+ +
+

+ + Joyeuses fêtes ! 🎄✨ +

+
+
+ + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..3cca598 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,51 @@ + + + + + Connexion - Calendrier de l'Avent + + + + +
+
+
+

+ Connexion +

+ + {% with messages = get_flashed_messages() %} + {% if messages %} + + {% endif %} + {% endwith %} + +
+
+ + +
+
+ + +
+ +
+ +
+ Admin par défaut: admin / admin +
+
+
+
+ + + + diff --git a/templates/project_view.html b/templates/project_view.html new file mode 100644 index 0000000..0a8f1cd --- /dev/null +++ b/templates/project_view.html @@ -0,0 +1,168 @@ + + + + + {{ project.name }} - Calendrier de l'Avent + + + + + +
+
+ Illustration projet +

{{ project.name }}

+

{{ project.description or '' }}

+
+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for m in messages %} +
{{ m }}
+ {% endfor %} + +
+ {% endif %} + {% endwith %} + +
+

🎄 Jour {{ today_day }} 🎄

+ {% if today_draw %} +
+ +
{{ today_draw.name }}
+ {{ today_draw.draw_time }} +
+
+ Déjà tiré ! +
+ {% else %} +
+ +

Prêt pour le tirage ?

+
+ {% endif %} + +
+ + Tirer aujourd'hui + + {% set missing_days = project.total_days - all_draws|length %} + {% if missing_days > 0 %} + + Rattraper ({{ missing_days }}) + + {% endif %} +
+
+ +
+
+
+
+
Tirages effectués
+

{{ all_draws|length }} / {{ project.total_days }}

+
+
+ {{ (all_draws|length / project.total_days * 100)|round(0) }}% +
+
+
+
+
+
+ +
+
+
+
+
Historique récent
+
+
+ {% if all_draws %} +
+ {% for d in all_draws[-8:] %} +
+
+
+ J{{ d.day }} + {{ d.name }} +
+ {{ d.draw_time[:10] }} +
+
+ {% endfor %} +
+ {% else %} +
+ + Aucun tirage +
+ {% endif %} +
+
+
+ +
+
+
+
Participants ({{ stats|length }})
+
+
+ + + + + + + + + + + {% for p in stats %} + + + + + + + {% endfor %} + +
NomTiragesMax tiragesRestant
{{ p.name }}{{ p.draws }}{{ p.max_draws }}{{ p.max_draws - p.draws }}
+
+
+
+
+ + +
+ + + + diff --git a/templates/projects.html b/templates/projects.html new file mode 100644 index 0000000..434b827 --- /dev/null +++ b/templates/projects.html @@ -0,0 +1,63 @@ + + + + + Tableau de bord + + + + +
+

Tableau de bord

+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for m in messages %} +
{{ m }}
+ {% endfor %} + +
+ {% endif %} + {% endwith %} + + + + + + + + + + + + + + {% for p in projects %} + + + + + + + + + {% endfor %} + +
IDIllustrationNom du projetDescriptionNombre de joursActions
{{ p.id }} + {% if p.image_url %} + Illustration + {% else %} + Aucune + {% endif %} + {{ p.name }}{{ p.description or '—' }}{{ p.total_days }} + Voir calendrier + Gérer utilisateurs +
+ + Déconnexion +
+ + + +