initial commit

This commit is contained in:
lelo 2025-06-28 19:12:47 +00:00
commit 985a70abf3
8 changed files with 771 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
venv
__pycache__
.env

406
app.py Normal file
View File

@ -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, weve wiped only *this users* 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 dont 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 questions correct answer
correct = questions[game['current_q']]['correct']
# take only the mostrecent 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 top4
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 newanswer 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)

47
docker-compose.yml Executable file
View File

@ -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

BIN
quizzes/questions.xlsx Normal file

Binary file not shown.

9
requirements.txt Normal file
View File

@ -0,0 +1,9 @@
flask
flask_socketio
pandas
qrcode
dotenv
gunicorn
eventlet
pillow
openpyxl

18
templates/base.html Normal file
View File

@ -0,0 +1,18 @@
<!-- base.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<title>Public Quiz</title>
</head>
<body class="p-4">
<div class="container">
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

143
templates/host.html Normal file
View File

@ -0,0 +1,143 @@
<!-- host.html -->
{% extends 'base.html' %}
{% block content %}
<h1 class="mb-4">Bibelquiz</h1>
<div class="container" id="host-app">
<!-- QR & Join Roster -->
<div id="qrContainer" class="mb-4 text-center">
<img id="qrImg"
src="data:image/png;base64,{{ qr_data }}"
class="img-fluid"
style="max-width:300px;"/>
</div>
<!-- new-game form -->
<form action="{{ url_for('new_game') }}" method="post" class="d-inline">
<button type="submit" class="btn btn-secondary mb-3" id="newGameButton">
Spiel neu starten
</button>
</form>
<button id="startBtn" class="btn btn-primary mb-3" disabled>Spiel beginnen</button>
<h3 id="rosterHeader">Spieler Beigetreten:</h3>
<ul id="roster" class="list-group mb-4"></ul>
<!-- Question & Current Leaderboard -->
<div id="host-question" style="display:none;">
<h2 id="qText" style="font-size:5rem;"></h2>
<button id="nextBtn" class="btn btn-warning mt-3">Nächste Frage</button>
</div>
<!-- Per-Question and Overall Results -->
<div id="host-results" style="display:none;">
<h4>Question Results</h4>
<ul id="perQ" class="list-group mb-3"></ul>
</div>
<!-- Final Top-10 Leaderboard -->
<div id="final-results" style="display:none;" class="mt-4">
<h4>Rangliste</h4>
<table class="table table-striped">
<thead>
<tr><th>Rang</th><th>Name</th><th>Punkte</th></tr>
</thead>
<tbody id="finalResultsBody"></tbody>
</table>
</div>
<!-- Persistent Overall Leaderboard Footer -->
<footer id="overallFooter" class="mt-4 bg-light border-top" style="position:fixed; bottom:0; left:0; width:100%; z-index:1000; display:none;">
<div class="container py-2">
<h4 class="mb-2">Rangliste</h4>
<table class="table table-striped mb-0">
<thead><tr><th>Name</th><th>Punkte</th></tr></thead>
<tbody id="overallFooterBody"></tbody>
</table>
</div>
</footer>
</div>
<script>
const socket = io();
// Connect and fetch roster
socket.on('connect', () => {
console.log('Connected as host');
socket.emit('get_roster');
});
// Update roster when players join
socket.on('update_roster', data => {
console.log('Roster update:', data.players);
$('#roster').empty();
data.players.forEach(name => {
$('#roster').append(`<li class="list-group-item">${name}</li>`);
});
$('#startBtn').prop('disabled', data.players.length === 0);
});
// Start game
$('#startBtn').on('click', () => {
console.log('Start game clicked');
$('#newGameButton').hide();
$('#overallFooter').show();
socket.emit('start_game');
});
// Handle new question
socket.on('new_question', data => {
console.log('New question:', data.question);
$('#qrContainer, #startBtn, #rosterHeader, #roster').hide();
$('#host-question').show();
$('#qText').text(data.question);
});
// Overall leaderboard updates (max 5 rows)
socket.on('overall_leader', data => {
console.log('Overall leader:', data.board);
window.lastOverall = data;
$('#overallFooterBody').empty();
const top5 = data.board.slice(0, 5);
top5.forEach(p => {
$('#overallFooterBody').append(`<tr><td>${p.name}</td><td>${p.score}</td></tr>`);
});
});
// Per-question results
socket.on('question_leader', data => {
console.log('Question leader:', data.results);
$('#perQ').empty();
data.results.forEach(r => {
$('#perQ').append(`<li class="list-group-item">${r.name}: +${r.points}</li>`);
});
});
// Next question
$('#nextBtn').off('click').on('click', () => {
console.log('Next question clicked');
socket.emit('next_question');
});
// Show final top-10 when game_over arrives
socket.on('game_over', data => {
if (data.board) {
// hide all the old UI
$('#qrContainer, #startBtn, #rosterHeader, #roster, #host-question, #host-results, #overallFooter').hide();
$('#newGameButton').show();
// fill & show your existing <div id="final-results">
const top10 = data.board.slice(0, 10);
$('#finalResultsBody').empty();
top10.forEach((p, i) => {
$('#finalResultsBody').append(
`<tr><td>${i+1}</td><td>${p.name}</td><td>${p.score}</td></tr>`
);
});
$('#final-results').show();
// make absolutely sure the “Start Game” button stays disabled
$('#startBtn').prop('disabled', true);
}
});
</script>
{% endblock %}

145
templates/player.html Normal file
View File

@ -0,0 +1,145 @@
<!-- player.html -->
{% extends 'base.html' %}
{% block content %}
<style>
.option.selected {
background-color: #0d6efd; /* bootstrap “primary” bg */
color: white;
}
.option.disabled {
background-color: #6c757d; /* bootstrap “secondary” bg */
color: white;
}
</style>
<div class="container" id="player-app">
<!-- Join View -->
<div id="view-join" {% if state == 'join' %} {% else %}style="display:none;"{% endif %}>
<h1 class="mb-4">Dem Quiz Beitreten</h1>
{% if not error %}
<form id="joinForm" action="{{ url_for('do_join') }}" method="post">
<div class="input-group mb-3">
<input name="name" id="nameInput" type="text"
class="form-control" placeholder="Name eingeben" />
<button type="submit" class="btn btn-success">Beitreten</button>
</div>
</form>
{% else %}
<!-- show backend-passed error if present -->
<div id="joinStatus" class="mt-2">
<div class="alert alert-danger" role="alert">{{ error }}</div>
</div>
{% endif %}
</div>
<!-- Wait View -->
<div id="view-wait" {% if state == 'waiting' %} {% else %}style="display:none;"{% endif %}>
<div class="alert alert-info mt-4" role="alert">
Bitte warten bis das Spiel beginnt.
</div>
</div>
<!-- Options View -->
<div id="view-question" {% if state == 'question' %} {% else %}style="display:none;"{% endif %}>
<div id="options" class="d-grid gap-2"></div>
</div>
<!-- End View -->
<div id="view-end" style="display:none;">
<div class="alert alert-success mt-4 text-center">
<h2>Spiel beendet!</h2>
<p>Du hast <strong><span id="playerScore">0</span> Punkte</strong> erzielt und Platz <strong><span id="playerRank"></span></strong> belegt.</p>
</div>
</div>
</div>
<script>
const socket = io();
// Restore state on load
socket.on('connect', () => {
socket.emit('rejoin_game');
});
// Sync initial or reconnect state
socket.on('sync_state', data => {
if (data.state === 'waiting') {
$('#view-join, #view-question').hide();
$('#view-wait').show();
}
else if (data.state === 'question') {
$('#view-join, #view-wait').hide();
$('#view-question').show();
renderOptions(data.options, data.answered);
}
else if (data.state === 'end') {
$('#view-join, #view-wait, #view-question').hide();
$('#view-end').show();
$('#playerScore').text(data.score);
$('#playerRank').text(data.placement);
}
});
// Host clicked “Start Game”
socket.on('game_started', () => {
$('#view-join, #view-wait').hide();
$('#view-question').show();
});
// A new question just dropped
socket.on('new_question', data => {
$('#view-join, #view-wait').hide();
$('#view-question').show();
renderOptions(data.options, false);
});
// Handle answer clicks
$(document).on('click', '.option', function() {
const $all = $('.option');
const $me = $(this);
const answer = $me.text();
// emit once
socket.emit('submit_answer', { answer });
// style the clicked one as selected
$me
.addClass('selected')
.removeClass('btn-outline-primary')
.prop('disabled', true);
// style all the others as disabled
$all.not($me)
.addClass('disabled')
.removeClass('btn-outline-primary btn-primary')
.prop('disabled', true);
});
// When re-rendering options (e.g. on sync or new_question), clear any classes:
function renderOptions(options, alreadyAnswered) {
const $opts = $('#options').empty();
options.forEach(opt => {
const btn = $(`<button class="btn option btn-outline-primary btn-lg mb-2">${opt}</button>`);
if (alreadyAnswered) {
btn.addClass('disabled')
.removeClass('btn-outline-primary')
.prop('disabled', true);
}
$opts.append(btn);
});
}
// Handle end-of-game for players
socket.on('game_over', data => {
if (data.placement !== undefined) {
// hide everything else
$('#view-join, #view-wait, #view-question').hide();
// show final screen
$('#view-end').show();
// fill in score & rank
$('#playerScore').text(data.score);
$('#playerRank').text(data.placement);
}
})
</script>
{% endblock %}