Commit 91d0281ad9caa56168ce12ae957da075c1593f0d
1 parent
45744cc0
Exists in
master
and in
1 other branch
fix lots of pylint warnings and errors
try to disable cache on certain handlers to fix windows10 loading problems (untested) use static_url in templates to use browser cache while files are not changed
Showing
14 changed files
with
537 additions
and
422 deletions
Show diff stats
BUGS.md
| 1 | 1 | ||
| 2 | # BUGS | 2 | # BUGS |
| 3 | 3 | ||
| 4 | +- GET can get filtered by browser cache | ||
| 5 | +- topicos chapter devem ser automaticamente completos assim que as dependencias são satisfeitas. Nao devia ser necessario (ou possivel?) clicar neles. | ||
| 6 | +- topicos do tipo learn deviam por defeito nao ser randomizados e assumir ficheiros `learn.yaml`. | ||
| 4 | - internal server error 500... experimentar cenario: aluno tem login efectuado, prof muda pw e faz login/logout. aluno obtem erro 500. | 7 | - internal server error 500... experimentar cenario: aluno tem login efectuado, prof muda pw e faz login/logout. aluno obtem erro 500. |
| 5 | - chapters deviam ser mostrados unlocked, antes de mostrar a medalha. alunos pensam que já terminaram e não conseguem progredir por causa das dependencias. | 8 | - chapters deviam ser mostrados unlocked, antes de mostrar a medalha. alunos pensam que já terminaram e não conseguem progredir por causa das dependencias. |
| 6 | - if topic deps on invalid ref terminates server with "Unknown error". | 9 | - if topic deps on invalid ref terminates server with "Unknown error". |
aprendizations/initdb.py
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | +''' | ||
| 4 | +Initializes or updates database | ||
| 5 | +''' | ||
| 6 | + | ||
| 3 | # python standard libraries | 7 | # python standard libraries |
| 4 | import csv | 8 | import csv |
| 5 | import argparse | 9 | import argparse |
| @@ -12,12 +16,14 @@ import bcrypt | @@ -12,12 +16,14 @@ import bcrypt | ||
| 12 | import sqlalchemy as sa | 16 | import sqlalchemy as sa |
| 13 | 17 | ||
| 14 | # this project | 18 | # this project |
| 15 | -from .models import Base, Student | 19 | +from aprendizations.models import Base, Student |
| 16 | 20 | ||
| 17 | 21 | ||
| 18 | # =========================================================================== | 22 | # =========================================================================== |
| 19 | # Parse command line options | 23 | # Parse command line options |
| 20 | def parse_commandline_arguments(): | 24 | def parse_commandline_arguments(): |
| 25 | + '''Parse command line arguments''' | ||
| 26 | + | ||
| 21 | argparser = argparse.ArgumentParser( | 27 | argparser = argparse.ArgumentParser( |
| 22 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, | 28 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
| 23 | description='Insert new users into a database. Users can be imported ' | 29 | description='Insert new users into a database. Users can be imported ' |
| @@ -65,9 +71,12 @@ def parse_commandline_arguments(): | @@ -65,9 +71,12 @@ def parse_commandline_arguments(): | ||
| 65 | 71 | ||
| 66 | 72 | ||
| 67 | # =========================================================================== | 73 | # =========================================================================== |
| 68 | -# SIIUE names have alien strings like "(TE)" and are sometimes capitalized | ||
| 69 | -# We remove them so that students dont keep asking what it means | ||
| 70 | def get_students_from_csv(filename): | 74 | def get_students_from_csv(filename): |
| 75 | + '''Reads CSV file with enrolled students in SIIUE format. | ||
| 76 | + SIIUE names can have suffixes like "(TE)" and are sometimes capitalized. | ||
| 77 | + These suffixes are removed.''' | ||
| 78 | + | ||
| 79 | + # SIIUE format for CSV files | ||
| 71 | csv_settings = { | 80 | csv_settings = { |
| 72 | 'delimiter': ';', | 81 | 'delimiter': ';', |
| 73 | 'quotechar': '"', | 82 | 'quotechar': '"', |
| @@ -75,8 +84,8 @@ def get_students_from_csv(filename): | @@ -75,8 +84,8 @@ def get_students_from_csv(filename): | ||
| 75 | } | 84 | } |
| 76 | 85 | ||
| 77 | try: | 86 | try: |
| 78 | - with open(filename, encoding='iso-8859-1') as f: | ||
| 79 | - csvreader = csv.DictReader(f, **csv_settings) | 87 | + with open(filename, encoding='iso-8859-1') as file: |
| 88 | + csvreader = csv.DictReader(file, **csv_settings) | ||
| 80 | students = [{ | 89 | students = [{ |
| 81 | 'uid': s['N.º'], | 90 | 'uid': s['N.º'], |
| 82 | 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip()) | 91 | 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip()) |
| @@ -92,52 +101,51 @@ def get_students_from_csv(filename): | @@ -92,52 +101,51 @@ def get_students_from_csv(filename): | ||
| 92 | 101 | ||
| 93 | 102 | ||
| 94 | # =========================================================================== | 103 | # =========================================================================== |
| 95 | -# replace password by hash for a single student | ||
| 96 | -def hashpw(student, pw=None): | 104 | +def hashpw(student, passw=None): |
| 105 | + '''replace password by hash for a single student''' | ||
| 97 | print('.', end='', flush=True) | 106 | print('.', end='', flush=True) |
| 98 | - pw = (pw or student.get('pw', None) or student['uid']).encode('utf-8') | ||
| 99 | - student['pw'] = bcrypt.hashpw(pw, bcrypt.gensalt()) | 107 | + passw = (passw or student.get('pw', None) or student['uid']).encode('utf-8') |
| 108 | + student['pw'] = bcrypt.hashpw(passw, bcrypt.gensalt()) | ||
| 100 | 109 | ||
| 101 | 110 | ||
| 102 | # =========================================================================== | 111 | # =========================================================================== |
| 103 | def show_students_in_database(session, verbose=False): | 112 | def show_students_in_database(session, verbose=False): |
| 104 | - try: | ||
| 105 | - users = session.query(Student).all() | ||
| 106 | - except Exception: | ||
| 107 | - raise | 113 | + '''print students that are in the database''' |
| 114 | + users = session.query(Student).all() | ||
| 115 | + total = len(users) | ||
| 116 | + | ||
| 117 | + print('\nRegistered users:') | ||
| 118 | + if total == 0: | ||
| 119 | + print(' -- none --') | ||
| 108 | else: | 120 | else: |
| 109 | - n = len(users) | ||
| 110 | - print(f'\nRegistered users:') | ||
| 111 | - if n == 0: | ||
| 112 | - print(' -- none --') | 121 | + users.sort(key=lambda u: f'{u.id:>12}') # sort by number |
| 122 | + if verbose: | ||
| 123 | + for user in users: | ||
| 124 | + print(f'{user.id:>12} {user.name}') | ||
| 113 | else: | 125 | else: |
| 114 | - users.sort(key=lambda u: f'{u.id:>12}') # sort by number | ||
| 115 | - if verbose: | ||
| 116 | - for u in users: | ||
| 117 | - print(f'{u.id:>12} {u.name}') | ||
| 118 | - else: | ||
| 119 | - print(f'{users[0].id:>12} {users[0].name}') | ||
| 120 | - if n > 1: | ||
| 121 | - print(f'{users[1].id:>12} {users[1].name}') | ||
| 122 | - if n > 3: | ||
| 123 | - print(' | |') | ||
| 124 | - if n > 2: | ||
| 125 | - print(f'{users[-1].id:>12} {users[-1].name}') | ||
| 126 | - print(f'Total: {n}.') | 126 | + print(f'{users[0].id:>12} {users[0].name}') |
| 127 | + if total > 1: | ||
| 128 | + print(f'{users[1].id:>12} {users[1].name}') | ||
| 129 | + if total > 3: | ||
| 130 | + print(' | |') | ||
| 131 | + if total > 2: | ||
| 132 | + print(f'{users[-1].id:>12} {users[-1].name}') | ||
| 133 | + print(f'Total: {total}.') | ||
| 127 | 134 | ||
| 128 | 135 | ||
| 129 | # =========================================================================== | 136 | # =========================================================================== |
| 130 | def main(): | 137 | def main(): |
| 138 | + '''performs the main functions''' | ||
| 139 | + | ||
| 131 | args = parse_commandline_arguments() | 140 | args = parse_commandline_arguments() |
| 132 | 141 | ||
| 133 | # --- database stuff | 142 | # --- database stuff |
| 134 | - print(f'Using database: ', args.db) | 143 | + print(f'Using database: {args.db}') |
| 135 | engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) | 144 | engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) |
| 136 | Base.metadata.create_all(engine) # Creates schema if needed | 145 | Base.metadata.create_all(engine) # Creates schema if needed |
| 137 | - Session = sa.orm.sessionmaker(bind=engine) | ||
| 138 | - session = Session() | 146 | + session = sa.orm.sessionmaker(bind=engine)() |
| 139 | 147 | ||
| 140 | - # --- make list of students to insert/update | 148 | + # --- build list of students to insert/update |
| 141 | students = [] | 149 | students = [] |
| 142 | 150 | ||
| 143 | for csvfile in args.csvfile: | 151 | for csvfile in args.csvfile: |
| @@ -159,13 +167,13 @@ def main(): | @@ -159,13 +167,13 @@ def main(): | ||
| 159 | 167 | ||
| 160 | if new_students: | 168 | if new_students: |
| 161 | # --- password hashing | 169 | # --- password hashing |
| 162 | - print(f'Generating password hashes', end='') | 170 | + print('Generating password hashes', end='') |
| 163 | with ThreadPoolExecutor() as executor: | 171 | with ThreadPoolExecutor() as executor: |
| 164 | executor.map(lambda s: hashpw(s, args.pw), new_students) | 172 | executor.map(lambda s: hashpw(s, args.pw), new_students) |
| 165 | 173 | ||
| 166 | print('\nAdding students:') | 174 | print('\nAdding students:') |
| 167 | - for s in new_students: | ||
| 168 | - print(f' + {s["uid"]}, {s["name"]}') | 175 | + for student in new_students: |
| 176 | + print(f' + {student["uid"]}, {student["name"]}') | ||
| 169 | 177 | ||
| 170 | try: | 178 | try: |
| 171 | session.add_all([Student(id=s['uid'], | 179 | session.add_all([Student(id=s['uid'], |
| @@ -182,15 +190,15 @@ def main(): | @@ -182,15 +190,15 @@ def main(): | ||
| 182 | print('There are no new students to add.') | 190 | print('There are no new students to add.') |
| 183 | 191 | ||
| 184 | # --- update data for student in the database | 192 | # --- update data for student in the database |
| 185 | - for s in args.update: | ||
| 186 | - print(f'Updating password of: {s}') | ||
| 187 | - u = session.query(Student).get(s) | ||
| 188 | - if u is not None: | ||
| 189 | - pw = (args.pw or s).encode('utf-8') | ||
| 190 | - u.password = bcrypt.hashpw(pw, bcrypt.gensalt()) | 193 | + for student_id in args.update: |
| 194 | + print(f'Updating password of: {student_id}') | ||
| 195 | + student = session.query(Student).get(student_id) | ||
| 196 | + if student is not None: | ||
| 197 | + passw = (args.pw or student_id).encode('utf-8') | ||
| 198 | + student.password = bcrypt.hashpw(passw, bcrypt.gensalt()) | ||
| 191 | session.commit() | 199 | session.commit() |
| 192 | else: | 200 | else: |
| 193 | - print(f'!!! Student {s} does not exist. Skipping update !!!') | 201 | + print(f'!!! Student {student_id} does not exist. Skipped!!!') |
| 194 | 202 | ||
| 195 | show_students_in_database(session, args.verbose) | 203 | show_students_in_database(session, args.verbose) |
| 196 | 204 |
aprendizations/learnapp.py
| @@ -85,10 +85,10 @@ class LearnApp(): | @@ -85,10 +85,10 @@ class LearnApp(): | ||
| 85 | 85 | ||
| 86 | try: | 86 | try: |
| 87 | config: Dict[str, Any] = load_yaml(courses) | 87 | config: Dict[str, Any] = load_yaml(courses) |
| 88 | - except Exception: | 88 | + except Exception as exc: |
| 89 | msg = f'Failed to load yaml file "{courses}"' | 89 | msg = f'Failed to load yaml file "{courses}"' |
| 90 | logger.error(msg) | 90 | logger.error(msg) |
| 91 | - raise LearnException(msg) | 91 | + raise LearnException(msg) from exc |
| 92 | 92 | ||
| 93 | # --- topic dependencies are shared between all courses | 93 | # --- topic dependencies are shared between all courses |
| 94 | self.deps = nx.DiGraph(prefix=prefix) | 94 | self.deps = nx.DiGraph(prefix=prefix) |
| @@ -336,9 +336,9 @@ class LearnApp(): | @@ -336,9 +336,9 @@ class LearnApp(): | ||
| 336 | student = self.online[uid]['state'] | 336 | student = self.online[uid]['state'] |
| 337 | try: | 337 | try: |
| 338 | student.start_course(course_id) | 338 | student.start_course(course_id) |
| 339 | - except Exception: | 339 | + except Exception as exc: |
| 340 | logger.warning('"%s" could not start course "%s"', uid, course_id) | 340 | logger.warning('"%s" could not start course "%s"', uid, course_id) |
| 341 | - raise LearnException() | 341 | + raise LearnException() from exc |
| 342 | else: | 342 | else: |
| 343 | logger.info('User "%s" started course "%s"', uid, course_id) | 343 | logger.info('User "%s" started course "%s"', uid, course_id) |
| 344 | 344 | ||
| @@ -392,9 +392,9 @@ class LearnApp(): | @@ -392,9 +392,9 @@ class LearnApp(): | ||
| 392 | count_students: int = sess.query(Student).count() | 392 | count_students: int = sess.query(Student).count() |
| 393 | count_topics: int = sess.query(Topic).count() | 393 | count_topics: int = sess.query(Topic).count() |
| 394 | count_answers: int = sess.query(Answer).count() | 394 | count_answers: int = sess.query(Answer).count() |
| 395 | - except Exception: | 395 | + except Exception as exc: |
| 396 | logger.error('Database "%s" not usable!', database) | 396 | logger.error('Database "%s" not usable!', database) |
| 397 | - raise DatabaseUnusableError() | 397 | + raise DatabaseUnusableError() from exc |
| 398 | else: | 398 | else: |
| 399 | logger.info('%6d students', count_students) | 399 | logger.info('%6d students', count_students) |
| 400 | logger.info('%6d topics', count_topics) | 400 | logger.info('%6d topics', count_topics) |
| @@ -416,7 +416,7 @@ class LearnApp(): | @@ -416,7 +416,7 @@ class LearnApp(): | ||
| 416 | 'type': 'topic', # chapter | 416 | 'type': 'topic', # chapter |
| 417 | 'file': 'questions.yaml', | 417 | 'file': 'questions.yaml', |
| 418 | 'shuffle_questions': True, | 418 | 'shuffle_questions': True, |
| 419 | - 'choose': 9999, | 419 | + 'choose': 99, |
| 420 | 'forgetting_factor': 1.0, # no forgetting | 420 | 'forgetting_factor': 1.0, # no forgetting |
| 421 | 'max_tries': 1, # in every question | 421 | 'max_tries': 1, # in every question |
| 422 | 'append_wrong': True, | 422 | 'append_wrong': True, |
| @@ -472,22 +472,20 @@ class LearnApp(): | @@ -472,22 +472,20 @@ class LearnApp(): | ||
| 472 | # load questions as list of dicts | 472 | # load questions as list of dicts |
| 473 | try: | 473 | try: |
| 474 | fullpath: str = join(topic['path'], topic['file']) | 474 | fullpath: str = join(topic['path'], topic['file']) |
| 475 | - except Exception: | ||
| 476 | - msg1 = f'Invalid topic "{tref}"' | ||
| 477 | - msg2 = 'Check dependencies of: ' + \ | 475 | + except Exception as exc: |
| 476 | + msg = f'Invalid topic "{tref}". Check dependencies of: ' + \ | ||
| 478 | ', '.join(self.deps.successors(tref)) | 477 | ', '.join(self.deps.successors(tref)) |
| 479 | - msg = f'{msg1}. {msg2}' | ||
| 480 | logger.error(msg) | 478 | logger.error(msg) |
| 481 | - raise LearnException(msg) | 479 | + raise LearnException(msg) from exc |
| 482 | logger.debug(' Loading %s', fullpath) | 480 | logger.debug(' Loading %s', fullpath) |
| 483 | try: | 481 | try: |
| 484 | questions: List[QDict] = load_yaml(fullpath) | 482 | questions: List[QDict] = load_yaml(fullpath) |
| 485 | - except Exception: | 483 | + except Exception as exc: |
| 486 | if topic['type'] == 'chapter': | 484 | if topic['type'] == 'chapter': |
| 487 | return factory # chapters may have no "questions" | 485 | return factory # chapters may have no "questions" |
| 488 | msg = f'Failed to load "{fullpath}"' | 486 | msg = f'Failed to load "{fullpath}"' |
| 489 | logger.error(msg) | 487 | logger.error(msg) |
| 490 | - raise LearnException(msg) | 488 | + raise LearnException(msg) from exc |
| 491 | 489 | ||
| 492 | if not isinstance(questions, list): | 490 | if not isinstance(questions, list): |
| 493 | msg = f'File "{fullpath}" must be a list of questions' | 491 | msg = f'File "{fullpath}" must be a list of questions' |
| @@ -548,8 +546,8 @@ class LearnApp(): | @@ -548,8 +546,8 @@ class LearnApp(): | ||
| 548 | # ------------------------------------------------------------------------ | 546 | # ------------------------------------------------------------------------ |
| 549 | def get_current_question(self, uid: str) -> Optional[Question]: | 547 | def get_current_question(self, uid: str) -> Optional[Question]: |
| 550 | '''Get the current question of a given user''' | 548 | '''Get the current question of a given user''' |
| 551 | - q: Optional[Question] = self.online[uid]['state'].get_current_question() | ||
| 552 | - return q | 549 | + question: Optional[Question] = self.online[uid]['state'].get_current_question() |
| 550 | + return question | ||
| 553 | 551 | ||
| 554 | # ------------------------------------------------------------------------ | 552 | # ------------------------------------------------------------------------ |
| 555 | def get_current_question_id(self, uid: str) -> str: | 553 | def get_current_question_id(self, uid: str) -> str: |
| @@ -655,6 +653,6 @@ class LearnApp(): | @@ -655,6 +653,6 @@ class LearnApp(): | ||
| 655 | return sorted(((u, name, progress[u], perf.get(u, 0.0)) | 653 | return sorted(((u, name, progress[u], perf.get(u, 0.0)) |
| 656 | for u, name in students | 654 | for u, name in students |
| 657 | if u in progress and (len(u) > 2 or len(uid) <= 2)), | 655 | if u in progress and (len(u) > 2 or len(uid) <= 2)), |
| 658 | - key=lambda x: x[2], reverse=True) | 656 | + key=lambda x: x[2], reverse=True) |
| 659 | 657 | ||
| 660 | # ------------------------------------------------------------------------ | 658 | # ------------------------------------------------------------------------ |
aprendizations/main.py
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | +''' | ||
| 4 | +Setup configurations and then runs the application. | ||
| 5 | +''' | ||
| 6 | + | ||
| 7 | + | ||
| 3 | # python standard library | 8 | # python standard library |
| 4 | import argparse | 9 | import argparse |
| 5 | import logging | 10 | import logging |
| @@ -9,14 +14,18 @@ import sys | @@ -9,14 +14,18 @@ import sys | ||
| 9 | from typing import Any, Dict | 14 | from typing import Any, Dict |
| 10 | 15 | ||
| 11 | # this project | 16 | # this project |
| 12 | -from .learnapp import LearnApp, DatabaseUnusableError, LearnException | ||
| 13 | -from .serve import run_webserver | ||
| 14 | -from .tools import load_yaml | ||
| 15 | -from . import APP_NAME, APP_VERSION | 17 | +from aprendizations.learnapp import LearnApp, DatabaseUnusableError, LearnException |
| 18 | +from aprendizations.serve import run_webserver | ||
| 19 | +from aprendizations.tools import load_yaml | ||
| 20 | +from aprendizations import APP_NAME, APP_VERSION | ||
| 16 | 21 | ||
| 17 | 22 | ||
| 18 | # ---------------------------------------------------------------------------- | 23 | # ---------------------------------------------------------------------------- |
| 19 | def parse_cmdline_arguments(): | 24 | def parse_cmdline_arguments(): |
| 25 | + ''' | ||
| 26 | + Parses command line arguments. Uses the argparse package. | ||
| 27 | + ''' | ||
| 28 | + | ||
| 20 | argparser = argparse.ArgumentParser( | 29 | argparser = argparse.ArgumentParser( |
| 21 | description='Webserver for interactive learning and practice. ' | 30 | description='Webserver for interactive learning and practice. ' |
| 22 | 'Please read the documentation included with this software before ' | 31 | 'Please read the documentation included with this software before ' |
| @@ -63,6 +72,12 @@ def parse_cmdline_arguments(): | @@ -63,6 +72,12 @@ def parse_cmdline_arguments(): | ||
| 63 | 72 | ||
| 64 | # ---------------------------------------------------------------------------- | 73 | # ---------------------------------------------------------------------------- |
| 65 | def get_logger_config(debug: bool = False) -> Any: | 74 | def get_logger_config(debug: bool = False) -> Any: |
| 75 | + ''' | ||
| 76 | + Loads logger configuration in yaml format from a file, otherwise sets up a | ||
| 77 | + default configuration. | ||
| 78 | + Returns the configuration. | ||
| 79 | + ''' | ||
| 80 | + | ||
| 66 | if debug: | 81 | if debug: |
| 67 | filename, level = 'logger-debug.yaml', 'DEBUG' | 82 | filename, level = 'logger-debug.yaml', 'DEBUG' |
| 68 | else: | 83 | else: |
| @@ -106,9 +121,11 @@ def get_logger_config(debug: bool = False) -> Any: | @@ -106,9 +121,11 @@ def get_logger_config(debug: bool = False) -> Any: | ||
| 106 | 121 | ||
| 107 | 122 | ||
| 108 | # ---------------------------------------------------------------------------- | 123 | # ---------------------------------------------------------------------------- |
| 109 | -# Start application and webserver | ||
| 110 | -# ---------------------------------------------------------------------------- | ||
| 111 | def main(): | 124 | def main(): |
| 125 | + ''' | ||
| 126 | + Start application and webserver | ||
| 127 | + ''' | ||
| 128 | + | ||
| 112 | # --- Commandline argument parsing | 129 | # --- Commandline argument parsing |
| 113 | arg = parse_cmdline_arguments() | 130 | arg = parse_cmdline_arguments() |
| 114 | 131 | ||
| @@ -122,8 +139,8 @@ def main(): | @@ -122,8 +139,8 @@ def main(): | ||
| 122 | 139 | ||
| 123 | try: | 140 | try: |
| 124 | logging.config.dictConfig(logger_config) | 141 | logging.config.dictConfig(logger_config) |
| 125 | - except Exception: | ||
| 126 | - print('An error ocurred while setting up the logging system.') | 142 | + except (ValueError, TypeError, AttributeError, ImportError) as exc: |
| 143 | + print('An error ocurred while setting up the logging system: %s', exc) | ||
| 127 | sys.exit(1) | 144 | sys.exit(1) |
| 128 | 145 | ||
| 129 | logging.info('====================== Start Logging ======================') | 146 | logging.info('====================== Start Logging ======================') |
| @@ -139,7 +156,7 @@ def main(): | @@ -139,7 +156,7 @@ def main(): | ||
| 139 | ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'), | 156 | ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'), |
| 140 | path.join(certs_dir, 'privkey.pem')) | 157 | path.join(certs_dir, 'privkey.pem')) |
| 141 | except FileNotFoundError: | 158 | except FileNotFoundError: |
| 142 | - logging.critical(f'SSL certificates missing in {certs_dir}') | 159 | + logging.critical('SSL certificates missing in %s', certs_dir) |
| 143 | print('--------------------------------------------------------------', | 160 | print('--------------------------------------------------------------', |
| 144 | 'Certificates should be issued by a certificate authority (CA),', | 161 | 'Certificates should be issued by a certificate authority (CA),', |
| 145 | 'such as https://letsencrypt.org. ', | 162 | 'such as https://letsencrypt.org. ', |
| @@ -183,7 +200,8 @@ def main(): | @@ -183,7 +200,8 @@ def main(): | ||
| 183 | sys.exit(1) | 200 | sys.exit(1) |
| 184 | except Exception: | 201 | except Exception: |
| 185 | logging.critical('Unknown error') | 202 | logging.critical('Unknown error') |
| 186 | - sys.exit(1) | 203 | + # sys.exit(1) |
| 204 | + raise | ||
| 187 | else: | 205 | else: |
| 188 | logging.info('LearnApp started') | 206 | logging.info('LearnApp started') |
| 189 | 207 |
aprendizations/questions.py
| 1 | +''' | ||
| 2 | +Classes the implement several types of questions. | ||
| 3 | +''' | ||
| 4 | + | ||
| 1 | 5 | ||
| 2 | # python standard library | 6 | # python standard library |
| 3 | import asyncio | 7 | import asyncio |
| 4 | from datetime import datetime | 8 | from datetime import datetime |
| 9 | +import logging | ||
| 10 | +from os import path | ||
| 5 | import random | 11 | import random |
| 6 | import re | 12 | import re |
| 7 | -from os import path | ||
| 8 | -import logging | ||
| 9 | from typing import Any, Dict, NewType | 13 | from typing import Any, Dict, NewType |
| 10 | import uuid | 14 | import uuid |
| 11 | 15 | ||
| 12 | # this project | 16 | # this project |
| 13 | -from .tools import run_script, run_script_async | 17 | +from aprendizations.tools import run_script, run_script_async |
| 14 | 18 | ||
| 15 | # setup logger for this module | 19 | # setup logger for this module |
| 16 | logger = logging.getLogger(__name__) | 20 | logger = logging.getLogger(__name__) |
| @@ -20,7 +24,7 @@ QDict = NewType('QDict', Dict[str, Any]) | @@ -20,7 +24,7 @@ QDict = NewType('QDict', Dict[str, Any]) | ||
| 20 | 24 | ||
| 21 | 25 | ||
| 22 | class QuestionException(Exception): | 26 | class QuestionException(Exception): |
| 23 | - pass | 27 | + '''Exceptions raised in this module''' |
| 24 | 28 | ||
| 25 | 29 | ||
| 26 | # ============================================================================ | 30 | # ============================================================================ |
| @@ -46,20 +50,23 @@ class Question(dict): | @@ -46,20 +50,23 @@ class Question(dict): | ||
| 46 | })) | 50 | })) |
| 47 | 51 | ||
| 48 | def set_answer(self, ans) -> None: | 52 | def set_answer(self, ans) -> None: |
| 53 | + '''set answer field and register time''' | ||
| 49 | self['answer'] = ans | 54 | self['answer'] = ans |
| 50 | self['finish_time'] = datetime.now() | 55 | self['finish_time'] = datetime.now() |
| 51 | 56 | ||
| 52 | def correct(self) -> None: | 57 | def correct(self) -> None: |
| 58 | + '''default correction (synchronous version)''' | ||
| 53 | self['comments'] = '' | 59 | self['comments'] = '' |
| 54 | self['grade'] = 0.0 | 60 | self['grade'] = 0.0 |
| 55 | 61 | ||
| 56 | async def correct_async(self) -> None: | 62 | async def correct_async(self) -> None: |
| 63 | + '''default correction (async version)''' | ||
| 57 | self.correct() | 64 | self.correct() |
| 58 | 65 | ||
| 59 | - def set_defaults(self, d: QDict) -> None: | ||
| 60 | - 'Add k:v pairs from default dict d for nonexistent keys' | ||
| 61 | - for k, v in d.items(): | ||
| 62 | - self.setdefault(k, v) | 66 | + def set_defaults(self, qdict: QDict) -> None: |
| 67 | + '''Add k:v pairs from default dict d for nonexistent keys''' | ||
| 68 | + for k, val in qdict.items(): | ||
| 69 | + self.setdefault(k, val) | ||
| 63 | 70 | ||
| 64 | 71 | ||
| 65 | # ============================================================================ | 72 | # ============================================================================ |
| @@ -80,74 +87,82 @@ class QuestionRadio(Question): | @@ -80,74 +87,82 @@ class QuestionRadio(Question): | ||
| 80 | super().__init__(q) | 87 | super().__init__(q) |
| 81 | 88 | ||
| 82 | try: | 89 | try: |
| 83 | - n = len(self['options']) | ||
| 84 | - except KeyError: | ||
| 85 | - msg = f'Missing `options` in radio question. See {self["path"]}' | ||
| 86 | - raise QuestionException(msg) | ||
| 87 | - except TypeError: | ||
| 88 | - msg = f'`options` must be a list. See {self["path"]}' | ||
| 89 | - raise QuestionException(msg) | 90 | + nopts = len(self['options']) |
| 91 | + except KeyError as exc: | ||
| 92 | + msg = f'Missing `options`. In question "{self["ref"]}"' | ||
| 93 | + logger.error(msg) | ||
| 94 | + raise QuestionException(msg) from exc | ||
| 95 | + except TypeError as exc: | ||
| 96 | + msg = f'`options` must be a list. In question "{self["ref"]}"' | ||
| 97 | + logger.error(msg) | ||
| 98 | + raise QuestionException(msg) from exc | ||
| 90 | 99 | ||
| 91 | self.set_defaults(QDict({ | 100 | self.set_defaults(QDict({ |
| 92 | 'text': '', | 101 | 'text': '', |
| 93 | 'correct': 0, | 102 | 'correct': 0, |
| 94 | 'shuffle': True, | 103 | 'shuffle': True, |
| 95 | 'discount': True, | 104 | 'discount': True, |
| 96 | - 'max_tries': (n + 3) // 4 # 1 try for each 4 options | 105 | + 'max_tries': (nopts + 3) // 4 # 1 try for each 4 options |
| 97 | })) | 106 | })) |
| 98 | 107 | ||
| 99 | # check correct bounds and convert int to list, | 108 | # check correct bounds and convert int to list, |
| 100 | # e.g. correct: 2 --> correct: [0,0,1,0,0] | 109 | # e.g. correct: 2 --> correct: [0,0,1,0,0] |
| 101 | if isinstance(self['correct'], int): | 110 | if isinstance(self['correct'], int): |
| 102 | - if not (0 <= self['correct'] < n): | ||
| 103 | - msg = (f'Correct option not in range 0..{n-1} in ' | ||
| 104 | - f'"{self["ref"]}"') | 111 | + if not 0 <= self['correct'] < nopts: |
| 112 | + msg = (f'`correct` out of range 0..{nopts-1}. ' | ||
| 113 | + f'In question "{self["ref"]}"') | ||
| 114 | + logger.error(msg) | ||
| 105 | raise QuestionException(msg) | 115 | raise QuestionException(msg) |
| 106 | 116 | ||
| 107 | self['correct'] = [1.0 if x == self['correct'] else 0.0 | 117 | self['correct'] = [1.0 if x == self['correct'] else 0.0 |
| 108 | - for x in range(n)] | 118 | + for x in range(nopts)] |
| 109 | 119 | ||
| 110 | elif isinstance(self['correct'], list): | 120 | elif isinstance(self['correct'], list): |
| 111 | # must match number of options | 121 | # must match number of options |
| 112 | - if len(self['correct']) != n: | ||
| 113 | - msg = (f'Incompatible sizes: {n} options vs ' | ||
| 114 | - f'{len(self["correct"])} correct in "{self["ref"]}"') | 122 | + if len(self['correct']) != nopts: |
| 123 | + msg = (f'{nopts} options vs {len(self["correct"])} correct. ' | ||
| 124 | + f'In question "{self["ref"]}"') | ||
| 125 | + logger.error(msg) | ||
| 115 | raise QuestionException(msg) | 126 | raise QuestionException(msg) |
| 127 | + | ||
| 116 | # make sure is a list of floats | 128 | # make sure is a list of floats |
| 117 | try: | 129 | try: |
| 118 | self['correct'] = [float(x) for x in self['correct']] | 130 | self['correct'] = [float(x) for x in self['correct']] |
| 119 | - except (ValueError, TypeError): | ||
| 120 | - msg = (f'Correct list must contain numbers [0.0, 1.0] or ' | ||
| 121 | - f'booleans in "{self["ref"]}"') | ||
| 122 | - raise QuestionException(msg) | 131 | + except (ValueError, TypeError) as exc: |
| 132 | + msg = ('`correct` must be list of numbers or booleans.' | ||
| 133 | + f'In "{self["ref"]}"') | ||
| 134 | + logger.error(msg) | ||
| 135 | + raise QuestionException(msg) from exc | ||
| 123 | 136 | ||
| 124 | # check grade boundaries | 137 | # check grade boundaries |
| 125 | if self['discount'] and not all(0.0 <= x <= 1.0 | 138 | if self['discount'] and not all(0.0 <= x <= 1.0 |
| 126 | for x in self['correct']): | 139 | for x in self['correct']): |
| 127 | - msg = (f'Correct values must be in the interval [0.0, 1.0] in ' | ||
| 128 | - f'"{self["ref"]}"') | 140 | + msg = ('`correct` values must be in the interval [0.0, 1.0]. ' |
| 141 | + f'In "{self["ref"]}"') | ||
| 142 | + logger.error(msg) | ||
| 129 | raise QuestionException(msg) | 143 | raise QuestionException(msg) |
| 130 | 144 | ||
| 131 | # at least one correct option | 145 | # at least one correct option |
| 132 | if all(x < 1.0 for x in self['correct']): | 146 | if all(x < 1.0 for x in self['correct']): |
| 133 | - msg = (f'At least one correct option is required in ' | ||
| 134 | - f'"{self["ref"]}"') | 147 | + msg = ('At least one correct option is required. ' |
| 148 | + f'In "{self["ref"]}"') | ||
| 149 | + logger.error(msg) | ||
| 135 | raise QuestionException(msg) | 150 | raise QuestionException(msg) |
| 136 | 151 | ||
| 137 | # If shuffle==false, all options are shown as defined | 152 | # If shuffle==false, all options are shown as defined |
| 138 | # otherwise, select 1 correct and choose a few wrong ones | 153 | # otherwise, select 1 correct and choose a few wrong ones |
| 139 | if self['shuffle']: | 154 | if self['shuffle']: |
| 140 | # lists with indices of right and wrong options | 155 | # lists with indices of right and wrong options |
| 141 | - right = [i for i in range(n) if self['correct'][i] >= 1] | ||
| 142 | - wrong = [i for i in range(n) if self['correct'][i] < 1] | 156 | + right = [i for i in range(nopts) if self['correct'][i] >= 1] |
| 157 | + wrong = [i for i in range(nopts) if self['correct'][i] < 1] | ||
| 143 | 158 | ||
| 144 | self.set_defaults(QDict({'choose': 1+len(wrong)})) | 159 | self.set_defaults(QDict({'choose': 1+len(wrong)})) |
| 145 | 160 | ||
| 146 | # try to choose 1 correct option | 161 | # try to choose 1 correct option |
| 147 | if right: | 162 | if right: |
| 148 | - r = random.choice(right) | ||
| 149 | - options = [self['options'][r]] | ||
| 150 | - correct = [self['correct'][r]] | 163 | + sel = random.choice(right) |
| 164 | + options = [self['options'][sel]] | ||
| 165 | + correct = [self['correct'][sel]] | ||
| 151 | else: | 166 | else: |
| 152 | options = [] | 167 | options = [] |
| 153 | correct = [] | 168 | correct = [] |
| @@ -164,20 +179,23 @@ class QuestionRadio(Question): | @@ -164,20 +179,23 @@ class QuestionRadio(Question): | ||
| 164 | self['correct'] = [correct[i] for i in perm] | 179 | self['correct'] = [correct[i] for i in perm] |
| 165 | 180 | ||
| 166 | # ------------------------------------------------------------------------ | 181 | # ------------------------------------------------------------------------ |
| 167 | - # can assign negative grades for wrong answers | ||
| 168 | def correct(self) -> None: | 182 | def correct(self) -> None: |
| 183 | + ''' | ||
| 184 | + Correct `answer` and set `grade`. | ||
| 185 | + Can assign negative grades for wrong answers | ||
| 186 | + ''' | ||
| 169 | super().correct() | 187 | super().correct() |
| 170 | 188 | ||
| 171 | if self['answer'] is not None: | 189 | if self['answer'] is not None: |
| 172 | - x = self['correct'][int(self['answer'])] # get grade of the answer | ||
| 173 | - n = len(self['options']) | ||
| 174 | - x_aver = sum(self['correct']) / n # expected value of grade | 190 | + grade = self['correct'][int(self['answer'])] # grade of the answer |
| 191 | + nopts = len(self['options']) | ||
| 192 | + grade_aver = sum(self['correct']) / nopts # expected value | ||
| 175 | 193 | ||
| 176 | # note: there are no numerical errors when summing 1.0s so the | 194 | # note: there are no numerical errors when summing 1.0s so the |
| 177 | # x_aver can be exactly 1.0 if all options are right | 195 | # x_aver can be exactly 1.0 if all options are right |
| 178 | - if self['discount'] and x_aver != 1.0: | ||
| 179 | - x = (x - x_aver) / (1.0 - x_aver) | ||
| 180 | - self['grade'] = float(x) | 196 | + if self['discount'] and grade_aver != 1.0: |
| 197 | + grade = (grade - grade_aver) / (1.0 - grade_aver) | ||
| 198 | + self['grade'] = grade | ||
| 181 | 199 | ||
| 182 | 200 | ||
| 183 | # ============================================================================ | 201 | # ============================================================================ |
| @@ -198,79 +216,86 @@ class QuestionCheckbox(Question): | @@ -198,79 +216,86 @@ class QuestionCheckbox(Question): | ||
| 198 | super().__init__(q) | 216 | super().__init__(q) |
| 199 | 217 | ||
| 200 | try: | 218 | try: |
| 201 | - n = len(self['options']) | ||
| 202 | - except KeyError: | ||
| 203 | - msg = f'Missing `options` in checkbox question. See {self["path"]}' | ||
| 204 | - raise QuestionException(msg) | ||
| 205 | - except TypeError: | ||
| 206 | - msg = f'`options` must be a list. See {self["path"]}' | ||
| 207 | - raise QuestionException(msg) | 219 | + nopts = len(self['options']) |
| 220 | + except KeyError as exc: | ||
| 221 | + msg = f'Missing `options`. In question "{self["ref"]}"' | ||
| 222 | + logger.error(msg) | ||
| 223 | + raise QuestionException(msg) from exc | ||
| 224 | + except TypeError as exc: | ||
| 225 | + msg = f'`options` must be a list. In question "{self["ref"]}"' | ||
| 226 | + logger.error(msg) | ||
| 227 | + raise QuestionException(msg) from exc | ||
| 208 | 228 | ||
| 209 | # set defaults if missing | 229 | # set defaults if missing |
| 210 | self.set_defaults(QDict({ | 230 | self.set_defaults(QDict({ |
| 211 | 'text': '', | 231 | 'text': '', |
| 212 | - 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options | 232 | + 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong) |
| 213 | 'shuffle': True, | 233 | 'shuffle': True, |
| 214 | 'discount': True, | 234 | 'discount': True, |
| 215 | - 'choose': n, # number of options | ||
| 216 | - 'max_tries': max(1, min(n - 1, 3)) | 235 | + 'choose': nopts, # number of options |
| 236 | + 'max_tries': max(1, min(nopts - 1, 3)) | ||
| 217 | })) | 237 | })) |
| 218 | 238 | ||
| 219 | # must be a list of numbers | 239 | # must be a list of numbers |
| 220 | if not isinstance(self['correct'], list): | 240 | if not isinstance(self['correct'], list): |
| 221 | msg = 'Correct must be a list of numbers or booleans' | 241 | msg = 'Correct must be a list of numbers or booleans' |
| 242 | + logger.error(msg) | ||
| 222 | raise QuestionException(msg) | 243 | raise QuestionException(msg) |
| 223 | 244 | ||
| 224 | # must match number of options | 245 | # must match number of options |
| 225 | - if len(self['correct']) != n: | ||
| 226 | - msg = (f'Incompatible sizes: {n} options vs ' | ||
| 227 | - f'{len(self["correct"])} correct in "{self["ref"]}"') | 246 | + if len(self['correct']) != nopts: |
| 247 | + msg = (f'{nopts} options vs {len(self["correct"])} correct. ' | ||
| 248 | + f'In question "{self["ref"]}"') | ||
| 249 | + logger.error(msg) | ||
| 228 | raise QuestionException(msg) | 250 | raise QuestionException(msg) |
| 229 | 251 | ||
| 230 | # make sure is a list of floats | 252 | # make sure is a list of floats |
| 231 | try: | 253 | try: |
| 232 | self['correct'] = [float(x) for x in self['correct']] | 254 | self['correct'] = [float(x) for x in self['correct']] |
| 233 | - except (ValueError, TypeError): | ||
| 234 | - msg = (f'Correct list must contain numbers or ' | ||
| 235 | - f'booleans in "{self["ref"]}"') | ||
| 236 | - raise QuestionException(msg) | 255 | + except (ValueError, TypeError) as exc: |
| 256 | + msg = ('`correct` must be list of numbers or booleans.' | ||
| 257 | + f'In "{self["ref"]}"') | ||
| 258 | + logger.error(msg) | ||
| 259 | + raise QuestionException(msg) from exc | ||
| 237 | 260 | ||
| 238 | # check grade boundaries | 261 | # check grade boundaries |
| 239 | if self['discount'] and not all(0.0 <= x <= 1.0 | 262 | if self['discount'] and not all(0.0 <= x <= 1.0 |
| 240 | for x in self['correct']): | 263 | for x in self['correct']): |
| 241 | - | ||
| 242 | - msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') | ||
| 243 | - msg1 = ('| Correct values in checkbox questions must be in the |') | ||
| 244 | - msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') | ||
| 245 | - msg3 = ('| behavior, for now, but you should fix it. |') | ||
| 246 | - msg4 = ('+------------------------------------------------------+') | ||
| 247 | - logger.warning(msg0) | ||
| 248 | - logger.warning(msg1) | ||
| 249 | - logger.warning(msg2) | ||
| 250 | - logger.warning(msg3) | ||
| 251 | - logger.warning(msg4) | ||
| 252 | - logger.warning(f'please fix "{self["ref"]}"') | ||
| 253 | - | ||
| 254 | - # normalize to [0,1] | ||
| 255 | - self['correct'] = [(x+1)/2 for x in self['correct']] | 264 | + msg = ('values in the `correct` field of checkboxes must be in ' |
| 265 | + 'the [0.0, 1.0] interval. ' | ||
| 266 | + f'Please fix "{self["ref"]}" in "{self["path"]}"') | ||
| 267 | + logger.error(msg) | ||
| 268 | + raise QuestionException(msg) | ||
| 269 | + # msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') | ||
| 270 | + # msg1 = ('| Correct values in checkbox questions must be in the |') | ||
| 271 | + # msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') | ||
| 272 | + # msg3 = ('| behavior, for now, but you should fix it. |') | ||
| 273 | + # msg4 = ('+------------------------------------------------------+') | ||
| 274 | + # logger.warning(msg0) | ||
| 275 | + # logger.warning(msg1) | ||
| 276 | + # logger.warning(msg2) | ||
| 277 | + # logger.warning(msg3) | ||
| 278 | + # logger.warning(msg4) | ||
| 279 | + # logger.warning('please fix "%s"', self["ref"]) | ||
| 280 | + # # normalize to [0,1] | ||
| 281 | + # self['correct'] = [(x+1)/2 for x in self['correct']] | ||
| 256 | 282 | ||
| 257 | # if an option is a list of (right, wrong), pick one | 283 | # if an option is a list of (right, wrong), pick one |
| 258 | options = [] | 284 | options = [] |
| 259 | correct = [] | 285 | correct = [] |
| 260 | - for o, c in zip(self['options'], self['correct']): | ||
| 261 | - if isinstance(o, list): | ||
| 262 | - r = random.randint(0, 1) | ||
| 263 | - o = o[r] | ||
| 264 | - if r == 1: | ||
| 265 | - # c = -c | ||
| 266 | - c = 1.0 - c | ||
| 267 | - options.append(str(o)) | ||
| 268 | - correct.append(c) | 286 | + for option, corr in zip(self['options'], self['correct']): |
| 287 | + if isinstance(option, list): | ||
| 288 | + sel = random.randint(0, 1) | ||
| 289 | + option = option[sel] | ||
| 290 | + if sel == 1: | ||
| 291 | + corr = 1.0 - corr | ||
| 292 | + options.append(str(option)) | ||
| 293 | + correct.append(corr) | ||
| 269 | 294 | ||
| 270 | # generate random permutation, e.g. [2,1,4,0,3] | 295 | # generate random permutation, e.g. [2,1,4,0,3] |
| 271 | # and apply to `options` and `correct` | 296 | # and apply to `options` and `correct` |
| 272 | if self['shuffle']: | 297 | if self['shuffle']: |
| 273 | - perm = random.sample(range(n), k=self['choose']) | 298 | + perm = random.sample(range(nopts), k=self['choose']) |
| 274 | self['options'] = [options[i] for i in perm] | 299 | self['options'] = [options[i] for i in perm] |
| 275 | self['correct'] = [correct[i] for i in perm] | 300 | self['correct'] = [correct[i] for i in perm] |
| 276 | else: | 301 | else: |
| @@ -283,18 +308,18 @@ class QuestionCheckbox(Question): | @@ -283,18 +308,18 @@ class QuestionCheckbox(Question): | ||
| 283 | super().correct() | 308 | super().correct() |
| 284 | 309 | ||
| 285 | if self['answer'] is not None: | 310 | if self['answer'] is not None: |
| 286 | - x = 0.0 | 311 | + grade = 0.0 |
| 287 | if self['discount']: | 312 | if self['discount']: |
| 288 | sum_abs = sum(abs(2*p-1) for p in self['correct']) | 313 | sum_abs = sum(abs(2*p-1) for p in self['correct']) |
| 289 | - for i, p in enumerate(self['correct']): | ||
| 290 | - x += 2*p-1 if str(i) in self['answer'] else 1-2*p | 314 | + for i, pts in enumerate(self['correct']): |
| 315 | + grade += 2*pts-1 if str(i) in self['answer'] else 1-2*pts | ||
| 291 | else: | 316 | else: |
| 292 | sum_abs = sum(abs(p) for p in self['correct']) | 317 | sum_abs = sum(abs(p) for p in self['correct']) |
| 293 | - for i, p in enumerate(self['correct']): | ||
| 294 | - x += p if str(i) in self['answer'] else 0.0 | 318 | + for i, pts in enumerate(self['correct']): |
| 319 | + grade += pts if str(i) in self['answer'] else 0.0 | ||
| 295 | 320 | ||
| 296 | try: | 321 | try: |
| 297 | - self['grade'] = x / sum_abs | 322 | + self['grade'] = grade / sum_abs |
| 298 | except ZeroDivisionError: | 323 | except ZeroDivisionError: |
| 299 | self['grade'] = 1.0 # limit p->0 | 324 | self['grade'] = 1.0 # limit p->0 |
| 300 | 325 | ||
| @@ -325,33 +350,35 @@ class QuestionText(Question): | @@ -325,33 +350,35 @@ class QuestionText(Question): | ||
| 325 | # make sure all elements of the list are strings | 350 | # make sure all elements of the list are strings |
| 326 | self['correct'] = [str(a) for a in self['correct']] | 351 | self['correct'] = [str(a) for a in self['correct']] |
| 327 | 352 | ||
| 328 | - for f in self['transform']: | ||
| 329 | - if f not in ('remove_space', 'trim', 'normalize_space', 'lower', | ||
| 330 | - 'upper'): | ||
| 331 | - msg = (f'Unknown transform "{f}" in "{self["ref"]}"') | 353 | + for transform in self['transform']: |
| 354 | + if transform not in ('remove_space', 'trim', 'normalize_space', | ||
| 355 | + 'lower', 'upper'): | ||
| 356 | + msg = (f'Unknown transform "{transform}" in "{self["ref"]}"') | ||
| 332 | raise QuestionException(msg) | 357 | raise QuestionException(msg) |
| 333 | 358 | ||
| 334 | # check if answers are invariant with respect to the transforms | 359 | # check if answers are invariant with respect to the transforms |
| 335 | if any(c != self.transform(c) for c in self['correct']): | 360 | if any(c != self.transform(c) for c in self['correct']): |
| 336 | - logger.warning(f'in "{self["ref"]}", correct answers are not ' | ||
| 337 | - 'invariant wrt transformations => never correct') | 361 | + logger.warning('in "%s", correct answers are not invariant wrt ' |
| 362 | + 'transformations => never correct', self["ref"]) | ||
| 338 | 363 | ||
| 339 | # ------------------------------------------------------------------------ | 364 | # ------------------------------------------------------------------------ |
| 340 | - # apply optional filters to the answer | ||
| 341 | def transform(self, ans): | 365 | def transform(self, ans): |
| 342 | - for f in self['transform']: | ||
| 343 | - if f == 'remove_space': # removes all spaces | 366 | + '''apply optional filters to the answer''' |
| 367 | + | ||
| 368 | + for transform in self['transform']: | ||
| 369 | + if transform == 'remove_space': # removes all spaces | ||
| 344 | ans = ans.replace(' ', '') | 370 | ans = ans.replace(' ', '') |
| 345 | - elif f == 'trim': # removes spaces around | 371 | + elif transform == 'trim': # removes spaces around |
| 346 | ans = ans.strip() | 372 | ans = ans.strip() |
| 347 | - elif f == 'normalize_space': # replaces multiple spaces by one | 373 | + elif transform == 'normalize_space': # replaces multiple spaces by one |
| 348 | ans = re.sub(r'\s+', ' ', ans.strip()) | 374 | ans = re.sub(r'\s+', ' ', ans.strip()) |
| 349 | - elif f == 'lower': # convert to lowercase | 375 | + elif transform == 'lower': # convert to lowercase |
| 350 | ans = ans.lower() | 376 | ans = ans.lower() |
| 351 | - elif f == 'upper': # convert to uppercase | 377 | + elif transform == 'upper': # convert to uppercase |
| 352 | ans = ans.upper() | 378 | ans = ans.upper() |
| 353 | else: | 379 | else: |
| 354 | - logger.warning(f'in "{self["ref"]}", unknown transform "{f}"') | 380 | + logger.warning('in "%s", unknown transform "%s"', |
| 381 | + self["ref"], transform) | ||
| 355 | return ans | 382 | return ans |
| 356 | 383 | ||
| 357 | # ------------------------------------------------------------------------ | 384 | # ------------------------------------------------------------------------ |
| @@ -391,23 +418,24 @@ class QuestionTextRegex(Question): | @@ -391,23 +418,24 @@ class QuestionTextRegex(Question): | ||
| 391 | # converts patterns to compiled versions | 418 | # converts patterns to compiled versions |
| 392 | try: | 419 | try: |
| 393 | self['correct'] = [re.compile(a) for a in self['correct']] | 420 | self['correct'] = [re.compile(a) for a in self['correct']] |
| 394 | - except Exception: | 421 | + except Exception as exc: |
| 395 | msg = f'Failed to compile regex in "{self["ref"]}"' | 422 | msg = f'Failed to compile regex in "{self["ref"]}"' |
| 396 | - raise QuestionException(msg) | 423 | + logger.error(msg) |
| 424 | + raise QuestionException(msg) from exc | ||
| 397 | 425 | ||
| 398 | # ------------------------------------------------------------------------ | 426 | # ------------------------------------------------------------------------ |
| 399 | def correct(self) -> None: | 427 | def correct(self) -> None: |
| 400 | super().correct() | 428 | super().correct() |
| 401 | if self['answer'] is not None: | 429 | if self['answer'] is not None: |
| 402 | self['grade'] = 0.0 | 430 | self['grade'] = 0.0 |
| 403 | - for r in self['correct']: | 431 | + for regex in self['correct']: |
| 404 | try: | 432 | try: |
| 405 | - if r.match(self['answer']): | 433 | + if regex.match(self['answer']): |
| 406 | self['grade'] = 1.0 | 434 | self['grade'] = 1.0 |
| 407 | return | 435 | return |
| 408 | except TypeError: | 436 | except TypeError: |
| 409 | - logger.error(f'While matching regex {r.pattern} with ' | ||
| 410 | - f'answer "{self["answer"]}".') | 437 | + logger.error('While matching regex %s with answer "%s".', |
| 438 | + regex.pattern, self["answer"]) | ||
| 411 | 439 | ||
| 412 | 440 | ||
| 413 | # ============================================================================ | 441 | # ============================================================================ |
| @@ -438,19 +466,22 @@ class QuestionNumericInterval(Question): | @@ -438,19 +466,22 @@ class QuestionNumericInterval(Question): | ||
| 438 | if len(self['correct']) != 2: | 466 | if len(self['correct']) != 2: |
| 439 | msg = (f'Numeric interval must be a list with two numbers, in ' | 467 | msg = (f'Numeric interval must be a list with two numbers, in ' |
| 440 | f'{self["ref"]}') | 468 | f'{self["ref"]}') |
| 469 | + logger.error(msg) | ||
| 441 | raise QuestionException(msg) | 470 | raise QuestionException(msg) |
| 442 | 471 | ||
| 443 | try: | 472 | try: |
| 444 | self['correct'] = [float(n) for n in self['correct']] | 473 | self['correct'] = [float(n) for n in self['correct']] |
| 445 | - except Exception: | 474 | + except Exception as exc: |
| 446 | msg = (f'Numeric interval must be a list with two numbers, in ' | 475 | msg = (f'Numeric interval must be a list with two numbers, in ' |
| 447 | f'{self["ref"]}') | 476 | f'{self["ref"]}') |
| 448 | - raise QuestionException(msg) | 477 | + logger.error(msg) |
| 478 | + raise QuestionException(msg) from exc | ||
| 449 | 479 | ||
| 450 | # invalid | 480 | # invalid |
| 451 | else: | 481 | else: |
| 452 | msg = (f'Numeric interval must be a list with two numbers, in ' | 482 | msg = (f'Numeric interval must be a list with two numbers, in ' |
| 453 | f'{self["ref"]}') | 483 | f'{self["ref"]}') |
| 484 | + logger.error(msg) | ||
| 454 | raise QuestionException(msg) | 485 | raise QuestionException(msg) |
| 455 | 486 | ||
| 456 | # ------------------------------------------------------------------------ | 487 | # ------------------------------------------------------------------------ |
| @@ -504,21 +535,22 @@ class QuestionTextArea(Question): | @@ -504,21 +535,22 @@ class QuestionTextArea(Question): | ||
| 504 | ) | 535 | ) |
| 505 | 536 | ||
| 506 | if out is None: | 537 | if out is None: |
| 507 | - logger.warning(f'No grade after running "{self["correct"]}".') | 538 | + logger.warning('No grade after running "%s".', self["correct"]) |
| 539 | + self['comments'] = 'O programa de correcção abortou...' | ||
| 508 | self['grade'] = 0.0 | 540 | self['grade'] = 0.0 |
| 509 | elif isinstance(out, dict): | 541 | elif isinstance(out, dict): |
| 510 | self['comments'] = out.get('comments', '') | 542 | self['comments'] = out.get('comments', '') |
| 511 | try: | 543 | try: |
| 512 | self['grade'] = float(out['grade']) | 544 | self['grade'] = float(out['grade']) |
| 513 | except ValueError: | 545 | except ValueError: |
| 514 | - logger.error(f'Output error in "{self["correct"]}".') | 546 | + logger.error('Output error in "%s".', self["correct"]) |
| 515 | except KeyError: | 547 | except KeyError: |
| 516 | - logger.error(f'No grade in "{self["correct"]}".') | 548 | + logger.error('No grade in "%s".', self["correct"]) |
| 517 | else: | 549 | else: |
| 518 | try: | 550 | try: |
| 519 | self['grade'] = float(out) | 551 | self['grade'] = float(out) |
| 520 | except (TypeError, ValueError): | 552 | except (TypeError, ValueError): |
| 521 | - logger.error(f'Invalid grade in "{self["correct"]}".') | 553 | + logger.error('Invalid grade in "%s".', self["correct"]) |
| 522 | 554 | ||
| 523 | # ------------------------------------------------------------------------ | 555 | # ------------------------------------------------------------------------ |
| 524 | async def correct_async(self) -> None: | 556 | async def correct_async(self) -> None: |
| @@ -533,25 +565,31 @@ class QuestionTextArea(Question): | @@ -533,25 +565,31 @@ class QuestionTextArea(Question): | ||
| 533 | ) | 565 | ) |
| 534 | 566 | ||
| 535 | if out is None: | 567 | if out is None: |
| 536 | - logger.warning(f'No grade after running "{self["correct"]}".') | 568 | + logger.warning('No grade after running "%s".', self["correct"]) |
| 569 | + self['comments'] = 'O programa de correcção abortou...' | ||
| 537 | self['grade'] = 0.0 | 570 | self['grade'] = 0.0 |
| 538 | elif isinstance(out, dict): | 571 | elif isinstance(out, dict): |
| 539 | self['comments'] = out.get('comments', '') | 572 | self['comments'] = out.get('comments', '') |
| 540 | try: | 573 | try: |
| 541 | self['grade'] = float(out['grade']) | 574 | self['grade'] = float(out['grade']) |
| 542 | except ValueError: | 575 | except ValueError: |
| 543 | - logger.error(f'Output error in "{self["correct"]}".') | 576 | + logger.error('Output error in "%s".', self["correct"]) |
| 544 | except KeyError: | 577 | except KeyError: |
| 545 | - logger.error(f'No grade in "{self["correct"]}".') | 578 | + logger.error('No grade in "%s".', self["correct"]) |
| 546 | else: | 579 | else: |
| 547 | try: | 580 | try: |
| 548 | self['grade'] = float(out) | 581 | self['grade'] = float(out) |
| 549 | except (TypeError, ValueError): | 582 | except (TypeError, ValueError): |
| 550 | - logger.error(f'Invalid grade in "{self["correct"]}".') | 583 | + logger.error('Invalid grade in "%s".', self["correct"]) |
| 551 | 584 | ||
| 552 | 585 | ||
| 553 | # ============================================================================ | 586 | # ============================================================================ |
| 554 | class QuestionInformation(Question): | 587 | class QuestionInformation(Question): |
| 588 | + ''' | ||
| 589 | + Not really a question, just an information panel. | ||
| 590 | + The correction is always right. | ||
| 591 | + ''' | ||
| 592 | + | ||
| 555 | # ------------------------------------------------------------------------ | 593 | # ------------------------------------------------------------------------ |
| 556 | def __init__(self, q: QDict) -> None: | 594 | def __init__(self, q: QDict) -> None: |
| 557 | super().__init__(q) | 595 | super().__init__(q) |
| @@ -566,38 +604,38 @@ class QuestionInformation(Question): | @@ -566,38 +604,38 @@ class QuestionInformation(Question): | ||
| 566 | 604 | ||
| 567 | 605 | ||
| 568 | # ============================================================================ | 606 | # ============================================================================ |
| 569 | -# | ||
| 570 | -# QFactory is a class that can generate question instances, e.g. by shuffling | ||
| 571 | -# options, running a script to generate the question, etc. | ||
| 572 | -# | ||
| 573 | -# To generate an instance of a question we use the method generate(). | ||
| 574 | -# It returns a question instance of the correct class. | ||
| 575 | -# There is also an asynchronous version called gen_async(). This version is | ||
| 576 | -# synchronous for all question types (radio, checkbox, etc) except for | ||
| 577 | -# generator types which run asynchronously. | ||
| 578 | -# | ||
| 579 | -# Example: | ||
| 580 | -# | ||
| 581 | -# # make a factory for a question | ||
| 582 | -# qfactory = QFactory({ | ||
| 583 | -# 'type': 'radio', | ||
| 584 | -# 'text': 'Choose one', | ||
| 585 | -# 'options': ['a', 'b'] | ||
| 586 | -# }) | ||
| 587 | -# | ||
| 588 | -# # generate synchronously | ||
| 589 | -# question = qfactory.generate() | ||
| 590 | -# | ||
| 591 | -# # generate asynchronously | ||
| 592 | -# question = await qfactory.gen_async() | ||
| 593 | -# | ||
| 594 | -# # answer one question and correct it | ||
| 595 | -# question['answer'] = 42 # set answer | ||
| 596 | -# question.correct() # correct answer | ||
| 597 | -# grade = question['grade'] # get grade | ||
| 598 | -# | ||
| 599 | -# ============================================================================ | ||
| 600 | -class QFactory(object): | 607 | +class QFactory(): |
| 608 | + ''' | ||
| 609 | + QFactory is a class that can generate question instances, e.g. by shuffling | ||
| 610 | + options, running a script to generate the question, etc. | ||
| 611 | + | ||
| 612 | + To generate an instance of a question we use the method generate(). | ||
| 613 | + It returns a question instance of the correct class. | ||
| 614 | + There is also an asynchronous version called gen_async(). This version is | ||
| 615 | + synchronous for all question types (radio, checkbox, etc) except for | ||
| 616 | + generator types which run asynchronously. | ||
| 617 | + | ||
| 618 | + Example: | ||
| 619 | + | ||
| 620 | + # make a factory for a question | ||
| 621 | + qfactory = QFactory({ | ||
| 622 | + 'type': 'radio', | ||
| 623 | + 'text': 'Choose one', | ||
| 624 | + 'options': ['a', 'b'] | ||
| 625 | + }) | ||
| 626 | + | ||
| 627 | + # generate synchronously | ||
| 628 | + question = qfactory.generate() | ||
| 629 | + | ||
| 630 | + # generate asynchronously | ||
| 631 | + question = await qfactory.gen_async() | ||
| 632 | + | ||
| 633 | + # answer one question and correct it | ||
| 634 | + question['answer'] = 42 # set answer | ||
| 635 | + question.correct() # correct answer | ||
| 636 | + grade = question['grade'] # get grade | ||
| 637 | + ''' | ||
| 638 | + | ||
| 601 | # Depending on the type of question, a different question class will be | 639 | # Depending on the type of question, a different question class will be |
| 602 | # instantiated. All these classes derive from the base class `Question`. | 640 | # instantiated. All these classes derive from the base class `Question`. |
| 603 | _types = { | 641 | _types = { |
| @@ -618,44 +656,52 @@ class QFactory(object): | @@ -618,44 +656,52 @@ class QFactory(object): | ||
| 618 | self.question = qdict | 656 | self.question = qdict |
| 619 | 657 | ||
| 620 | # ------------------------------------------------------------------------ | 658 | # ------------------------------------------------------------------------ |
| 621 | - # generates a question instance of QuestionRadio, QuestionCheckbox, ..., | ||
| 622 | - # which is a descendent of base class Question. | ||
| 623 | - # ------------------------------------------------------------------------ | ||
| 624 | async def gen_async(self) -> Question: | 659 | async def gen_async(self) -> Question: |
| 625 | - logger.debug(f'generating {self.question["ref"]}...') | 660 | + ''' |
| 661 | + generates a question instance of QuestionRadio, QuestionCheckbox, ..., | ||
| 662 | + which is a descendent of base class Question. | ||
| 663 | + ''' | ||
| 664 | + | ||
| 665 | + logger.debug('generating %s...', self.question["ref"]) | ||
| 626 | # Shallow copy so that script generated questions will not replace | 666 | # Shallow copy so that script generated questions will not replace |
| 627 | # the original generators | 667 | # the original generators |
| 628 | - q = self.question.copy() | ||
| 629 | - q['qid'] = str(uuid.uuid4()) # unique for each generated question | 668 | + question = self.question.copy() |
| 669 | + question['qid'] = str(uuid.uuid4()) # unique for each question | ||
| 630 | 670 | ||
| 631 | # If question is of generator type, an external program will be run | 671 | # If question is of generator type, an external program will be run |
| 632 | # which will print a valid question in yaml format to stdout. This | 672 | # which will print a valid question in yaml format to stdout. This |
| 633 | # output is then yaml parsed into a dictionary `q`. | 673 | # output is then yaml parsed into a dictionary `q`. |
| 634 | - if q['type'] == 'generator': | ||
| 635 | - logger.debug(f' \\_ Running "{q["script"]}".') | ||
| 636 | - q.setdefault('args', []) | ||
| 637 | - q.setdefault('stdin', '') | ||
| 638 | - script = path.join(q['path'], q['script']) | ||
| 639 | - out = await run_script_async(script=script, args=q['args'], | ||
| 640 | - stdin=q['stdin']) | ||
| 641 | - q.update(out) | 674 | + if question['type'] == 'generator': |
| 675 | + logger.debug(' \\_ Running "%s".', question['script']) | ||
| 676 | + question.setdefault('args', []) | ||
| 677 | + question.setdefault('stdin', '') | ||
| 678 | + script = path.join(question['path'], question['script']) | ||
| 679 | + out = await run_script_async(script=script, | ||
| 680 | + args=question['args'], | ||
| 681 | + stdin=question['stdin']) | ||
| 682 | + question.update(out) | ||
| 642 | 683 | ||
| 643 | # Get class for this question type | 684 | # Get class for this question type |
| 644 | try: | 685 | try: |
| 645 | - qclass = self._types[q['type']] | 686 | + qclass = self._types[question['type']] |
| 646 | except KeyError: | 687 | except KeyError: |
| 647 | - logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') | 688 | + logger.error('Invalid type "%s" in "%s"', |
| 689 | + question['type'], question['ref']) | ||
| 648 | raise | 690 | raise |
| 649 | 691 | ||
| 650 | # Finally create an instance of Question() | 692 | # Finally create an instance of Question() |
| 651 | try: | 693 | try: |
| 652 | - qinstance = qclass(QDict(q)) | ||
| 653 | - except QuestionException as e: | ||
| 654 | - # logger.error(e) | ||
| 655 | - raise e | 694 | + qinstance = qclass(QDict(question)) |
| 695 | + except QuestionException: | ||
| 696 | + logger.error('Error generating question "%s". See "%s/%s"', | ||
| 697 | + question['ref'], | ||
| 698 | + question['path'], | ||
| 699 | + question['filename']) | ||
| 700 | + raise | ||
| 656 | 701 | ||
| 657 | return qinstance | 702 | return qinstance |
| 658 | 703 | ||
| 659 | # ------------------------------------------------------------------------ | 704 | # ------------------------------------------------------------------------ |
| 660 | def generate(self) -> Question: | 705 | def generate(self) -> Question: |
| 706 | + '''generate question (synchronous version)''' | ||
| 661 | return asyncio.get_event_loop().run_until_complete(self.gen_async()) | 707 | return asyncio.get_event_loop().run_until_complete(self.gen_async()) |
aprendizations/serve.py
| @@ -88,7 +88,7 @@ class BaseHandler(tornado.web.RequestHandler): | @@ -88,7 +88,7 @@ class BaseHandler(tornado.web.RequestHandler): | ||
| 88 | 88 | ||
| 89 | def get_current_user(self): | 89 | def get_current_user(self): |
| 90 | '''called on every method decorated with @tornado.web.authenticated''' | 90 | '''called on every method decorated with @tornado.web.authenticated''' |
| 91 | - user_cookie = self.get_secure_cookie('user') | 91 | + user_cookie = self.get_secure_cookie('aprendizations_user') |
| 92 | if user_cookie is not None: | 92 | if user_cookie is not None: |
| 93 | uid = user_cookie.decode('utf-8') | 93 | uid = user_cookie.decode('utf-8') |
| 94 | counter = self.get_secure_cookie('counter').decode('utf-8') | 94 | counter = self.get_secure_cookie('counter').decode('utf-8') |
| @@ -148,7 +148,7 @@ class LoginHandler(BaseHandler): | @@ -148,7 +148,7 @@ class LoginHandler(BaseHandler): | ||
| 148 | 148 | ||
| 149 | if login_ok: | 149 | if login_ok: |
| 150 | counter = str(self.learn.get_login_counter(userid)) | 150 | counter = str(self.learn.get_login_counter(userid)) |
| 151 | - self.set_secure_cookie('user', userid) | 151 | + self.set_secure_cookie('aprendizations_user', userid) |
| 152 | self.set_secure_cookie('counter', counter) | 152 | self.set_secure_cookie('counter', counter) |
| 153 | self.redirect('/') | 153 | self.redirect('/') |
| 154 | else: | 154 | else: |
| @@ -221,6 +221,9 @@ class CoursesHandler(BaseHandler): | @@ -221,6 +221,9 @@ class CoursesHandler(BaseHandler): | ||
| 221 | ''' | 221 | ''' |
| 222 | Handles /courses | 222 | Handles /courses |
| 223 | ''' | 223 | ''' |
| 224 | + def set_default_headers(self, *args, **kwargs): | ||
| 225 | + self.set_header('Cache-Control', 'no-cache') | ||
| 226 | + | ||
| 224 | @tornado.web.authenticated | 227 | @tornado.web.authenticated |
| 225 | def get(self): | 228 | def get(self): |
| 226 | '''Renders list of available courses''' | 229 | '''Renders list of available courses''' |
| @@ -270,6 +273,9 @@ class TopicHandler(BaseHandler): | @@ -270,6 +273,9 @@ class TopicHandler(BaseHandler): | ||
| 270 | ''' | 273 | ''' |
| 271 | Handles a topic | 274 | Handles a topic |
| 272 | ''' | 275 | ''' |
| 276 | + def set_default_headers(self, *args, **kwargs): | ||
| 277 | + self.set_header('Cache-Control', 'no-cache') | ||
| 278 | + | ||
| 273 | @tornado.web.authenticated | 279 | @tornado.web.authenticated |
| 274 | async def get(self, topic): | 280 | async def get(self, topic): |
| 275 | ''' | 281 | ''' |
aprendizations/student.py
| 1 | 1 | ||
| 2 | +''' | ||
| 3 | +Implementation of the StudentState class. | ||
| 4 | +Each object of this class will contain the state of a student while logged in. | ||
| 5 | +Manages things like current course, topic, question, etc, and defines the | ||
| 6 | +logic of the application in what it applies to a single student. | ||
| 7 | +''' | ||
| 8 | + | ||
| 2 | # python standard library | 9 | # python standard library |
| 3 | from datetime import datetime | 10 | from datetime import datetime |
| 4 | import logging | 11 | import logging |
| 5 | import random | 12 | import random |
| 6 | -from typing import List, Optional, Tuple | 13 | +from typing import List, Optional |
| 7 | 14 | ||
| 8 | # third party libraries | 15 | # third party libraries |
| 9 | import networkx as nx | 16 | import networkx as nx |
| 10 | 17 | ||
| 11 | # this project | 18 | # this project |
| 12 | -from .questions import Question | 19 | +from aprendizations.questions import Question |
| 13 | 20 | ||
| 14 | 21 | ||
| 15 | # setup logger for this module | 22 | # setup logger for this module |
| 16 | logger = logging.getLogger(__name__) | 23 | logger = logging.getLogger(__name__) |
| 17 | 24 | ||
| 18 | 25 | ||
| 19 | -# ---------------------------------------------------------------------------- | ||
| 20 | -# kowledge state of a student: | ||
| 21 | -# uid - string with userid, e.g. '12345' | ||
| 22 | -# state - dict of unlocked topics and their levels | ||
| 23 | -# {'topic1': {'level': 0.5, 'date': datetime}, ...} | ||
| 24 | -# topic_sequence - recommended topic sequence ['topic1', 'topic2', ...] | ||
| 25 | -# questions - [Question, ...] for the current topic | ||
| 26 | -# current_course - string or None | ||
| 27 | -# current_topic - string or None | ||
| 28 | -# current_question - Question or None | ||
| 29 | -# | ||
| 30 | -# | ||
| 31 | -# also has access to shared data between students: | ||
| 32 | -# courses - dictionary {course: [topic1, ...]} | ||
| 33 | -# deps - dependency graph as a networkx digraph | ||
| 34 | -# factory - dictionary {ref: QFactory} | ||
| 35 | -# ---------------------------------------------------------------------------- | ||
| 36 | -class StudentState(object): | ||
| 37 | - # ======================================================================= | 26 | +# ============================================================================ |
| 27 | +class StudentState(): | ||
| 28 | + ''' | ||
| 29 | + kowledge state of a student: | ||
| 30 | + uid - string with userid, e.g. '12345' | ||
| 31 | + state - dict of unlocked topics and their levels | ||
| 32 | + {'topic1': {'level': 0.5, 'date': datetime}, ...} | ||
| 33 | + topic_sequence - recommended topic sequence ['topic1', 'topic2', ...] | ||
| 34 | + questions - [Question, ...] for the current topic | ||
| 35 | + current_course - string or None | ||
| 36 | + current_topic - string or None | ||
| 37 | + current_question - Question or None | ||
| 38 | + also has access to shared data between students: | ||
| 39 | + courses - dictionary {course: [topic1, ...]} | ||
| 40 | + deps - dependency graph as a networkx digraph | ||
| 41 | + factory - dictionary {ref: QFactory} | ||
| 42 | + ''' | ||
| 43 | + | ||
| 44 | + # ======================================================================== | ||
| 38 | # methods that update state | 45 | # methods that update state |
| 39 | - # ======================================================================= | 46 | + # ======================================================================== |
| 40 | def __init__(self, uid, state, courses, deps, factory) -> None: | 47 | def __init__(self, uid, state, courses, deps, factory) -> None: |
| 41 | # shared application data between all students | 48 | # shared application data between all students |
| 42 | self.deps = deps # dependency graph | 49 | self.deps = deps # dependency graph |
| @@ -54,6 +61,10 @@ class StudentState(object): | @@ -54,6 +61,10 @@ class StudentState(object): | ||
| 54 | 61 | ||
| 55 | # ------------------------------------------------------------------------ | 62 | # ------------------------------------------------------------------------ |
| 56 | def start_course(self, course: Optional[str]) -> None: | 63 | def start_course(self, course: Optional[str]) -> None: |
| 64 | + ''' | ||
| 65 | + Tries to start a course. | ||
| 66 | + Finds the recommended sequence of topics for the student. | ||
| 67 | + ''' | ||
| 57 | if course is None: | 68 | if course is None: |
| 58 | logger.debug('no active course') | 69 | logger.debug('no active course') |
| 59 | self.current_course: Optional[str] = None | 70 | self.current_course: Optional[str] = None |
| @@ -63,19 +74,21 @@ class StudentState(object): | @@ -63,19 +74,21 @@ class StudentState(object): | ||
| 63 | try: | 74 | try: |
| 64 | topics = self.courses[course]['goals'] | 75 | topics = self.courses[course]['goals'] |
| 65 | except KeyError: | 76 | except KeyError: |
| 66 | - logger.debug(f'course "{course}" does not exist') | 77 | + logger.debug('course "%s" does not exist', course) |
| 67 | raise | 78 | raise |
| 68 | - logger.debug(f'starting course "{course}"') | 79 | + logger.debug('starting course "%s"', course) |
| 69 | self.current_course = course | 80 | self.current_course = course |
| 70 | - self.topic_sequence = self.recommend_sequence(topics) | 81 | + self.topic_sequence = self._recommend_sequence(topics) |
| 71 | 82 | ||
| 72 | # ------------------------------------------------------------------------ | 83 | # ------------------------------------------------------------------------ |
| 73 | - # Start a new topic. | ||
| 74 | - # questions: list of generated questions to do in the given topic | ||
| 75 | - # current_question: the current question to be presented | ||
| 76 | - # ------------------------------------------------------------------------ | ||
| 77 | async def start_topic(self, topic: str) -> None: | 84 | async def start_topic(self, topic: str) -> None: |
| 78 | - logger.debug(f'start topic "{topic}"') | 85 | + ''' |
| 86 | + Start a new topic. | ||
| 87 | + questions: list of generated questions to do in the given topic | ||
| 88 | + current_question: the current question to be presented | ||
| 89 | + ''' | ||
| 90 | + | ||
| 91 | + logger.debug('start topic "%s"', topic) | ||
| 79 | 92 | ||
| 80 | # avoid regenerating questions in the middle of the current topic | 93 | # avoid regenerating questions in the middle of the current topic |
| 81 | if self.current_topic == topic and self.uid != '0': | 94 | if self.current_topic == topic and self.uid != '0': |
| @@ -84,7 +97,7 @@ class StudentState(object): | @@ -84,7 +97,7 @@ class StudentState(object): | ||
| 84 | 97 | ||
| 85 | # do not allow locked topics | 98 | # do not allow locked topics |
| 86 | if self.is_locked(topic) and self.uid != '0': | 99 | if self.is_locked(topic) and self.uid != '0': |
| 87 | - logger.debug(f'is locked "{topic}"') | 100 | + logger.debug('is locked "%s"', topic) |
| 88 | return | 101 | return |
| 89 | 102 | ||
| 90 | self.previous_topic: Optional[str] = None | 103 | self.previous_topic: Optional[str] = None |
| @@ -93,95 +106,104 @@ class StudentState(object): | @@ -93,95 +106,104 @@ class StudentState(object): | ||
| 93 | self.current_topic = topic | 106 | self.current_topic = topic |
| 94 | self.correct_answers = 0 | 107 | self.correct_answers = 0 |
| 95 | self.wrong_answers = 0 | 108 | self.wrong_answers = 0 |
| 96 | - t = self.deps.nodes[topic] | ||
| 97 | - k = t['choose'] | ||
| 98 | - if t['shuffle_questions']: | ||
| 99 | - questions = random.sample(t['questions'], k=k) | 109 | + topic = self.deps.nodes[topic] |
| 110 | + k = topic['choose'] | ||
| 111 | + if topic['shuffle_questions']: | ||
| 112 | + questions = random.sample(topic['questions'], k=k) | ||
| 100 | else: | 113 | else: |
| 101 | - questions = t['questions'][:k] | ||
| 102 | - logger.debug(f'selected questions: {", ".join(questions)}') | 114 | + questions = topic['questions'][:k] |
| 115 | + logger.debug('selected questions: %s', ', '.join(questions)) | ||
| 103 | 116 | ||
| 104 | self.questions: List[Question] = [await self.factory[ref].gen_async() | 117 | self.questions: List[Question] = [await self.factory[ref].gen_async() |
| 105 | for ref in questions] | 118 | for ref in questions] |
| 106 | 119 | ||
| 107 | - logger.debug(f'generated {len(self.questions)} questions') | 120 | + logger.debug('generated %s questions', len(self.questions)) |
| 108 | 121 | ||
| 109 | # get first question | 122 | # get first question |
| 110 | self.next_question() | 123 | self.next_question() |
| 111 | 124 | ||
| 112 | # ------------------------------------------------------------------------ | 125 | # ------------------------------------------------------------------------ |
| 113 | - # corrects current question | ||
| 114 | - # updates keys: answer, grade, finish_time, status, tries | ||
| 115 | - # ------------------------------------------------------------------------ | ||
| 116 | async def check_answer(self, answer) -> None: | 126 | async def check_answer(self, answer) -> None: |
| 117 | - q = self.current_question | ||
| 118 | - if q is None: | 127 | + ''' |
| 128 | + Corrects current question. | ||
| 129 | + Updates keys: `answer`, `grade`, `finish_time`, `status`, `tries` | ||
| 130 | + ''' | ||
| 131 | + | ||
| 132 | + question = self.current_question | ||
| 133 | + if question is None: | ||
| 119 | logger.error('check_answer called but current_question is None!') | 134 | logger.error('check_answer called but current_question is None!') |
| 120 | return None | 135 | return None |
| 121 | 136 | ||
| 122 | - q.set_answer(answer) | ||
| 123 | - await q.correct_async() # updates q['grade'] | 137 | + question.set_answer(answer) |
| 138 | + await question.correct_async() # updates q['grade'] | ||
| 124 | 139 | ||
| 125 | - if q['grade'] > 0.999: | 140 | + if question['grade'] > 0.999: |
| 126 | self.correct_answers += 1 | 141 | self.correct_answers += 1 |
| 127 | - q['status'] = 'right' | 142 | + question['status'] = 'right' |
| 128 | 143 | ||
| 129 | else: | 144 | else: |
| 130 | self.wrong_answers += 1 | 145 | self.wrong_answers += 1 |
| 131 | - q['tries'] -= 1 | ||
| 132 | - if q['tries'] > 0: | ||
| 133 | - q['status'] = 'try_again' | 146 | + question['tries'] -= 1 |
| 147 | + if question['tries'] > 0: | ||
| 148 | + question['status'] = 'try_again' | ||
| 134 | else: | 149 | else: |
| 135 | - q['status'] = 'wrong' | 150 | + question['status'] = 'wrong' |
| 136 | 151 | ||
| 137 | - logger.debug(f'ref = {q["ref"]}, status = {q["status"]}') | 152 | + logger.debug('ref = %s, status = %s', |
| 153 | + question["ref"], question["status"]) | ||
| 138 | 154 | ||
| 139 | # ------------------------------------------------------------------------ | 155 | # ------------------------------------------------------------------------ |
| 140 | - # gets next question to show if the status is 'right' or 'wrong', | ||
| 141 | - # otherwise just returns the current question | ||
| 142 | - # ------------------------------------------------------------------------ | ||
| 143 | async def get_question(self) -> Optional[Question]: | 156 | async def get_question(self) -> Optional[Question]: |
| 144 | - q = self.current_question | ||
| 145 | - if q is None: | 157 | + ''' |
| 158 | + Gets next question to show if the status is 'right' or 'wrong', | ||
| 159 | + otherwise just returns the current question. | ||
| 160 | + ''' | ||
| 161 | + | ||
| 162 | + question = self.current_question | ||
| 163 | + if question is None: | ||
| 146 | logger.error('get_question called but current_question is None!') | 164 | logger.error('get_question called but current_question is None!') |
| 147 | return None | 165 | return None |
| 148 | 166 | ||
| 149 | - logger.debug(f'{q["ref"]} status = {q["status"]}') | 167 | + logger.debug('%s status = %s', question["ref"], question["status"]) |
| 150 | 168 | ||
| 151 | - if q['status'] == 'right': | 169 | + if question['status'] == 'right': |
| 152 | self.next_question() | 170 | self.next_question() |
| 153 | - elif q['status'] == 'wrong': | ||
| 154 | - if q['append_wrong']: | 171 | + elif question['status'] == 'wrong': |
| 172 | + if question['append_wrong']: | ||
| 155 | logger.debug(' wrong answer => append new question') | 173 | logger.debug(' wrong answer => append new question') |
| 156 | - new_question = await self.factory[q['ref']].gen_async() | 174 | + new_question = await self.factory[question['ref']].gen_async() |
| 157 | self.questions.append(new_question) | 175 | self.questions.append(new_question) |
| 158 | self.next_question() | 176 | self.next_question() |
| 159 | 177 | ||
| 160 | return self.current_question | 178 | return self.current_question |
| 161 | 179 | ||
| 162 | # ------------------------------------------------------------------------ | 180 | # ------------------------------------------------------------------------ |
| 163 | - # moves to next question | ||
| 164 | - # ------------------------------------------------------------------------ | ||
| 165 | def next_question(self) -> None: | 181 | def next_question(self) -> None: |
| 182 | + ''' | ||
| 183 | + Moves to next question | ||
| 184 | + ''' | ||
| 185 | + | ||
| 166 | try: | 186 | try: |
| 167 | - q = self.questions.pop(0) | 187 | + question = self.questions.pop(0) |
| 168 | except IndexError: | 188 | except IndexError: |
| 169 | self.finish_topic() | 189 | self.finish_topic() |
| 170 | return | 190 | return |
| 171 | 191 | ||
| 172 | - t = self.deps.nodes[self.current_topic] | ||
| 173 | - q['start_time'] = datetime.now() | ||
| 174 | - q['tries'] = q.get('max_tries', t['max_tries']) | ||
| 175 | - q['status'] = 'new' | ||
| 176 | - self.current_question: Optional[Question] = q | 192 | + topic = self.deps.nodes[self.current_topic] |
| 193 | + question['start_time'] = datetime.now() | ||
| 194 | + question['tries'] = question.get('max_tries', topic['max_tries']) | ||
| 195 | + question['status'] = 'new' | ||
| 196 | + self.current_question: Optional[Question] = question | ||
| 177 | 197 | ||
| 178 | # ------------------------------------------------------------------------ | 198 | # ------------------------------------------------------------------------ |
| 179 | - # The topic has finished and there are no more questions. | ||
| 180 | - # The topic level is updated in state and unlocks are performed. | ||
| 181 | - # The current topic is unchanged. | ||
| 182 | - # ------------------------------------------------------------------------ | ||
| 183 | def finish_topic(self) -> None: | 199 | def finish_topic(self) -> None: |
| 184 | - logger.debug(f'finished {self.current_topic} in {self.current_course}') | 200 | + ''' |
| 201 | + The topic has finished and there are no more questions. | ||
| 202 | + The topic level is updated in state and unlocks are performed. | ||
| 203 | + The current topic is unchanged. | ||
| 204 | + ''' | ||
| 205 | + | ||
| 206 | + logger.debug('finished %s in %s', self.current_topic, self.current_course) | ||
| 185 | 207 | ||
| 186 | self.state[self.current_topic] = { | 208 | self.state[self.current_topic] = { |
| 187 | 'date': datetime.now(), | 209 | 'date': datetime.now(), |
| @@ -194,22 +216,25 @@ class StudentState(object): | @@ -194,22 +216,25 @@ class StudentState(object): | ||
| 194 | self.unlock_topics() | 216 | self.unlock_topics() |
| 195 | 217 | ||
| 196 | # ------------------------------------------------------------------------ | 218 | # ------------------------------------------------------------------------ |
| 197 | - # Update proficiency level of the topics using a forgetting factor | ||
| 198 | - # ------------------------------------------------------------------------ | ||
| 199 | def update_topic_levels(self) -> None: | 219 | def update_topic_levels(self) -> None: |
| 220 | + ''' | ||
| 221 | + Update proficiency level of the topics using a forgetting factor | ||
| 222 | + ''' | ||
| 223 | + | ||
| 200 | now = datetime.now() | 224 | now = datetime.now() |
| 201 | - for tref, s in self.state.items(): | ||
| 202 | - dt = now - s['date'] | 225 | + for tref, state in self.state.items(): |
| 226 | + elapsed = now - state['date'] | ||
| 203 | try: | 227 | try: |
| 204 | forgetting_factor = self.deps.nodes[tref]['forgetting_factor'] | 228 | forgetting_factor = self.deps.nodes[tref]['forgetting_factor'] |
| 205 | - s['level'] *= forgetting_factor ** dt.days # forgetting factor | 229 | + state['level'] *= forgetting_factor ** elapsed.days |
| 206 | except KeyError: | 230 | except KeyError: |
| 207 | - logger.warning(f'Update topic levels: {tref} not in the graph') | 231 | + logger.warning('Update topic levels: %s not in the graph', tref) |
| 208 | 232 | ||
| 209 | # ------------------------------------------------------------------------ | 233 | # ------------------------------------------------------------------------ |
| 210 | - # Unlock topics whose dependencies are satisfied (> min_level) | ||
| 211 | - # ------------------------------------------------------------------------ | ||
| 212 | def unlock_topics(self) -> None: | 234 | def unlock_topics(self) -> None: |
| 235 | + ''' | ||
| 236 | + Unlock topics whose dependencies are satisfied (> min_level) | ||
| 237 | + ''' | ||
| 213 | for topic in self.deps.nodes(): | 238 | for topic in self.deps.nodes(): |
| 214 | if topic not in self.state: # if locked | 239 | if topic not in self.state: # if locked |
| 215 | pred = self.deps.predecessors(topic) | 240 | pred = self.deps.predecessors(topic) |
| @@ -221,7 +246,7 @@ class StudentState(object): | @@ -221,7 +246,7 @@ class StudentState(object): | ||
| 221 | 'level': 0.0, # unlock | 246 | 'level': 0.0, # unlock |
| 222 | 'date': datetime.now() | 247 | 'date': datetime.now() |
| 223 | } | 248 | } |
| 224 | - logger.debug(f'unlocked "{topic}"') | 249 | + logger.debug('unlocked "%s"', topic) |
| 225 | # else: # lock this topic if deps do not satisfy min_level | 250 | # else: # lock this topic if deps do not satisfy min_level |
| 226 | # del self.state[topic] | 251 | # del self.state[topic] |
| 227 | 252 | ||
| @@ -230,64 +255,78 @@ class StudentState(object): | @@ -230,64 +255,78 @@ class StudentState(object): | ||
| 230 | # ======================================================================== | 255 | # ======================================================================== |
| 231 | 256 | ||
| 232 | def topic_has_finished(self) -> bool: | 257 | def topic_has_finished(self) -> bool: |
| 258 | + ''' | ||
| 259 | + Checks if the all the questions in the current topic have been | ||
| 260 | + answered. | ||
| 261 | + ''' | ||
| 233 | return self.current_topic is None and self.previous_topic is not None | 262 | return self.current_topic is None and self.previous_topic is not None |
| 234 | 263 | ||
| 235 | # ------------------------------------------------------------------------ | 264 | # ------------------------------------------------------------------------ |
| 236 | - # compute recommended sequence of topics ['a', 'b', ...] | ||
| 237 | - # ------------------------------------------------------------------------ | ||
| 238 | - def recommend_sequence(self, goals: List[str] = []) -> List[str]: | ||
| 239 | - G = self.deps | ||
| 240 | - ts = set(goals) | ||
| 241 | - for t in goals: | ||
| 242 | - ts.update(nx.ancestors(G, t)) # include dependencies not in goals | 265 | + def _recommend_sequence(self, goals: List[str]) -> List[str]: |
| 266 | + ''' | ||
| 267 | + compute recommended sequence of topics ['a', 'b', ...] | ||
| 268 | + ''' | ||
| 269 | + | ||
| 270 | + topics = set(goals) | ||
| 271 | + # include dependencies not in goals | ||
| 272 | + for topic in goals: | ||
| 273 | + topics.update(nx.ancestors(self.deps, topic)) | ||
| 243 | 274 | ||
| 244 | todo = [] | 275 | todo = [] |
| 245 | - for t in ts: | ||
| 246 | - level = self.state[t]['level'] if t in self.state else 0.0 | ||
| 247 | - min_level = G.nodes[t]['min_level'] | ||
| 248 | - if t in goals or level < min_level: | ||
| 249 | - todo.append(t) | 276 | + for topic in topics: |
| 277 | + level = self.state[topic]['level'] if topic in self.state else 0.0 | ||
| 278 | + min_level = self.deps.nodes[topic]['min_level'] | ||
| 279 | + if topic in goals or level < min_level: | ||
| 280 | + todo.append(topic) | ||
| 250 | 281 | ||
| 251 | - logger.debug(f' {len(ts)} total topics, {len(todo)} listed ') | 282 | + logger.debug(' %s total topics, %s listed ', len(topics), len(todo)) |
| 252 | 283 | ||
| 253 | # FIXME topological sort is a poor way to sort topics | 284 | # FIXME topological sort is a poor way to sort topics |
| 254 | - tl = list(nx.topological_sort(G.subgraph(todo))) | 285 | + topic_seq = list(nx.topological_sort(self.deps.subgraph(todo))) |
| 255 | 286 | ||
| 256 | # sort with unlocked first | 287 | # sort with unlocked first |
| 257 | - unlocked = [t for t in tl if t in self.state] | ||
| 258 | - locked = [t for t in tl if t not in unlocked] | 288 | + unlocked = [t for t in topic_seq if t in self.state] |
| 289 | + locked = [t for t in topic_seq if t not in unlocked] | ||
| 259 | return unlocked + locked | 290 | return unlocked + locked |
| 260 | 291 | ||
| 261 | # ------------------------------------------------------------------------ | 292 | # ------------------------------------------------------------------------ |
| 262 | def get_current_question(self) -> Optional[Question]: | 293 | def get_current_question(self) -> Optional[Question]: |
| 294 | + '''gets current question''' | ||
| 263 | return self.current_question | 295 | return self.current_question |
| 264 | 296 | ||
| 265 | # ------------------------------------------------------------------------ | 297 | # ------------------------------------------------------------------------ |
| 266 | def get_current_topic(self) -> Optional[str]: | 298 | def get_current_topic(self) -> Optional[str]: |
| 299 | + '''gets current topic''' | ||
| 267 | return self.current_topic | 300 | return self.current_topic |
| 268 | 301 | ||
| 269 | # ------------------------------------------------------------------------ | 302 | # ------------------------------------------------------------------------ |
| 270 | def get_previous_topic(self) -> Optional[str]: | 303 | def get_previous_topic(self) -> Optional[str]: |
| 304 | + '''gets previous topic''' | ||
| 271 | return self.previous_topic | 305 | return self.previous_topic |
| 272 | 306 | ||
| 273 | # ------------------------------------------------------------------------ | 307 | # ------------------------------------------------------------------------ |
| 274 | def get_current_course_title(self) -> str: | 308 | def get_current_course_title(self) -> str: |
| 309 | + '''gets current course title''' | ||
| 275 | return str(self.courses[self.current_course]['title']) | 310 | return str(self.courses[self.current_course]['title']) |
| 276 | 311 | ||
| 277 | # ------------------------------------------------------------------------ | 312 | # ------------------------------------------------------------------------ |
| 278 | def get_current_course_id(self) -> Optional[str]: | 313 | def get_current_course_id(self) -> Optional[str]: |
| 314 | + '''gets current course id''' | ||
| 279 | return self.current_course | 315 | return self.current_course |
| 280 | 316 | ||
| 281 | # ------------------------------------------------------------------------ | 317 | # ------------------------------------------------------------------------ |
| 282 | def is_locked(self, topic: str) -> bool: | 318 | def is_locked(self, topic: str) -> bool: |
| 319 | + '''checks if a given topic is locked''' | ||
| 283 | return topic not in self.state | 320 | return topic not in self.state |
| 284 | 321 | ||
| 285 | # ------------------------------------------------------------------------ | 322 | # ------------------------------------------------------------------------ |
| 286 | - # Return list of {ref: 'xpto', name: 'long name', leve: 0.5} | ||
| 287 | - # Levels are in the interval [0, 1] if unlocked or None if locked. | ||
| 288 | - # Topics unlocked but not yet done have level 0.0. | ||
| 289 | - # ------------------------------------------------------------------------ | ||
| 290 | def get_knowledge_state(self): | 323 | def get_knowledge_state(self): |
| 324 | + ''' | ||
| 325 | + Return list of {ref: 'xpto', name: 'long name', leve: 0.5} | ||
| 326 | + Levels are in the interval [0, 1] if unlocked or None if locked. | ||
| 327 | + Topics unlocked but not yet done have level 0.0. | ||
| 328 | + ''' | ||
| 329 | + | ||
| 291 | return [{ | 330 | return [{ |
| 292 | 'ref': ref, | 331 | 'ref': ref, |
| 293 | 'type': self.deps.nodes[ref]['type'], | 332 | 'type': self.deps.nodes[ref]['type'], |
| @@ -297,19 +336,16 @@ class StudentState(object): | @@ -297,19 +336,16 @@ class StudentState(object): | ||
| 297 | 336 | ||
| 298 | # ------------------------------------------------------------------------ | 337 | # ------------------------------------------------------------------------ |
| 299 | def get_topic_progress(self) -> float: | 338 | def get_topic_progress(self) -> float: |
| 339 | + '''computes progress of the current topic''' | ||
| 300 | return self.correct_answers / (1 + self.correct_answers + | 340 | return self.correct_answers / (1 + self.correct_answers + |
| 301 | len(self.questions)) | 341 | len(self.questions)) |
| 302 | 342 | ||
| 303 | # ------------------------------------------------------------------------ | 343 | # ------------------------------------------------------------------------ |
| 304 | def get_topic_level(self, topic: str) -> float: | 344 | def get_topic_level(self, topic: str) -> float: |
| 345 | + '''gets level of a given topic''' | ||
| 305 | return float(self.state[topic]['level']) | 346 | return float(self.state[topic]['level']) |
| 306 | 347 | ||
| 307 | # ------------------------------------------------------------------------ | 348 | # ------------------------------------------------------------------------ |
| 308 | def get_topic_date(self, topic: str): | 349 | def get_topic_date(self, topic: str): |
| 350 | + '''gets date of a given topic''' | ||
| 309 | return self.state[topic]['date'] | 351 | return self.state[topic]['date'] |
| 310 | - | ||
| 311 | - # ------------------------------------------------------------------------ | ||
| 312 | - # Recommends a topic to practice/learn from the state. | ||
| 313 | - # ------------------------------------------------------------------------ | ||
| 314 | - # def get_recommended_topic(self): # FIXME untested | ||
| 315 | - # return min(self.state.items(), key=lambda x: x[1]['level'])[0] |
aprendizations/templates/courses.html
| @@ -4,28 +4,28 @@ | @@ -4,28 +4,28 @@ | ||
| 4 | 4 | ||
| 5 | <head> | 5 | <head> |
| 6 | <title>{{appname}}</title> | 6 | <title>{{appname}}</title> |
| 7 | - <link rel="icon" href="/static/favicon.ico"> | 7 | + <link rel="icon" href="favicon.ico"> |
| 8 | <meta charset="utf-8"> | 8 | <meta charset="utf-8"> |
| 9 | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | 9 | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> |
| 10 | <meta name="author" content="Miguel Barão"> | 10 | <meta name="author" content="Miguel Barão"> |
| 11 | <!-- Styles --> | 11 | <!-- Styles --> |
| 12 | - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css"> | ||
| 13 | - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css"> | ||
| 14 | - <link rel="stylesheet" href="/static/css/maintopics.css"> | ||
| 15 | - <link rel="stylesheet" href="/static/css/sticky-footer-navbar.css"> | 12 | + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}"> |
| 13 | + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}"> | ||
| 14 | + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}"> | ||
| 15 | + <link rel="stylesheet" href="{{static_url('css/sticky-footer-navbar.css')}}"> | ||
| 16 | <!-- Scripts --> | 16 | <!-- Scripts --> |
| 17 | - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script> | ||
| 18 | - <script defer src="/static/mdbootstrap/js/popper.min.js"></script> | ||
| 19 | - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script> | ||
| 20 | - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script> | ||
| 21 | - <script defer src="/static/fontawesome-free/js/all.min.js"></script> | ||
| 22 | - <script defer src="/static/js/maintopics.js"></script> | 17 | + <script defer src="{{static_url('mdbootstrap/js/jquery.min.js')}}"></script> |
| 18 | + <script defer src="{{static_url('mdbootstrap/js/popper.min.js')}}"></script> | ||
| 19 | + <script defer src="{{static_url('mdbootstrap/js/bootstrap.min.js')}}"></script> | ||
| 20 | + <script defer src="{{static_url('mdbootstrap/js/mdb.min.js')}}"></script> | ||
| 21 | + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script> | ||
| 22 | + <script defer src="{{static_url('js/maintopics.js')}}"></script> | ||
| 23 | </head> | 23 | </head> |
| 24 | 24 | ||
| 25 | <body> | 25 | <body> |
| 26 | <!-- ===== navbar ==================================================== --> | 26 | <!-- ===== navbar ==================================================== --> |
| 27 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> | 27 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> |
| 28 | - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora"> | 28 | + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora"> |
| 29 | <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> | 29 | <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> |
| 30 | <span class="navbar-toggler-icon"></span> | 30 | <span class="navbar-toggler-icon"></span> |
| 31 | </button> | 31 | </button> |
aprendizations/templates/login.html
| @@ -9,15 +9,15 @@ | @@ -9,15 +9,15 @@ | ||
| 9 | <meta name="author" content="Miguel Barão"> | 9 | <meta name="author" content="Miguel Barão"> |
| 10 | 10 | ||
| 11 | <!-- Styles --> | 11 | <!-- Styles --> |
| 12 | - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css"> | ||
| 13 | - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css"> | 12 | + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}"> |
| 13 | + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}"> | ||
| 14 | 14 | ||
| 15 | <!-- Scripts --> | 15 | <!-- Scripts --> |
| 16 | - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script> | ||
| 17 | - <script defer src="/static/mdbootstrap/js/popper.min.js"></script> | ||
| 18 | - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script> | ||
| 19 | - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script> | ||
| 20 | - <script defer src="/static/fontawesome-free/js/all.min.js"></script> | 16 | + <script defer src="{{static_url('mdbootstrap/js/jquery.min.js')}}"></script> |
| 17 | + <script defer src="{{static_url('mdbootstrap/js/popper.min.js')}}"></script> | ||
| 18 | + <script defer src="{{static_url('mdbootstrap/js/bootstrap.min.js')}}"></script> | ||
| 19 | + <script defer src="{{static_url('mdbootstrap/js/mdb.min.js')}}"></script> | ||
| 20 | + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script> | ||
| 21 | 21 | ||
| 22 | </head> | 22 | </head> |
| 23 | <!-- =================================================================== --> | 23 | <!-- =================================================================== --> |
| @@ -28,7 +28,7 @@ | @@ -28,7 +28,7 @@ | ||
| 28 | <div class="row"> | 28 | <div class="row"> |
| 29 | 29 | ||
| 30 | <div class="col-sm-9"> | 30 | <div class="col-sm-9"> |
| 31 | - <img src="/static/logo_horizontal_login.png" class="img-responsive mb-3" width="50%" alt="Universidade de Évora"> | 31 | + <img src="{{static_url('logo_horizontal_login.png') }}" class="img-responsive mb-3" width="50%" alt="Universidade de Évora"> |
| 32 | </div> | 32 | </div> |
| 33 | 33 | ||
| 34 | <div class="col-sm-3"> | 34 | <div class="col-sm-3"> |
aprendizations/templates/maintopics-table.html
| @@ -11,23 +11,23 @@ | @@ -11,23 +11,23 @@ | ||
| 11 | <meta name="author" content="Miguel Barão"> | 11 | <meta name="author" content="Miguel Barão"> |
| 12 | 12 | ||
| 13 | <!-- Styles --> | 13 | <!-- Styles --> |
| 14 | - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css"> | ||
| 15 | - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css"> | ||
| 16 | - <link rel="stylesheet" href="/static/css/maintopics.css"> | 14 | + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}"> |
| 15 | + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}"> | ||
| 16 | + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}"> | ||
| 17 | 17 | ||
| 18 | <!-- Scripts --> | 18 | <!-- Scripts --> |
| 19 | - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script> | ||
| 20 | - <script defer src="/static/mdbootstrap/js/popper.min.js"></script> | ||
| 21 | - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script> | ||
| 22 | - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script> | ||
| 23 | - <script defer src="/static/fontawesome-free/js/all.min.js"></script> | ||
| 24 | - <script defer src="/static/js/maintopics.js"></script> | 19 | + <script defer src="{{static_url('mdbootstrap/js/jquery.min.js')}}"></script> |
| 20 | + <script defer src="{{static_url('mdbootstrap/js/popper.min.js')}}"></script> | ||
| 21 | + <script defer src="{{static_url('mdbootstrap/js/bootstrap.min.js')}}"></script> | ||
| 22 | + <script defer src="{{static_url('mdbootstrap/js/mdb.min.js')}}"></script> | ||
| 23 | + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script> | ||
| 24 | + <script defer src="{{static_url('js/maintopics.js')}}"></script> | ||
| 25 | 25 | ||
| 26 | </head> | 26 | </head> |
| 27 | <!-- ===================================================================== --> | 27 | <!-- ===================================================================== --> |
| 28 | <body> | 28 | <body> |
| 29 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> | 29 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> |
| 30 | - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora"> | 30 | + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora"> |
| 31 | <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> | 31 | <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> |
| 32 | <span class="navbar-toggler-icon"></span> | 32 | <span class="navbar-toggler-icon"></span> |
| 33 | </button> | 33 | </button> |
aprendizations/templates/rankings.html
| @@ -11,23 +11,23 @@ | @@ -11,23 +11,23 @@ | ||
| 11 | <meta name="author" content="Miguel Barão"> | 11 | <meta name="author" content="Miguel Barão"> |
| 12 | 12 | ||
| 13 | <!-- Styles --> | 13 | <!-- Styles --> |
| 14 | - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css"> | ||
| 15 | - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css"> | ||
| 16 | - <link rel="stylesheet" href="/static/css/maintopics.css"> | 14 | + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}"> |
| 15 | + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}"> | ||
| 16 | + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}"> | ||
| 17 | 17 | ||
| 18 | <!-- Scripts --> | 18 | <!-- Scripts --> |
| 19 | - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script> | ||
| 20 | - <script defer src="/static/mdbootstrap/js/popper.min.js"></script> | ||
| 21 | - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script> | ||
| 22 | - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script> | ||
| 23 | - <script defer src="/static/fontawesome-free/js/all.min.js"></script> | ||
| 24 | - <script defer src="/static/js/maintopics.js"></script> | 19 | + <script defer src="{{static_url('mdbootstrap/js/jquery.min.js')}}"></script> |
| 20 | + <script defer src="{{static_url('mdbootstrap/js/popper.min.js')}}"></script> | ||
| 21 | + <script defer src="{{static_url('mdbootstrap/js/bootstrap.min.js')}}"></script> | ||
| 22 | + <script defer src="{{static_url('mdbootstrap/js/mdb.min.js')}}"></script> | ||
| 23 | + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script> | ||
| 24 | + <script defer src="{{static_url('js/maintopics.js')}}"></script> | ||
| 25 | 25 | ||
| 26 | </head> | 26 | </head> |
| 27 | <!-- ===================================================================== --> | 27 | <!-- ===================================================================== --> |
| 28 | <body> | 28 | <body> |
| 29 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> | 29 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> |
| 30 | - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora"> | 30 | + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora"> |
| 31 | <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> | 31 | <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> |
| 32 | <span class="navbar-toggler-icon"></span> | 32 | <span class="navbar-toggler-icon"></span> |
| 33 | </button> | 33 | </button> |
aprendizations/templates/topic.html
| @@ -21,21 +21,21 @@ | @@ -21,21 +21,21 @@ | ||
| 21 | <!-- Scripts --> | 21 | <!-- Scripts --> |
| 22 | <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script> | 22 | <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script> |
| 23 | <!-- <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg-full.js"></script> --> | 23 | <!-- <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg-full.js"></script> --> |
| 24 | - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script> | ||
| 25 | - <script defer src="/static/mdbootstrap/js/popper.min.js"></script> | ||
| 26 | - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script> | ||
| 27 | - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script> | ||
| 28 | - <script defer src="/static/fontawesome-free/js/all.min.js"></script> | ||
| 29 | - <script defer src="/static/codemirror/lib/codemirror.js"></script> | ||
| 30 | - <script defer src="/static/js/topic.js"></script> | 24 | + <script defer src="{{static_url('mdbootstrap/js/jquery.min.js')}}"></script> |
| 25 | + <script defer src="{{static_url('mdbootstrap/js/popper.min.js')}}"></script> | ||
| 26 | + <script defer src="{{static_url('mdbootstrap/js/bootstrap.min.js')}}"></script> | ||
| 27 | + <script defer src="{{static_url('mdbootstrap/js/mdb.min.js')}}"></script> | ||
| 28 | + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script> | ||
| 29 | + <script defer src="{{static_url('codemirror/lib/codemirror.js')}}"></script> | ||
| 30 | + <script defer src="{{static_url('js/topic.js')}}"></script> | ||
| 31 | 31 | ||
| 32 | <!-- Styles --> | 32 | <!-- Styles --> |
| 33 | - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css"> | ||
| 34 | - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css"> | ||
| 35 | - <link rel="stylesheet" href="/static/codemirror/lib/codemirror.css"> | ||
| 36 | - <link rel="stylesheet" href="/static/css/animate.min.css"> | ||
| 37 | - <link rel="stylesheet" href="/static/css/github.css"> | ||
| 38 | - <link rel="stylesheet" href="/static/css/topic.css"> | 33 | + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}"> |
| 34 | + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}"> | ||
| 35 | + <link rel="stylesheet" href="{{static_url('codemirror/lib/codemirror.css')}}"> | ||
| 36 | + <link rel="stylesheet" href="{{static_url('css/animate.min.css')}}"> | ||
| 37 | + <link rel="stylesheet" href="{{static_url('css/github.css')}}"> | ||
| 38 | + <link rel="stylesheet" href="{{static_url('css/topic.css')}}"> | ||
| 39 | </head> | 39 | </head> |
| 40 | <!-- ===================================================================== --> | 40 | <!-- ===================================================================== --> |
| 41 | 41 | ||
| @@ -47,7 +47,7 @@ | @@ -47,7 +47,7 @@ | ||
| 47 | 47 | ||
| 48 | <!-- Navbar --> | 48 | <!-- Navbar --> |
| 49 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> | 49 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> |
| 50 | - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora"> | 50 | + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora"> |
| 51 | <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> | 51 | <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> |
| 52 | <span class="navbar-toggler-icon"></span> | 52 | <span class="navbar-toggler-icon"></span> |
| 53 | </button> | 53 | </button> |
package-lock.json
| @@ -3,19 +3,19 @@ | @@ -3,19 +3,19 @@ | ||
| 3 | "lockfileVersion": 1, | 3 | "lockfileVersion": 1, |
| 4 | "dependencies": { | 4 | "dependencies": { |
| 5 | "@fortawesome/fontawesome-free": { | 5 | "@fortawesome/fontawesome-free": { |
| 6 | - "version": "5.12.1", | ||
| 7 | - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.12.1.tgz", | ||
| 8 | - "integrity": "sha512-ZtjIIFplxncqxvogq148C3hBLQE+W3iJ8E4UvJ09zIJUgzwLcROsWwFDErVSXY2Plzao5J9KUYNHKHMEUYDMKw==" | 6 | + "version": "5.15.1", |
| 7 | + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.1.tgz", | ||
| 8 | + "integrity": "sha512-OEdH7SyC1suTdhBGW91/zBfR6qaIhThbcN8PUXtXilY4GYnSBbVqOntdHbC1vXwsDnX0Qix2m2+DSU1J51ybOQ==" | ||
| 9 | }, | 9 | }, |
| 10 | "codemirror": { | 10 | "codemirror": { |
| 11 | - "version": "5.52.0", | ||
| 12 | - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.52.0.tgz", | ||
| 13 | - "integrity": "sha512-K2UB6zjscrfME03HeRe/IuOmCeqNpw7PLKGHThYpLbZEuKf+ZoujJPhxZN4hHJS1O7QyzEsV7JJZGxuQWVaFCg==" | 11 | + "version": "5.58.2", |
| 12 | + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.58.2.tgz", | ||
| 13 | + "integrity": "sha512-K/hOh24cCwRutd1Mk3uLtjWzNISOkm4fvXiMO7LucCrqbh6aJDdtqUziim3MZUI6wOY0rvY1SlL1Ork01uMy6w==" | ||
| 14 | }, | 14 | }, |
| 15 | "mdbootstrap": { | 15 | "mdbootstrap": { |
| 16 | - "version": "4.14.0", | ||
| 17 | - "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.14.0.tgz", | ||
| 18 | - "integrity": "sha512-RBa3uB64m1DpO1exIupqQkL0Ca108zpjXnOH+xyJ6bIyr4BJw2djSmWoizQHhN5o1v3ujN3PtNJ3x7k+ITmFRw==" | 16 | + "version": "4.19.1", |
| 17 | + "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.19.1.tgz", | ||
| 18 | + "integrity": "sha512-vzYd7UQ0H1tyJfDqCYwsAv+sxol/xRkJP/5FMhcdW3ZbN9xUnmWiSPHx3A6ddGxdOQbfJTWxT3G8M+I++Qdk6w==" | ||
| 19 | } | 19 | } |
| 20 | } | 20 | } |
| 21 | } | 21 | } |
package.json
| @@ -2,9 +2,9 @@ | @@ -2,9 +2,9 @@ | ||
| 2 | "description": "Javascript libraries required to run the server", | 2 | "description": "Javascript libraries required to run the server", |
| 3 | "email": "mjsb@uevora.pt", | 3 | "email": "mjsb@uevora.pt", |
| 4 | "dependencies": { | 4 | "dependencies": { |
| 5 | - "@fortawesome/fontawesome-free": "^5.12.1", | ||
| 6 | - "codemirror": "^5.52.0", | ||
| 7 | - "mdbootstrap": "^4.14.0" | 5 | + "@fortawesome/fontawesome-free": "^5.15.1", |
| 6 | + "codemirror": "^5.58.2", | ||
| 7 | + "mdbootstrap": "^4.19.1" | ||
| 8 | }, | 8 | }, |
| 9 | "private": true | 9 | "private": true |
| 10 | } | 10 | } |