diff --git a/BUGS.md b/BUGS.md index bd9adcd..3da7246 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,27 +1,23 @@ # BUGS +- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande) - nao esta a seguir o max_tries definido no ficheiro de dependencias. -- registar last_seen e remover os antigos de cada vez que houver um login. - initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a) - double click submits twice. -- marking all options right in a radio question breaks! - duplo clicks no botao "responder" dessincroniza as questões, ver debounce em https://stackoverflow.com/questions/20281546/how-to-prevent-calling-of-en-event-handler-twice-on-fast-clicks -- quando termina topico devia apagar as perguntas todas (se falhar a gerar novo topico, aparecem perguntas do antigo) -- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande) - devia mostrar timeout para o aluno saber a razao. - permitir configuracao para escolher entre static files locais ou remotos - templates question-*.html tem input hidden question_ref que não é usado. remover? -- click numa opcao checkbox fora da checkbox+label não está a funcionar. - shift-enter não está a funcionar - default prefix should be obtained from each course (yaml conf)? -- tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question. # TODO +- checkbox devia ter correct no intervalo [0,1] tal como radio. em caso de desconto a correccção faz 2*x-1. isto permite a mesma semantica nos dois tipos de perguntas. +- registar last_seen e remover os antigos de cada vez que houver um login. - indicar qtos topicos faltam (>=50%) para terminar o curso. -- use run_script_async to run run_script using asyncio.run? -- ao fim de 3 tentativas de login, envial email para aluno com link para definir nova password (com timeout de 5 minutos). +- ao fim de 3 tentativas com password errada, envia email com nova password. - mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo expande as dependencias. - mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado topicos. - botão não sei... @@ -33,14 +29,16 @@ - tabela com perguntas / quantidade de respostas certas/erradas. - tabela com topicos / quantidade de estrelas. - pymips: activar/desactivar instruções -- implementar servidor http com redirect para https. -- ao fim de 3 tentativas com password errada, envia email com nova password. - titulos das perguntas não suportam markdown. - pagina report que permita ver tabela alunos/topicos, estatisticas perguntas mais falhadas, tempo médio por pergunta. - normalizar com perguntations. # FIXED +- marking all options right in a radio question breaks! +- implementar servidor http com redirect para https. +- tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question. +- click numa opcao checkbox fora da checkbox+label não está a funcionar. - mathjax, formulas $$f(x)$$ nas opções de escolha multipla, não ficam centradas em toda a coluna mas apenas na largura do parágrafo. - QFactory.generate() devia fazer run da gen_async, ou remover. - classificacoes so devia mostrar os que ja fizeram alguma coisa diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index 7ae92ce..9d410d3 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -16,7 +16,7 @@ import sqlalchemy as sa # this project from .models import Student, Answer, Topic, StudentTopic -from .questions import Question, QFactory, QDict +from .questions import Question, QFactory, QDict, QuestionException from .student import StudentState from .tools import load_yaml @@ -100,7 +100,6 @@ class LearnApp(object): # if graph has topics that are not in the database, add them self.add_missing_topics(self.deps.nodes()) - if check: self.sanity_check_questions() @@ -113,8 +112,8 @@ class LearnApp(object): logger.debug(f'checking {qref}...') try: q = self.factory[qref].generate() - except Exception: - logger.error(f'Failed to generate "{qref}".') + except QuestionException as e: + logger.error(e) errors += 1 continue # to next question @@ -137,7 +136,7 @@ class LearnApp(object): continue # to next test if errors > 0: - logger.error(f'{errors:>6} errors found.') + logger.error(f'{errors:>6} error(s) found.') raise LearnException('Sanity checks') else: logger.info(' 0 errors found.') @@ -345,7 +344,7 @@ class LearnApp(object): logger.info(f'{m:6} topics') logger.info(f'{q:6} answers') - # ============================================================================ + # ======================================================================== # Populates a digraph. # # Nodes are the topic references e.g. 'my/topic' @@ -400,7 +399,7 @@ class LearnApp(object): def make_factory(self) -> Dict[str, QFactory]: logger.info('Building questions factory:') - factory: Dict[str, QFactory] = {} + factory = {} g = self.deps for tref in g.nodes(): t = g.nodes[tref] @@ -418,7 +417,7 @@ class LearnApp(object): # within the file for i, q in enumerate(questions): qref = q.get('ref', str(i)) # ref or number - q['ref'] = tref + ':' + qref + q['ref'] = f'{tref}:{qref}' q['path'] = topicpath q.setdefault('append_wrong', t['append_wrong']) diff --git a/aprendizations/questions.py b/aprendizations/questions.py index 82bc440..689f913 100644 --- a/aprendizations/questions.py +++ b/aprendizations/questions.py @@ -72,7 +72,6 @@ class QuestionRadio(Question): ''' # ------------------------------------------------------------------------ - # FIXME marking all options right breaks def __init__(self, q: QDict) -> None: super().__init__(q) @@ -86,18 +85,46 @@ class QuestionRadio(Question): 'max_tries': (n + 3) // 4 # 1 try for each 4 options })) - # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0] - # correctness levels from 0.0 to 1.0 (no discount here!) + # 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"]}"') + raise QuestionException(msg) + self['correct'] = [1.0 if x == self['correct'] else 0.0 for x in range(n)] - if len(self['correct']) != n: - msg = ('Number of options and correct differ in ' - f'"{self["ref"]}", file "{self["filename"]}".') - logger.error(msg) - raise QuestionException(msg) - + 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"]}"') + 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) + + # check grade boundaries + if self['discount'] and not all(0.0 <= x <= 1.0 + for x in self['correct']): + msg = (f'If discount=true, correct values must be in the ' + f'interval [0.0, 1.0] in "{self["ref"]}"') + 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"]}"') + 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] @@ -123,7 +150,7 @@ class QuestionRadio(Question): # final shuffle of the options perm = random.sample(range(self['choose']), k=self['choose']) self['options'] = [str(options[i]) for i in perm] - self['correct'] = [float(correct[i]) for i in perm] + self['correct'] = [correct[i] for i in perm] # ------------------------------------------------------------------------ # can assign negative grades for wrong answers @@ -131,10 +158,13 @@ class QuestionRadio(Question): super().correct() if self['answer'] is not None: - x = self['correct'][int(self['answer'])] - if self['discount']: - n = len(self['options']) # number of options - x_aver = sum(self['correct']) / n + 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 + + # 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'] = x @@ -168,12 +198,32 @@ class QuestionCheckbox(Question): 'max_tries': max(1, min(n - 1, 3)) })) + # must be a list of numbers + if not isinstance(self['correct'], list): + msg = 'Correct must be a list of numbers or booleans' + raise QuestionException(msg) + + # must match number of options if len(self['correct']) != n: - msg = (f'Options and correct size mismatch in ' - f'"{self["ref"]}", file "{self["filename"]}".') - logger.error(msg) + msg = (f'Incompatible sizes: {n} options vs ' + f'{len(self["correct"])} correct in "{self["ref"]}"') 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) + + # check grade boundaries (FUTURE) + # if self['discount'] and not all(0.0 <= x <= 1.0 + # for x in self['correct']): + # msg = (f'If discount=true, correct values must be in the ' + # f'interval [0.0, 1.0] in "{self["ref"]}"') + # raise QuestionException(msg) + # if an option is a list of (right, wrong), pick one options = [] correct = [] @@ -526,17 +576,21 @@ class QFactory(object): stdin=q['stdin']) q.update(out) - # Finally we create an instance of Question() + # Get class for this question type try: - qinstance = self._types[q['type']](QDict(q)) # of matching class - except QuestionException as e: - logger.error(e) - raise e + qclass = self._types[q['type']] except KeyError: logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') raise - else: - return qinstance + + # Finally create an instance of Question() + try: + qinstance = qclass(QDict(q)) + except QuestionException as e: + # logger.error(e) + raise e + + return qinstance # ------------------------------------------------------------------------ def generate(self) -> Question: diff --git a/aprendizations/serve.py b/aprendizations/serve.py index 920718e..160d9ff 100644 --- a/aprendizations/serve.py +++ b/aprendizations/serve.py @@ -254,7 +254,6 @@ class TopicHandler(BaseHandler): class FileHandler(BaseHandler): @tornado.web.authenticated async def get(self, filename): - # logger.debug(f'[FileHandler] {filename}') uid = self.current_user public_dir = self.learn.get_current_public_dir(uid) filepath = path.expanduser(path.join(public_dir, filename)) @@ -331,7 +330,7 @@ class QuestionHandler(BaseHandler): user = self.current_user answer = self.get_body_arguments('answer') # list qid = self.get_body_arguments('qid')[0] - logger.debug(f'[QuestionHandler] user={user}, answer={answer}') + logger.debug(f'[QuestionHandler] answer={answer}') # --- check if browser opened different questions simultaneously if qid != self.learn.get_current_question_id(user): diff --git a/config/logger-debug.yaml b/config/logger-debug.yaml index 45d6b09..96053d5 100644 --- a/config/logger-debug.yaml +++ b/config/logger-debug.yaml @@ -2,50 +2,52 @@ version: 1 formatters: - void: - format: '' - standard: - format: '%(asctime)s | %(thread)-15d | %(levelname)-8s | %(module)-10s | %(funcName)-20s | %(message)s' - # datefmt: '%H:%M:%S' + void: + format: '' + standard: + format: '%(asctime)s | %(levelname)-8s | %(module)-10s | %(funcName)-22s | + %(message)s' + # | %(thread)-15d + # datefmt: '%H:%M:%S' handlers: - default: - level: 'DEBUG' - class: 'logging.StreamHandler' - formatter: 'standard' - stream: 'ext://sys.stdout' + default: + level: 'DEBUG' + class: 'logging.StreamHandler' + formatter: 'standard' + stream: 'ext://sys.stdout' loggers: - '': - handlers: ['default'] - level: 'DEBUG' - - 'aprendizations.factory': - handlers: ['default'] - level: 'DEBUG' - propagate: false - - 'aprendizations.student': - handlers: ['default'] - level: 'DEBUG' - propagate: false - - 'aprendizations.learnapp': - handlers: ['default'] - level: 'DEBUG' - propagate: false - - 'aprendizations.questions': - handlers: ['default'] - level: 'DEBUG' - propagate: false - - 'aprendizations.tools': - handlers: ['default'] - level: 'DEBUG' - propagate: false - - 'aprendizations.serve': - handlers: ['default'] - level: 'DEBUG' - propagate: false + '': + handlers: ['default'] + level: 'DEBUG' + + 'aprendizations.factory': + handlers: ['default'] + level: 'DEBUG' + propagate: false + + 'aprendizations.student': + handlers: ['default'] + level: 'DEBUG' + propagate: false + + 'aprendizations.learnapp': + handlers: ['default'] + level: 'DEBUG' + propagate: false + + 'aprendizations.questions': + handlers: ['default'] + level: 'DEBUG' + propagate: false + + 'aprendizations.tools': + handlers: ['default'] + level: 'DEBUG' + propagate: false + + 'aprendizations.serve': + handlers: ['default'] + level: 'DEBUG' + propagate: false diff --git a/demo/astronomy/solar-system/questions.yaml b/demo/astronomy/solar-system/questions.yaml index f77979e..c3fdbb7 100644 --- a/demo/astronomy/solar-system/questions.yaml +++ b/demo/astronomy/solar-system/questions.yaml @@ -28,6 +28,7 @@ - Têm todos o mesmo tamanho # opcional correct: 2 + # discount: true shuffle: false solution: | O maior planeta é Júpiter. Tem uma massa 1000 vezes inferior ao Sol, mas diff --git a/demo/math/addition/questions.yaml b/demo/math/addition/questions.yaml index 0ab7d95..6480bd4 100644 --- a/demo/math/addition/questions.yaml +++ b/demo/math/addition/questions.yaml @@ -5,6 +5,7 @@ script: addition-two-digits.py args: [10, 20] +# --------------------------------------------------------------------------- - type: checkbox ref: addition-properties title: Propriedades da adição -- libgit2 0.21.2