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
BUGS.md
1 1  
2 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 7 - internal server error 500... experimentar cenario: aluno tem login efectuado, prof muda pw e faz login/logout. aluno obtem erro 500.
5 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 9 - if topic deps on invalid ref terminates server with "Unknown error".
... ...
aprendizations/initdb.py
1 1 #!/usr/bin/env python3
2 2  
  3 +'''
  4 +Initializes or updates database
  5 +'''
  6 +
3 7 # python standard libraries
4 8 import csv
5 9 import argparse
... ... @@ -12,12 +16,14 @@ import bcrypt
12 16 import sqlalchemy as sa
13 17  
14 18 # this project
15   -from .models import Base, Student
  19 +from aprendizations.models import Base, Student
16 20  
17 21  
18 22 # ===========================================================================
19 23 # Parse command line options
20 24 def parse_commandline_arguments():
  25 + '''Parse command line arguments'''
  26 +
21 27 argparser = argparse.ArgumentParser(
22 28 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
23 29 description='Insert new users into a database. Users can be imported '
... ... @@ -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 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 80 csv_settings = {
72 81 'delimiter': ';',
73 82 'quotechar': '"',
... ... @@ -75,8 +84,8 @@ def get_students_from_csv(filename):
75 84 }
76 85  
77 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 89 students = [{
81 90 'uid': s['N.º'],
82 91 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())
... ... @@ -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 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 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 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 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 137 def main():
  138 + '''performs the main functions'''
  139 +
131 140 args = parse_commandline_arguments()
132 141  
133 142 # --- database stuff
134   - print(f'Using database: ', args.db)
  143 + print(f'Using database: {args.db}')
135 144 engine = sa.create_engine(f'sqlite:///{args.db}', echo=False)
136 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 149 students = []
142 150  
143 151 for csvfile in args.csvfile:
... ... @@ -159,13 +167,13 @@ def main():
159 167  
160 168 if new_students:
161 169 # --- password hashing
162   - print(f'Generating password hashes', end='')
  170 + print('Generating password hashes', end='')
163 171 with ThreadPoolExecutor() as executor:
164 172 executor.map(lambda s: hashpw(s, args.pw), new_students)
165 173  
166 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 178 try:
171 179 session.add_all([Student(id=s['uid'],
... ... @@ -182,15 +190,15 @@ def main():
182 190 print('There are no new students to add.')
183 191  
184 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 199 session.commit()
192 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 203 show_students_in_database(session, args.verbose)
196 204  
... ...
aprendizations/learnapp.py
... ... @@ -85,10 +85,10 @@ class LearnApp():
85 85  
86 86 try:
87 87 config: Dict[str, Any] = load_yaml(courses)
88   - except Exception:
  88 + except Exception as exc:
89 89 msg = f'Failed to load yaml file "{courses}"'
90 90 logger.error(msg)
91   - raise LearnException(msg)
  91 + raise LearnException(msg) from exc
92 92  
93 93 # --- topic dependencies are shared between all courses
94 94 self.deps = nx.DiGraph(prefix=prefix)
... ... @@ -336,9 +336,9 @@ class LearnApp():
336 336 student = self.online[uid]['state']
337 337 try:
338 338 student.start_course(course_id)
339   - except Exception:
  339 + except Exception as exc:
340 340 logger.warning('"%s" could not start course "%s"', uid, course_id)
341   - raise LearnException()
  341 + raise LearnException() from exc
342 342 else:
343 343 logger.info('User "%s" started course "%s"', uid, course_id)
344 344  
... ... @@ -392,9 +392,9 @@ class LearnApp():
392 392 count_students: int = sess.query(Student).count()
393 393 count_topics: int = sess.query(Topic).count()
394 394 count_answers: int = sess.query(Answer).count()
395   - except Exception:
  395 + except Exception as exc:
396 396 logger.error('Database "%s" not usable!', database)
397   - raise DatabaseUnusableError()
  397 + raise DatabaseUnusableError() from exc
398 398 else:
399 399 logger.info('%6d students', count_students)
400 400 logger.info('%6d topics', count_topics)
... ... @@ -416,7 +416,7 @@ class LearnApp():
416 416 'type': 'topic', # chapter
417 417 'file': 'questions.yaml',
418 418 'shuffle_questions': True,
419   - 'choose': 9999,
  419 + 'choose': 99,
420 420 'forgetting_factor': 1.0, # no forgetting
421 421 'max_tries': 1, # in every question
422 422 'append_wrong': True,
... ... @@ -472,22 +472,20 @@ class LearnApp():
472 472 # load questions as list of dicts
473 473 try:
474 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 477 ', '.join(self.deps.successors(tref))
479   - msg = f'{msg1}. {msg2}'
480 478 logger.error(msg)
481   - raise LearnException(msg)
  479 + raise LearnException(msg) from exc
482 480 logger.debug(' Loading %s', fullpath)
483 481 try:
484 482 questions: List[QDict] = load_yaml(fullpath)
485   - except Exception:
  483 + except Exception as exc:
486 484 if topic['type'] == 'chapter':
487 485 return factory # chapters may have no "questions"
488 486 msg = f'Failed to load "{fullpath}"'
489 487 logger.error(msg)
490   - raise LearnException(msg)
  488 + raise LearnException(msg) from exc
491 489  
492 490 if not isinstance(questions, list):
493 491 msg = f'File "{fullpath}" must be a list of questions'
... ... @@ -548,8 +546,8 @@ class LearnApp():
548 546 # ------------------------------------------------------------------------
549 547 def get_current_question(self, uid: str) -> Optional[Question]:
550 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 553 def get_current_question_id(self, uid: str) -> str:
... ... @@ -655,6 +653,6 @@ class LearnApp():
655 653 return sorted(((u, name, progress[u], perf.get(u, 0.0))
656 654 for u, name in students
657 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 1 #!/usr/bin/env python3
2 2  
  3 +'''
  4 +Setup configurations and then runs the application.
  5 +'''
  6 +
  7 +
3 8 # python standard library
4 9 import argparse
5 10 import logging
... ... @@ -9,14 +14,18 @@ import sys
9 14 from typing import Any, Dict
10 15  
11 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 24 def parse_cmdline_arguments():
  25 + '''
  26 + Parses command line arguments. Uses the argparse package.
  27 + '''
  28 +
20 29 argparser = argparse.ArgumentParser(
21 30 description='Webserver for interactive learning and practice. '
22 31 'Please read the documentation included with this software before '
... ... @@ -63,6 +72,12 @@ def parse_cmdline_arguments():
63 72  
64 73 # ----------------------------------------------------------------------------
65 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 81 if debug:
67 82 filename, level = 'logger-debug.yaml', 'DEBUG'
68 83 else:
... ... @@ -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 124 def main():
  125 + '''
  126 + Start application and webserver
  127 + '''
  128 +
112 129 # --- Commandline argument parsing
113 130 arg = parse_cmdline_arguments()
114 131  
... ... @@ -122,8 +139,8 @@ def main():
122 139  
123 140 try:
124 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 144 sys.exit(1)
128 145  
129 146 logging.info('====================== Start Logging ======================')
... ... @@ -139,7 +156,7 @@ def main():
139 156 ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'),
140 157 path.join(certs_dir, 'privkey.pem'))
141 158 except FileNotFoundError:
142   - logging.critical(f'SSL certificates missing in {certs_dir}')
  159 + logging.critical('SSL certificates missing in %s', certs_dir)
143 160 print('--------------------------------------------------------------',
144 161 'Certificates should be issued by a certificate authority (CA),',
145 162 'such as https://letsencrypt.org. ',
... ... @@ -183,7 +200,8 @@ def main():
183 200 sys.exit(1)
184 201 except Exception:
185 202 logging.critical('Unknown error')
186   - sys.exit(1)
  203 + # sys.exit(1)
  204 + raise
187 205 else:
188 206 logging.info('LearnApp started')
189 207  
... ...
aprendizations/questions.py
  1 +'''
  2 +Classes the implement several types of questions.
  3 +'''
  4 +
1 5  
2 6 # python standard library
3 7 import asyncio
4 8 from datetime import datetime
  9 +import logging
  10 +from os import path
5 11 import random
6 12 import re
7   -from os import path
8   -import logging
9 13 from typing import Any, Dict, NewType
10 14 import uuid
11 15  
12 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 19 # setup logger for this module
16 20 logger = logging.getLogger(__name__)
... ... @@ -20,7 +24,7 @@ QDict = NewType(&#39;QDict&#39;, Dict[str, Any])
20 24  
21 25  
22 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 50 }))
47 51  
48 52 def set_answer(self, ans) -> None:
  53 + '''set answer field and register time'''
49 54 self['answer'] = ans
50 55 self['finish_time'] = datetime.now()
51 56  
52 57 def correct(self) -> None:
  58 + '''default correction (synchronous version)'''
53 59 self['comments'] = ''
54 60 self['grade'] = 0.0
55 61  
56 62 async def correct_async(self) -> None:
  63 + '''default correction (async version)'''
57 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 87 super().__init__(q)
81 88  
82 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 100 self.set_defaults(QDict({
92 101 'text': '',
93 102 'correct': 0,
94 103 'shuffle': True,
95 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 108 # check correct bounds and convert int to list,
100 109 # e.g. correct: 2 --> correct: [0,0,1,0,0]
101 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 115 raise QuestionException(msg)
106 116  
107 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 120 elif isinstance(self['correct'], list):
111 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 126 raise QuestionException(msg)
  127 +
116 128 # make sure is a list of floats
117 129 try:
118 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 137 # check grade boundaries
125 138 if self['discount'] and not all(0.0 <= x <= 1.0
126 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 143 raise QuestionException(msg)
130 144  
131 145 # at least one correct option
132 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 150 raise QuestionException(msg)
136 151  
137 152 # If shuffle==false, all options are shown as defined
138 153 # otherwise, select 1 correct and choose a few wrong ones
139 154 if self['shuffle']:
140 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 159 self.set_defaults(QDict({'choose': 1+len(wrong)}))
145 160  
146 161 # try to choose 1 correct option
147 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 166 else:
152 167 options = []
153 168 correct = []
... ... @@ -164,20 +179,23 @@ class QuestionRadio(Question):
164 179 self['correct'] = [correct[i] for i in perm]
165 180  
166 181 # ------------------------------------------------------------------------
167   - # can assign negative grades for wrong answers
168 182 def correct(self) -> None:
  183 + '''
  184 + Correct `answer` and set `grade`.
  185 + Can assign negative grades for wrong answers
  186 + '''
169 187 super().correct()
170 188  
171 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 194 # note: there are no numerical errors when summing 1.0s so the
177 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 216 super().__init__(q)
199 217  
200 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 229 # set defaults if missing
210 230 self.set_defaults(QDict({
211 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 233 'shuffle': True,
214 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 239 # must be a list of numbers
220 240 if not isinstance(self['correct'], list):
221 241 msg = 'Correct must be a list of numbers or booleans'
  242 + logger.error(msg)
222 243 raise QuestionException(msg)
223 244  
224 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 250 raise QuestionException(msg)
229 251  
230 252 # make sure is a list of floats
231 253 try:
232 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 261 # check grade boundaries
239 262 if self['discount'] and not all(0.0 <= x <= 1.0
240 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 283 # if an option is a list of (right, wrong), pick one
258 284 options = []
259 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 295 # generate random permutation, e.g. [2,1,4,0,3]
271 296 # and apply to `options` and `correct`
272 297 if self['shuffle']:
273   - perm = random.sample(range(n), k=self['choose'])
  298 + perm = random.sample(range(nopts), k=self['choose'])
274 299 self['options'] = [options[i] for i in perm]
275 300 self['correct'] = [correct[i] for i in perm]
276 301 else:
... ... @@ -283,18 +308,18 @@ class QuestionCheckbox(Question):
283 308 super().correct()
284 309  
285 310 if self['answer'] is not None:
286   - x = 0.0
  311 + grade = 0.0
287 312 if self['discount']:
288 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 316 else:
292 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 321 try:
297   - self['grade'] = x / sum_abs
  322 + self['grade'] = grade / sum_abs
298 323 except ZeroDivisionError:
299 324 self['grade'] = 1.0 # limit p->0
300 325  
... ... @@ -325,33 +350,35 @@ class QuestionText(Question):
325 350 # make sure all elements of the list are strings
326 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 357 raise QuestionException(msg)
333 358  
334 359 # check if answers are invariant with respect to the transforms
335 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 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 370 ans = ans.replace(' ', '')
345   - elif f == 'trim': # removes spaces around
  371 + elif transform == 'trim': # removes spaces around
346 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 374 ans = re.sub(r'\s+', ' ', ans.strip())
349   - elif f == 'lower': # convert to lowercase
  375 + elif transform == 'lower': # convert to lowercase
350 376 ans = ans.lower()
351   - elif f == 'upper': # convert to uppercase
  377 + elif transform == 'upper': # convert to uppercase
352 378 ans = ans.upper()
353 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 382 return ans
356 383  
357 384 # ------------------------------------------------------------------------
... ... @@ -391,23 +418,24 @@ class QuestionTextRegex(Question):
391 418 # converts patterns to compiled versions
392 419 try:
393 420 self['correct'] = [re.compile(a) for a in self['correct']]
394   - except Exception:
  421 + except Exception as exc:
395 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 427 def correct(self) -> None:
400 428 super().correct()
401 429 if self['answer'] is not None:
402 430 self['grade'] = 0.0
403   - for r in self['correct']:
  431 + for regex in self['correct']:
404 432 try:
405   - if r.match(self['answer']):
  433 + if regex.match(self['answer']):
406 434 self['grade'] = 1.0
407 435 return
408 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 466 if len(self['correct']) != 2:
439 467 msg = (f'Numeric interval must be a list with two numbers, in '
440 468 f'{self["ref"]}')
  469 + logger.error(msg)
441 470 raise QuestionException(msg)
442 471  
443 472 try:
444 473 self['correct'] = [float(n) for n in self['correct']]
445   - except Exception:
  474 + except Exception as exc:
446 475 msg = (f'Numeric interval must be a list with two numbers, in '
447 476 f'{self["ref"]}')
448   - raise QuestionException(msg)
  477 + logger.error(msg)
  478 + raise QuestionException(msg) from exc
449 479  
450 480 # invalid
451 481 else:
452 482 msg = (f'Numeric interval must be a list with two numbers, in '
453 483 f'{self["ref"]}')
  484 + logger.error(msg)
454 485 raise QuestionException(msg)
455 486  
456 487 # ------------------------------------------------------------------------
... ... @@ -504,21 +535,22 @@ class QuestionTextArea(Question):
504 535 )
505 536  
506 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 540 self['grade'] = 0.0
509 541 elif isinstance(out, dict):
510 542 self['comments'] = out.get('comments', '')
511 543 try:
512 544 self['grade'] = float(out['grade'])
513 545 except ValueError:
514   - logger.error(f'Output error in "{self["correct"]}".')
  546 + logger.error('Output error in "%s".', self["correct"])
515 547 except KeyError:
516   - logger.error(f'No grade in "{self["correct"]}".')
  548 + logger.error('No grade in "%s".', self["correct"])
517 549 else:
518 550 try:
519 551 self['grade'] = float(out)
520 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 556 async def correct_async(self) -> None:
... ... @@ -533,25 +565,31 @@ class QuestionTextArea(Question):
533 565 )
534 566  
535 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 570 self['grade'] = 0.0
538 571 elif isinstance(out, dict):
539 572 self['comments'] = out.get('comments', '')
540 573 try:
541 574 self['grade'] = float(out['grade'])
542 575 except ValueError:
543   - logger.error(f'Output error in "{self["correct"]}".')
  576 + logger.error('Output error in "%s".', self["correct"])
544 577 except KeyError:
545   - logger.error(f'No grade in "{self["correct"]}".')
  578 + logger.error('No grade in "%s".', self["correct"])
546 579 else:
547 580 try:
548 581 self['grade'] = float(out)
549 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 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 594 def __init__(self, q: QDict) -> None:
557 595 super().__init__(q)
... ... @@ -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 639 # Depending on the type of question, a different question class will be
602 640 # instantiated. All these classes derive from the base class `Question`.
603 641 _types = {
... ... @@ -618,44 +656,52 @@ class QFactory(object):
618 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 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 666 # Shallow copy so that script generated questions will not replace
627 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 671 # If question is of generator type, an external program will be run
632 672 # which will print a valid question in yaml format to stdout. This
633 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 684 # Get class for this question type
644 685 try:
645   - qclass = self._types[q['type']]
  686 + qclass = self._types[question['type']]
646 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 690 raise
649 691  
650 692 # Finally create an instance of Question()
651 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 702 return qinstance
658 703  
659 704 # ------------------------------------------------------------------------
660 705 def generate(self) -> Question:
  706 + '''generate question (synchronous version)'''
661 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 88  
89 89 def get_current_user(self):
90 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 92 if user_cookie is not None:
93 93 uid = user_cookie.decode('utf-8')
94 94 counter = self.get_secure_cookie('counter').decode('utf-8')
... ... @@ -148,7 +148,7 @@ class LoginHandler(BaseHandler):
148 148  
149 149 if login_ok:
150 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 152 self.set_secure_cookie('counter', counter)
153 153 self.redirect('/')
154 154 else:
... ... @@ -221,6 +221,9 @@ class CoursesHandler(BaseHandler):
221 221 '''
222 222 Handles /courses
223 223 '''
  224 + def set_default_headers(self, *args, **kwargs):
  225 + self.set_header('Cache-Control', 'no-cache')
  226 +
224 227 @tornado.web.authenticated
225 228 def get(self):
226 229 '''Renders list of available courses'''
... ... @@ -270,6 +273,9 @@ class TopicHandler(BaseHandler):
270 273 '''
271 274 Handles a topic
272 275 '''
  276 + def set_default_headers(self, *args, **kwargs):
  277 + self.set_header('Cache-Control', 'no-cache')
  278 +
273 279 @tornado.web.authenticated
274 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 9 # python standard library
3 10 from datetime import datetime
4 11 import logging
5 12 import random
6   -from typing import List, Optional, Tuple
  13 +from typing import List, Optional
7 14  
8 15 # third party libraries
9 16 import networkx as nx
10 17  
11 18 # this project
12   -from .questions import Question
  19 +from aprendizations.questions import Question
13 20  
14 21  
15 22 # setup logger for this module
16 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 45 # methods that update state
39   - # =======================================================================
  46 + # ========================================================================
40 47 def __init__(self, uid, state, courses, deps, factory) -> None:
41 48 # shared application data between all students
42 49 self.deps = deps # dependency graph
... ... @@ -54,6 +61,10 @@ class StudentState(object):
54 61  
55 62 # ------------------------------------------------------------------------
56 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 68 if course is None:
58 69 logger.debug('no active course')
59 70 self.current_course: Optional[str] = None
... ... @@ -63,19 +74,21 @@ class StudentState(object):
63 74 try:
64 75 topics = self.courses[course]['goals']
65 76 except KeyError:
66   - logger.debug(f'course "{course}" does not exist')
  77 + logger.debug('course "%s" does not exist', course)
67 78 raise
68   - logger.debug(f'starting course "{course}"')
  79 + logger.debug('starting course "%s"', course)
69 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 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 93 # avoid regenerating questions in the middle of the current topic
81 94 if self.current_topic == topic and self.uid != '0':
... ... @@ -84,7 +97,7 @@ class StudentState(object):
84 97  
85 98 # do not allow locked topics
86 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 101 return
89 102  
90 103 self.previous_topic: Optional[str] = None
... ... @@ -93,95 +106,104 @@ class StudentState(object):
93 106 self.current_topic = topic
94 107 self.correct_answers = 0
95 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 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 117 self.questions: List[Question] = [await self.factory[ref].gen_async()
105 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 122 # get first question
110 123 self.next_question()
111 124  
112 125 # ------------------------------------------------------------------------
113   - # corrects current question
114   - # updates keys: answer, grade, finish_time, status, tries
115   - # ------------------------------------------------------------------------
116 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 134 logger.error('check_answer called but current_question is None!')
120 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 141 self.correct_answers += 1
127   - q['status'] = 'right'
  142 + question['status'] = 'right'
128 143  
129 144 else:
130 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 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 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 164 logger.error('get_question called but current_question is None!')
147 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 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 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 175 self.questions.append(new_question)
158 176 self.next_question()
159 177  
160 178 return self.current_question
161 179  
162 180 # ------------------------------------------------------------------------
163   - # moves to next question
164   - # ------------------------------------------------------------------------
165 181 def next_question(self) -> None:
  182 + '''
  183 + Moves to next question
  184 + '''
  185 +
166 186 try:
167   - q = self.questions.pop(0)
  187 + question = self.questions.pop(0)
168 188 except IndexError:
169 189 self.finish_topic()
170 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 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 208 self.state[self.current_topic] = {
187 209 'date': datetime.now(),
... ... @@ -194,22 +216,25 @@ class StudentState(object):
194 216 self.unlock_topics()
195 217  
196 218 # ------------------------------------------------------------------------
197   - # Update proficiency level of the topics using a forgetting factor
198   - # ------------------------------------------------------------------------
199 219 def update_topic_levels(self) -> None:
  220 + '''
  221 + Update proficiency level of the topics using a forgetting factor
  222 + '''
  223 +
200 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 227 try:
204 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 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 234 def unlock_topics(self) -> None:
  235 + '''
  236 + Unlock topics whose dependencies are satisfied (> min_level)
  237 + '''
213 238 for topic in self.deps.nodes():
214 239 if topic not in self.state: # if locked
215 240 pred = self.deps.predecessors(topic)
... ... @@ -221,7 +246,7 @@ class StudentState(object):
221 246 'level': 0.0, # unlock
222 247 'date': datetime.now()
223 248 }
224   - logger.debug(f'unlocked "{topic}"')
  249 + logger.debug('unlocked "%s"', topic)
225 250 # else: # lock this topic if deps do not satisfy min_level
226 251 # del self.state[topic]
227 252  
... ... @@ -230,64 +255,78 @@ class StudentState(object):
230 255 # ========================================================================
231 256  
232 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 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 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 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 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 290 return unlocked + locked
260 291  
261 292 # ------------------------------------------------------------------------
262 293 def get_current_question(self) -> Optional[Question]:
  294 + '''gets current question'''
263 295 return self.current_question
264 296  
265 297 # ------------------------------------------------------------------------
266 298 def get_current_topic(self) -> Optional[str]:
  299 + '''gets current topic'''
267 300 return self.current_topic
268 301  
269 302 # ------------------------------------------------------------------------
270 303 def get_previous_topic(self) -> Optional[str]:
  304 + '''gets previous topic'''
271 305 return self.previous_topic
272 306  
273 307 # ------------------------------------------------------------------------
274 308 def get_current_course_title(self) -> str:
  309 + '''gets current course title'''
275 310 return str(self.courses[self.current_course]['title'])
276 311  
277 312 # ------------------------------------------------------------------------
278 313 def get_current_course_id(self) -> Optional[str]:
  314 + '''gets current course id'''
279 315 return self.current_course
280 316  
281 317 # ------------------------------------------------------------------------
282 318 def is_locked(self, topic: str) -> bool:
  319 + '''checks if a given topic is locked'''
283 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 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 330 return [{
292 331 'ref': ref,
293 332 'type': self.deps.nodes[ref]['type'],
... ... @@ -297,19 +336,16 @@ class StudentState(object):
297 336  
298 337 # ------------------------------------------------------------------------
299 338 def get_topic_progress(self) -> float:
  339 + '''computes progress of the current topic'''
300 340 return self.correct_answers / (1 + self.correct_answers +
301 341 len(self.questions))
302 342  
303 343 # ------------------------------------------------------------------------
304 344 def get_topic_level(self, topic: str) -> float:
  345 + '''gets level of a given topic'''
305 346 return float(self.state[topic]['level'])
306 347  
307 348 # ------------------------------------------------------------------------
308 349 def get_topic_date(self, topic: str):
  350 + '''gets date of a given topic'''
309 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 4  
5 5 <head>
6 6 <title>{{appname}}</title>
7   - <link rel="icon" href="/static/favicon.ico">
  7 + <link rel="icon" href="favicon.ico">
8 8 <meta charset="utf-8">
9 9 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
10 10 <meta name="author" content="Miguel Barão">
11 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 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 23 </head>
24 24  
25 25 <body>
26 26 <!-- ===== navbar ==================================================== -->
27 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 29 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
30 30 <span class="navbar-toggler-icon"></span>
31 31 </button>
... ...
aprendizations/templates/login.html
... ... @@ -9,15 +9,15 @@
9 9 <meta name="author" content="Miguel Barão">
10 10  
11 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 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 22 </head>
23 23 <!-- =================================================================== -->
... ... @@ -28,7 +28,7 @@
28 28 <div class="row">
29 29  
30 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 32 </div>
33 33  
34 34 <div class="col-sm-3">
... ...
aprendizations/templates/maintopics-table.html
... ... @@ -11,23 +11,23 @@
11 11 <meta name="author" content="Miguel Barão">
12 12  
13 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 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 26 </head>
27 27 <!-- ===================================================================== -->
28 28 <body>
29 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 31 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
32 32 <span class="navbar-toggler-icon"></span>
33 33 </button>
... ...
aprendizations/templates/rankings.html
... ... @@ -11,23 +11,23 @@
11 11 <meta name="author" content="Miguel Barão">
12 12  
13 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 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 26 </head>
27 27 <!-- ===================================================================== -->
28 28 <body>
29 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 31 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
32 32 <span class="navbar-toggler-icon"></span>
33 33 </button>
... ...
aprendizations/templates/topic.html
... ... @@ -21,21 +21,21 @@
21 21 <!-- Scripts -->
22 22 <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
23 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 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 39 </head>
40 40 <!-- ===================================================================== -->
41 41  
... ... @@ -47,7 +47,7 @@
47 47  
48 48 <!-- Navbar -->
49 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 51 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
52 52 <span class="navbar-toggler-icon"></span>
53 53 </button>
... ...
package-lock.json
... ... @@ -3,19 +3,19 @@
3 3 "lockfileVersion": 1,
4 4 "dependencies": {
5 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 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 15 "mdbootstrap": {
16   - "version": "4.14.0",
17   - "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.14.0.tgz",
18   - "integrity": "sha512-RBa3uB64m1DpO1exIupqQkL0Ca108zpjXnOH+xyJ6bIyr4BJw2djSmWoizQHhN5o1v3ujN3PtNJ3x7k+ITmFRw=="
  16 + "version": "4.19.1",
  17 + "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.19.1.tgz",
  18 + "integrity": "sha512-vzYd7UQ0H1tyJfDqCYwsAv+sxol/xRkJP/5FMhcdW3ZbN9xUnmWiSPHx3A6ddGxdOQbfJTWxT3G8M+I++Qdk6w=="
19 19 }
20 20 }
21 21 }
... ...
package.json
... ... @@ -2,9 +2,9 @@
2 2 "description": "Javascript libraries required to run the server",
3 3 "email": "mjsb@uevora.pt",
4 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 9 "private": true
10 10 }
... ...