quizzer: Add user keys to the answer database and stack
authorW. Trevor King <wking@tremily.us>
Thu, 14 Mar 2013 00:01:36 +0000 (20:01 -0400)
committerW. Trevor King <wking@tremily.us>
Thu, 14 Mar 2013 10:57:58 +0000 (06:57 -0400)
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
quizzer/ui/__init__.py
quizzer/ui/cli.py
quizzer/ui/wsgi.py

index 8671da1d463e647a16ebc675dd3befedae14e1a5..a7f7ece5337ff4ead03add8b042f67e4c6e5725d 100644 (file)
@@ -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
index 04c560cd2e1cdc3edf575b80e90ce0981a8fca0d..b750abdd680ffd1cafc797a068dde168dc774a7a 100644 (file)
@@ -14,6 +14,7 @@
 # You should have received a copy of the GNU General Public License along with
 # quizzer.  If not, see <http://www.gnu.org/licenses/>.
 
+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)
 
 
index 60d649fdc6f2b332a35de1b4d0f8fb570446a60b..579d789696c836d779e3af3d4ae74348d53d644d 100644 (file)
@@ -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)))
index 303db6d12a35a799f494aa05df3d81707a699af5..da46c0508ab2c9f0f03622e3033709da8ce1d873 100644 (file)
@@ -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 = [
             '<html>',
             '  <head>',
@@ -146,7 +141,11 @@ class QuestionApp (WSGI_DataObject):
         if self.ui.quiz.introduction:
             lines.append('    <p>{}</p>'.format(self.ui.quiz.introduction))
         lines.extend([
-                '    <p><a href="question/">Start the quiz</a>.</p>',
+                '    <form name="question" action="../question/" method="post">',
+                '      <p>Username: <input type="text" size="20" name="user">',
+                '        (required, alphanumeric)</p>',
+                '      <input type="submit" value="Start the quiz">',
+                '    </form>',
                 '  </body>',
                 '</html>',
                 ])
@@ -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 = [
             '<html>',
             '  <head>',
@@ -164,10 +167,11 @@ class QuestionApp (WSGI_DataObject):
             '  <body>',
             '    <h1>Results</h1>',
             ]
+        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([
                 '  </body>',
                 '</html>',
@@ -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('</ol>')
         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 = (
                 '<textarea rows="5" cols="60" name="answer"></textarea>')
@@ -245,6 +254,7 @@ class QuestionApp (WSGI_DataObject):
             '  <body>',
             '    <h1>Question</h1>',
             '    <form name="question" action="../answer/" method="post">',
+            '      <input type="hidden" name="user" value="{}">'.format(user),
             '      <input type="hidden" name="question" value="{}">'.format(
                 question.id),
             '      <p>{}</p>'.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):
             '    <pre>{}</pre>'.format(raw_answer),
             '    <p>{}</p>'.format(correct_msg),
             details or '',
-            '    <a href="{}">{}</a>.'.format(link_target, link_text),
+            '    <form name="question" action="{}" method="post">'.format(
+                link_target),
+            '      <input type="hidden" name="user" value="{}">'.format(user),
+            '      <input type="submit" value="{}">'.format(link_text),
+            '    </form>',
             '  </body>',
             '</html>',
             '',