Compare commits

...

18 Commits

Author SHA1 Message Date
58932cdd7b Add OpenRC config and update nginx conf 2025-11-07 10:53:10 +01:00
366d368446 Refer to license 2023-11-29 14:12:11 +01:00
4fd9d1eb81 Explain TSV field 2023-11-29 14:10:54 +01:00
872e10ba08 Fix regression resetting inputs after wait
The wait timer for the next question would trigger the reset of inputs
because FSG thought the question was over without a question submitted
2023-11-22 20:38:53 +01:00
ca644a6e66 Update systemd service file 2023-11-22 20:38:33 +01:00
5cb2182f4c Add FSA progress bar and some bonus point counting
Resolves #14
2023-01-31 00:03:57 +01:00
d25f7f2f0c FSG: Not activey submitting will always be regarded as no answer (fail)
resolves #12
2023-01-30 22:15:18 +01:00
3f2fe5c9a1 Set default FSCzech wait time to 60s. Resolves #15 2023-01-28 23:11:18 +01:00
095716c2eb Fix #11 by remembering whether results are shown in state 2022-01-15 22:33:54 +01:00
0a9055431f Skip question using only shift+click because ctrl is hard on mac/safari 2022-01-03 20:53:17 +01:00
bfb7f477af Only close files when they've been opened 2021-11-11 19:33:45 +01:00
27f8b6d5d0 Add debug statements 2021-11-11 19:03:45 +01:00
99da9da68a Write to FS on every POST to prevent data loss on server crash 2021-11-11 18:56:36 +01:00
64ca89eefb Introduce timer skip/bypass using Ctrl+Shift (Resolves #10) 2021-02-09 12:02:42 +01:00
314c9e8c67 Fix total time counting for timed questions and lots of cleanup 2021-01-23 19:47:19 +01:00
6cdf7dbb4d FSA counter workaround for questions without time 2021-01-23 11:16:32 +01:00
bcbf149c6c Add workaround so the fsczech quizzes saved before work again 2021-01-23 11:05:03 +01:00
e3a15ac60f Fix bug that didn't allow infinity values to be saved 2021-01-23 11:02:11 +01:00
12 changed files with 210 additions and 50 deletions

View File

@ -3,6 +3,13 @@ FS Quiz Tool question format
This document provides examples for the question format This document provides examples for the question format
The web tool will accept these in TSV format, as copied from a spreadsheet like Google Sheets. One Question is one row of the TSV, and each column is one of the following fields, like:
Input Type | Question | Choices/Answer Options | Answers | Explanation | Author | Picture Link | Time in minutes
-----------|----------|-------------------------|---------|-------------|--------|-----------------|----------------
ChooseOne | ... | a<br>b | 2 | ... | ... | https://url.jpg | 3
Text | ... | Answer in W, 2 decimals | 4.20 | ... | ... | | 5
## ChooseOne ## ChooseOne
Single-Choice question Single-Choice question

View File

@ -12,3 +12,7 @@ python3 -m fs-quiz-tool-db
``` ```
Then, open <http://localhost:12345> in your browser. Then, open <http://localhost:12345> in your browser.
## License
See LICENSE.md

View File

@ -19,8 +19,11 @@ def startup():
try: try:
fd = open(filename) fd = open(filename)
data = json.load(fd) data = json.load(fd)
fd.close()
except FileNotFoundError: except FileNotFoundError:
pass print("DB file not found, creating on POST/write.")
print("Read", len(data), "quizzes from DB")
@app.route('/db/<id>', methods = ['GET']) @app.route('/db/<id>', methods = ['GET'])
@ -45,20 +48,16 @@ def post():
id = generateNewKey() id = generateNewKey()
data[id] = request.data.decode('UTF-8') data[id] = request.data.decode('UTF-8')
return id, 201 print("Received new quiz. ID:", id)
def dumpDB():
if len(data) == 0:
return
try: try:
fd = open(filename, 'w+') fd = open(filename, 'w+')
json.dump(data, fd) json.dump(data, fd, indent=2)
finally:
fd.close() fd.close()
except e:
print("Failed to open/create DB file for writing:", e)
return id, 201
startup() startup()
atexit.register(dumpDB)

View File

@ -3,7 +3,7 @@ server {
server_name quiz.fasttube.de; server_name quiz.fasttube.de;
root /root/fs-quiz-tool/web; root /opt/fs-quiz-tool/web;
index index.html; index index.html;
@ -15,8 +15,6 @@ server {
location /db { location /db {
proxy_pass http://127.0.0.1:12345; proxy_pass http://127.0.0.1:12345;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
} }
ssl_certificate /usr/local/etc/letsencrypt/live/quiz.fasttube.de/fullchain.pem; ssl_certificate /usr/local/etc/letsencrypt/live/quiz.fasttube.de/fullchain.pem;

29
res/fs-quiz-tool.openrc Normal file
View File

@ -0,0 +1,29 @@
#!/sbin/openrc-run
name="$RC_SVCNAME"
directory="/opt/fs-quiz-tool"
command="/opt/fs-quiz-tool/.venv/bin/python"
command_args="-m fs-quiz-tool-db"
command_user="www"
pidfile="/run/$RC_SVCNAME/$RC_SVCNAME.pid"
command_background="yes"
depend() {
need net
}
start_pre() {
checkpath --directory --owner $command_user:$command_user --mode 0775 \
/run/$RC_SVCNAME /var/log/$RC_SVCNAME
}
start() {
ebegin "Starting ${RC_SVCNAME}"
start-stop-daemon --start --exec ${command} \
--background --make-pidfile --pidfile ${pidfile} \
--chdir ${directory} \
--stdout /var/log/fs-quiz-tool/out.log \
--stderr /var/log/fs-quiz-tool/err.log \
-- ${command_args}
eend $?
}

View File

@ -4,6 +4,9 @@ After=syslog.target network.target nginx.service
[Service] [Service]
User=www-data User=www-data
Group=www-data
ReadWriteDirectories=/var/fs-quiz-tool-db
WorkingDirectory=/var/fs-quiz-tool-db
ExecStart=/usr/bin/python3 -m fs-quiz-tool-db ExecStart=/usr/bin/python3 -m fs-quiz-tool-db
Restart=always Restart=always

View File

@ -91,7 +91,11 @@
</div> </div>
<form> <form>
</form> </form>
<div id="fsateamcounttroll"></div> <div id="fsastuff">
<div id="fsabonuspointsinfo"></div>
<div id="fsateamcounttroll"></div>
<div id="fsapointbar"><div></div></div>
</div>
<input type="button" value="Submit" onclick="submitQuiz()" style="background: #008029" id="quizSubmitButton"> <input type="button" value="Submit" onclick="submitQuiz()" style="background: #008029" id="quizSubmitButton">
<input type="button" value="Abort" onclick="abortQuiz()"> <input type="button" value="Abort" onclick="abortQuiz()">
<span id="submitinfo"></span> <span id="submitinfo"></span>

BIN
web/nyan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@ -5,17 +5,16 @@ function showFSATeamCountTroll() {
} }
function updateFSATeamCountTroll() { function updateFSATeamCountTroll(time) {
if (state.fsaTeamCountTroll > (128*Math.random())) { if (state.fsaTeamCountTroll > (128*Math.random())) {
showFSATeamCountTroll() showFSATeamCountTroll()
return return
} }
var time = state.questions[state.currentQuestion].time var timeratio = 1 - state.submitTimer / time
var timeratio = 1 - (state.submitTimer / time)
var slowAnswerChance = Math.random()*2*Math.pow(timeratio, 2) var slowAnswerChance = Math.random()*2*Math.pow(timeratio, 2)
var quickAnswerChance = (Math.random()+Math.sqrt(69/time))/3 var quickAnswerChance = (Math.random()+Math.sqrt(69/time))/4
var chance = Math.round(slowAnswerChance + quickAnswerChance) var chance = Math.round(slowAnswerChance + quickAnswerChance)
var magnitude = Math.round((Math.random()/2+timeratio)*6) var magnitude = Math.round((Math.random()/2+timeratio)*6)
var nt = chance * magnitude var nt = chance * magnitude
@ -25,19 +24,46 @@ function updateFSATeamCountTroll() {
} }
function updateFSABonusPoints(time) {
if (state.waitNextQuestion)
return
var bonuspart = (state.submitTimer - 0.333 * time) / (time * 0.666)
var barel = document.querySelector('#fsapointbar > div')
barel.style.width = Math.round(bonuspart*100) + '%'
var bonusinfoel = document.getElementById('fsabonuspointsinfo')
var points = Math.round(bonuspart*10)
bonusinfoel.innerHTML = ((points > 0) ? points : 'No') + ' Bonus points'
}
function updateFSAStuff() {
// `|| state.submitTime` is a workaround for saved Qs that don't have a time set yet
var time = state.questions[state.currentQuestion].time || state.submitTime
updateFSABonusPoints(time)
updateFSATeamCountTroll(time)
}
function updateTotalTimer() { function updateTotalTimer() {
var timerEls = document.querySelectorAll('.totaltimer') var timerEls = document.querySelectorAll('.totaltimer')
for (timerEl of timerEls) for (timerEl of timerEls)
timerEl.innerHTML = formatTime(state.totalTimer); timerEl.innerHTML = 'Total time: ' + formatTime(state.totalTimer);
state.totalTimer++ if (!state.waitNextQuestion)
state.totalTimer++
localStorage.setItem('state', JSON.stringify(state)) localStorage.setItem('state', JSON.stringify(state))
if (state.style == 'FSA') if (state.style == 'FSA')
updateFSATeamCountTroll() updateFSAStuff()
} }
@ -52,6 +78,18 @@ function startTotalTimer() {
} }
function skipWaitNextQuestion(event) {
if (!event.shiftKey)
return
console.log('Skipping/Bypassing wait timer')
state.submitTimer = 0
updateSubmitTimer()
}
function updateSubmitInfo() { function updateSubmitInfo() {
var button = document.getElementById('quizSubmitButton') var button = document.getElementById('quizSubmitButton')
@ -61,29 +99,51 @@ function updateSubmitInfo() {
if (state.submitTimer > 0) { if (state.submitTimer > 0) {
si.innerHTML = state.waitNextQuestion ? 'Waiting for next question' : 'Wait to retry' si.innerHTML = state.waitNextQuestion ? 'Waiting for next question' : 'Wait to retry'
button.value = 'Wait ' + formatTime(state.submitTimer) button.value = 'Wait ' + formatTime(state.submitTimer)
button.disabled = true button.readOnly = true
button.addEventListener('click', skipWaitNextQuestion)
return return
} }
} }
si.innerHTML = '' si.innerHTML = ''
button.disabled = false button.readOnly = false
button.removeEventListener('click', skipWaitNextQuestion)
if (getRule('questionTimeout')) { if (getRule('questionTimeout')) {
if (state.submitTimer > 0) { if (state.submitTimer > 0) {
if (getRule('allowQOvertime')) if (getRule('allowQOvertime'))
si.innerHTML = ('Losing bonus points in ' + formatTime(state.submitTimer)) si.innerHTML = ('Next question timer starts in ' + formatTime(state.submitTimer))
else else
si.innerHTML = ('Forced hand-in in ' + formatTime(state.submitTimer)) si.innerHTML = ( 'Question failed in ' + formatTime(state.submitTimer))
} else { } else {
if (getRule('allowQOvertime')) if (getRule('allowQOvertime'))
document.getElementById('submitinfo').innerHTML = 'Bonus points lost.' document.getElementById('submitinfo').innerHTML = 'Next question timer started.'
} }
} }
var lastQuestion = (state.currentQuestion == (state.questions.length - 1)) var lastQuestion = (state.currentQuestion == (state.questions.length - 1))
button.value = lastQuestion || !getRule('sequential') ? 'Submit Answers' : 'Next Question' button.value = lastQuestion || !getRule('sequential') ? 'Submit Answers' :
((state.submitTimer > 0) ? 'Submit' : 'Next Question')
}
function resetQuestionResponse(qn) {
var q = document.querySelector('#quiz form #question' + qn)
var inputs = q.querySelectorAll('input')
for (input of inputs) {
switch (input.type) {
case 'radio':
case 'checkbox':
input.checked = false
break;
case 'text':
input.value = ""
break;
}
}
} }
@ -96,9 +156,15 @@ function updateSubmitTimer() {
clearInterval(state.submitInterval) clearInterval(state.submitInterval)
if (getRule('questionTimeout')) if (getRule('questionTimeout') && !getRule('allowQOvertime')) {
if (state.waitNextQuestion || !getRule('allowQOvertime'))
submitQuiz() // Force next question if (!state.waitNextQuestion) {
console.log("Question not submitted, clearing input");
resetQuestionResponse(state.currentQuestion);
}
submitQuiz() // Force next question
}
} }
@ -122,7 +188,7 @@ function startSubmitTimer(time) {
} }
function showSequentialQuestion(n) { function updateSequentialQuestion() {
var questions = document.querySelectorAll('.question') var questions = document.querySelectorAll('.question')
for (q of questions) for (q of questions)
@ -130,8 +196,8 @@ function showSequentialQuestion(n) {
updateSubmitInfo() updateSubmitInfo()
if (n !== null) { if (state.currentQuestion !== null && !state.waitNextQuestion) {
var q = document.querySelector('#quiz form #question' + n) var q = document.querySelector('#quiz form #question' + state.currentQuestion)
q.style.display = 'block' q.style.display = 'block'
} }
@ -142,10 +208,17 @@ function nextQuestion() {
// Save time taken // Save time taken
state.questions[state.currentQuestion].timeTaken = state.totalTimer - state.questionStartTotalTimer state.questions[state.currentQuestion].timeTaken = state.totalTimer - state.questionStartTotalTimer
if (getRule('questionTimeout') && (state.submitTimer > 0) // Last question
&& (state.currentQuestion != (state.questions.length-1))) { if (state.currentQuestion == (state.questions.length - 1)) {
state.currentQuestion = null
updateSequentialQuestion()
return
}
// Waiting for next question
if (getRule('questionTimeout') && (state.submitTimer > 0)) {
state.waitNextQuestion = true; state.waitNextQuestion = true;
showSequentialQuestion(null) // hide question updateSequentialQuestion() // hide question
return return
} }
@ -154,6 +227,9 @@ function nextQuestion() {
if (state.style == 'FSA') { if (state.style == 'FSA') {
state.fsaTeamCountTroll = 0 state.fsaTeamCountTroll = 0
showFSATeamCountTroll() showFSATeamCountTroll()
// a lil' surprise
document.querySelector('#fsapointbar > div').style.background =
(Math.random() < 0.05) ? "left / auto 100% url('nyan.png')" : "darkred"
} }
// Start next question // Start next question
@ -161,12 +237,9 @@ function nextQuestion() {
state.currentQuestion++ state.currentQuestion++
if (state.currentQuestion == state.questions.length) updateSequentialQuestion()
state.currentQuestion = null
showSequentialQuestion(state.currentQuestion) if (getRule('questionTimeout'))
if (state.currentQuestion !== null && getRule('questionTimeout'))
startSubmitTimer() startSubmitTimer()
} }
@ -216,6 +289,9 @@ function renderQuiz() {
} }
var fsastuffel = document.getElementById('fsastuff')
fsastuffel.style.display = (state.style == 'FSA') ? 'block' : 'none'
} }
@ -231,7 +307,7 @@ function startQuiz() {
changeView('quiz') changeView('quiz')
if (getRule('sequential')) if (getRule('sequential'))
showSequentialQuestion(state.currentQuestion) updateSequentialQuestion()
if (state.submitTimer > 0) if (state.submitTimer > 0)
startSubmitTimer(state.submitTimer) startSubmitTimer(state.submitTimer)
@ -262,6 +338,7 @@ function reStartQuiz() {
state.totalTimer = defaultState.totalTimer state.totalTimer = defaultState.totalTimer
state.totalInterval = defaultState.totalInterval state.totalInterval = defaultState.totalInterval
state.fsaTeamCountTroll = defaultState.fsaTeamCountTroll state.fsaTeamCountTroll = defaultState.fsaTeamCountTroll
state.resultsShown = defaultState.resultsShown
changeView('prescreen') changeView('prescreen')
@ -331,6 +408,10 @@ function showQuizResults() {
changeView('quiz') changeView('quiz')
// Prevent result metadata being added more than once
if (state.resultsShown)
return
for (var [idx, value] of state.responses.entries()) { for (var [idx, value] of state.responses.entries()) {
console.log(idx + ':', value) console.log(idx + ':', value)
@ -372,13 +453,17 @@ function showQuizResults() {
qinfo += ('. Time taken: ' + formatTime(q.timeTaken)) qinfo += ('. Time taken: ' + formatTime(q.timeTaken))
if (value.correct && getRule('allowQOvertime')) if (value.correct && getRule('allowQOvertime'))
qinfo += (', Bonus points ' + ((q.timeTaken <= q.time) ? 'received' : 'lost')) qinfo += (', Bonus points ' + ((q.timeTaken <= q.time) ? 'received' : 'lost'))
else if (q.timeTaken == q.time) else if (q.timeTaken >= (q.time - 1) && !getRule('allowQOvertime'))
qinfo += ', Time ran out' qinfo += ', Time ran out'
} }
el.querySelector('h3').innerHTML += ` &nbsp; <span class="meta">${qinfo}</span>` el.querySelector('h3').innerHTML += ` &nbsp; <span class="meta">${qinfo}</span>`
} }
state.resultsShown = true
document.querySelector('#fsateamcounttroll').innerHTML = ''
document.querySelector('#submitinfo').innerHTML = ''
document.querySelector('#quizSubmitButton').value = 'Back' document.querySelector('#quizSubmitButton').value = 'Back'
} }
@ -467,7 +552,7 @@ function submitQuiz() {
renderQuiz() renderQuiz()
if (getRule('sequential')) { if (getRule('sequential')) {
state.currentQuestion = 0 state.currentQuestion = 0
showSequentialQuestion(state.currentQuestion) updateSequentialQuestion()
} }
if (getRule('submitTimeout')) if (getRule('submitTimeout'))
@ -547,6 +632,11 @@ window.onload = async function() {
state.submitTime = quiz.submitTime state.submitTime = quiz.submitTime
state.id = urlId state.id = urlId
// Workaround for quizzes that were saved before the fix
// Where the stored value is null instead of Infinity
if (state.style == 'FSCzech')
state.submitTries = Infinity
changeView('prescreen') changeView('prescreen')
} }

View File

@ -52,7 +52,7 @@ async function shareQuiz() {
var response = await fetch(db, { var response = await fetch(db, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'text/plain'}, headers: {'Content-Type': 'text/plain'},
body: JSON.stringify(quizData) body: JSON.stringify(quizData, (k,v) => (v == Infinity) ? '__Infinity' : v)
}) })
if (response.ok == false) { if (response.ok == false) {
@ -92,7 +92,9 @@ async function fetchQuiz(id) {
return return
} }
var json = await response.json() var text = await response.text()
var json = JSON.parse(text, (k,v) => (v == '__Infinity') ? Infinity : v)
console.log('Quiz fetched. Response:') console.log('Quiz fetched. Response:')
console.log(json) console.log(json)

View File

@ -84,7 +84,7 @@ input[type="button"].center, input[type="submit"].center {
float: none; float: none;
} }
input[type="button"]:disabled, input[type="submit"]:disabled { input[type="button"][readonly], input[type="submit"][readonly] {
cursor: auto; cursor: auto;
background: #888 !important; background: #888 !important;
} }
@ -189,8 +189,31 @@ input[type="radio"]:hover + p, input[type="checkbox"]:hover + p {
padding: 12px 0; padding: 12px 0;
} }
#fsastuff {
display: none;
}
#fsabonuspointsinfo {
float: left;
font-size: 15px;
font-style: italic;
}
#fsateamcounttroll { #fsateamcounttroll {
text-align: right; text-align: right;
font-size: 15px; font-size: 15px;
font-style: italic; font-style: italic;
} }
#fsapointbar {
width: 100%;
height: 42px;
border: 1px solid black
}
#fsapointbar > div {
height: 100%;
background: darkred;
width: 100%;
transition: 1s linear width;
}

View File

@ -16,7 +16,8 @@ const defaultState = {
questionStartTotalTimer: 0, questionStartTotalTimer: 0,
totalTimer: 0, totalTimer: 0,
totalInterval: null, totalInterval: null,
fsaTeamCountTroll: 0 fsaTeamCountTroll: 0,
resultsShown: false
} }
var state var state
@ -49,7 +50,7 @@ var rules = {
'FSA' : { sequential: true, questionTimeout: 5, allowQOvertime: true }, 'FSA' : { sequential: true, questionTimeout: 5, allowQOvertime: true },
'FSN' : { sequential: true }, 'FSN' : { sequential: true },
'FSEast' : { sequential: false }, 'FSEast' : { sequential: false },
'FSCzech' : { sequential: false, submitTries: Infinity, submitTimeout: 30 }, 'FSCzech' : { sequential: false, submitTries: Infinity, submitTimeout: 60 },
'FSSpain' : { sequential: true, submitTries: 10 }, 'FSSpain' : { sequential: true, submitTries: 10 },
'FSSwitzerland' : { sequential: true }, 'FSSwitzerland' : { sequential: true },
} }