From a7cd56530747be1cfc3de7bb8767356ddfa6f656 Mon Sep 17 00:00:00 2001 From: "W. Trevor King" Date: Wed, 13 Mar 2013 20:01:36 -0400 Subject: [PATCH] quizzer: Add user keys to the answer database and stack Instead of: answerdb[question.id] = [list, of, answers] ui.stack = [list, of, questions] Now we have: answerdb[user.name][question.id] = [list, of, answers] ui.stack[user.name] = [list, of, questions] This will allow us to use a single answer database and stack for a multi-user interface (e.g. WSGI). I've added the appropriate upgrade rules to update existing answer databases once we bump to v0.4. To reduce redundancy and ease future maintenance, I also factored out AnswerDatabase._get_questions() from .get_unanswered() and friends. There's a bit of hackery to deal with the default None user in the answer database. Because JSON objects (the equivalent of Python's dicts) are keyed by strings, you get things like: >>> import json >>> json.dumps({None: 'value'}) '{"null": "value"}' We avoid this and preserve the None key through a save/load cycle by replacing it with the empty string. This means that you shouldn't use the empty string as a user name when interacting with the database from Python, and we'll raise a ValueError if you try to do that through AnswerDatabase-specific methods. --- quizzer/answerdb.py | 61 +++++++++++++++++++++++++++++++---------- quizzer/ui/__init__.py | 22 +++++++++------ quizzer/ui/cli.py | 17 +++++++----- quizzer/ui/wsgi.py | 62 +++++++++++++++++++++++++++--------------- 4 files changed, 110 insertions(+), 52 deletions(-) diff --git a/quizzer/answerdb.py b/quizzer/answerdb.py index 8671da1..a7f7ece 100644 --- a/quizzer/answerdb.py +++ b/quizzer/answerdb.py @@ -47,44 +47,75 @@ class AnswerDatabase (dict): version, __version__)) from e data = upgrader(data) self.update(data['answers']) + if '' in self: + self[None] = self.pop('') def save(self, **kwargs): + answers = dict(self) + if None in answers: + answers[''] = answers.pop(None) data = { 'version': __version__, - 'answers': self, + 'answers': answers, } with self._open(mode='w', **kwargs) as f: _json.dump( data, f, indent=2, separators=(',', ': '), sort_keys=True) f.write('\n') - def add(self, question, answer, correct): - if question.id not in self: - self[question.id] = [] + def add(self, question, answer, correct, user=None): + if user == '': + raise ValueError('the empty string is an invalid username') + if user not in self: + self[user] = {} + if question.id not in self[user]: + self[user][question.id] = [] timezone = _datetime.timezone.utc timestamp = _datetime.datetime.now(tz=timezone).isoformat() - self[question.id].append({ + self[user][question.id].append({ 'answer': answer, 'correct': correct, 'timestamp': timestamp, }) - def get_answered(self, questions): - return [q for q in questions if q.id in self] + def get_answers(self, user=None): + if user == '': + raise ValueError('the empty string is an invalid username') + return self.get(user, {}) - def get_unanswered(self, questions): - return [q for q in questions if q.id not in self] + def _get_questions(self, check, questions, user=None): + if user == '': + raise ValueError('the empty string is an invalid username') + answers = self.get_answers(user=user) + return [q for q in questions if check(question=q, answers=answers)] - def get_correctly_answered(self, questions): - return [q for q in questions - if True in [a['correct'] for a in self.get(q.id, [])]] + def get_answered(self, **kwargs): + return self._get_questions( + check=lambda question, answers: question.id in answers, + **kwargs) - def get_never_correctly_answered(self, questions): - return [q for q in questions - if True not in [a['correct'] for a in self.get(q.id, [])]] + def get_unanswered(self, **kwargs): + return self._get_questions( + check=lambda question, answers: question.id not in answers, + **kwargs) + + def get_correctly_answered(self, **kwargs): + return self._get_questions( + check=lambda question, answers: + True in [a['correct'] for a in answers.get(question.id, [])], + **kwargs) + + def get_never_correctly_answered(self, **kwargs): + return self._get_questions( + check=lambda question, answers: + True not in [a['correct'] + for a in answers.get(question.id, [])], + **kwargs) def _upgrade_from_0_1(self, data): data['version'] = __version__ + data['answers'] = {'': data['answers']} # add user-id key return data _upgrade_from_0_2 = _upgrade_from_0_1 + _upgrade_from_0_3 = _upgrade_from_0_1 diff --git a/quizzer/ui/__init__.py b/quizzer/ui/__init__.py index 04c560c..b750abd 100644 --- a/quizzer/ui/__init__.py +++ b/quizzer/ui/__init__.py @@ -14,6 +14,7 @@ # You should have received a copy of the GNU General Public License along with # quizzer. If not, see . +import collections as _collections import importlib as _importlib from .. import answerdb as _answerdb @@ -32,22 +33,27 @@ class UserInterface (object): if stack is None: stack = self.answers.get_never_correctly_answered( questions=quiz.leaf_questions()) - self.stack = stack + self._stack = stack + self.stack = _collections.defaultdict(self._new_stack) + + def _new_stack(self): + return list(self._stack) # make a new copy for a new user def run(self): raise NotImplementedError() - def get_question(self): - if self.stack: - return self.stack.pop(0) + def get_question(self, user=None): + if self.stack[user]: + return self.stack[user].pop(0) - def process_answer(self, question, answer, **kwargs): + def process_answer(self, question, answer, user=None, **kwargs): correct,details = question.check(answer=answer, **kwargs) - self.answers.add(question=question, answer=answer, correct=correct) + self.answers.add( + question=question, answer=answer, correct=correct, user=user) if not correct: - self.stack.insert(0, question) + self.stack[user].insert(0, question) for qid in reversed(question.dependencies): - self.stack.insert(0, self.quiz.get(id=qid)) + self.stack[user].insert(0, self.quiz.get(id=qid)) return (correct, details) diff --git a/quizzer/ui/cli.py b/quizzer/ui/cli.py index 60d649f..579d789 100644 --- a/quizzer/ui/cli.py +++ b/quizzer/ui/cli.py @@ -48,7 +48,7 @@ class QuestionCommandLine (_cmd.Cmd): self._tempdir = None def get_question(self): - self.question = self.ui.get_question() + self.question = self.ui.get_question(user=self.ui.user) if self.question: self._reset() else: @@ -154,7 +154,7 @@ class QuestionCommandLine (_cmd.Cmd): def do_skip(self, arg): "Skip the current question, and continue with the quiz" - self.ui.stack.append(self.question) + self.ui.stack[self.ui.user].append(self.question) return self.get_question() def do_hint(self, arg): @@ -186,7 +186,8 @@ class CommandLineInterface (_UserInterface): } def run(self): - if self.stack: + self.user = None + if self.stack[self.user]: cmd = QuestionCommandLine(ui=self) cmd.cmdloop() print() @@ -194,14 +195,15 @@ class CommandLineInterface (_UserInterface): def _display_results(self): print(_colorize(self.colors['result'], 'results:')) + answers = self.answers.get_answers(user=self.user) for question in self.quiz: - if question.id in self.answers: + if question.id in answers: self._display_result(question=question) print() self._display_totals() def _display_result(self, question): - answers = self.answers.get(question.id, []) + answers = self.answers.get_answers(user=self.user).get(question.id, []) print('question:') print(' {}'.format( _colorize( @@ -224,9 +226,10 @@ class CommandLineInterface (_UserInterface): print(' which was: {}'.format(correct)) def _display_totals(self): - answered = self.answers.get_answered(questions=self.quiz) + answered = self.answers.get_answered( + questions=self.quiz, user=self.user) correctly_answered = self.answers.get_correctly_answered( - questions=self.quiz) + questions=self.quiz, user=self.user) la = len(answered) lc = len(correctly_answered) print('answered {} of {} questions'.format(la, len(self.quiz))) diff --git a/quizzer/ui/wsgi.py b/quizzer/ui/wsgi.py index 303db6d..da46c05 100644 --- a/quizzer/ui/wsgi.py +++ b/quizzer/ui/wsgi.py @@ -116,6 +116,7 @@ class QuestionApp (WSGI_DataObject): (_re.compile('^results/'), self._results), ] self.setting = 'quizzer' + self.user_regexp = _re.compile('^\w+$') def __call__(self, environ, start_response): "WSGI entry point" @@ -129,12 +130,6 @@ class QuestionApp (WSGI_DataObject): raise HandlerError(404, 'Not Found') def _index(self, environ, start_response): - if self.ui.stack: - return self._start(environ, start_response) - else: - return self._results(environ, start_response) - - def _start(self, environ, start_response): lines = [ '', ' ', @@ -146,7 +141,11 @@ class QuestionApp (WSGI_DataObject): if self.ui.quiz.introduction: lines.append('

{}

'.format(self.ui.quiz.introduction)) lines.extend([ - '

Start the quiz.

', + '
', + '

Username: ', + ' (required, alphanumeric)

', + ' ', + '
', ' ', '', ]) @@ -156,6 +155,10 @@ class QuestionApp (WSGI_DataObject): content_type='text/html') def _results(self, environ, start_response): + data = self.post_data(environ) + user = data.get('user', '') + if not self.user_regexp.match(user): + raise HandlerError(303, 'See Other', headers=[('Location', '/')]) lines = [ '', ' ', @@ -164,10 +167,11 @@ class QuestionApp (WSGI_DataObject): ' ', '

Results

', ] + answers = self.ui.answers.get_answers(user=user) for question in self.ui.quiz: - if question.id in self.ui.answers: - lines.extend(self._format_result(question=question)) - lines.extend(self._format_totals()) + if question.id in answers: + lines.extend(self._format_result(question=question, user=user)) + lines.extend(self._format_totals(user=user)) lines.extend([ ' ', '', @@ -177,8 +181,8 @@ class QuestionApp (WSGI_DataObject): environ, start_response, content=content, encoding='utf-8', content_type='text/html') - def _format_result(self, question): - answers = self.ui.answers.get(question.id, []) + def _format_result(self, question, user): + answers = self.ui.answers.get_answers(user=user).get(question.id, []) la = len(answers) lc = len([a for a in answers if a['correct']]) lines = [ @@ -206,10 +210,11 @@ class QuestionApp (WSGI_DataObject): lines.append('') return lines - def _format_totals(self): - answered = self.ui.answers.get_answered(questions=self.ui.quiz) + def _format_totals(self, user=None): + answered = self.ui.answers.get_answered( + questions=self.ui.quiz, user=user) correctly_answered = self.ui.answers.get_correctly_answered( - questions=self.ui.quiz) + questions=self.ui.quiz, user=user) la = len(answered) lc = len(correctly_answered) return [ @@ -225,13 +230,17 @@ class QuestionApp (WSGI_DataObject): data = self.post_data(environ) else: data = {} + user = data.get('user', '') + if not self.user_regexp.match(user): + raise HandlerError(303, 'See Other', headers=[('Location', '/')]) question = data.get('question', None) if not question: - question = self.ui.get_question() + question = self.ui.get_question(user=user) # put the question back on the stack until it's answered - self.ui.stack.insert(0, question) + self.ui.stack[user].insert(0, question) if question is None: - return self._index(environ, start_response) + raise HandlerError( + 307, 'Temporary Redirect', headers=[('Location', '/results/')]) if question.multiline: answer_element = ( '') @@ -245,6 +254,7 @@ class QuestionApp (WSGI_DataObject): ' ', '

Question

', '
', + ' '.format(user), ' '.format( question.id), '

{}

'.format( @@ -264,6 +274,9 @@ class QuestionApp (WSGI_DataObject): def _answer(self, environ, start_response): data = self.post_data(environ) + user = data.get('user', '') + if not self.user_regexp.match(user): + raise HandlerError(303, 'See Other', headers=[('Location', '/')]) question_id = data.get('question', None) raw_answer = data.get('answer', None) if not question_id or not raw_answer: @@ -278,12 +291,13 @@ class QuestionApp (WSGI_DataObject): else: answer = raw_answer correct,details = self.ui.process_answer( - question=question, answer=answer) + question=question, answer=answer, user=user) link_target = '../question/' if correct: correct_msg = 'correct' - self.ui.stack = [q for q in self.ui.stack if q != question] - if self.ui.stack: + self.ui.stack[user] = [q for q in self.ui.stack[user] + if q != question] + if self.ui.stack[user]: link_text = 'Next question' else: link_text = 'Results' @@ -305,7 +319,11 @@ class QuestionApp (WSGI_DataObject): '
{}
'.format(raw_answer), '

{}

'.format(correct_msg), details or '', - ' {}.'.format(link_target, link_text), + ' '.format( + link_target), + ' '.format(user), + ' '.format(link_text), + '
', ' ', '', '', -- 2.26.2