From d187aad4125f77a4f052a1a33c81e4f922efc683 Mon Sep 17 00:00:00 2001 From: Miguel Barão Date: Wed, 25 Sep 2019 23:18:00 +0100 Subject: [PATCH] - adds courses - updates mathjax to version 3 (not yet working correctly) - updates other javascript libraries --- BUGS.md | 6 ++++++ README.md | 6 +++--- aprendizations/learnapp.py | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++--------------------- aprendizations/main.py | 8 +++++--- aprendizations/questions.py | 6 ++++-- aprendizations/serve.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- aprendizations/static/js/topic.js | 17 +++++++++++------ aprendizations/student.py | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------- aprendizations/templates/courses.html | 102 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ aprendizations/templates/maintopics-table.html | 5 ++++- aprendizations/templates/topic.html | 21 ++++++++++++--------- package-lock.json | 185 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------- package.json | 8 ++++---- 13 files changed, 450 insertions(+), 175 deletions(-) create mode 100644 aprendizations/templates/courses.html diff --git a/BUGS.md b/BUGS.md index 226527f..5b7c593 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,6 +1,11 @@ # BUGS +- classificacoes so devia mostrar os que ja fizeram alguma coisa +- menu nao mostra as opcoes correctamente +- mathjax nao esta a correr sobre o titulo e sobre as solucoes. +- QFactory.generate() devia fazer run da gen_async, ou remover. +- finish topic vai para a lista de cursos. devia ficar no mesmo curso. - marking all options right in a radio question breaks! - impedir que quando students.db não é encontrado, crie um ficheiro vazio. - opcao --prefix devia afectar a base de dados? @@ -24,6 +29,7 @@ sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in # TODO +- use run_script_async to run run_script using asyncio.run? - ao fim de 3 tentativas de login, envial email para aluno com link para definir nova password (com timeout de 5 minutos). - mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo expande as dependencias. - mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado topicos. diff --git a/README.md b/README.md index 696c1b2..9fffa75 100644 --- a/README.md +++ b/README.md @@ -284,11 +284,11 @@ me:\ Common database manipulations: ```sh -initdb-aprendizations -u 12345 --pw alibaba # reset student password -initdb-aprendizations -a 12345 --pw alibaba # add new student +initdb-aprendizations -u 12345 --pw alibaba # reset student password +initdb-aprendizations -a 007 "James Bond" --pw mi6 # add new student "007" ``` -Common database queries: +Some common database queries: ```sh # Which students did at least one topic? diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index 6e55f4c..207a813 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -1,22 +1,22 @@ # python standard library -from os import path -import logging -from contextlib import contextmanager # `with` statement in db sessions import asyncio +from contextlib import contextmanager # `with` statement in db sessions from datetime import datetime +import logging from random import random +from os import path from typing import Any, Dict, Iterable, List, Optional, Tuple # third party libraries import bcrypt -import sqlalchemy as sa import networkx as nx +import sqlalchemy as sa # this project from .models import Student, Answer, Topic, StudentTopic -from .student import StudentState from .questions import Question, QFactory, QDict +from .student import StudentState from .tools import load_yaml # setup logger for this module @@ -57,7 +57,7 @@ class LearnApp(object): # init # ------------------------------------------------------------------------ def __init__(self, - config_files: List[str], + courses: str, # filename with course configurations prefix: str, # path to topics db: str, # database filename check: bool = False) -> None: @@ -65,9 +65,18 @@ class LearnApp(object): self.db_setup(db) # setup database and check students self.online: Dict[str, Dict] = dict() # online students + config: Dict[str, Any] = load_yaml(courses) + + # courses dict + self.courses = config['courses'] + logger.info(f'Courses: {", ".join(self.courses.keys())}') + + # topic dependencies are shared between all courses self.deps = nx.DiGraph(prefix=prefix) - for c in config_files: - self.populate_graph(c) + logger.info('Populating graph:') + for f in config['deps']: + self.populate_graph(f) + logger.info(f'Graph has {len(self.deps)} topics') # factory is a dict with question generators for all topics self.factory: Dict[str, QFactory] = self.make_factory() @@ -163,8 +172,9 @@ class LearnApp(object): self.online[uid] = { 'number': uid, 'name': name, - 'state': StudentState(deps=self.deps, factory=self.factory, - state=state), + 'state': StudentState(uid=uid, state=state, + courses=self.courses, deps=self.deps, + factory=self.factory), 'counter': counter + 1, # counts simultaneous logins } @@ -202,10 +212,10 @@ class LearnApp(object): # checks answer (updating student state) and returns grade. FIXME type of answer # ------------------------------------------------------------------------ async def check_answer(self, uid: str, answer) -> Tuple[Question, str]: - knowledge = self.online[uid]['state'] - topic = knowledge.get_current_topic() + student = self.online[uid]['state'] + topic = student.get_current_topic() - q, action = await knowledge.check_answer(answer) # may move questions + q, action = await student.check_answer(answer) # may move questions logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"') @@ -220,11 +230,11 @@ class LearnApp(object): topic_id=topic)) logger.debug(f'db insert answer of {q["ref"]}') - if knowledge.topic_has_finished(): + if student.topic_has_finished(): # finished topic, save into database logger.info(f'User "{uid}" finished "{topic}"') - level: float = knowledge.get_topic_level(topic) - date: str = str(knowledge.get_topic_date(topic)) + level: float = student.get_topic_level(topic) + date: str = str(student.get_topic_date(topic)) with self.db_session() as s: a = s.query(StudentTopic) \ @@ -250,6 +260,18 @@ class LearnApp(object): return q, action # ------------------------------------------------------------------------ + # Start course + # ------------------------------------------------------------------------ + def start_course(self, uid: str, course: str) -> None: + student = self.online[uid]['state'] + try: + student.start_course(course) + except Exception as e: + logger.warning(f'"{uid}" could not start course "{course}": {e}') + else: + logger.info(f'"{uid}" started course "{course}"') + + # ------------------------------------------------------------------------ # Start new topic # ------------------------------------------------------------------------ async def start_topic(self, uid: str, topic: str) -> None: @@ -305,8 +327,6 @@ class LearnApp(object): # Edges are obtained from the deps defined in the YAML file for each topic. # ------------------------------------------------------------------------ def populate_graph(self, conffile: str) -> None: - - logger.info(f'Populating graph from: {conffile}...') config: Dict[str, Any] = load_yaml(conffile) # course configuration # default attributes that apply to the topics @@ -347,7 +367,7 @@ class LearnApp(object): t['append_wrong'] = attr.get('append_wrong', default_append_wrong) t['questions'] = attr.get('questions', []) - logger.info(f'Loaded {g.number_of_nodes()} topics') + logger.info(f'{len(topics):>6} topics in {conffile}') # ======================================================================== # methods that do not change state (pure functions) @@ -428,8 +448,13 @@ class LearnApp(object): return self.online[uid]['state'].get_current_topic() # ------------------------------------------------------------------------ - def get_title(self) -> str: - return self.deps.graph.get('title', '') + def get_student_course_title(self, uid: str) -> str: + return self.online[uid]['state'].get_current_course_title() + + # ------------------------------------------------------------------------ + # def get_title(self) -> str: + # # return self.deps.graph.get('title', '') + # return self. # ------------------------------------------------------------------------ def get_topic_name(self, ref: str) -> str: @@ -442,6 +467,10 @@ class LearnApp(object): return path.join(prefix, topic, 'public') # ------------------------------------------------------------------------ + def get_courses(self, uid: str) -> Dict: + return self.courses + + # ------------------------------------------------------------------------ def get_rankings(self, uid: str) -> Iterable[Tuple[str, str, float, float]]: logger.info(f'User "{uid}" get rankings') diff --git a/aprendizations/main.py b/aprendizations/main.py index d622a0f..4bde154 100644 --- a/aprendizations/main.py +++ b/aprendizations/main.py @@ -24,8 +24,8 @@ def parse_cmdline_arguments(): ) argparser.add_argument( - 'conffile', type=str, nargs='*', - help='Topics configuration file in YAML format.' + 'courses', type=str, # nargs='*', + help='Courses configuration file in YAML format.' ) argparser.add_argument( @@ -161,7 +161,9 @@ def main(): # --- start application try: - learnapp = LearnApp(arg.conffile, prefix=arg.prefix, db=arg.db, + learnapp = LearnApp(courses=arg.courses, + prefix=arg.prefix, + db=arg.db, check=arg.check) except DatabaseUnusableError: logging.critical('Failed to start application.') diff --git a/aprendizations/questions.py b/aprendizations/questions.py index 9125696..eed52a6 100644 --- a/aprendizations/questions.py +++ b/aprendizations/questions.py @@ -461,6 +461,9 @@ class QFactory(object): # i.e. a question object (radio, checkbox, ...). # ----------------------------------------------------------------------- def generate(self) -> Question: + # FIXME this is almost the same as the one below + # return asyncio.run(self.gen_async()) + logger.debug(f'generating {self.question["ref"]}...') # Shallow copy so that script generated questions will not replace # the original generators @@ -491,7 +494,7 @@ class QFactory(object): return qinstance # ----------------------------------------------------------------------- - async def generate_async(self) -> Question: + async def gen_async(self) -> Question: logger.debug(f'generating {self.question["ref"]}...') # Shallow copy so that script generated questions will not replace # the original generators @@ -520,5 +523,4 @@ class QFactory(object): logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') raise else: - logger.debug('ok') return qinstance diff --git a/aprendizations/serve.py b/aprendizations/serve.py index a16813a..8f057fe 100644 --- a/aprendizations/serve.py +++ b/aprendizations/serve.py @@ -24,7 +24,7 @@ logger = logging.getLogger(__name__) # ---------------------------------------------------------------------------- -# Decorator used to restrict access to the administrator only +# Decorator used to restrict access to the administrator # ---------------------------------------------------------------------------- def admin_only(func): @functools.wraps(func) @@ -46,11 +46,14 @@ class WebApplication(tornado.web.Application): (r'/login', LoginHandler), (r'/logout', LogoutHandler), (r'/change_password', ChangePasswordHandler), - (r'/question', QuestionHandler), # renders each question - (r'/rankings', RankingsHandler), # student rankings - (r'/topic/(.+)', TopicHandler), # start a topic - (r'/file/(.+)', FileHandler), # serve files - (r'/', RootHandler), # show list of topics + (r'/question', QuestionHandler), # render question + (r'/rankings', RankingsHandler), # rankings table + # (r'/topics', TopicsHandler), # show list of topics + (r'/topic/(.+)', TopicHandler), # start topic + (r'/file/(.+)', FileHandler), # serve file + (r'/courses', CoursesHandler), # show list of courses + (r'/course/(.+)', CourseHandler), # show course topics + (r'/', RootHandler), # redirects ] settings = { 'template_path': path.join(path.dirname(__file__), 'templates'), @@ -166,23 +169,73 @@ class ChangePasswordHandler(BaseHandler): # ---------------------------------------------------------------------------- -# / (main page) -# Shows a list of topics and proficiency (stars, locked). +# / +# redirects to appropriate place # ---------------------------------------------------------------------------- class RootHandler(BaseHandler): @tornado.web.authenticated def get(self): + self.redirect('/courses') + + +# ---------------------------------------------------------------------------- +# /courses +# Shows a list of available courses +# ---------------------------------------------------------------------------- +class CoursesHandler(BaseHandler): + @tornado.web.authenticated + def get(self): uid = self.current_user + self.render('courses.html', + appname=APP_NAME, + uid=uid, + name=self.learn.get_student_name(uid), + courses=self.learn.get_courses(uid), + ) + + +# ---------------------------------------------------------------------------- +# /course/... +# Start a given course +# ---------------------------------------------------------------------------- +class CourseHandler(BaseHandler): + @tornado.web.authenticated + def get(self, course): + uid = self.current_user + + try: + self.learn.start_course(uid, course) + except KeyError: + self.redirect('/courses') + + # print('TITULO: ---------------->', self.learn.get_title()) self.render('maintopics-table.html', appname=APP_NAME, uid=uid, name=self.learn.get_student_name(uid), state=self.learn.get_student_state(uid), - title=self.learn.get_title(), + title=self.learn.get_student_course_title(uid), ) # ---------------------------------------------------------------------------- +# /topics +# Shows a list of topics and proficiency (stars, locked). +# ---------------------------------------------------------------------------- +# class TopicsHandler(BaseHandler): +# @tornado.web.authenticated +# def get(self): +# uid = self.current_user +# self.render('maintopics-table.html', +# appname=APP_NAME, +# uid=uid, +# name=self.learn.get_student_name(uid), +# state=self.learn.get_student_state(uid), +# title=self.learn.get_title(), +# ) + + +# ---------------------------------------------------------------------------- # /topic/... # Start a given topic # ---------------------------------------------------------------------------- @@ -194,7 +247,7 @@ class TopicHandler(BaseHandler): try: await self.learn.start_topic(uid, topic) except KeyError: - self.redirect('/') + self.redirect('/topics') self.render('topic.html', appname=APP_NAME, diff --git a/aprendizations/static/js/topic.js b/aprendizations/static/js/topic.js index dcdce20..d33ab96 100644 --- a/aprendizations/static/js/topic.js +++ b/aprendizations/static/js/topic.js @@ -69,7 +69,7 @@ function new_question(type, question, tries, progress) { var btntext = (type == "information") ? "Continuar" : "Responder"; $("#submit").html(btntext).off().click(postAnswer); $('#topic_progress').css('width', (100*progress)+'%').attr('aria-valuenow', 100*progress); - MathJax.Hub.Queue(["Typeset",MathJax.Hub,"question_div"]); + MathJax.typeset(); if (type == "radio") { $(".list-group-item").click(function (e) { @@ -130,12 +130,15 @@ function getFeedback(response) { $('#right').show(); $('#wrong').hide(); $('#comments').html(params['comments']); - MathJax.Hub.Queue(["Typeset", MathJax.Hub, "#comments"]); + MathJax.typeset(); + $("#submit").html("Continuar").off().click(getQuestion); $("#link_solution_on_right").click(function(){ $("#right").hide(); $('#solution').html(params['solution']).animateCSS('flipInX'); - MathJax.Hub.Queue(["Typeset", MathJax.Hub, "#solution"]); + // MathJax.Hub.Queue(["Typeset", MathJax.Hub, "#solution"]); + MathJax.typeset(); + }); break; @@ -144,7 +147,7 @@ function getFeedback(response) { $('#topic_progress').css('width', (100*params["progress"])+'%').attr('aria-valuenow', 100*params["progress"]); showTriesLeft(params["tries"]); $('#comments').html(params['comments']); - MathJax.Hub.Queue(["Typeset",MathJax.Hub,"#comments"]); + MathJax.typeset(); break; case "wrong": @@ -153,11 +156,13 @@ function getFeedback(response) { $('#topic_progress').css('width', (100*params["progress"])+'%').attr('aria-valuenow', 100*params["progress"]); showTriesLeft(params["tries"]); $('#comments').html(params['comments']); - MathJax.Hub.Queue(["Typeset", MathJax.Hub, "#comments"]); + // MathJax.typeset(); + MathJax.typeset(); $("#link_solution_on_wrong").click(function () { $("#wrong").hide(); $('#solution').html(params['solution']).animateCSS('flipInX'); - MathJax.Hub.Queue(["Typeset", MathJax.Hub, "#solution"]); + // MathJax.Hub.Queue(["Typeset", MathJax.Hub, "#solution"]); + MathJax.typeset(); }); $("fieldset").attr("disabled", "disabled"); $("#submit").html("Continuar").off().click(getQuestion); diff --git a/aprendizations/student.py b/aprendizations/student.py index a815700..e672e4e 100644 --- a/aprendizations/student.py +++ b/aprendizations/student.py @@ -1,9 +1,9 @@ # python standard library -import random from datetime import datetime import logging -from typing import List, Optional, Tuple +import random +from typing import Dict, List, Optional, Tuple # third party libraries import networkx as nx @@ -28,45 +28,33 @@ class StudentState(object): # ======================================================================= # methods that update state # ======================================================================= - def __init__(self, deps, factory, state={}) -> None: - self.deps = deps # shared dependency graph + def __init__(self, uid, state, courses, deps, factory) -> None: + # shared application data between all students + self.deps = deps # dependency graph self.factory = factory # question factory + self.courses = courses # {'course': ['topic_id1', 'topic_id2',...]} + + # data of this student + self.uid = uid # user id '12345' self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...} + # prepare for running self.update_topic_levels() # applies forgetting factor self.unlock_topics() # whose dependencies have been completed + self.start_course(None) - self.topic_sequence: List[str] = self.recommend_topic_sequence() - self.current_topic: Optional[str] = None - - # ------------------------------------------------------------------------ - # Updates the proficiency levels of the topics, with forgetting factor - # ------------------------------------------------------------------------ - def update_topic_levels(self) -> None: - now = datetime.now() - for tref, s in self.state.items(): - dt = now - s['date'] - forgetting_factor = self.deps.node[tref]['forgetting_factor'] - s['level'] *= forgetting_factor ** dt.days # forgetting factor - - # ------------------------------------------------------------------------ - # Unlock topics whose dependencies are satisfied (> min_level) # ------------------------------------------------------------------------ - def unlock_topics(self) -> None: - for topic in self.deps.nodes(): - if topic not in self.state: # if locked - pred = self.deps.predecessors(topic) - min_level = self.deps.node[topic]['min_level'] - if all(d in self.state and self.state[d]['level'] > min_level - for d in pred): # all deps are greater than min_level - - self.state[topic] = { - 'level': 0.0, # unlocked - 'date': datetime.now() - } - logger.debug(f'unlocked "{topic}"') - # else: # lock this topic if deps do not satisfy min_level - # del self.state[topic] + def start_course(self, course: Optional[str]) -> None: + if course is None: + logger.debug('no active course') + self.current_course: Optional[str] = None + self.topic_sequence: List[str] = [] + self.current_topic: Optional[str] = None + else: + logger.debug(f'starting course {course}') + self.current_course = course + topics = self.courses[course]['topics'] + self.topic_sequence = self.recommend_topic_sequence(topics) # ------------------------------------------------------------------------ # Start a new topic. @@ -99,8 +87,8 @@ class StudentState(object): questions = t['questions'][:k] logger.debug(f'selected questions: {", ".join(questions)}') - self.questions: List[Question] = [await self.factory[ref].generate_async() - for ref in questions] + self.questions: List[Question] = [await self.factory[ref].gen_async() + for ref in questions] n = len(self.questions) logger.debug(f'generated {n} questions') @@ -153,7 +141,9 @@ class StudentState(object): action = 'wrong' if self.current_question['append_wrong']: logger.debug('wrong answer, append new question') - self.questions.append(self.factory[q['ref']].generate()) + # self.questions.append(self.factory[q['ref']].generate()) + new_question = await self.factory[q['ref']].gen_async() + self.questions.append(new_question) self.next_question() # returns corrected question (not new one) which might include comments @@ -177,6 +167,35 @@ class StudentState(object): return self.current_question # question or None + # ------------------------------------------------------------------------ + # Update proficiency level of the topics using a forgetting factor + # ------------------------------------------------------------------------ + def update_topic_levels(self) -> None: + now = datetime.now() + for tref, s in self.state.items(): + dt = now - s['date'] + forgetting_factor = self.deps.node[tref]['forgetting_factor'] + s['level'] *= forgetting_factor ** dt.days # forgetting factor + + # ------------------------------------------------------------------------ + # Unlock topics whose dependencies are satisfied (> min_level) + # ------------------------------------------------------------------------ + def unlock_topics(self) -> None: + for topic in self.deps.nodes(): + if topic not in self.state: # if locked + pred = self.deps.predecessors(topic) + min_level = self.deps.node[topic]['min_level'] + if all(d in self.state and self.state[d]['level'] > min_level + for d in pred): # all deps are greater than min_level + + self.state[topic] = { + 'level': 0.0, # unlock + 'date': datetime.now() + } + logger.debug(f'unlocked "{topic}"') + # else: # lock this topic if deps do not satisfy min_level + # del self.state[topic] + # ======================================================================== # pure functions of the state (no side effects) # ======================================================================== @@ -187,17 +206,17 @@ class StudentState(object): # ------------------------------------------------------------------------ # compute recommended sequence of topics ['a', 'b', ...] # ------------------------------------------------------------------------ - def recommend_topic_sequence(self, target: str = '') -> List[str]: - tt = list(nx.topological_sort(self.deps)) - try: - idx = tt.index(target) - except ValueError: - pass - else: - del tt[idx:] + def recommend_topic_sequence(self, targets: List[str] = []) -> List[str]: + G = self.deps + ts = set(targets) + for t in targets: + ts.update(nx.ancestors(G, t)) - unlocked = [t for t in tt if t in self.state] - locked = [t for t in tt if t not in unlocked] + tl = list(nx.topological_sort(G.subgraph(ts))) + + # sort with unlocked first + unlocked = [t for t in tl if t in self.state] + locked = [t for t in tl if t not in unlocked] return unlocked + locked # ------------------------------------------------------------------------ @@ -209,6 +228,10 @@ class StudentState(object): return self.current_topic # ------------------------------------------------------------------------ + def get_current_course_title(self) -> Optional[str]: + return self.courses[self.current_course]['title'] + + # ------------------------------------------------------------------------ def is_locked(self, topic: str) -> bool: return topic not in self.state diff --git a/aprendizations/templates/courses.html b/aprendizations/templates/courses.html new file mode 100644 index 0000000..ee704ca --- /dev/null +++ b/aprendizations/templates/courses.html @@ -0,0 +1,102 @@ +{% autoescape %} + + + + + {{appname}} + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + {% for k,v in courses.items() %} + + +
+
{{ v['title'] }}
+
+
+ + {% end %} + + + + + + + diff --git a/aprendizations/templates/maintopics-table.html b/aprendizations/templates/maintopics-table.html index 949bad1..3e57a07 100644 --- a/aprendizations/templates/maintopics-table.html +++ b/aprendizations/templates/maintopics-table.html @@ -34,8 +34,11 @@