diff --git a/BUGS.md b/BUGS.md index 14c7450..9673b14 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,6 +1,9 @@ # BUGS +- GET can get filtered by browser cache +- topicos chapter devem ser automaticamente completos assim que as dependencias são satisfeitas. Nao devia ser necessario (ou possivel?) clicar neles. +- topicos do tipo learn deviam por defeito nao ser randomizados e assumir ficheiros `learn.yaml`. - internal server error 500... experimentar cenario: aluno tem login efectuado, prof muda pw e faz login/logout. aluno obtem erro 500. - chapters deviam ser mostrados unlocked, antes de mostrar a medalha. alunos pensam que já terminaram e não conseguem progredir por causa das dependencias. - if topic deps on invalid ref terminates server with "Unknown error". diff --git a/aprendizations/initdb.py b/aprendizations/initdb.py index 9288ebb..81e4906 100644 --- a/aprendizations/initdb.py +++ b/aprendizations/initdb.py @@ -1,5 +1,9 @@ #!/usr/bin/env python3 +''' +Initializes or updates database +''' + # python standard libraries import csv import argparse @@ -12,12 +16,14 @@ import bcrypt import sqlalchemy as sa # this project -from .models import Base, Student +from aprendizations.models import Base, Student # =========================================================================== # Parse command line options def parse_commandline_arguments(): + '''Parse command line arguments''' + argparser = argparse.ArgumentParser( formatter_class=argparse.ArgumentDefaultsHelpFormatter, description='Insert new users into a database. Users can be imported ' @@ -65,9 +71,12 @@ def parse_commandline_arguments(): # =========================================================================== -# SIIUE names have alien strings like "(TE)" and are sometimes capitalized -# We remove them so that students dont keep asking what it means def get_students_from_csv(filename): + '''Reads CSV file with enrolled students in SIIUE format. + SIIUE names can have suffixes like "(TE)" and are sometimes capitalized. + These suffixes are removed.''' + + # SIIUE format for CSV files csv_settings = { 'delimiter': ';', 'quotechar': '"', @@ -75,8 +84,8 @@ def get_students_from_csv(filename): } try: - with open(filename, encoding='iso-8859-1') as f: - csvreader = csv.DictReader(f, **csv_settings) + with open(filename, encoding='iso-8859-1') as file: + csvreader = csv.DictReader(file, **csv_settings) students = [{ 'uid': s['N.º'], 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip()) @@ -92,52 +101,51 @@ def get_students_from_csv(filename): # =========================================================================== -# replace password by hash for a single student -def hashpw(student, pw=None): +def hashpw(student, passw=None): + '''replace password by hash for a single student''' print('.', end='', flush=True) - pw = (pw or student.get('pw', None) or student['uid']).encode('utf-8') - student['pw'] = bcrypt.hashpw(pw, bcrypt.gensalt()) + passw = (passw or student.get('pw', None) or student['uid']).encode('utf-8') + student['pw'] = bcrypt.hashpw(passw, bcrypt.gensalt()) # =========================================================================== def show_students_in_database(session, verbose=False): - try: - users = session.query(Student).all() - except Exception: - raise + '''print students that are in the database''' + users = session.query(Student).all() + total = len(users) + + print('\nRegistered users:') + if total == 0: + print(' -- none --') else: - n = len(users) - print(f'\nRegistered users:') - if n == 0: - print(' -- none --') + users.sort(key=lambda u: f'{u.id:>12}') # sort by number + if verbose: + for user in users: + print(f'{user.id:>12} {user.name}') else: - users.sort(key=lambda u: f'{u.id:>12}') # sort by number - if verbose: - for u in users: - print(f'{u.id:>12} {u.name}') - else: - print(f'{users[0].id:>12} {users[0].name}') - if n > 1: - print(f'{users[1].id:>12} {users[1].name}') - if n > 3: - print(' | |') - if n > 2: - print(f'{users[-1].id:>12} {users[-1].name}') - print(f'Total: {n}.') + print(f'{users[0].id:>12} {users[0].name}') + if total > 1: + print(f'{users[1].id:>12} {users[1].name}') + if total > 3: + print(' | |') + if total > 2: + print(f'{users[-1].id:>12} {users[-1].name}') + print(f'Total: {total}.') # =========================================================================== def main(): + '''performs the main functions''' + args = parse_commandline_arguments() # --- database stuff - print(f'Using database: ', args.db) + print(f'Using database: {args.db}') engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) Base.metadata.create_all(engine) # Creates schema if needed - Session = sa.orm.sessionmaker(bind=engine) - session = Session() + session = sa.orm.sessionmaker(bind=engine)() - # --- make list of students to insert/update + # --- build list of students to insert/update students = [] for csvfile in args.csvfile: @@ -159,13 +167,13 @@ def main(): if new_students: # --- password hashing - print(f'Generating password hashes', end='') + print('Generating password hashes', end='') with ThreadPoolExecutor() as executor: executor.map(lambda s: hashpw(s, args.pw), new_students) print('\nAdding students:') - for s in new_students: - print(f' + {s["uid"]}, {s["name"]}') + for student in new_students: + print(f' + {student["uid"]}, {student["name"]}') try: session.add_all([Student(id=s['uid'], @@ -182,15 +190,15 @@ def main(): print('There are no new students to add.') # --- update data for student in the database - for s in args.update: - print(f'Updating password of: {s}') - u = session.query(Student).get(s) - if u is not None: - pw = (args.pw or s).encode('utf-8') - u.password = bcrypt.hashpw(pw, bcrypt.gensalt()) + for student_id in args.update: + print(f'Updating password of: {student_id}') + student = session.query(Student).get(student_id) + if student is not None: + passw = (args.pw or student_id).encode('utf-8') + student.password = bcrypt.hashpw(passw, bcrypt.gensalt()) session.commit() else: - print(f'!!! Student {s} does not exist. Skipping update !!!') + print(f'!!! Student {student_id} does not exist. Skipped!!!') show_students_in_database(session, args.verbose) diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index 67d3a2e..76094de 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -85,10 +85,10 @@ class LearnApp(): try: config: Dict[str, Any] = load_yaml(courses) - except Exception: + except Exception as exc: msg = f'Failed to load yaml file "{courses}"' logger.error(msg) - raise LearnException(msg) + raise LearnException(msg) from exc # --- topic dependencies are shared between all courses self.deps = nx.DiGraph(prefix=prefix) @@ -336,9 +336,9 @@ class LearnApp(): student = self.online[uid]['state'] try: student.start_course(course_id) - except Exception: + except Exception as exc: logger.warning('"%s" could not start course "%s"', uid, course_id) - raise LearnException() + raise LearnException() from exc else: logger.info('User "%s" started course "%s"', uid, course_id) @@ -392,9 +392,9 @@ class LearnApp(): count_students: int = sess.query(Student).count() count_topics: int = sess.query(Topic).count() count_answers: int = sess.query(Answer).count() - except Exception: + except Exception as exc: logger.error('Database "%s" not usable!', database) - raise DatabaseUnusableError() + raise DatabaseUnusableError() from exc else: logger.info('%6d students', count_students) logger.info('%6d topics', count_topics) @@ -416,7 +416,7 @@ class LearnApp(): 'type': 'topic', # chapter 'file': 'questions.yaml', 'shuffle_questions': True, - 'choose': 9999, + 'choose': 99, 'forgetting_factor': 1.0, # no forgetting 'max_tries': 1, # in every question 'append_wrong': True, @@ -472,22 +472,20 @@ class LearnApp(): # load questions as list of dicts try: fullpath: str = join(topic['path'], topic['file']) - except Exception: - msg1 = f'Invalid topic "{tref}"' - msg2 = 'Check dependencies of: ' + \ + except Exception as exc: + msg = f'Invalid topic "{tref}". Check dependencies of: ' + \ ', '.join(self.deps.successors(tref)) - msg = f'{msg1}. {msg2}' logger.error(msg) - raise LearnException(msg) + raise LearnException(msg) from exc logger.debug(' Loading %s', fullpath) try: questions: List[QDict] = load_yaml(fullpath) - except Exception: + except Exception as exc: if topic['type'] == 'chapter': return factory # chapters may have no "questions" msg = f'Failed to load "{fullpath}"' logger.error(msg) - raise LearnException(msg) + raise LearnException(msg) from exc if not isinstance(questions, list): msg = f'File "{fullpath}" must be a list of questions' @@ -548,8 +546,8 @@ class LearnApp(): # ------------------------------------------------------------------------ def get_current_question(self, uid: str) -> Optional[Question]: '''Get the current question of a given user''' - q: Optional[Question] = self.online[uid]['state'].get_current_question() - return q + question: Optional[Question] = self.online[uid]['state'].get_current_question() + return question # ------------------------------------------------------------------------ def get_current_question_id(self, uid: str) -> str: @@ -655,6 +653,6 @@ class LearnApp(): return sorted(((u, name, progress[u], perf.get(u, 0.0)) for u, name in students if u in progress and (len(u) > 2 or len(uid) <= 2)), - key=lambda x: x[2], reverse=True) + key=lambda x: x[2], reverse=True) # ------------------------------------------------------------------------ diff --git a/aprendizations/main.py b/aprendizations/main.py index 5a38e68..a04cc33 100644 --- a/aprendizations/main.py +++ b/aprendizations/main.py @@ -1,5 +1,10 @@ #!/usr/bin/env python3 +''' +Setup configurations and then runs the application. +''' + + # python standard library import argparse import logging @@ -9,14 +14,18 @@ import sys from typing import Any, Dict # this project -from .learnapp import LearnApp, DatabaseUnusableError, LearnException -from .serve import run_webserver -from .tools import load_yaml -from . import APP_NAME, APP_VERSION +from aprendizations.learnapp import LearnApp, DatabaseUnusableError, LearnException +from aprendizations.serve import run_webserver +from aprendizations.tools import load_yaml +from aprendizations import APP_NAME, APP_VERSION # ---------------------------------------------------------------------------- def parse_cmdline_arguments(): + ''' + Parses command line arguments. Uses the argparse package. + ''' + argparser = argparse.ArgumentParser( description='Webserver for interactive learning and practice. ' 'Please read the documentation included with this software before ' @@ -63,6 +72,12 @@ def parse_cmdline_arguments(): # ---------------------------------------------------------------------------- def get_logger_config(debug: bool = False) -> Any: + ''' + Loads logger configuration in yaml format from a file, otherwise sets up a + default configuration. + Returns the configuration. + ''' + if debug: filename, level = 'logger-debug.yaml', 'DEBUG' else: @@ -106,9 +121,11 @@ def get_logger_config(debug: bool = False) -> Any: # ---------------------------------------------------------------------------- -# Start application and webserver -# ---------------------------------------------------------------------------- def main(): + ''' + Start application and webserver + ''' + # --- Commandline argument parsing arg = parse_cmdline_arguments() @@ -122,8 +139,8 @@ def main(): try: logging.config.dictConfig(logger_config) - except Exception: - print('An error ocurred while setting up the logging system.') + except (ValueError, TypeError, AttributeError, ImportError) as exc: + print('An error ocurred while setting up the logging system: %s', exc) sys.exit(1) logging.info('====================== Start Logging ======================') @@ -139,7 +156,7 @@ def main(): ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'), path.join(certs_dir, 'privkey.pem')) except FileNotFoundError: - logging.critical(f'SSL certificates missing in {certs_dir}') + logging.critical('SSL certificates missing in %s', certs_dir) print('--------------------------------------------------------------', 'Certificates should be issued by a certificate authority (CA),', 'such as https://letsencrypt.org. ', @@ -183,7 +200,8 @@ def main(): sys.exit(1) except Exception: logging.critical('Unknown error') - sys.exit(1) + # sys.exit(1) + raise else: logging.info('LearnApp started') diff --git a/aprendizations/questions.py b/aprendizations/questions.py index cfb9675..8d06cbd 100644 --- a/aprendizations/questions.py +++ b/aprendizations/questions.py @@ -1,16 +1,20 @@ +''' +Classes the implement several types of questions. +''' + # python standard library import asyncio from datetime import datetime +import logging +from os import path import random import re -from os import path -import logging from typing import Any, Dict, NewType import uuid # this project -from .tools import run_script, run_script_async +from aprendizations.tools import run_script, run_script_async # setup logger for this module logger = logging.getLogger(__name__) @@ -20,7 +24,7 @@ QDict = NewType('QDict', Dict[str, Any]) class QuestionException(Exception): - pass + '''Exceptions raised in this module''' # ============================================================================ @@ -46,20 +50,23 @@ class Question(dict): })) def set_answer(self, ans) -> None: + '''set answer field and register time''' self['answer'] = ans self['finish_time'] = datetime.now() def correct(self) -> None: + '''default correction (synchronous version)''' self['comments'] = '' self['grade'] = 0.0 async def correct_async(self) -> None: + '''default correction (async version)''' self.correct() - def set_defaults(self, d: QDict) -> None: - 'Add k:v pairs from default dict d for nonexistent keys' - for k, v in d.items(): - self.setdefault(k, v) + def set_defaults(self, qdict: QDict) -> None: + '''Add k:v pairs from default dict d for nonexistent keys''' + for k, val in qdict.items(): + self.setdefault(k, val) # ============================================================================ @@ -80,74 +87,82 @@ class QuestionRadio(Question): super().__init__(q) try: - n = len(self['options']) - except KeyError: - msg = f'Missing `options` in radio question. See {self["path"]}' - raise QuestionException(msg) - except TypeError: - msg = f'`options` must be a list. See {self["path"]}' - raise QuestionException(msg) + nopts = len(self['options']) + except KeyError as exc: + msg = f'Missing `options`. In question "{self["ref"]}"' + logger.error(msg) + raise QuestionException(msg) from exc + except TypeError as exc: + msg = f'`options` must be a list. In question "{self["ref"]}"' + logger.error(msg) + raise QuestionException(msg) from exc self.set_defaults(QDict({ 'text': '', 'correct': 0, 'shuffle': True, 'discount': True, - 'max_tries': (n + 3) // 4 # 1 try for each 4 options + 'max_tries': (nopts + 3) // 4 # 1 try for each 4 options })) # check correct bounds and convert int to list, # e.g. correct: 2 --> correct: [0,0,1,0,0] if isinstance(self['correct'], int): - if not (0 <= self['correct'] < n): - msg = (f'Correct option not in range 0..{n-1} in ' - f'"{self["ref"]}"') + if not 0 <= self['correct'] < nopts: + msg = (f'`correct` out of range 0..{nopts-1}. ' + f'In question "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) self['correct'] = [1.0 if x == self['correct'] else 0.0 - for x in range(n)] + for x in range(nopts)] elif isinstance(self['correct'], list): # must match number of options - if len(self['correct']) != n: - msg = (f'Incompatible sizes: {n} options vs ' - f'{len(self["correct"])} correct in "{self["ref"]}"') + if len(self['correct']) != nopts: + msg = (f'{nopts} options vs {len(self["correct"])} correct. ' + f'In question "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) + # make sure is a list of floats try: self['correct'] = [float(x) for x in self['correct']] - except (ValueError, TypeError): - msg = (f'Correct list must contain numbers [0.0, 1.0] or ' - f'booleans in "{self["ref"]}"') - raise QuestionException(msg) + except (ValueError, TypeError) as exc: + msg = ('`correct` must be list of numbers or booleans.' + f'In "{self["ref"]}"') + logger.error(msg) + raise QuestionException(msg) from exc # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 for x in self['correct']): - msg = (f'Correct values must be in the interval [0.0, 1.0] in ' - f'"{self["ref"]}"') + msg = ('`correct` values must be in the interval [0.0, 1.0]. ' + f'In "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) # at least one correct option if all(x < 1.0 for x in self['correct']): - msg = (f'At least one correct option is required in ' - f'"{self["ref"]}"') + msg = ('At least one correct option is required. ' + f'In "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) # If shuffle==false, all options are shown as defined # otherwise, select 1 correct and choose a few wrong ones if self['shuffle']: # lists with indices of right and wrong options - right = [i for i in range(n) if self['correct'][i] >= 1] - wrong = [i for i in range(n) if self['correct'][i] < 1] + right = [i for i in range(nopts) if self['correct'][i] >= 1] + wrong = [i for i in range(nopts) if self['correct'][i] < 1] self.set_defaults(QDict({'choose': 1+len(wrong)})) # try to choose 1 correct option if right: - r = random.choice(right) - options = [self['options'][r]] - correct = [self['correct'][r]] + sel = random.choice(right) + options = [self['options'][sel]] + correct = [self['correct'][sel]] else: options = [] correct = [] @@ -164,20 +179,23 @@ class QuestionRadio(Question): self['correct'] = [correct[i] for i in perm] # ------------------------------------------------------------------------ - # can assign negative grades for wrong answers def correct(self) -> None: + ''' + Correct `answer` and set `grade`. + Can assign negative grades for wrong answers + ''' super().correct() if self['answer'] is not None: - x = self['correct'][int(self['answer'])] # get grade of the answer - n = len(self['options']) - x_aver = sum(self['correct']) / n # expected value of grade + grade = self['correct'][int(self['answer'])] # grade of the answer + nopts = len(self['options']) + grade_aver = sum(self['correct']) / nopts # expected value # note: there are no numerical errors when summing 1.0s so the # x_aver can be exactly 1.0 if all options are right - if self['discount'] and x_aver != 1.0: - x = (x - x_aver) / (1.0 - x_aver) - self['grade'] = float(x) + if self['discount'] and grade_aver != 1.0: + grade = (grade - grade_aver) / (1.0 - grade_aver) + self['grade'] = grade # ============================================================================ @@ -198,79 +216,86 @@ class QuestionCheckbox(Question): super().__init__(q) try: - n = len(self['options']) - except KeyError: - msg = f'Missing `options` in checkbox question. See {self["path"]}' - raise QuestionException(msg) - except TypeError: - msg = f'`options` must be a list. See {self["path"]}' - raise QuestionException(msg) + nopts = len(self['options']) + except KeyError as exc: + msg = f'Missing `options`. In question "{self["ref"]}"' + logger.error(msg) + raise QuestionException(msg) from exc + except TypeError as exc: + msg = f'`options` must be a list. In question "{self["ref"]}"' + logger.error(msg) + raise QuestionException(msg) from exc # set defaults if missing self.set_defaults(QDict({ 'text': '', - 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options + 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong) 'shuffle': True, 'discount': True, - 'choose': n, # number of options - 'max_tries': max(1, min(n - 1, 3)) + 'choose': nopts, # number of options + 'max_tries': max(1, min(nopts - 1, 3)) })) # must be a list of numbers if not isinstance(self['correct'], list): msg = 'Correct must be a list of numbers or booleans' + logger.error(msg) raise QuestionException(msg) # must match number of options - if len(self['correct']) != n: - msg = (f'Incompatible sizes: {n} options vs ' - f'{len(self["correct"])} correct in "{self["ref"]}"') + if len(self['correct']) != nopts: + msg = (f'{nopts} options vs {len(self["correct"])} correct. ' + f'In question "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) # make sure is a list of floats try: self['correct'] = [float(x) for x in self['correct']] - except (ValueError, TypeError): - msg = (f'Correct list must contain numbers or ' - f'booleans in "{self["ref"]}"') - raise QuestionException(msg) + except (ValueError, TypeError) as exc: + msg = ('`correct` must be list of numbers or booleans.' + f'In "{self["ref"]}"') + logger.error(msg) + raise QuestionException(msg) from exc # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 for x in self['correct']): - - msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') - msg1 = ('| Correct values in checkbox questions must be in the |') - msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') - msg3 = ('| behavior, for now, but you should fix it. |') - msg4 = ('+------------------------------------------------------+') - logger.warning(msg0) - logger.warning(msg1) - logger.warning(msg2) - logger.warning(msg3) - logger.warning(msg4) - logger.warning(f'please fix "{self["ref"]}"') - - # normalize to [0,1] - self['correct'] = [(x+1)/2 for x in self['correct']] + msg = ('values in the `correct` field of checkboxes must be in ' + 'the [0.0, 1.0] interval. ' + f'Please fix "{self["ref"]}" in "{self["path"]}"') + logger.error(msg) + raise QuestionException(msg) + # msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') + # msg1 = ('| Correct values in checkbox questions must be in the |') + # msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') + # msg3 = ('| behavior, for now, but you should fix it. |') + # msg4 = ('+------------------------------------------------------+') + # logger.warning(msg0) + # logger.warning(msg1) + # logger.warning(msg2) + # logger.warning(msg3) + # logger.warning(msg4) + # logger.warning('please fix "%s"', self["ref"]) + # # normalize to [0,1] + # self['correct'] = [(x+1)/2 for x in self['correct']] # if an option is a list of (right, wrong), pick one options = [] correct = [] - for o, c in zip(self['options'], self['correct']): - if isinstance(o, list): - r = random.randint(0, 1) - o = o[r] - if r == 1: - # c = -c - c = 1.0 - c - options.append(str(o)) - correct.append(c) + for option, corr in zip(self['options'], self['correct']): + if isinstance(option, list): + sel = random.randint(0, 1) + option = option[sel] + if sel == 1: + corr = 1.0 - corr + options.append(str(option)) + correct.append(corr) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` if self['shuffle']: - perm = random.sample(range(n), k=self['choose']) + perm = random.sample(range(nopts), k=self['choose']) self['options'] = [options[i] for i in perm] self['correct'] = [correct[i] for i in perm] else: @@ -283,18 +308,18 @@ class QuestionCheckbox(Question): super().correct() if self['answer'] is not None: - x = 0.0 + grade = 0.0 if self['discount']: sum_abs = sum(abs(2*p-1) for p in self['correct']) - for i, p in enumerate(self['correct']): - x += 2*p-1 if str(i) in self['answer'] else 1-2*p + for i, pts in enumerate(self['correct']): + grade += 2*pts-1 if str(i) in self['answer'] else 1-2*pts else: sum_abs = sum(abs(p) for p in self['correct']) - for i, p in enumerate(self['correct']): - x += p if str(i) in self['answer'] else 0.0 + for i, pts in enumerate(self['correct']): + grade += pts if str(i) in self['answer'] else 0.0 try: - self['grade'] = x / sum_abs + self['grade'] = grade / sum_abs except ZeroDivisionError: self['grade'] = 1.0 # limit p->0 @@ -325,33 +350,35 @@ class QuestionText(Question): # make sure all elements of the list are strings self['correct'] = [str(a) for a in self['correct']] - for f in self['transform']: - if f not in ('remove_space', 'trim', 'normalize_space', 'lower', - 'upper'): - msg = (f'Unknown transform "{f}" in "{self["ref"]}"') + for transform in self['transform']: + if transform not in ('remove_space', 'trim', 'normalize_space', + 'lower', 'upper'): + msg = (f'Unknown transform "{transform}" in "{self["ref"]}"') raise QuestionException(msg) # check if answers are invariant with respect to the transforms if any(c != self.transform(c) for c in self['correct']): - logger.warning(f'in "{self["ref"]}", correct answers are not ' - 'invariant wrt transformations => never correct') + logger.warning('in "%s", correct answers are not invariant wrt ' + 'transformations => never correct', self["ref"]) # ------------------------------------------------------------------------ - # apply optional filters to the answer def transform(self, ans): - for f in self['transform']: - if f == 'remove_space': # removes all spaces + '''apply optional filters to the answer''' + + for transform in self['transform']: + if transform == 'remove_space': # removes all spaces ans = ans.replace(' ', '') - elif f == 'trim': # removes spaces around + elif transform == 'trim': # removes spaces around ans = ans.strip() - elif f == 'normalize_space': # replaces multiple spaces by one + elif transform == 'normalize_space': # replaces multiple spaces by one ans = re.sub(r'\s+', ' ', ans.strip()) - elif f == 'lower': # convert to lowercase + elif transform == 'lower': # convert to lowercase ans = ans.lower() - elif f == 'upper': # convert to uppercase + elif transform == 'upper': # convert to uppercase ans = ans.upper() else: - logger.warning(f'in "{self["ref"]}", unknown transform "{f}"') + logger.warning('in "%s", unknown transform "%s"', + self["ref"], transform) return ans # ------------------------------------------------------------------------ @@ -391,23 +418,24 @@ class QuestionTextRegex(Question): # converts patterns to compiled versions try: self['correct'] = [re.compile(a) for a in self['correct']] - except Exception: + except Exception as exc: msg = f'Failed to compile regex in "{self["ref"]}"' - raise QuestionException(msg) + logger.error(msg) + raise QuestionException(msg) from exc # ------------------------------------------------------------------------ def correct(self) -> None: super().correct() if self['answer'] is not None: self['grade'] = 0.0 - for r in self['correct']: + for regex in self['correct']: try: - if r.match(self['answer']): + if regex.match(self['answer']): self['grade'] = 1.0 return except TypeError: - logger.error(f'While matching regex {r.pattern} with ' - f'answer "{self["answer"]}".') + logger.error('While matching regex %s with answer "%s".', + regex.pattern, self["answer"]) # ============================================================================ @@ -438,19 +466,22 @@ class QuestionNumericInterval(Question): if len(self['correct']) != 2: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') + logger.error(msg) raise QuestionException(msg) try: self['correct'] = [float(n) for n in self['correct']] - except Exception: + except Exception as exc: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') - raise QuestionException(msg) + logger.error(msg) + raise QuestionException(msg) from exc # invalid else: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') + logger.error(msg) raise QuestionException(msg) # ------------------------------------------------------------------------ @@ -504,21 +535,22 @@ class QuestionTextArea(Question): ) if out is None: - logger.warning(f'No grade after running "{self["correct"]}".') + logger.warning('No grade after running "%s".', self["correct"]) + self['comments'] = 'O programa de correcção abortou...' self['grade'] = 0.0 elif isinstance(out, dict): self['comments'] = out.get('comments', '') try: self['grade'] = float(out['grade']) except ValueError: - logger.error(f'Output error in "{self["correct"]}".') + logger.error('Output error in "%s".', self["correct"]) except KeyError: - logger.error(f'No grade in "{self["correct"]}".') + logger.error('No grade in "%s".', self["correct"]) else: try: self['grade'] = float(out) except (TypeError, ValueError): - logger.error(f'Invalid grade in "{self["correct"]}".') + logger.error('Invalid grade in "%s".', self["correct"]) # ------------------------------------------------------------------------ async def correct_async(self) -> None: @@ -533,25 +565,31 @@ class QuestionTextArea(Question): ) if out is None: - logger.warning(f'No grade after running "{self["correct"]}".') + logger.warning('No grade after running "%s".', self["correct"]) + self['comments'] = 'O programa de correcção abortou...' self['grade'] = 0.0 elif isinstance(out, dict): self['comments'] = out.get('comments', '') try: self['grade'] = float(out['grade']) except ValueError: - logger.error(f'Output error in "{self["correct"]}".') + logger.error('Output error in "%s".', self["correct"]) except KeyError: - logger.error(f'No grade in "{self["correct"]}".') + logger.error('No grade in "%s".', self["correct"]) else: try: self['grade'] = float(out) except (TypeError, ValueError): - logger.error(f'Invalid grade in "{self["correct"]}".') + logger.error('Invalid grade in "%s".', self["correct"]) # ============================================================================ class QuestionInformation(Question): + ''' + Not really a question, just an information panel. + The correction is always right. + ''' + # ------------------------------------------------------------------------ def __init__(self, q: QDict) -> None: super().__init__(q) @@ -566,38 +604,38 @@ class QuestionInformation(Question): # ============================================================================ -# -# QFactory is a class that can generate question instances, e.g. by shuffling -# options, running a script to generate the question, etc. -# -# To generate an instance of a question we use the method generate(). -# It returns a question instance of the correct class. -# There is also an asynchronous version called gen_async(). This version is -# synchronous for all question types (radio, checkbox, etc) except for -# generator types which run asynchronously. -# -# Example: -# -# # make a factory for a question -# qfactory = QFactory({ -# 'type': 'radio', -# 'text': 'Choose one', -# 'options': ['a', 'b'] -# }) -# -# # generate synchronously -# question = qfactory.generate() -# -# # generate asynchronously -# question = await qfactory.gen_async() -# -# # answer one question and correct it -# question['answer'] = 42 # set answer -# question.correct() # correct answer -# grade = question['grade'] # get grade -# -# ============================================================================ -class QFactory(object): +class QFactory(): + ''' + QFactory is a class that can generate question instances, e.g. by shuffling + options, running a script to generate the question, etc. + + To generate an instance of a question we use the method generate(). + It returns a question instance of the correct class. + There is also an asynchronous version called gen_async(). This version is + synchronous for all question types (radio, checkbox, etc) except for + generator types which run asynchronously. + + Example: + + # make a factory for a question + qfactory = QFactory({ + 'type': 'radio', + 'text': 'Choose one', + 'options': ['a', 'b'] + }) + + # generate synchronously + question = qfactory.generate() + + # generate asynchronously + question = await qfactory.gen_async() + + # answer one question and correct it + question['answer'] = 42 # set answer + question.correct() # correct answer + grade = question['grade'] # get grade + ''' + # Depending on the type of question, a different question class will be # instantiated. All these classes derive from the base class `Question`. _types = { @@ -618,44 +656,52 @@ class QFactory(object): self.question = qdict # ------------------------------------------------------------------------ - # generates a question instance of QuestionRadio, QuestionCheckbox, ..., - # which is a descendent of base class Question. - # ------------------------------------------------------------------------ async def gen_async(self) -> Question: - logger.debug(f'generating {self.question["ref"]}...') + ''' + generates a question instance of QuestionRadio, QuestionCheckbox, ..., + which is a descendent of base class Question. + ''' + + logger.debug('generating %s...', self.question["ref"]) # Shallow copy so that script generated questions will not replace # the original generators - q = self.question.copy() - q['qid'] = str(uuid.uuid4()) # unique for each generated question + question = self.question.copy() + question['qid'] = str(uuid.uuid4()) # unique for each question # If question is of generator type, an external program will be run # which will print a valid question in yaml format to stdout. This # output is then yaml parsed into a dictionary `q`. - if q['type'] == 'generator': - logger.debug(f' \\_ Running "{q["script"]}".') - q.setdefault('args', []) - q.setdefault('stdin', '') - script = path.join(q['path'], q['script']) - out = await run_script_async(script=script, args=q['args'], - stdin=q['stdin']) - q.update(out) + if question['type'] == 'generator': + logger.debug(' \\_ Running "%s".', question['script']) + question.setdefault('args', []) + question.setdefault('stdin', '') + script = path.join(question['path'], question['script']) + out = await run_script_async(script=script, + args=question['args'], + stdin=question['stdin']) + question.update(out) # Get class for this question type try: - qclass = self._types[q['type']] + qclass = self._types[question['type']] except KeyError: - logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') + logger.error('Invalid type "%s" in "%s"', + question['type'], question['ref']) raise # Finally create an instance of Question() try: - qinstance = qclass(QDict(q)) - except QuestionException as e: - # logger.error(e) - raise e + qinstance = qclass(QDict(question)) + except QuestionException: + logger.error('Error generating question "%s". See "%s/%s"', + question['ref'], + question['path'], + question['filename']) + raise return qinstance # ------------------------------------------------------------------------ def generate(self) -> Question: + '''generate question (synchronous version)''' return asyncio.get_event_loop().run_until_complete(self.gen_async()) diff --git a/aprendizations/serve.py b/aprendizations/serve.py index 53f0261..886fcef 100644 --- a/aprendizations/serve.py +++ b/aprendizations/serve.py @@ -88,7 +88,7 @@ class BaseHandler(tornado.web.RequestHandler): def get_current_user(self): '''called on every method decorated with @tornado.web.authenticated''' - user_cookie = self.get_secure_cookie('user') + user_cookie = self.get_secure_cookie('aprendizations_user') if user_cookie is not None: uid = user_cookie.decode('utf-8') counter = self.get_secure_cookie('counter').decode('utf-8') @@ -148,7 +148,7 @@ class LoginHandler(BaseHandler): if login_ok: counter = str(self.learn.get_login_counter(userid)) - self.set_secure_cookie('user', userid) + self.set_secure_cookie('aprendizations_user', userid) self.set_secure_cookie('counter', counter) self.redirect('/') else: @@ -221,6 +221,9 @@ class CoursesHandler(BaseHandler): ''' Handles /courses ''' + def set_default_headers(self, *args, **kwargs): + self.set_header('Cache-Control', 'no-cache') + @tornado.web.authenticated def get(self): '''Renders list of available courses''' @@ -270,6 +273,9 @@ class TopicHandler(BaseHandler): ''' Handles a topic ''' + def set_default_headers(self, *args, **kwargs): + self.set_header('Cache-Control', 'no-cache') + @tornado.web.authenticated async def get(self, topic): ''' diff --git a/aprendizations/student.py b/aprendizations/student.py index e5e99fd..ebeac9b 100644 --- a/aprendizations/student.py +++ b/aprendizations/student.py @@ -1,42 +1,49 @@ +''' +Implementation of the StudentState class. +Each object of this class will contain the state of a student while logged in. +Manages things like current course, topic, question, etc, and defines the +logic of the application in what it applies to a single student. +''' + # python standard library from datetime import datetime import logging import random -from typing import List, Optional, Tuple +from typing import List, Optional # third party libraries import networkx as nx # this project -from .questions import Question +from aprendizations.questions import Question # setup logger for this module logger = logging.getLogger(__name__) -# ---------------------------------------------------------------------------- -# kowledge state of a student: -# uid - string with userid, e.g. '12345' -# state - dict of unlocked topics and their levels -# {'topic1': {'level': 0.5, 'date': datetime}, ...} -# topic_sequence - recommended topic sequence ['topic1', 'topic2', ...] -# questions - [Question, ...] for the current topic -# current_course - string or None -# current_topic - string or None -# current_question - Question or None -# -# -# also has access to shared data between students: -# courses - dictionary {course: [topic1, ...]} -# deps - dependency graph as a networkx digraph -# factory - dictionary {ref: QFactory} -# ---------------------------------------------------------------------------- -class StudentState(object): - # ======================================================================= +# ============================================================================ +class StudentState(): + ''' + kowledge state of a student: + uid - string with userid, e.g. '12345' + state - dict of unlocked topics and their levels + {'topic1': {'level': 0.5, 'date': datetime}, ...} + topic_sequence - recommended topic sequence ['topic1', 'topic2', ...] + questions - [Question, ...] for the current topic + current_course - string or None + current_topic - string or None + current_question - Question or None + also has access to shared data between students: + courses - dictionary {course: [topic1, ...]} + deps - dependency graph as a networkx digraph + factory - dictionary {ref: QFactory} + ''' + + # ======================================================================== # methods that update state - # ======================================================================= + # ======================================================================== def __init__(self, uid, state, courses, deps, factory) -> None: # shared application data between all students self.deps = deps # dependency graph @@ -54,6 +61,10 @@ class StudentState(object): # ------------------------------------------------------------------------ def start_course(self, course: Optional[str]) -> None: + ''' + Tries to start a course. + Finds the recommended sequence of topics for the student. + ''' if course is None: logger.debug('no active course') self.current_course: Optional[str] = None @@ -63,19 +74,21 @@ class StudentState(object): try: topics = self.courses[course]['goals'] except KeyError: - logger.debug(f'course "{course}" does not exist') + logger.debug('course "%s" does not exist', course) raise - logger.debug(f'starting course "{course}"') + logger.debug('starting course "%s"', course) self.current_course = course - self.topic_sequence = self.recommend_sequence(topics) + self.topic_sequence = self._recommend_sequence(topics) # ------------------------------------------------------------------------ - # Start a new topic. - # questions: list of generated questions to do in the given topic - # current_question: the current question to be presented - # ------------------------------------------------------------------------ async def start_topic(self, topic: str) -> None: - logger.debug(f'start topic "{topic}"') + ''' + Start a new topic. + questions: list of generated questions to do in the given topic + current_question: the current question to be presented + ''' + + logger.debug('start topic "%s"', topic) # avoid regenerating questions in the middle of the current topic if self.current_topic == topic and self.uid != '0': @@ -84,7 +97,7 @@ class StudentState(object): # do not allow locked topics if self.is_locked(topic) and self.uid != '0': - logger.debug(f'is locked "{topic}"') + logger.debug('is locked "%s"', topic) return self.previous_topic: Optional[str] = None @@ -93,95 +106,104 @@ class StudentState(object): self.current_topic = topic self.correct_answers = 0 self.wrong_answers = 0 - t = self.deps.nodes[topic] - k = t['choose'] - if t['shuffle_questions']: - questions = random.sample(t['questions'], k=k) + topic = self.deps.nodes[topic] + k = topic['choose'] + if topic['shuffle_questions']: + questions = random.sample(topic['questions'], k=k) else: - questions = t['questions'][:k] - logger.debug(f'selected questions: {", ".join(questions)}') + questions = topic['questions'][:k] + logger.debug('selected questions: %s', ', '.join(questions)) self.questions: List[Question] = [await self.factory[ref].gen_async() for ref in questions] - logger.debug(f'generated {len(self.questions)} questions') + logger.debug('generated %s questions', len(self.questions)) # get first question self.next_question() # ------------------------------------------------------------------------ - # corrects current question - # updates keys: answer, grade, finish_time, status, tries - # ------------------------------------------------------------------------ async def check_answer(self, answer) -> None: - q = self.current_question - if q is None: + ''' + Corrects current question. + Updates keys: `answer`, `grade`, `finish_time`, `status`, `tries` + ''' + + question = self.current_question + if question is None: logger.error('check_answer called but current_question is None!') return None - q.set_answer(answer) - await q.correct_async() # updates q['grade'] + question.set_answer(answer) + await question.correct_async() # updates q['grade'] - if q['grade'] > 0.999: + if question['grade'] > 0.999: self.correct_answers += 1 - q['status'] = 'right' + question['status'] = 'right' else: self.wrong_answers += 1 - q['tries'] -= 1 - if q['tries'] > 0: - q['status'] = 'try_again' + question['tries'] -= 1 + if question['tries'] > 0: + question['status'] = 'try_again' else: - q['status'] = 'wrong' + question['status'] = 'wrong' - logger.debug(f'ref = {q["ref"]}, status = {q["status"]}') + logger.debug('ref = %s, status = %s', + question["ref"], question["status"]) # ------------------------------------------------------------------------ - # gets next question to show if the status is 'right' or 'wrong', - # otherwise just returns the current question - # ------------------------------------------------------------------------ async def get_question(self) -> Optional[Question]: - q = self.current_question - if q is None: + ''' + Gets next question to show if the status is 'right' or 'wrong', + otherwise just returns the current question. + ''' + + question = self.current_question + if question is None: logger.error('get_question called but current_question is None!') return None - logger.debug(f'{q["ref"]} status = {q["status"]}') + logger.debug('%s status = %s', question["ref"], question["status"]) - if q['status'] == 'right': + if question['status'] == 'right': self.next_question() - elif q['status'] == 'wrong': - if q['append_wrong']: + elif question['status'] == 'wrong': + if question['append_wrong']: logger.debug(' wrong answer => append new question') - new_question = await self.factory[q['ref']].gen_async() + new_question = await self.factory[question['ref']].gen_async() self.questions.append(new_question) self.next_question() return self.current_question # ------------------------------------------------------------------------ - # moves to next question - # ------------------------------------------------------------------------ def next_question(self) -> None: + ''' + Moves to next question + ''' + try: - q = self.questions.pop(0) + question = self.questions.pop(0) except IndexError: self.finish_topic() return - t = self.deps.nodes[self.current_topic] - q['start_time'] = datetime.now() - q['tries'] = q.get('max_tries', t['max_tries']) - q['status'] = 'new' - self.current_question: Optional[Question] = q + topic = self.deps.nodes[self.current_topic] + question['start_time'] = datetime.now() + question['tries'] = question.get('max_tries', topic['max_tries']) + question['status'] = 'new' + self.current_question: Optional[Question] = question # ------------------------------------------------------------------------ - # The topic has finished and there are no more questions. - # The topic level is updated in state and unlocks are performed. - # The current topic is unchanged. - # ------------------------------------------------------------------------ def finish_topic(self) -> None: - logger.debug(f'finished {self.current_topic} in {self.current_course}') + ''' + The topic has finished and there are no more questions. + The topic level is updated in state and unlocks are performed. + The current topic is unchanged. + ''' + + logger.debug('finished %s in %s', self.current_topic, self.current_course) self.state[self.current_topic] = { 'date': datetime.now(), @@ -194,22 +216,25 @@ class StudentState(object): self.unlock_topics() # ------------------------------------------------------------------------ - # Update proficiency level of the topics using a forgetting factor - # ------------------------------------------------------------------------ def update_topic_levels(self) -> None: + ''' + Update proficiency level of the topics using a forgetting factor + ''' + now = datetime.now() - for tref, s in self.state.items(): - dt = now - s['date'] + for tref, state in self.state.items(): + elapsed = now - state['date'] try: forgetting_factor = self.deps.nodes[tref]['forgetting_factor'] - s['level'] *= forgetting_factor ** dt.days # forgetting factor + state['level'] *= forgetting_factor ** elapsed.days except KeyError: - logger.warning(f'Update topic levels: {tref} not in the graph') + logger.warning('Update topic levels: %s not in the graph', tref) # ------------------------------------------------------------------------ - # Unlock topics whose dependencies are satisfied (> min_level) - # ------------------------------------------------------------------------ def unlock_topics(self) -> None: + ''' + Unlock topics whose dependencies are satisfied (> min_level) + ''' for topic in self.deps.nodes(): if topic not in self.state: # if locked pred = self.deps.predecessors(topic) @@ -221,7 +246,7 @@ class StudentState(object): 'level': 0.0, # unlock 'date': datetime.now() } - logger.debug(f'unlocked "{topic}"') + logger.debug('unlocked "%s"', topic) # else: # lock this topic if deps do not satisfy min_level # del self.state[topic] @@ -230,64 +255,78 @@ class StudentState(object): # ======================================================================== def topic_has_finished(self) -> bool: + ''' + Checks if the all the questions in the current topic have been + answered. + ''' return self.current_topic is None and self.previous_topic is not None # ------------------------------------------------------------------------ - # compute recommended sequence of topics ['a', 'b', ...] - # ------------------------------------------------------------------------ - def recommend_sequence(self, goals: List[str] = []) -> List[str]: - G = self.deps - ts = set(goals) - for t in goals: - ts.update(nx.ancestors(G, t)) # include dependencies not in goals + def _recommend_sequence(self, goals: List[str]) -> List[str]: + ''' + compute recommended sequence of topics ['a', 'b', ...] + ''' + + topics = set(goals) + # include dependencies not in goals + for topic in goals: + topics.update(nx.ancestors(self.deps, topic)) todo = [] - for t in ts: - level = self.state[t]['level'] if t in self.state else 0.0 - min_level = G.nodes[t]['min_level'] - if t in goals or level < min_level: - todo.append(t) + for topic in topics: + level = self.state[topic]['level'] if topic in self.state else 0.0 + min_level = self.deps.nodes[topic]['min_level'] + if topic in goals or level < min_level: + todo.append(topic) - logger.debug(f' {len(ts)} total topics, {len(todo)} listed ') + logger.debug(' %s total topics, %s listed ', len(topics), len(todo)) # FIXME topological sort is a poor way to sort topics - tl = list(nx.topological_sort(G.subgraph(todo))) + topic_seq = list(nx.topological_sort(self.deps.subgraph(todo))) # sort with unlocked first - unlocked = [t for t in tl if t in self.state] - locked = [t for t in tl if t not in unlocked] + unlocked = [t for t in topic_seq if t in self.state] + locked = [t for t in topic_seq if t not in unlocked] return unlocked + locked # ------------------------------------------------------------------------ def get_current_question(self) -> Optional[Question]: + '''gets current question''' return self.current_question # ------------------------------------------------------------------------ def get_current_topic(self) -> Optional[str]: + '''gets current topic''' return self.current_topic # ------------------------------------------------------------------------ def get_previous_topic(self) -> Optional[str]: + '''gets previous topic''' return self.previous_topic # ------------------------------------------------------------------------ def get_current_course_title(self) -> str: + '''gets current course title''' return str(self.courses[self.current_course]['title']) # ------------------------------------------------------------------------ def get_current_course_id(self) -> Optional[str]: + '''gets current course id''' return self.current_course # ------------------------------------------------------------------------ def is_locked(self, topic: str) -> bool: + '''checks if a given topic is locked''' return topic not in self.state # ------------------------------------------------------------------------ - # Return list of {ref: 'xpto', name: 'long name', leve: 0.5} - # Levels are in the interval [0, 1] if unlocked or None if locked. - # Topics unlocked but not yet done have level 0.0. - # ------------------------------------------------------------------------ def get_knowledge_state(self): + ''' + Return list of {ref: 'xpto', name: 'long name', leve: 0.5} + Levels are in the interval [0, 1] if unlocked or None if locked. + Topics unlocked but not yet done have level 0.0. + ''' + return [{ 'ref': ref, 'type': self.deps.nodes[ref]['type'], @@ -297,19 +336,16 @@ class StudentState(object): # ------------------------------------------------------------------------ def get_topic_progress(self) -> float: + '''computes progress of the current topic''' return self.correct_answers / (1 + self.correct_answers + len(self.questions)) # ------------------------------------------------------------------------ def get_topic_level(self, topic: str) -> float: + '''gets level of a given topic''' return float(self.state[topic]['level']) # ------------------------------------------------------------------------ def get_topic_date(self, topic: str): + '''gets date of a given topic''' return self.state[topic]['date'] - - # ------------------------------------------------------------------------ - # Recommends a topic to practice/learn from the state. - # ------------------------------------------------------------------------ - # def get_recommended_topic(self): # FIXME untested - # return min(self.state.items(), key=lambda x: x[1]['level'])[0] diff --git a/aprendizations/templates/courses.html b/aprendizations/templates/courses.html index bbe903e..2a8508e 100644 --- a/aprendizations/templates/courses.html +++ b/aprendizations/templates/courses.html @@ -4,28 +4,28 @@ {{appname}} - + - - - - + + + + - - - - - - + + + + + +