/
mchoiceapp.py
390 lines (333 loc) · 17.6 KB
/
mchoiceapp.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
# -*- coding: utf-8 -*-
# mchoiceapp.py - Software by Michiel Overtoom, motoom@xs4all.nl
# Ideas/TODOs:
#
# - Rotating logfile.
# - Extra flowers (madelief, distel, etc...).
# - Online highscore list, enter your name when you get a high score.
# - Show high score rankings.
# - Feedback form when complete: which were the flowers you had most problems with?.
# - Meticulous logging (or analysis of logs afterwards) so we can see how people play the quiz.
# - Decorator to automatically persist/restore state to/from session.
import sys
import os
import cherrypy
from cherrypy import expose
import random
import time
import socket
from ovotemplate import Ovotemplate
import logging
import autoreload
import imp
import inspect
import illustrators
threshold = 2 # (3) The number of times the user has to answer an item correctly in a row before it's considered a known item. (suggestion: 3)
tolearnsize = 7 # (10) Number of items currently being learned.
lowatermark = 5 # (5) When the learning has shrunken to 'lowatermark' or less items, refill it from the items to learn.
alternatives = 4 # (4) Nr. of alternatives to show at each multiple-choice question.
def clamp(value, lower, upper):
if value < lower: return lower
if value > upper: return upper
return value
# For multiple choice nomenclature, see http://en.wikipedia.org/wiki/Multiple_choice
class Item(object):
def __repr__(self):
return "%s: %s (%d correct in a row, %d correct total, %d incorrect total)" % (self.prompt, self.answer, self.correctrow, self.correct, self.incorrect)
class Mchoice(object):
def __init__(self):
self.language = cherrypy.config["language"]
self.tem_site = self.loadtemplate("site.tpl")
self.tem_levelstart = self.loadtemplate("levelstart.tpl")
self.tem_correct = self.loadtemplate("correct.tpl")
self.tem_wrong = self.loadtemplate("wrong.tpl")
self.tem_progress = self.loadtemplate("progress.tpl")
self.tem_verdict = self.loadtemplate("verdict.tpl")
# Check whether itembank contains non-ascii characters.
# Such characters should be specified as HTML entities.
for _, itembank, _, _ in itembankdb.itembanks:
for _, _, answer, hint, attribution, license in itembank:
answer.encode("ascii")
hint.encode("ascii")
attribution.encode("ascii")
# And also check the licensing of the pictures, Creative Commons or Public Domain.
if license not in ("cc", "pd"): raise Exception("%s: license type must be 'cc' or 'pd'" % answer)
# TODO: Check if all illustrator image directories exist
def loadtemplate(self, fn):
return Ovotemplate().fromfile(os.path.join("templates", self.language, fn))
def populatesession(self, difficulty=0):
# If a specific illustrator was chosen before, stick with it.
if cherrypy.session.get("items"):
_, _, _, _, _, _, _, _, _, illustratorslug, _ = cherrypy.session["items"]
else:
illustratorslug = illustrators.pick()
itembankname, itembank, _, _ = itembankdb.itembanks[difficulty]
tolearn = list(itembank) # Pool of items still to learn.
learned = [] # Questions which have been answered 'threshold' times more correct than incorrect
learning = [] # Items currently being learned
tickets = set() # Submit tickets seen (to prevent reloads from messing up the game)
random.shuffle(tolearn)
options = key = rightone = None
first = True
done = False
# TODO: Put all this state into one object, and persist that. (state.learned, state.tolearn etc...)
cherrypy.session["items"] = difficulty, learning, learned, tolearn, options, key, rightone, done, tickets, illustratorslug, first
def addtolearn(self, tolearn, tolearnsize, learning):
while len(learning) < tolearnsize and tolearn:
item = Item()
item.prompt, item.pronoun, item.answer, item.hint, item.attribution, item.license = tolearn.pop()
item.correctrow = item.correct = item.incorrect = 0
learning.append(item)
def generateticket(self):
return "%f" % time.time()
@expose
def reset(self, *args, **kwargs):
difficulty = int(kwargs.get("difficulty", 0))
difficulty = clamp(difficulty, 0, len(itembankdb.itembanks) - 1)
self.populatesession(difficulty)
raise cherrypy.InternalRedirect('/')
def randomcorrect(self):
return random.choice(texts.corrects)
def randomwrong(self):
return random.choice(texts.wrongs)
# Waaaah! Reduce the size of this routine. It's too long.
@expose
def index(self, *args, **kwargs):
# Is there an answer specified on the URL? If so, what is it? (None, 0, 1, ... alternatives-1)
answer = kwargs.get("a")
if answer is not None:
answer = clamp(int(answer), 0, alternatives-1)
# Session initialisation (if first visit) or retrieving current progress from session (on subsequent visits).
visits = cherrypy.session.get("visits")
if visits is None:
# if answer is not None: print "Note: This website only works when you have cookies enabled in your webbrowser"
visits = 1
self.populatesession()
visits = visits + 1
cherrypy.session["visits"] = visits
difficulty, learning, learned, tolearn, options, key, rightone, done, tickets, illustratorslug, first = cherrypy.session["items"]
# Make it possible to visit the website and specify an illustrator
# like http://www.leer-de-bloemen.nl/?i=herman-roozen
slug = kwargs.get("i")
if slug is not None:
if illustrators.isvalid(slug):
illustratorslug = slug
else:
raise cherrypy.HTTPError(404)
message = ""
wascorrect = False
ticket = kwargs.get("t")
if not ticket in tickets: # Protect against resubmits with same parameters.
tickets.add(ticket) # TODO: Protect against DoS, empty tickets when there are 20.000 in there (or some other high improbable number). Or remove the oldest entries.
# If an answer is given, check whether it's correct.
if answer is None or key is None:
if not len(learned):
# Show an encouraging starting message at start of game:
message = itembankdb.itembanks[difficulty][2] # [2] is the item bank intro text
elif answer == rightone:
wascorrect = True
key.correct += 1
key.correctrow += 1
correctanswer = key.answer.lower()
# See if the key has been sufficiently correctly answered; if so, remove it from the items to learn, add it to the learned items, and add a fresh key to the items to learn.
known = False
if key.correctrow >= threshold:
known = True
learned.append(key)
learning.remove(key)
if len(learning) <= lowatermark:
self.addtolearn(tolearn, tolearnsize, learning)
if not len(learning):
# The user knows all the itembank now.
# TODO: Review the items just learned, sorted on the most errors made.
done = True
message = ""
if done:
message = ""
else:
smallerprompt = key.prompt.replace(".jpg", "s.jpg") # XXXX.jpg -> XXXXs.jpg
message = self.tem_correct.render(dict(correct=self.randomcorrect(), language=self.language, prompt=smallerprompt, correctpronoun=key.pronoun, correctanswer=correctanswer, known=known))
first = False
else:
key.incorrect += 1
key.correctrow = 0
wronganswer = options[answer].answer.lower()
correctanswer = key.answer.lower()
smallerprompt = key.prompt.replace(".jpg", "s.jpg")
message = self.tem_wrong.render(dict(wrong=self.randomwrong(), wrongpronoun=options[answer].pronoun, wronganswer=wronganswer, language=self.language, prompt=smallerprompt, correctpronoun=key.pronoun, correctanswer=correctanswer))
first = False
# Calculate progress
completed = len(learned)
itembankname, itembank, itembankintro, itembankdone = itembankdb.itembanks[difficulty]
total = len(itembank)
if total:
percent = (100.0 * completed) / total
else:
percent = 0.0
# Only show the progress bar if somewhere between 0 and 100%.
progress = ""
if percent > 0:
if percent < 100:
progress = self.tem_progress.render(dict(percent=str(int(percent))))
else:
progress = texts.youknowthemall
# Show narrator picture in which mood? -4 = angry, 0 = neutral, 4 = happy (and values in-between).
feedbackmood = "0"
if key and not done:
if wascorrect:
feedbackmood = clamp(percent/20, 1,4)
else:
feedbackmood = clamp(key.correct - key.incorrect, -4, -1)
feedbackmood = str(int(feedbackmood))
if done:
logmsg = "Level %d completed by %s" % (difficulty, cherrypy.request.remote.ip) # TODO: Also try to keep stats like how long it took, how many good/bad answers, etc.
cherrypy.log(logmsg, "COMPLETE", logging.INFO)
url = cherrypy.url("reset")
urlagain = urlsame = urlnext = urlprev = None
if difficulty == 0: # First level.
urlsame = cherrypy.url("reset", "difficulty=%d" % (difficulty,))
urlnext = cherrypy.url("reset", "difficulty=%d" % (difficulty + 1,))
elif difficulty == len(itembankdb.itembanks) - 1: # Last level.
urlagain = cherrypy.url("reset", "difficulty=0")
urlsame = cherrypy.url("reset", "difficulty=%d" % (difficulty))
else: # Intermediate levels.
urlprev = cherrypy.url("reset", "difficulty=%d" % (difficulty - 1,))
urlsame = cherrypy.url("reset", "difficulty=%d" % (difficulty,))
urlnext = cherrypy.url("reset", "difficulty=%d" % (difficulty + 1,))
verdict = self.tem_verdict.render(dict(alldone=itembankdone,urlnext=urlnext, urlagain=urlagain, urlprev=urlprev, urlsame=urlsame))
answers = None
key = Item()
key.prompt = key.hint = key.attribution = key.license = ""
else:
verdict = None
self.addtolearn(tolearn, tolearnsize, learning)
# Choose 'alternative' random items (or less, if there aren't enough anymore) from the current learning set,
# appoint one the key (the other will be used as distractors) and do a multiple choice question with those.
options = random.sample(learning, min(alternatives, len(learning)))
if len(options) < alternatives:
options.extend(random.sample(learned, alternatives - len(learning)))
random.shuffle(options)
# Select one to be the key, preferably different than the previous
# key (i.e., avoid asking the same question immediately again)
safety = 0
while True:
safety += 1
if safety > 200:
raise Exception(texts.logicbug)
rightone = random.randint(0, alternatives-1) # Appoint a random item.
candidatekey = options[rightone]
if candidatekey in learned: continue # If the item is already learned, select an other.
if key is None or candidatekey.prompt != key.prompt: break # If the item chosen is different than the previous item, we're done.
if len(learning) == 1: break # In the case the user learned all items except the last one, we're done too.
# Found a usable key.
key = options[rightone]
question = key.prompt
answers = []
for nr, option in enumerate(options):
url = cherrypy.url("", "a=%d&t=%s" % (nr, self.generateticket()))
answers.append(dict(answer=option.answer, url=url))
# Persist the whole state back into the session.
cherrypy.session["items"] = difficulty, learning, learned, tolearn, options, key, rightone, done, tickets, illustratorslug, first
# Choose a 'narrator' picture with his mood (negative, positive, neutral)
feedback = bool(progress) or bool(message)
if not message and not done:
message = self.tem_levelstart.render({})
feedbackmood = "0"
feedback = True
# Special occasions: at the first question, and when all flowers are done.
if first: feedbackmood = "a"
if done: feedbackmood = "b"
feedbackpicture = os.path.join("img", "feedback", illustratorslug, "%s.png" % feedbackmood)
promptpicture = os.path.join("pictures", self.language, key.prompt)
# Photo attribution and license.
license = ""
if key.license == "pd":
license = "<a target=\"_new\" href=\"http://wiki.creativecommons.org/Public_domain\">public domain</a>"
elif key.license == "cc":
license = "<a target=\"_new\" href=\"http://creativecommons.org/licenses/by-sa/3.0/deed.nl\">creative commons</a>"
# Illustrator
illustratorname, illustratorurl = illustrators.name_and_url(illustratorslug)
# Finally, render the page.
vars = dict(answers=answers, weetje=key.hint,
attribution=key.attribution, license=license,
illustratorname=illustratorname, illustratorurl=illustratorurl,
language=self.language, prompt=promptpicture, feedback=feedback,
feedbackpicture=feedbackpicture, progress=progress, message=message, verdict=verdict)
return self.tem_site.render(vars)
# A (static) page describing the illustrators.
@cherrypy.expose
def illustrators(self):
raise cherrypy.HTTPRedirect(cherrypy.url("static/" + self.language + "/illustrators.html"))
# A list of all flowers, grouped by item bank.
@cherrypy.expose
def showall(self):
s = "<a href=\"/\">%s</a>" % texts.toquiz
for itembankname, itembank, _, _ in itembankdb.itembanks:
s += "<h2>%s</h2><table>\n" % itembankname
for nr, (key, pronoun, prompt, hint, attribution, license) in enumerate(itembank):
s += "<tr>"
src = os.path.join("pictures", self.language, key)
acell = "<img src=\"%s\">" % src
bcell = "<h3>%d. %s</h3><p>%s</p><p><small>%s (%s)</small></p>" % (nr, prompt, hint, attribution, license)
if nr & 1:
acell, bcell = bcell, acell
s += "<td align=\"right\" valign=\"top\">%s</td>\n" % acell
s += "<td valign=\"top\">%s</td>\n" % bcell
s += "</tr>"
s += "</table>\n"
return s
# A list of all flowers, grouped by item bank, name hidden.
# TODO: Show the name with jQuery when user clicks/hover on it!
@cherrypy.expose
def showallwithout(self):
s = "<a href=\"/\">%s</a>" % texts.toquiz
for itembankname, itembank, _, _ in itembankdb.itembanks:
for nr, (key, pronoun, prompt, hint, attribution, license) in enumerate(itembank):
s += "<tr>"
src = os.path.join("pictures", self.language, key)
acell = "<img src=\"%s\"><br><p><small>%s (%s)</small></p>" % (src, attribution, license)
s += "<td align=\"right\" valign=\"top\">%s</td>\n" % acell
s += "<td valign=\"top\">TEST</td>\n"
s += "</tr>"
s += "</table>\n"
return s
def importbyname(modname):
modf, modfn, moddesc = imp.find_module(modname, [os.path.join("templates", cherrypy.config["language"])])
mod = imp.load_module(modname, modf, modfn, moddesc)
modf.close()
return mod
# Main program.
# Determine the language from from the path on which this program runs.
language = "nl" # Default dutch.
path = os.path.abspath(inspect.getsourcefile(Mchoice))
if "leer-de-bloemen.nl" in path: language = "nl"
if "learn-the-flowers.com" in path: language = "en"
# language = "en" # Force English...
cherrypy.log("Using language '%s'" % language, "ENGINE")
cherrypy.config["language"] = language
cfg = socket.gethostname()
if "." in cfg: cfg = cfg.split(".")[0]
cfg = os.path.join("cfg", cfg + "-" + language + ".cfg");
cherrypy.log("Using configuration file '%s'" % cfg, "ENGINE")
cherrypy.config.update(cfg)
# Import "templates/<language>/texts.py" which contains the translated strings.
# Also import the itembank database, which is also language-dependent.
texts = importbyname("texts")
itembankdb = importbyname("itembankdb")
# Instantiate and mount the app.
mchoice = Mchoice()
root = cherrypy.tree.mount(mchoice, "/", cfg)
# Drop privileges, if user/group given.
user, group = cherrypy.config.get("server.user"), cherrypy.config.get("server.group")
dropargs = {}
if user: dropargs["uid"] = user
if group: dropargs["gid"] = group
if dropargs:
cherrypy.process.plugins.DropPrivileges(cherrypy.engine, **dropargs).subscribe()
if __name__ == "__main__":
# Webapp started on the commandline.
autoreload.addautoreloaddir("templates")
cherrypy.quickstart(root)
else:
# Webapp started by cherryd
pass