Commit 91d0281ad9caa56168ce12ae957da075c1593f0d

Authored by Miguel Barão
1 parent 45744cc0
Exists in master and in 1 other branch dev

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
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) -&gt; Any: @@ -106,9 +121,11 @@ def get_logger_config(debug: bool = False) -&gt; 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(&#39;QDict&#39;, Dict[str, Any]) @@ -20,7 +24,7 @@ QDict = NewType(&#39;QDict&#39;, 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 }
@@ -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 }