diff --git a/BUGS.md b/BUGS.md index 41e9db0..6223124 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,17 +1,17 @@ # BUGS -- ir para inicio da pagina quando le nova pergunta. +- goals se forem do tipo chapter deve importar todas as dependencias do chapter (e não mostrar chapters?). - nao esta a seguir o max_tries definido no ficheiro de dependencias. - devia mostrar timeout para o aluno saber a razao. - permitir configuracao para escolher entre static files locais ou remotos - templates question-*.html tem input hidden question_ref que não é usado. remover? - shift-enter não está a funcionar - default prefix should be obtained from each course (yaml conf)? -- initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a) # TODO +- alterar tabelas para incluir email de recuperacao de password (e outros avisos) - registar last_seen e remover os antigos de cada vez que houver um login. - indicar qtos topicos faltam (>=50%) para terminar o curso. - ao fim de 3 tentativas com password errada, envia email com nova password. @@ -32,6 +32,9 @@ # FIXED +- initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a) +- dependencias que não são goals de um curso, só devem aparecer se ainda não tiverem sido feitas. +- ir para inicio da pagina quando le nova pergunta. - CRITICAL nao esta a guardar o progresso na base de dados. - mesma ref no mesmo ficheiro não é detectado. - enter nas respostas mostra json diff --git a/aprendizations/__init__.py b/aprendizations/__init__.py index 995c624..a98f005 100644 --- a/aprendizations/__init__.py +++ b/aprendizations/__init__.py @@ -30,7 +30,7 @@ are progressively uncovered as the students progress. ''' APP_NAME = 'aprendizations' -APP_VERSION = '2020.01.dev1' +APP_VERSION = '2020.01.dev2' APP_DESCRIPTION = __doc__ __author__ = 'Miguel Barão' diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index db1037b..edc7e4b 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -36,6 +36,16 @@ class DatabaseUnusableError(LearnException): # ============================================================================ # LearnApp - application logic +# +# self.deps - networkx topic dependencies +# self.courses - dict {course_id: {'title': ..., +# 'description': ..., +# 'goals': ...,}, ...} +# self.factory = dict {qref: QFactory()} +# self.online - dict {student_id: {'number': ..., +# 'name': ..., +# 'state': StudentState(), +# 'counter': ...}, ...} # ============================================================================ class LearnApp(object): # ------------------------------------------------------------------------ @@ -67,7 +77,12 @@ 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) + try: + config: Dict[str, Any] = load_yaml(courses) + except Exception: + msg = f'Failed to load yaml file "{courses}"' + logger.error(msg) + raise LearnException(msg) # --- topic dependencies are shared between all courses self.deps = nx.DiGraph(prefix=prefix) @@ -78,7 +93,6 @@ class LearnApp(object): logger.info(f'{len(t):>6} topics in {courses}') for f in config.get('topics_from', []): c = load_yaml(f) # course configuration - # FIXME set defaults?? logger.info(f'{len(c["topics"]):>6} topics imported from {f}') self.populate_graph(c) @@ -91,8 +105,9 @@ class LearnApp(object): d.setdefault('title', '') # course title undefined for goal in d['goals']: if goal not in self.deps.nodes(): - raise LearnException(f'Goal "{goal}" from course "{c}" ' - ' does not exist') + msg = f'Goal "{goal}" from course "{c}" does not exist' + logger.error(msg) + raise LearnException(msg) # --- factory is a dict with question generators for all topics self.factory: Dict[str, QFactory] = self.make_factory() @@ -230,7 +245,7 @@ class LearnApp(object): async def check_answer(self, uid: str, answer) -> Question: student = self.online[uid]['state'] await student.check_answer(answer) - q = student.get_current_question() + q: Question = student.get_current_question() logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"') @@ -287,21 +302,25 @@ class LearnApp(object): # ------------------------------------------------------------------------ # Start course # ------------------------------------------------------------------------ - def start_course(self, uid: str, course: str) -> None: + def start_course(self, uid: str, course_id: 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}') + student.start_course(course_id) + except Exception: + logger.warning(f'"{uid}" could not start course "{course_id}"') raise else: - logger.info(f'User "{uid}" started course "{course}"') + logger.info(f'User "{uid}" started course "{course_id}"') # ------------------------------------------------------------------------ # Start new topic # ------------------------------------------------------------------------ async def start_topic(self, uid: str, topic: str) -> None: student = self.online[uid]['state'] + if uid == '0': + logger.warning(f'Reloading "{topic}"') + self.factory.update(self.factory_for(topic)) + try: await student.start_topic(topic) except Exception as e: @@ -393,66 +412,81 @@ class LearnApp(object): # ------------------------------------------------------------------------ # Buils dictionary of question factories + # - visits each topic in the graph, + # - adds factory for each topic. # ------------------------------------------------------------------------ def make_factory(self) -> Dict[str, QFactory]: logger.info('Building questions factory:') - factory = {} + factory = dict() g = self.deps for tref in g.nodes(): - t = g.nodes[tref] + factory.update(self.factory_for(tref)) - # load questions as list of dicts - topicpath: str = path.join(g.graph['prefix'], tref) - try: - fullpath: str = path.join(topicpath, t['file']) - except Exception: - msg1 = f'Invalid topic "{tref}"' - msg2 = f'Check dependencies of {", ".join(g.successors(tref))}' - raise LearnException(f'{msg1}. {msg2}') + logger.info(f'Factory has {len(factory)} questions') + return factory - logger.debug(f' Loading {fullpath}') - try: - questions: List[QDict] = load_yaml(fullpath) - except Exception: + # ------------------------------------------------------------------------ + # makes factory for a single topic + # ------------------------------------------------------------------------ + def factory_for(self, tref: str) -> Dict[str, QFactory]: + factory: Dict[str, QFactory] = {} + g = self.deps + t = g.nodes[tref] # get node + + # load questions as list of dicts + topicpath: str = path.join(g.graph['prefix'], tref) + try: + fullpath: str = path.join(topicpath, t['file']) + except Exception: + msg1 = f'Invalid topic "{tref}"' + msg2 = f'Check dependencies of {", ".join(g.successors(tref))}' + raise LearnException(f'{msg1}. {msg2}') + + logger.debug(f' Loading {fullpath}') + try: + questions: List[QDict] = load_yaml(fullpath) + except Exception: + if t['type'] == 'chapter': + return factory # chapters may have no "questions" + else: msg = f'Failed to load "{fullpath}"' logger.error(msg) raise LearnException(msg) - if not isinstance(questions, list): - msg = f'File "{fullpath}" must be a list of questions' + if not isinstance(questions, list): + msg = f'File "{fullpath}" must be a list of questions' + raise LearnException(msg) + + # update refs to include topic as prefix. + # refs are required to be unique only within the file. + # undefined are set to topic:n, where n is the question number + # within the file + localrefs: Set[str] = set() # refs in current file + for i, q in enumerate(questions): + qref = q.get('ref', str(i)) # ref or number + if qref in localrefs: + msg = f'Duplicate ref "{qref}" in "{topicpath}"' raise LearnException(msg) + localrefs.add(qref) - # update refs to include topic as prefix. - # refs are required to be unique only within the file. - # undefined are set to topic:n, where n is the question number - # within the file - localrefs: Set[str] = set() # refs in current file - for i, q in enumerate(questions): - qref = q.get('ref', str(i)) # ref or number - if qref in localrefs: - msg = f'Duplicate ref "{qref}" in "{topicpath}"' - raise LearnException(msg) - localrefs.add(qref) - - q['ref'] = f'{tref}:{qref}' - q['path'] = topicpath - q.setdefault('append_wrong', t['append_wrong']) + q['ref'] = f'{tref}:{qref}' + q['path'] = topicpath + q.setdefault('append_wrong', t['append_wrong']) - # if questions are left undefined, include all. - if not t['questions']: - t['questions'] = [q['ref'] for q in questions] + # if questions are left undefined, include all. + if not t['questions']: + t['questions'] = [q['ref'] for q in questions] - t['choose'] = min(t['choose'], len(t['questions'])) + t['choose'] = min(t['choose'], len(t['questions'])) - for q in questions: - if q['ref'] in t['questions']: - factory[q['ref']] = QFactory(q) - logger.debug(f' + {q["ref"]}') + for q in questions: + if q['ref'] in t['questions']: + factory[q['ref']] = QFactory(q) + logger.debug(f' + {q["ref"]}') - logger.info(f'{len(t["questions"]):6} questions in {tref}') + logger.info(f'{len(t["questions"]):6} questions in {tref}') - logger.info(f'Factory has {len(factory)} questions') return factory # ------------------------------------------------------------------------ @@ -469,47 +503,53 @@ class LearnApp(object): # ------------------------------------------------------------------------ def get_student_progress(self, uid: str) -> float: - return self.online[uid]['state'].get_topic_progress() + return float(self.online[uid]['state'].get_topic_progress()) # ------------------------------------------------------------------------ def get_current_question(self, uid: str) -> Optional[Question]: - return self.online[uid]['state'].get_current_question() + q: Optional[Question] = self.online[uid]['state'].get_current_question() + return q # ------------------------------------------------------------------------ def get_current_question_id(self, uid: str) -> str: - return self.online[uid]['state'].get_current_question()['qid'] + return str(self.online[uid]['state'].get_current_question()['qid']) # ------------------------------------------------------------------------ def get_student_question_type(self, uid: str) -> str: - return self.online[uid]['state'].get_current_question()['type'] + return str(self.online[uid]['state'].get_current_question()['type']) # ------------------------------------------------------------------------ def get_student_topic(self, uid: str) -> str: - return self.online[uid]['state'].get_current_topic() + return str(self.online[uid]['state'].get_current_topic()) # ------------------------------------------------------------------------ def get_student_course_title(self, uid: str) -> str: - return self.online[uid]['state'].get_current_course_title() + return str(self.online[uid]['state'].get_current_course_title()) # ------------------------------------------------------------------------ - def get_student_course_id(self, uid: str) -> Optional[str]: - return self.online[uid]['state'].get_current_course_id() + def get_current_course_id(self, uid: str) -> Optional[str]: + cid: Optional[str] = self.online[uid]['state'].get_current_course_id() + return cid # ------------------------------------------------------------------------ def get_topic_name(self, ref: str) -> str: - return self.deps.nodes[ref]['name'] + return str(self.deps.nodes[ref]['name']) # ------------------------------------------------------------------------ def get_current_public_dir(self, uid: str) -> str: - topic: str = self.online[uid]['state'].get_current_topic() # FIXME returns None if its the last question in the topic + topic: str = self.online[uid]['state'].get_current_topic() prefix: str = self.deps.graph['prefix'] return path.join(prefix, topic, 'public') # ------------------------------------------------------------------------ - def get_courses(self, uid: str) -> Dict: + def get_courses(self) -> Dict[str, Dict[str, Any]]: return self.courses # ------------------------------------------------------------------------ + def get_course(self, course_id: str) -> Dict[str, Any]: + return self.courses[course_id] + + # ------------------------------------------------------------------------ def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]: logger.info(f'User "{uid}" get rankings for {course_id}') diff --git a/aprendizations/main.py b/aprendizations/main.py index 294168a..daacbed 100644 --- a/aprendizations/main.py +++ b/aprendizations/main.py @@ -179,10 +179,10 @@ def main(): sep='\n') sys.exit(1) except LearnException as e: - logging.critical(e) + logging.critical('Failed to start backend') sys.exit(1) except Exception: - logging.critical('Failed to start backend.') + logging.critical('Unknown error') sys.exit(1) else: logging.info('LearnApp started') diff --git a/aprendizations/serve.py b/aprendizations/serve.py index 5acc6a7..57bcbdd 100644 --- a/aprendizations/serve.py +++ b/aprendizations/serve.py @@ -96,7 +96,7 @@ class RankingsHandler(BaseHandler): @tornado.web.authenticated def get(self): uid = self.current_user - current_course = self.learn.get_student_course_id(uid) + current_course = self.learn.get_current_course_id(uid) course_id = self.get_query_argument('course', default=current_course) rankings = self.learn.get_rankings(uid, course_id) self.render('rankings.html', @@ -195,7 +195,7 @@ class CoursesHandler(BaseHandler): appname=APP_NAME, uid=uid, name=self.learn.get_student_name(uid), - courses=self.learn.get_courses(uid), + courses=self.learn.get_courses(), ) @@ -205,13 +205,11 @@ class CoursesHandler(BaseHandler): # ---------------------------------------------------------------------------- class CourseHandler(BaseHandler): @tornado.web.authenticated - def get(self, course): + def get(self, course_id): uid = self.current_user - if course == '': - course = self.learn.get_student_course_id(uid) try: - self.learn.start_course(uid, course) + self.learn.start_course(uid, course_id) except KeyError: self.redirect('/courses') @@ -220,8 +218,8 @@ class CourseHandler(BaseHandler): uid=uid, name=self.learn.get_student_name(uid), state=self.learn.get_student_state(uid), - course_title=self.learn.get_student_course_title(uid), - course_id=self.learn.get_student_course_id(uid), + course_id=course_id, + course=self.learn.get_course(course_id) ) @@ -244,7 +242,7 @@ class TopicHandler(BaseHandler): uid=uid, name=self.learn.get_student_name(uid), # course_title=self.learn.get_student_course_title(uid), - course_id=self.learn.get_student_course_id(uid), + course_id=self.learn.get_current_course_id(uid), ) diff --git a/aprendizations/student.py b/aprendizations/student.py index 6f747bb..87298eb 100644 --- a/aprendizations/student.py +++ b/aprendizations/student.py @@ -75,12 +75,12 @@ class StudentState(object): logger.debug(f'start topic "{topic}"') # avoid regenerating questions in the middle of the current topic - if self.current_topic == topic: + if self.current_topic == topic and self.uid != '0': logger.info('Restarting current topic is not allowed.') return # do not allow locked topics - if self.is_locked(topic): + if self.is_locked(topic) and self.uid != '0': logger.debug(f'is locked "{topic}"') return @@ -236,9 +236,17 @@ class StudentState(object): G = self.deps ts = set(goals) for t in goals: - ts.update(nx.ancestors(G, t)) - # FIXME filter by level done, only level < 50% are included - tl = list(nx.topological_sort(G.subgraph(ts))) + ts.update(nx.ancestors(G, t)) # include dependencies not in goals + + todo = [] + for t in ts: + level = self.state[t]['level'] if t in self.state else 0.0 + min_level = G.nodes[t]['min_level'] + if t in goals or level < min_level: + todo.append(t) + + # FIXME topological sort is a poor way to sort topics + tl = list(nx.topological_sort(G.subgraph(todo))) # sort with unlocked first unlocked = [t for t in tl if t in self.state] diff --git a/aprendizations/templates/maintopics-table.html b/aprendizations/templates/maintopics-table.html index f764a79..fad46b5 100644 --- a/aprendizations/templates/maintopics-table.html +++ b/aprendizations/templates/maintopics-table.html @@ -60,7 +60,7 @@
-

{{ course_title }}

+

{{ course['title'] }}

diff --git a/package-lock.json b/package-lock.json index dd8aefb..0ddf4af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,14 @@ "integrity": "sha512-vKDJUuE2GAdBERaQWmmtsciAMzjwNrROXA5KTGSZvayAsmuTGjam5z6QNqNPCwDfVljLWuov1nEC3mEQf/n6fQ==" }, "codemirror": { - "version": "5.50.0", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.50.0.tgz", - "integrity": "sha512-32LAmGcBNhKtJP4WGgkcaCVQDyChAyaWA6jasg778ziZzo3PWBuhpAQIJMO8//Id45RoaLyXjuhcRUBoS8Vg+Q==" + "version": "5.51.0", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.51.0.tgz", + "integrity": "sha512-vyuYYRv3eXL0SCuZA4spRFlKNzQAewHcipRQCOKgRy7VNAvZxTKzbItdbCl4S5AgPZ5g3WkHp+ibWQwv9TLG7Q==" }, "mdbootstrap": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.11.0.tgz", - "integrity": "sha512-3yhRRo8UQDqRgeEutSpx9jIECzkyPebOq/oYsG2TLAmXVmujDBb+OoTW6+yZ1MtaQZCu8AF8D1/pM9Y8sLj3uA==" + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.12.0.tgz", + "integrity": "sha512-+X4x63tE96zpVOcRlVUGdcR65M9Ud+/l1TvdmcwUjEGo3ktn9TO3e6S3DBLTvchO9U5eKuJh/MIWIGac7+569g==" } } } diff --git a/package.json b/package.json index a4c6495..00e3fd9 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,8 @@ "email": "mjsb@uevora.pt", "dependencies": { "@fortawesome/fontawesome-free": "^5.12.0", - "codemirror": "^5.50.0", - "mdbootstrap": "^4.11.0" + "codemirror": "^5.51.0", + "mdbootstrap": "^4.12.0" }, "private": true } -- libgit2 0.21.2