From e0818d92d66cce06139aff325954f2068b473d72 Mon Sep 17 00:00:00 2001 From: Miguel Barão Date: Mon, 22 Oct 2018 16:15:53 +0100 Subject: [PATCH] - show remaining tries for each question. - cosmetic changes, mostly. --- BUGS.md | 2 ++ README.md | 7 ++++--- factory.py | 2 +- initdb.py | 2 +- knowledge.py | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------ learnapp.py | 26 ++++++++++++++++---------- serve.py | 72 +++++++++++++++++++++++++++++++++++++++--------------------------------- static/fontawesome | 2 +- static/js/maintopics.js | 42 +++++++++++++++++++++--------------------- static/js/topic.js | 11 ++++++++--- templates/login.html | 2 +- templates/maintopics-table.html | 2 +- templates/question.html | 2 ++ templates/topic.html | 4 +++- 14 files changed, 172 insertions(+), 106 deletions(-) diff --git a/BUGS.md b/BUGS.md index 4354319..ee94d55 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,6 +1,7 @@ # BUGS +- max tries não avança para seguinte ao fim das tentativas. - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question. - nas perguntas de código, quando erra nao se devia acrescentar mesma pergunta no fim. @@ -27,6 +28,7 @@ # FIXED +- ver se ref guardada na BD é só a da pergunta ou inclui o path. -> so ref - nao esta a guardar as respostas erradas. - reload do topic não gera novas perguntas (alunos abusavam do reload) - usar codemirror no textarea diff --git a/README.md b/README.md index 99cb8f2..3d96adb 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,9 @@ sudo pkg install py27-certbot # FreeBSD Shutdown the firewall and any server running. Then run the script to generate the certificate: ```sh -sudo pfctl -d # disable pf firewall (FreeBSD) +sudo service pf stop # disable pf firewall (FreeBSD) sudo certbot certonly --standalone -d www.example.com -sudo pfctl -e; sudo pfctl -f /etc/pf.conf # enable pf firewall +sudo service pf start # enable pf firewall ``` Certificates are saved under `/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to `aprendizations/certs` and change permissions to be readable: @@ -130,9 +130,10 @@ Renews can be done as follows: ```sh sudo service pf stop # shutdown firewall sudo certbot renew +sudo service pf start # start firewall ``` -and then copy the `cert.pem` and `privkey.pem` files to `aprendizations/certs` directory. Change permissions as appropriate. +and then copy the `cert.pem` and `privkey.pem` files to `aprendizations/certs` directory. Change permissions and ownership as appropriate. ### Testing diff --git a/factory.py b/factory.py index 7097753..e0f2c0f 100644 --- a/factory.py +++ b/factory.py @@ -76,7 +76,7 @@ class QFactory(object): # which will print a valid question in yaml format to stdout. This # output is then yaml parsed into a dictionary `q`. if q['type'] == 'generator': - logger.debug(f' \_ Running "{q["script"]}".') + logger.debug(f' \\_ Running "{q["script"]}".') q.setdefault('arg', '') # optional arguments will be sent to stdin script = path.join(q['path'], q['script']) out = run_script(script=script, stdin=q['arg']) diff --git a/initdb.py b/initdb.py index 3c81d20..d7695ba 100755 --- a/initdb.py +++ b/initdb.py @@ -73,7 +73,7 @@ def get_students_from_csv(filename): else: students = [{ 'uid': s['N.º'], - 'name': string.capwords(re.sub('\(.*\)', '', s['Nome']).strip()) + 'name': string.capwords(re.sub(r'\(.*\)', '', s['Nome']).strip()) } for s in csvreader] return students diff --git a/knowledge.py b/knowledge.py index 3f640c2..981a72f 100644 --- a/knowledge.py +++ b/knowledge.py @@ -27,13 +27,13 @@ class StudentKnowledge(object): def __init__(self, deps, state={}): self.deps = deps # dependency graph shared among students self.state = state # {'topic': {'level':0.5, 'date': datetime}, ...} + self.update_topic_levels() # forgetting factor self.topic_sequence = self.recommend_topic_sequence() # ['a', 'b', ...] - self.unlock_topics() - + self.unlock_topics() # whose dependencies have been done self.current_topic = None - self.MAX_QUESTIONS = 6 # FIXME get from configuration file?? + self.MAX_QUESTIONS = 6 # FIXME get from yaml configuration file?? # ------------------------------------------------------------------------ # Updates the proficiency levels of the topics, with forgetting factor @@ -57,9 +57,10 @@ class StudentKnowledge(object): for topic in self.topic_sequence: if topic not in self.state: # if locked pred = self.deps.predecessors(topic) - if all(d in self.state and self.state[d]['level'] > min_level for d in pred): # and all dependencies are done + if all(d in self.state and self.state[d]['level'] > min_level for d in pred): + # all dependencies are done self.state[topic] = { - 'level': 0.0, # then unlock + 'level': 0.0, # unlocked 'date': datetime.now() } logger.debug(f'Unlocked "{topic}".') @@ -73,42 +74,53 @@ class StudentKnowledge(object): def init_topic(self, topic=''): logger.debug(f'StudentKnowledge.init_topic({topic})') + # maybe get topic recommendation if not topic: topic = self.get_recommended_topic() + logger.debug(f'Recommended topic is {topic}') - # check if it's unlocked + # do not allow locked topics if self.is_locked(topic): - return False - - if self.current_topic is not None and topic == self.current_topic: - return True + logger.debug(f'Topic {topic} is locked') + return + # starting new topic self.current_topic = topic - # generate question instances for current topic factory = self.deps.node[topic]['factory'] questionlist = self.deps.node[topic]['questions'] self.correct_answers = 0 self.wrong_answers = 0 + # select a random set of questions for this topic size = min(self.MAX_QUESTIONS, len(questionlist)) # number of questions questionlist = random.sample(questionlist, k=size) logger.debug(f'Questions: {", ".join(questionlist)}') + # generate instances of questions self.questions = [factory[qref].generate() for qref in questionlist] logger.debug(f'Total: {len(self.questions)} questions') - try: - self.current_question = self.questions.pop(0) # FIXME crash if empty - except IndexError: - # self.current_question = None - self.finish_topic() # FIXME if no questions, what should be done? - return False - else: - self.current_question['start_time'] = datetime.now() - return True + # get first question + self.next_question() + + + # def init_learning(self, topic=''): + # logger.debug(f'StudentKnowledge.init_learning({topic})') + + # if self.is_locked(topic): + # logger.debug(f'Topic {topic} is locked') + # return False + + # self.current_topic = topic + # factory = self.deps.node[topic]['factory'] + # lesson = self.deps.node[topic]['lesson'] + # self.questions = [factory[qref].generate() for qref in lesson] + # logger.debug(f'Total: {len(self.questions)} questions') + + # self.next_question_in_lesson() # ------------------------------------------------------------------------ # The topic has finished and there are no more questions. @@ -127,7 +139,10 @@ class StudentKnowledge(object): # ------------------------------------------------------------------------ - # returns the current question with correction, time and comments updated + # corrects current question with provided answer. + # implements the logic: + # - if answer ok, goes to next question + # - if wrong, counts number of tries. If exceeded, moves on. # ------------------------------------------------------------------------ def check_answer(self, answer): logger.debug('StudentKnowledge.check_answer()') @@ -142,21 +157,48 @@ class StudentKnowledge(object): # if answer is correct, get next question if grade > 0.999: self.correct_answers += 1 - try: - self.current_question = self.questions.pop(0) # FIXME empty? - except IndexError: - self.finish_topic() - else: - self.current_question['start_time'] = datetime.now() + self.next_question() # if wrong, keep same question and append a similar one at the end else: self.wrong_answers += 1 - factory = self.deps.node[self.current_topic]['factory'] - self.questions.append(factory[q['ref']].generate()) - # returns answered and corrected question + + self.current_question['tries'] -= 1 + + logger.debug(f'Wrong answers = {self.wrong_answers}; Tries = {self.current_question["tries"]}') + + # append a new instance of the current question to the end and + # move to the next question + if self.current_question['tries'] <= 0: + logger.debug("Appending new instance of this question to the end") + factory = self.deps.node[self.current_topic]['factory'] + self.questions.append(factory[q['ref']].generate()) + self.next_question() + + # returns answered and corrected question (not new one) return q + # ------------------------------------------------------------------------ + # Move to next question + # ------------------------------------------------------------------------ + def next_question(self): + try: + self.current_question = self.questions.pop(0) + except IndexError: + self.finish_topic() + else: + self.current_question['start_time'] = datetime.now() + self.current_question['tries'] = self.current_question.get('max_tries', 3) # FIXME hardcoded 3 + logger.debug(f'Next question is "{self.current_question["ref"]}"') + + # def next_question_in_lesson(self): + # try: + # self.current_question = self.questions.pop(0) + # except IndexError: + # self.current_question = None + # else: + # logger.debug(f'Next question is "{self.current_question["ref"]}"') + # ======================================================================== # pure functions of the state (no side effects) diff --git a/learnapp.py b/learnapp.py index 6b2b1cd..86d9eef 100644 --- a/learnapp.py +++ b/learnapp.py @@ -143,23 +143,28 @@ class LearnApp(object): s.add(a) logger.debug(f'Saved topic "{topic}" into database') - return q['grade'] + + if knowledge.get_current_question() is None: + return 'finished_topic' + if q['tries'] > 0 and q['grade'] <= 0.999: + return 'wrong' + # elif q['tries'] <= 0 and q['grade'] <= 0.999: + # return 'max_tries_exceeded' + else: + return 'new_question' + # return q['grade'] # ------------------------------------------------------------------------ # Start new topic # ------------------------------------------------------------------------ def start_topic(self, uid, topic): try: - ok = self.online[uid]['state'].init_topic(topic) + self.online[uid]['state'].init_topic(topic) except KeyError as e: logger.warning(f'User "{uid}" tried to open nonexistent topic: "{topic}"') raise e else: - if ok: - logger.info(f'User "{uid}" started "{topic}"') - else: - logger.warning(f'User "{uid}" restarted "{topic}"') - return ok + logger.info(f'User "{uid}" started "{topic}"') # ------------------------------------------------------------------------ # Start new topic @@ -245,7 +250,7 @@ class LearnApp(object): return self.online[uid]['state'].get_topic_progress() # ------------------------------------------------------------------------ - def get_student_question(self, uid): + def get_current_question(self, uid): return self.online[uid]['state'].get_current_question() # dict # ------------------------------------------------------------------------ @@ -316,11 +321,12 @@ def build_dependency_graph(config={}): g.add_edges_from((d,ref) for d in attr.get('deps', [])) fullpath = path.expanduser(path.join(prefix, ref)) + + # load questions filename = path.join(fullpath, 'questions.yaml') loaded_questions = load_yaml(filename, default=[]) # list - - # if questions not in configuration then load all, preserving order if not tnode['questions']: + # if questions not in configuration then load all, preserving order tnode['questions'] = [q.setdefault('ref', f'{ref}:{i}') for i,q in enumerate(loaded_questions)] # make questions factory (without repeating same question) diff --git a/serve.py b/serve.py index 61411bc..b1002d2 100755 --- a/serve.py +++ b/serve.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python3.6 +#!/usr/bin/env python3 # python standard library from os import path @@ -35,7 +35,7 @@ class WebApplication(tornado.web.Application): (r'/question', QuestionHandler), # renders each question (r'/topic/(.+)', TopicHandler), # page for exercising a topic # (r'/learn/(.+)', LearnHandler), # page for learning a topic - (r'/file/(.+)', FileHandler), # FIXME + (r'/file/(.+)', FileHandler), # serve files, images, etc (r'/', RootHandler), # show list of topics ] settings = { @@ -139,18 +139,14 @@ class TopicHandler(BaseHandler): uid = self.current_user try: - ok = self.learn.start_topic(uid, topic) + self.learn.start_topic(uid, topic) except KeyError: self.redirect('/') else: - if ok: - self.render('topic.html', - uid=uid, - name=self.learn.get_student_name(uid), - ) - else: - self.redirect('/') - + self.render('topic.html', + uid=uid, + name=self.learn.get_student_name(uid), + ) # class LearnHandler(BaseHandler): # @tornado.web.authenticated @@ -225,9 +221,6 @@ class QuestionHandler(BaseHandler): 'information': 'question-information.html', 'info': 'question-information.html', 'success': 'question-success.html', - # 'warning': '', FIXME - # 'warn': '', FIXME - # 'alert': '', FIXME } # --- get question to render @@ -236,16 +229,20 @@ class QuestionHandler(BaseHandler): logging.debug('QuestionHandler.get()') user = self.current_user - question = self.learn.get_student_question(user) - template = self.templates[question['type']] - question_html = self.render_string(template, question=question, md=md_to_html) + question = self.learn.get_current_question(user) + + question_html = self.render_string(self.templates[question['type']], + question=question, md=md_to_html) + + print(question['tries']) self.write({ 'method': 'new_question', 'params': { 'question': tornado.escape.to_unicode(question_html), - 'progress': self.learn.get_student_progress(user) , - } + 'progress': self.learn.get_student_progress(user), + 'tries': question['tries'], + }, }) # --- post answer, returns what to do next: shake, new_question, finished @@ -253,13 +250,11 @@ class QuestionHandler(BaseHandler): async def post(self): logging.debug('QuestionHandler.post()') user = self.current_user - - # check answer and get next question (same, new or None) answer = self.get_body_arguments('answer') # list - # answers returned in a list. fix depending on question type + # answers are returned in a list. fix depending on question type qtype = self.learn.get_student_question_type(user) - if qtype in ('success', 'information', 'info'): # FIXME unused? + if qtype in ('success', 'information', 'info'): answer = None elif qtype == 'radio' and not answer: answer = None @@ -267,20 +262,25 @@ class QuestionHandler(BaseHandler): answer = answer[0] # check answer in another thread (nonblocking) - loop = asyncio.get_event_loop() - grade = await loop.run_in_executor(None, self.learn.check_answer, user, answer) - question = self.learn.get_student_question(user) + action = await asyncio.get_event_loop().run_in_executor(None, + self.learn.check_answer, user, answer) + + # get next question (same, new or None) + question = self.learn.get_current_question(user) - if grade <= 0.999: # wrong answer - comments_html = self.render_string('comments.html', comments=question['comments'], md=md_to_html) + if action == 'wrong': + comments_html = self.render_string('comments.html', + comments=question['comments'], md=md_to_html) self.write({ - 'method': 'shake', + 'method': action, 'params': { 'progress': self.learn.get_student_progress(user), 'comments': tornado.escape.to_unicode(comments_html), # FIXME + 'tries': question['tries'], } }) - elif question is None: # right answer, finished topic + + elif action == 'finished_topic': # right answer, finished topic finished_topic_html = self.render_string('finished_topic.html') self.write({ 'method': 'finished_topic', @@ -288,18 +288,23 @@ class QuestionHandler(BaseHandler): 'question': tornado.escape.to_unicode(finished_topic_html) } }) - else: # right answer, get next question in the topic + + elif action == 'new_question': # get next question in the topic template = self.templates[question['type']] - question_html = self.render_string( - template, question=question, md=md_to_html) + question_html = self.render_string(template, + question=question, md=md_to_html) self.write({ 'method': 'new_question', 'params': { 'question': tornado.escape.to_unicode(question_html), 'progress': self.learn.get_student_progress(user), + 'tries': question['tries'], } }) + else: + logger.error(f'Unknown action {action}') + # ------------------------------------------------------------------------- def signal_handler(signal, frame): @@ -332,6 +337,7 @@ def main(): except: print('An error ocurred while setting up the logging system.') sys.exit(1) + logging.info('====================================================') # --- start application diff --git a/static/fontawesome b/static/fontawesome index d36699e..c3096a3 120000 --- a/static/fontawesome +++ b/static/fontawesome @@ -1 +1 @@ -libs/fontawesome-free-5.0.13/svg-with-js/js/ \ No newline at end of file +libs/fontawesome-free-5.1.1-web/ \ No newline at end of file diff --git a/static/js/maintopics.js b/static/js/maintopics.js index f7c6fe0..3f6642f 100644 --- a/static/js/maintopics.js +++ b/static/js/maintopics.js @@ -1,32 +1,32 @@ function notify(msg) { - $("#notifications").html(msg); - $("#notifications").fadeIn(250).delay(3000).fadeOut(500); + $("#notifications").html(msg); + $("#notifications").fadeIn(250).delay(3000).fadeOut(500); } function getCookie(name) { - var r = document.cookie.match("\\b" + name + "=([^;]*)\\b"); - return r ? r[1] : undefined; + var r = document.cookie.match("\\b" + name + "=([^;]*)\\b"); + return r ? r[1] : undefined; } function change_password() { - var token = getCookie('_xsrf'); - $.ajax({ - type: "POST", - url: "/change_password", - headers: {'X-XSRFToken' : token }, - data: { - "new_password": $("#new_password").val(), - }, - dataType: "json", - success: function(r) { - notify(r['msg']); - }, - error: function(r) { - notify(r['msg']); - }, - }); + var token = getCookie('_xsrf'); + $.ajax({ + type: "POST", + url: "/change_password", + headers: {'X-XSRFToken': token}, + data: { + "new_password": $("#new_password").val(), + }, + dataType: "json", + success: function(r) { + notify(r['msg']); + }, + error: function(r) { + notify(r['msg']); + }, + }); } $(document).ready(function() { - $("#change_password").click(change_password); + $("#change_password").click(change_password); }); diff --git a/static/js/topic.js b/static/js/topic.js index ecaab71..767eed9 100644 --- a/static/js/topic.js +++ b/static/js/topic.js @@ -7,12 +7,15 @@ $.fn.extend({ } }); -// Process response given by the server +// updates question according to the response given by the server function updateQuestion(response){ + switch (response["method"]) { case "new_question": $("#question_div").html(response["params"]["question"]); $("#comments").html(""); + $("#tries").html(response["params"]["tries"]); + $('#topic_progress').css('width', (100*response["params"]["progress"])+'%').attr('aria-valuenow', 100*response["params"]["progress"]); MathJax.Hub.Queue(["Typeset",MathJax.Hub,"question_div"]); @@ -28,10 +31,11 @@ function updateQuestion(response){ $('#question_div').animateCSS('bounceInDown'); break; - case "shake": + case "wrong": $('#topic_progress').css('width', (100*response["params"]["progress"])+'%').attr('aria-valuenow', 100*response["params"]["progress"]); $('#question_div').animateCSS('shake'); $('#comments').html(response['params']['comments']); + $("#tries").html(response["params"]["tries"]); MathJax.Hub.Queue(["Typeset",MathJax.Hub,"#comments"]); break; @@ -57,7 +61,8 @@ function getQuestion() { } // Send answer and receive a response. -// The response can be a new_question or a shake if the answer is wrong. +// The response can be a new_question or a shake if the answer is wrong, which +// is then passed to updateQuestion() function postQuestion() { if (typeof editor === 'object') editor.save(); diff --git a/templates/login.html b/templates/login.html index b0f70de..fa88772 100644 --- a/templates/login.html +++ b/templates/login.html @@ -14,7 +14,7 @@ - + diff --git a/templates/maintopics-table.html b/templates/maintopics-table.html index a6758b0..1522384 100644 --- a/templates/maintopics-table.html +++ b/templates/maintopics-table.html @@ -18,7 +18,7 @@ - + diff --git a/templates/question.html b/templates/question.html index ede6b41..bbf009f 100644 --- a/templates/question.html +++ b/templates/question.html @@ -7,3 +7,5 @@ {% block answer %}{% end %} + +

( tentativas)

diff --git a/templates/topic.html b/templates/topic.html index bf58a97..0ede92d 100644 --- a/templates/topic.html +++ b/templates/topic.html @@ -29,7 +29,7 @@ - + @@ -86,6 +86,8 @@
+ + -- libgit2 0.21.2