From 985a70abf3e8aa962d060744547ffdd551d239e7 Mon Sep 17 00:00:00 2001 From: lelo Date: Sat, 28 Jun 2025 19:12:47 +0000 Subject: [PATCH] initial commit --- .gitignore | 3 + app.py | 406 +++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 47 +++++ quizzes/questions.xlsx | Bin 0 -> 10501 bytes requirements.txt | 9 + templates/base.html | 18 ++ templates/host.html | 143 +++++++++++++++ templates/player.html | 145 +++++++++++++++ 8 files changed, 771 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100755 docker-compose.yml create mode 100644 quizzes/questions.xlsx create mode 100644 requirements.txt create mode 100644 templates/base.html create mode 100644 templates/host.html create mode 100644 templates/player.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..771ba65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +venv +__pycache__ +.env \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..bb6b953 --- /dev/null +++ b/app.py @@ -0,0 +1,406 @@ +# --- app.py --- +import os +import io +import base64 +import pandas as pd +import qrcode +import uuid +from flask import Flask, render_template, request, session, redirect, url_for, abort +from flask_socketio import SocketIO, emit, join_room +from dotenv import load_dotenv + +# global store of all games, keyed by secret +games = {} + +# Load environment vars +load_dotenv() +app = Flask(__name__) +app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'dev') +# manage_session ensures Flask session is available in SocketIO handlers +socketio = SocketIO(app, manage_session=True) + +# Expect columns: question, answer1..answer4, correct (column name) +df = pd.read_excel('quizzes/questions.xlsx') +questions = [] +for _, row in df.iterrows(): + correct_col = row['correct'] # e.g. 'answer3' + correct_text = row[correct_col] + questions.append({ + 'question': row['question'], + 'options': [row[f'answer{i}'] for i in range(1,5)], # only four options + 'correct': correct_text + }) + +# In-memory game state +game = { + 'players': {}, + 'order': [], + 'current_q': 0, + 'answered': [], + 'started': False +} + +@app.route('/') +def host(): + session['role'] = 'host' + # — manage per-game secret in session — + secret = session.get('secret') + if not secret: + secret = uuid.uuid4().hex[:8] + session['secret'] = secret + # ensure game exists + if secret not in games: + games[secret] = { + 'players': {}, 'order': [], + 'current_q': 0, 'answered': [], + 'started': False, 'finished': False + } + + # build join URL + QR + join_url = url_for('player', secret=secret, _external=True) + img = qrcode.make(join_url) + buf = io.BytesIO() + img.save(buf, format='PNG') + qr_data = base64.b64encode(buf.getvalue()).decode('ascii') + + return render_template( + 'host.html', + qr_data=qr_data, + join_url=join_url, + secret=secret + ) + + +@app.route('/new_game', methods=['POST']) +def new_game(): + if session.get('role') != 'host': + abort(403) + # remove the old game entirely (so its secret is no longer valid) + old_secret = session.pop('secret', None) + if old_secret and old_secret in games: + del games[old_secret] + return redirect(url_for('host')) + +@app.route('/player') +def player(): + session['role'] = 'player' + + # 1) Grab any secret from the URL + new_secret = request.args.get('secret') + old_secret = session.get('secret') + + # 2) If they provided a new secret, it *must* already exist or we reject immediately + if new_secret: + if new_secret not in games: + return render_template( + 'player.html', + joined=False, + state='join', + options=[], + answered=False, + error='Spiel nicht gefunden' + ), 400 + # only clear join info if it really *is* a different, valid game + if new_secret != old_secret: + for k in ('joined','player_id','player_name'): + session.pop(k, None) + secret = new_secret + else: + secret = old_secret + + # 3) We need *some* secret in session — and it must still be in our games dict + if not secret or secret not in games: + return render_template( + 'player.html', + joined=False, + state='join', + options=[], + answered=False, + error='Spiel nicht gefunden' + ), 400 + + # 4) Save it for future Socket.IO events + session['secret'] = secret + + # At this point, we’ve wiped only *this user’s* join info if they scanned a new code. + # We did *not* delete games[old_secret], so old games remain intact. + + joined = session.get('joined', False) + # We don’t emit() here; the socket handlers will PUSH the right state. + return render_template( + 'player.html', + joined=joined, + state='join', # actual view switched via sync_state over Socket.IO + options=[], + answered=False + ) + + +@app.route('/player/join', methods=['POST']) +def do_join(): + name = request.form.get('name') or 'Anonymous' + session['role'] = 'player' + session['joined'] = True + # make sure the player is still talking to a live game + secret = session.get('secret') + if not secret or secret not in games: + return render_template( + 'player.html', + joined=False, + state='join', + options=[], + answered=False, + error='Spiel nicht gefunden' + ), 400 + + session['player_name'] = name + session['player_id'] = str(uuid.uuid4()) + return redirect( + url_for('player', secret=session.get('secret')) + ) + + +@socketio.on('connect') +def on_connect(): + secret = session.get('secret') + if not secret or secret not in games: + return + game = games[secret] + role = session.get('role') + + if role == 'host': + join_room('host') + + # ─── if the game is already finished, just re-emit the final board ─── + if game.get('finished'): + board = sorted( + [{'name': v['name'], 'score': v['score']} for v in game['players'].values()], + key=lambda x: -x['score'] + ) + emit('game_over', {'board': board}, room='host') + return + + # otherwise fall back to normal roster / question logic + players = [game['players'][pid]['name'] for pid in game['order']] + emit('update_roster', {'players': players}) + + if game.get('started') and game['current_q'] < len(questions): + q = questions[game['current_q']] + emit('new_question', {'question': q['question']}, room='host') + return + + # player logic + if role != 'player' or not session.get('joined'): + return + + pid = session['player_id'] + join_room('player') + join_room(pid) + + if pid not in game['players']: + game['players'][pid] = { + 'name': session.get('player_name'), + 'score': 0 + } + game['order'].append(pid) + socketio.emit( + 'update_roster', + {'players': [game['players'][p]['name'] for p in game['order']]}, + room='host' + ) + + # delegate to rejoin logic + _send_sync_state(pid, game) + + + +@socketio.on('start_game') +def on_start_game(): + secret = session.get('secret') + if session.get('role') != 'host' or not secret or secret not in games: + return + game = games[secret] + + # if already through, reset + if game.get('started') or game['current_q'] >= len(questions): + game.update({ + 'current_q': 0, + 'answered': [], + 'started': False, + 'finished': False + }) + for p in game['players'].values(): + p['score'] = 0 + + game['started'] = True + socketio.emit('game_started', room='player') + _send_question(secret) + + + +@socketio.on('get_roster') +def on_get_roster(): + secret = session.get('secret') + if not secret or secret not in games: + return + game = games[secret] + roster = [game['players'][s]['name'] for s in game['order']] + emit('update_roster', {'players': roster}) + + + +@socketio.on('submit_answer') +def on_submit_answer(data): + secret = session.get('secret') + pid = session.get('player_id') + if not secret or secret not in games or pid not in games[secret]['players']: + return + game = games[secret] + if any(pid == p for p,_ in game['answered']): + return + + game['answered'].append((pid, data.get('answer'))) + _score_and_emit(secret) + + +@socketio.on('next_question') +def on_next_question(): + secret = session.get('secret') + if session.get('role') != 'host' or not secret or secret not in games: + return + game = games[secret] + game['current_q'] += 1 + + if game['current_q'] < len(questions): + _send_question(secret) + else: + # mark finished & emit final boards + game['finished'] = True + board = sorted( + [{'name':v['name'],'score':v['score']} for v in game['players'].values()], + key=lambda x: -x['score'] + ) + socketio.emit('game_over', {'board': board}, room='host') + for psid,pdata in game['players'].items(): + placement = next(i+1 for i,p in enumerate(board) if p['name']==pdata['name']) + socketio.emit( + 'game_over', + {'placement': placement, 'score': pdata['score']}, + room=psid + ) + + + +@socketio.on('rejoin_game') +def on_rejoin_game(): + secret = session.get('secret') + pid = session.get('player_id') + if not secret or secret not in games or not pid: + return + game = games[secret] + join_room('player') + join_room(pid) + _send_sync_state(pid, game) + + +# helpers + +def _send_sync_state(pid, game): + # finished? + if game.get('finished'): + board = sorted( + [{'name': v['name'], 'score': v['score']} for v in game['players'].values()], + key=lambda x: -x['score'] + ) + me = game['players'][pid] + placement = next(i+1 for i,p in enumerate(board) if p['name']==me['name']) + emit('sync_state', { + 'state': 'end', + 'score': me['score'], + 'placement': placement + }, room=pid) + # waiting? + elif not game['started']: + emit('sync_state', {'state':'waiting'}, room=pid) + # question? + else: + idx = game['current_q'] + q = questions[idx] + answered = any(pid == p for p,_ in game['answered']) + emit('sync_state', { + 'state': 'question', + 'question': q['question'], + 'options': q['options'], + 'answered': answered + }, room=pid) + + +def _send_question(secret): + game = games[secret] + q = questions[game['current_q']] + # clear previous answers + game['answered'].clear() + # broadcast + socketio.emit( + 'new_question', + {'question': q['question'], 'options': q['options']}, + room='player' + ) + socketio.emit( + 'new_question', + {'question': q['question']}, + room='host' + ) + + +def _score_and_emit(secret): + game = games[secret] + score_map = {1: 4, 2: 3, 3: 2, 4: 1} + per_q = [] + + # grab the current question’s correct answer + correct = questions[game['current_q']]['correct'] + # take only the most‐recent submission + pid, ans = game['answered'][-1] + + # count how many *earlier* answers were correct + prev_correct = sum(1 for p, a in game['answered'][:-1] if a == correct) + rank = prev_correct + 1 + + # assign points only if this one is correct and within top‐4 + if ans == correct and rank <= 4: + pts = score_map[rank] + game['players'][pid]['score'] += pts + per_q.append({ + 'name': game['players'][pid]['name'], + 'points': pts + }) + + # rebuild overall leaderboard + board = sorted( + [{'name': v['name'], 'score': v['score']} for v in game['players'].values()], + key=lambda x: -x['score'] + ) + + # emit just the new‐answer results, plus the overall board + socketio.emit('question_leader', {'results': per_q}, room='host') + socketio.emit('question_leader', {'results': per_q}, room='player') + socketio.emit('overall_leader', {'board': board}, room='host') + socketio.emit('overall_leader', {'board': board}, room='player') + + # if that was the last question, finish up + if game['current_q'] >= len(questions): + game['finished'] = True + socketio.emit('game_over', {'board': board}, room='host') + for psid, pdata in game['players'].items(): + placement = next(i+1 for i, p in enumerate(board) if p['name'] == pdata['name']) + socketio.emit( + 'game_over', + {'placement': placement, 'score': pdata['score']}, + room=psid + ) + + + +if __name__ == '__main__': + socketio.run(app, host='0.0.0.0', port=5000) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..090e0e6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + flask-app: + image: python:3.11-slim + container_name: "${CONTAINER_NAME}" + restart: always + working_dir: /app + volumes: + - ./:/app + - ./templates:/app/templates + - type: bind + source: /mnt + target: /mnt + bind: + propagation: rshared + environment: + - FLASK_APP=app.py + - FLASK_ENV=production + - TITLE_SHORT=${TITLE_SHORT} + - TITLE_LONG=${TITLE_LONG} + networks: + - traefik + labels: + - "traefik.enable=true" + + # HTTP router (port 80), redirecting to HTTPS + - "traefik.http.routers.${CONTAINER_NAME}.rule=${HOST_RULE}" + - "traefik.http.routers.${CONTAINER_NAME}.entrypoints=web" + - "traefik.http.routers.${CONTAINER_NAME}.middlewares=redirect-to-https" + - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" + + # HTTPS router (TLS via Let's Encrypt) + - "traefik.http.routers.${CONTAINER_NAME}-secure.rule=${HOST_RULE}" + - "traefik.http.routers.${CONTAINER_NAME}-secure.entrypoints=websecure" + - "traefik.http.routers.${CONTAINER_NAME}-secure.tls=true" + - "traefik.http.routers.${CONTAINER_NAME}-secure.tls.certresolver=myresolver" + + # Internal port + - "traefik.http.services.${CONTAINER_NAME}.loadbalancer.server.port=5000" + + # Production-ready Gunicorn command with eventlet + command: > + sh -c "pip install -r requirements.txt && + gunicorn --worker-class eventlet -w 1 -b 0.0.0.0:5000 app:app" + +networks: + traefik: + external: true diff --git a/quizzes/questions.xlsx b/quizzes/questions.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..1320c2f6dc92cf6e0a22ff22ae2001589cffb4e4 GIT binary patch literal 10501 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 TK{yBiph8}B5E>8A{yh4Bgasj4 literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..48b10e7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +flask +flask_socketio +pandas +qrcode +dotenv +gunicorn +eventlet +pillow +openpyxl \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..707e5cb --- /dev/null +++ b/templates/base.html @@ -0,0 +1,18 @@ + + + + + + + + + + Public Quiz + + +
+ {% block content %}{% endblock %} +
+ + + \ No newline at end of file diff --git a/templates/host.html b/templates/host.html new file mode 100644 index 0000000..114f523 --- /dev/null +++ b/templates/host.html @@ -0,0 +1,143 @@ + +{% extends 'base.html' %} +{% block content %} +

Bibelquiz

+
+ +
+ +
+ + +
+ +
+ + +

Spieler Beigetreten:

+
    + + + + + + + + + + + +
    +
    +

    Rangliste

    + + + +
    NamePunkte
    +
    +
    +
    + + +{% endblock %} diff --git a/templates/player.html b/templates/player.html new file mode 100644 index 0000000..3ac140e --- /dev/null +++ b/templates/player.html @@ -0,0 +1,145 @@ + +{% extends 'base.html' %} +{% block content %} + +
    + +
    +

    Dem Quiz Beitreten

    + {% if not error %} +
    +
    + + +
    +
    + {% else %} + +
    + +
    + {% endif %} +
    + + +
    + +
    + + +
    +
    +
    + + + + +
    + + +{% endblock %} \ No newline at end of file