Commit 45744cc0856d8f38a4debe4c383ed469a2fb15b3

Authored by Miguel Barão
1 parent 33af3401
Exists in master and in 1 other branch dev

- adds docstrings in functions and classes

- fix pylint warnings
- fix math not rendering: repaced tex-svg.js -> tex-mml-chtml.js
- fix progress bar in topic so that it doesn't scroll with the page.
1 1
2 # BUGS 2 # BUGS
3 3
  4 +- 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.
  6 +- if topic deps on invalid ref terminates server with "Unknown error".
  7 +- warning nos topics que não são usados em nenhum curso
4 - nao esta a seguir o max_tries definido no ficheiro de dependencias. 8 - nao esta a seguir o max_tries definido no ficheiro de dependencias.
5 - devia mostrar timeout para o aluno saber a razao. 9 - devia mostrar timeout para o aluno saber a razao.
6 - permitir configuracao para escolher entre static files locais ou remotos 10 - permitir configuracao para escolher entre static files locais ou remotos
@@ -141,7 +141,7 @@ sudo pkg install py36-certbot # FreeBSD @@ -141,7 +141,7 @@ sudo pkg install py36-certbot # FreeBSD
141 sudo apt install certbot # Ubuntu 141 sudo apt install certbot # Ubuntu
142 ``` 142 ```
143 143
144 -To generate or renew the certificates, ports 80 and 443 have to be accessible. The firewall and webserver have to be stopped. 144 +To generate or renew the certificates, ports 80 and 443 have to be accessible. **The firewall and webserver have to be stopped**.
145 145
146 ```sh 146 ```sh
147 sudo certbot certonly --standalone -d www.example.com # first time 147 sudo certbot certonly --standalone -d www.example.com # first time
@@ -151,6 +151,7 @@ sudo certbot renew # renew @@ -151,6 +151,7 @@ sudo certbot renew # renew
151 Certificates are saved under `/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to `~/.local/share/certs` and change permissions to be readable: 151 Certificates are saved under `/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to `~/.local/share/certs` and change permissions to be readable:
152 152
153 ```sh 153 ```sh
  154 +cd ~/.local/share/certs
154 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem . 155 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem .
155 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem . 156 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem .
156 chmod 400 cert.pem privkey.pem 157 chmod 400 cert.pem privkey.pem
aprendizations/learnapp.py
  1 +'''
  2 +Learn application.
  3 +This is the main controller of the application.
  4 +'''
1 5
2 # python standard library 6 # python standard library
3 import asyncio 7 import asyncio
@@ -6,7 +10,7 @@ from contextlib import contextmanager # `with` statement in db sessions @@ -6,7 +10,7 @@ from contextlib import contextmanager # `with` statement in db sessions
6 from datetime import datetime 10 from datetime import datetime
7 import logging 11 import logging
8 from random import random 12 from random import random
9 -from os import path 13 +from os.path import join, exists
10 from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict 14 from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict
11 15
12 # third party libraries 16 # third party libraries
@@ -15,10 +19,10 @@ import networkx as nx @@ -15,10 +19,10 @@ import networkx as nx
15 import sqlalchemy as sa 19 import sqlalchemy as sa
16 20
17 # this project 21 # this project
18 -from .models import Student, Answer, Topic, StudentTopic  
19 -from .questions import Question, QFactory, QDict, QuestionException  
20 -from .student import StudentState  
21 -from .tools import load_yaml 22 +from aprendizations.models import Student, Answer, Topic, StudentTopic
  23 +from aprendizations.questions import Question, QFactory, QDict, QuestionException
  24 +from aprendizations.student import StudentState
  25 +from aprendizations.tools import load_yaml
22 26
23 27
24 # setup logger for this module 28 # setup logger for this module
@@ -27,33 +31,37 @@ logger = logging.getLogger(__name__) @@ -27,33 +31,37 @@ logger = logging.getLogger(__name__)
27 31
28 # ============================================================================ 32 # ============================================================================
29 class LearnException(Exception): 33 class LearnException(Exception):
30 - pass 34 + '''Exceptions raised from the LearnApp class'''
31 35
32 36
33 class DatabaseUnusableError(LearnException): 37 class DatabaseUnusableError(LearnException):
34 - pass 38 + '''Exception raised if the database fails in the initialization'''
35 39
36 40
37 # ============================================================================ 41 # ============================================================================
38 -# LearnApp - application logic  
39 -#  
40 -# self.deps - networkx topic dependencies  
41 -# self.courses - dict {course_id: {'title': ...,  
42 -# 'description': ...,  
43 -# 'goals': ...,}, ...}  
44 -# self.factory = dict {qref: QFactory()}  
45 -# self.online - dict {student_id: {'number': ...,  
46 -# 'name': ...,  
47 -# 'state': StudentState(),  
48 -# 'counter': ...}, ...}  
49 -# ============================================================================  
50 -class LearnApp(object):  
51 - # ------------------------------------------------------------------------  
52 - # helper to manage db sessions using the `with` statement, for example  
53 - # with self.db_session() as s: s.query(...) 42 +class LearnApp():
  43 + '''
  44 + LearnApp - application logic
  45 +
  46 + self.deps - networkx topic dependencies
  47 + self.courses - dict {course_id: {'title': ...,
  48 + 'description': ...,
  49 + 'goals': ...,}, ...}
  50 + self.factory = dict {qref: QFactory()}
  51 + self.online - dict {student_id: {'number': ...,
  52 + 'name': ...,
  53 + 'state': StudentState(),
  54 + 'counter': ...}, ...}
  55 + '''
  56 +
  57 +
54 # ------------------------------------------------------------------------ 58 # ------------------------------------------------------------------------
55 @contextmanager 59 @contextmanager
56 - def db_session(self, **kw): 60 + def _db_session(self, **kw):
  61 + '''
  62 + helper to manage db sessions using the `with` statement, for example
  63 + with self._db_session() as s: s.query(...)
  64 + '''
57 session = self.Session(**kw) 65 session = self.Session(**kw)
58 try: 66 try:
59 yield session 67 yield session
@@ -66,15 +74,13 @@ class LearnApp(object): @@ -66,15 +74,13 @@ class LearnApp(object):
66 session.close() 74 session.close()
67 75
68 # ------------------------------------------------------------------------ 76 # ------------------------------------------------------------------------
69 - # init  
70 - # ------------------------------------------------------------------------  
71 def __init__(self, 77 def __init__(self,
72 courses: str, # filename with course configurations 78 courses: str, # filename with course configurations
73 prefix: str, # path to topics 79 prefix: str, # path to topics
74 db: str, # database filename 80 db: str, # database filename
75 check: bool = False) -> None: 81 check: bool = False) -> None:
76 82
77 - self.db_setup(db) # setup database and check students 83 + self._db_setup(db) # setup database and check students
78 self.online: Dict[str, Dict] = dict() # online students 84 self.online: Dict[str, Dict] = dict() # online students
79 85
80 try: 86 try:
@@ -88,123 +94,130 @@ class LearnApp(object): @@ -88,123 +94,130 @@ class LearnApp(object):
88 self.deps = nx.DiGraph(prefix=prefix) 94 self.deps = nx.DiGraph(prefix=prefix)
89 logger.info('Populating topic graph:') 95 logger.info('Populating topic graph:')
90 96
91 - t = config.get('topics', {}) # topics defined directly in courses file  
92 - self.populate_graph(t)  
93 - logger.info(f'{len(t):>6} topics in {courses}')  
94 - for f in config.get('topics_from', []):  
95 - c = load_yaml(f) # course configuration 97 + # topics defined directly in the courses file, usually empty
  98 + base_topics = config.get('topics', {})
  99 + self._populate_graph(base_topics)
  100 + logger.info('%6d topics in %s', len(base_topics), courses)
  101 +
  102 + # load other course files with the topics the their deps
  103 + for course_file in config.get('topics_from', []):
  104 + course_conf = load_yaml(course_file) # course configuration
96 # FIXME set defaults?? 105 # FIXME set defaults??
97 - logger.info(f'{len(c["topics"]):>6} topics imported from {f}')  
98 - self.populate_graph(c)  
99 - logger.info(f'Graph has {len(self.deps)} topics') 106 + logger.info('%6d topics imported from %s',
  107 + len(course_conf["topics"]), course_file)
  108 + self._populate_graph(course_conf)
  109 + logger.info('Graph has %d topics', len(self.deps))
100 110
101 # --- courses dict 111 # --- courses dict
102 self.courses = config['courses'] 112 self.courses = config['courses']
103 - logger.info(f'Courses: {", ".join(self.courses.keys())}')  
104 - for c, d in self.courses.items():  
105 - d.setdefault('title', '') # course title undefined  
106 - for goal in d['goals']: 113 + logger.info('Courses: %s', ', '.join(self.courses.keys()))
  114 + for cid, course in self.courses.items():
  115 + course.setdefault('title', '') # course title undefined
  116 + for goal in course['goals']:
107 if goal not in self.deps.nodes(): 117 if goal not in self.deps.nodes():
108 - msg = f'Goal "{goal}" from course "{c}" does not exist' 118 + msg = f'Goal "{goal}" from course "{cid}" does not exist'
109 logger.error(msg) 119 logger.error(msg)
110 raise LearnException(msg) 120 raise LearnException(msg)
111 - elif self.deps.nodes[goal]['type'] == 'chapter':  
112 - d['goals'] += [g for g in self.deps.predecessors(goal)  
113 - if g not in d['goals']] 121 + if self.deps.nodes[goal]['type'] == 'chapter':
  122 + course['goals'] += [g for g in self.deps.predecessors(goal)
  123 + if g not in course['goals']]
114 124
115 # --- factory is a dict with question generators for all topics 125 # --- factory is a dict with question generators for all topics
116 - self.factory: Dict[str, QFactory] = self.make_factory() 126 + self.factory: Dict[str, QFactory] = self._make_factory()
117 127
118 # if graph has topics that are not in the database, add them 128 # if graph has topics that are not in the database, add them
119 - self.add_missing_topics(self.deps.nodes()) 129 + self._add_missing_topics(self.deps.nodes())
120 130
121 if check: 131 if check:
122 - self.sanity_check_questions() 132 + self._sanity_check_questions()
123 133
124 # ------------------------------------------------------------------------ 134 # ------------------------------------------------------------------------
125 - def sanity_check_questions(self) -> None: 135 + def _sanity_check_questions(self) -> None:
  136 + '''
  137 + Unity tests for all questions
  138 +
  139 + Generates all questions, give right and wrong answers and corrects.
  140 + '''
126 logger.info('Starting sanity checks (may take a while...)') 141 logger.info('Starting sanity checks (may take a while...)')
127 142
128 errors: int = 0 143 errors: int = 0
129 for qref in self.factory: 144 for qref in self.factory:
130 - logger.debug(f'checking {qref}...') 145 + logger.debug('checking %s...', qref)
131 try: 146 try:
132 - q = self.factory[qref].generate()  
133 - except QuestionException as e:  
134 - logger.error(e) 147 + question = self.factory[qref].generate()
  148 + except QuestionException as exc:
  149 + logger.error(exc)
135 errors += 1 150 errors += 1
136 continue # to next question 151 continue # to next question
137 152
138 - if 'tests_right' in q:  
139 - for t in q['tests_right']:  
140 - q['answer'] = t  
141 - q.correct()  
142 - if q['grade'] < 1.0:  
143 - logger.error(f'Failed right answer in "{qref}".') 153 + if 'tests_right' in question:
  154 + for right_answer in question['tests_right']:
  155 + question['answer'] = right_answer
  156 + question.correct()
  157 + if question['grade'] < 1.0:
  158 + logger.error('Failed right answer in "%s".', qref)
144 errors += 1 159 errors += 1
145 continue # to next test 160 continue # to next test
146 - elif q['type'] == 'textarea':  
147 - msg = f' consider adding tests to {q["ref"]}' 161 + elif question['type'] == 'textarea':
  162 + msg = f'- consider adding tests to {question["ref"]}'
148 logger.warning(msg) 163 logger.warning(msg)
149 164
150 - if 'tests_wrong' in q:  
151 - for t in q['tests_wrong']:  
152 - q['answer'] = t  
153 - q.correct()  
154 - if q['grade'] >= 1.0:  
155 - logger.error(f'Failed wrong answer in "{qref}".') 165 + if 'tests_wrong' in question:
  166 + for wrong_answer in question['tests_wrong']:
  167 + question['answer'] = wrong_answer
  168 + question.correct()
  169 + if question['grade'] >= 1.0:
  170 + logger.error('Failed wrong answer in "%s".', qref)
156 errors += 1 171 errors += 1
157 continue # to next test 172 continue # to next test
158 173
159 if errors > 0: 174 if errors > 0:
160 - logger.error(f'{errors:>6} error(s) found.') 175 + logger.error('%6d error(s) found.', errors) # {errors:>6}
161 raise LearnException('Sanity checks') 176 raise LearnException('Sanity checks')
162 - else:  
163 - logger.info(' 0 errors found.') 177 + logger.info(' 0 errors found.')
164 178
165 # ------------------------------------------------------------------------ 179 # ------------------------------------------------------------------------
166 - # login  
167 - # ------------------------------------------------------------------------  
168 - async def login(self, uid: str, pw: str) -> bool: 180 + async def login(self, uid: str, password: str) -> bool:
  181 + '''user login'''
169 182
170 - with self.db_session() as s:  
171 - found = s.query(Student.name, Student.password) \  
172 - .filter_by(id=uid) \  
173 - .one_or_none() 183 + with self._db_session() as sess:
  184 + found = sess.query(Student.name, Student.password) \
  185 + .filter_by(id=uid) \
  186 + .one_or_none()
174 187
175 # wait random time to minimize timing attacks 188 # wait random time to minimize timing attacks
176 await asyncio.sleep(random()) 189 await asyncio.sleep(random())
177 190
178 loop = asyncio.get_running_loop() 191 loop = asyncio.get_running_loop()
179 if found is None: 192 if found is None:
180 - logger.info(f'User "{uid}" does not exist') 193 + logger.info('User "%s" does not exist', uid)
181 await loop.run_in_executor(None, bcrypt.hashpw, b'', 194 await loop.run_in_executor(None, bcrypt.hashpw, b'',
182 bcrypt.gensalt()) # just spend time 195 bcrypt.gensalt()) # just spend time
183 return False 196 return False
184 197
185 - else:  
186 - name, hashed_pw = found  
187 - pw_ok: bool = await loop.run_in_executor(None,  
188 - bcrypt.checkpw,  
189 - pw.encode('utf-8'),  
190 - hashed_pw) 198 + name, hashed_pw = found
  199 + pw_ok: bool = await loop.run_in_executor(None,
  200 + bcrypt.checkpw,
  201 + password.encode('utf-8'),
  202 + hashed_pw)
191 203
192 if pw_ok: 204 if pw_ok:
193 if uid in self.online: 205 if uid in self.online:
194 - logger.warning(f'User "{uid}" already logged in') 206 + logger.warning('User "%s" already logged in', uid)
195 counter = self.online[uid]['counter'] 207 counter = self.online[uid]['counter']
196 else: 208 else:
197 - logger.info(f'User "{uid}" logged in') 209 + logger.info('User "%s" logged in', uid)
198 counter = 0 210 counter = 0
199 211
200 # get topics of this student and set its current state 212 # get topics of this student and set its current state
201 - with self.db_session() as s:  
202 - tt = s.query(StudentTopic).filter_by(student_id=uid) 213 + with self._db_session() as sess:
  214 + student_topics = sess.query(StudentTopic) \
  215 + .filter_by(student_id=uid)
203 216
204 state = {t.topic_id: { 217 state = {t.topic_id: {
205 'level': t.level, 218 'level': t.level,
206 'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f") 219 'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f")
207 - } for t in tt} 220 + } for t in student_topics}
208 221
209 self.online[uid] = { 222 self.online[uid] = {
210 'number': uid, 223 'number': uid,
@@ -216,179 +229,192 @@ class LearnApp(object): @@ -216,179 +229,192 @@ class LearnApp(object):
216 } 229 }
217 230
218 else: 231 else:
219 - logger.info(f'User "{uid}" wrong password') 232 + logger.info('User "%s" wrong password', uid)
220 233
221 return pw_ok 234 return pw_ok
222 235
223 # ------------------------------------------------------------------------ 236 # ------------------------------------------------------------------------
224 - # logout  
225 - # ------------------------------------------------------------------------  
226 def logout(self, uid: str) -> None: 237 def logout(self, uid: str) -> None:
  238 + '''User logout'''
227 del self.online[uid] 239 del self.online[uid]
228 - logger.info(f'User "{uid}" logged out') 240 + logger.info('User "%s" logged out', uid)
229 241
230 # ------------------------------------------------------------------------ 242 # ------------------------------------------------------------------------
231 - # change_password. returns True if password is successfully changed.  
232 - # ------------------------------------------------------------------------  
233 - async def change_password(self, uid: str, pw: str) -> bool:  
234 - if not pw: 243 + async def change_password(self, uid: str, password: str) -> bool:
  244 + '''
  245 + Change user Password.
  246 + Returns True if password is successfully changed
  247 + '''
  248 + if not password:
235 return False 249 return False
236 250
237 loop = asyncio.get_running_loop() 251 loop = asyncio.get_running_loop()
238 - pw = await loop.run_in_executor(None, bcrypt.hashpw,  
239 - pw.encode('utf-8'), bcrypt.gensalt()) 252 + password = await loop.run_in_executor(None,
  253 + bcrypt.hashpw,
  254 + password.encode('utf-8'),
  255 + bcrypt.gensalt())
240 256
241 - with self.db_session() as s:  
242 - u = s.query(Student).get(uid)  
243 - u.password = pw 257 + with self._db_session() as sess:
  258 + user = sess.query(Student).get(uid)
  259 + user.password = password
244 260
245 - logger.info(f'User "{uid}" changed password') 261 + logger.info('User "%s" changed password', uid)
246 return True 262 return True
247 263
248 # ------------------------------------------------------------------------ 264 # ------------------------------------------------------------------------
249 - # Checks answer and update database. Returns corrected question.  
250 - # ------------------------------------------------------------------------  
251 async def check_answer(self, uid: str, answer) -> Question: 265 async def check_answer(self, uid: str, answer) -> Question:
  266 + '''
  267 + Checks answer and update database.
  268 + Returns corrected question.
  269 + '''
252 student = self.online[uid]['state'] 270 student = self.online[uid]['state']
253 await student.check_answer(answer) 271 await student.check_answer(answer)
254 - q: Question = student.get_current_question()  
255 272
256 - logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"') 273 + topic_id = student.get_current_topic()
  274 + question: Question = student.get_current_question()
  275 + grade = question["grade"]
  276 + ref = question["ref"]
  277 +
  278 + logger.info('User "%s" got %.2f in "%s"', uid, grade, ref)
257 279
258 # always save grade of answered question 280 # always save grade of answered question
259 - with self.db_session() as s:  
260 - s.add(Answer(  
261 - ref=q['ref'],  
262 - grade=q['grade'],  
263 - starttime=str(q['start_time']),  
264 - finishtime=str(q['finish_time']),  
265 - student_id=uid,  
266 - topic_id=student.get_current_topic())) 281 + with self._db_session() as sess:
  282 + sess.add(Answer(ref=ref,
  283 + grade=grade,
  284 + starttime=str(question['start_time']),
  285 + finishtime=str(question['finish_time']),
  286 + student_id=uid,
  287 + topic_id=topic_id))
267 288
268 - return q 289 + return question
269 290
270 # ------------------------------------------------------------------------ 291 # ------------------------------------------------------------------------
271 - # get the question to show (current or new one)  
272 - # if no more questions, save/update level in database  
273 - # ------------------------------------------------------------------------  
274 async def get_question(self, uid: str) -> Optional[Question]: 292 async def get_question(self, uid: str) -> Optional[Question]:
  293 + '''
  294 + Get the question to show (current or new one)
  295 + If no more questions, save/update level in database
  296 + '''
275 student = self.online[uid]['state'] 297 student = self.online[uid]['state']
276 - q: Optional[Question] = await student.get_question() 298 + question: Optional[Question] = await student.get_question()
277 299
278 # save topic to database if finished 300 # save topic to database if finished
279 if student.topic_has_finished(): 301 if student.topic_has_finished():
280 topic: str = student.get_previous_topic() 302 topic: str = student.get_previous_topic()
281 level: float = student.get_topic_level(topic) 303 level: float = student.get_topic_level(topic)
282 date: str = str(student.get_topic_date(topic)) 304 date: str = str(student.get_topic_date(topic))
283 - logger.info(f'User "{uid}" finished "{topic}" (level={level:.2})') 305 + logger.info('User "%s" finished "%s" (level=%.2f)',
  306 + uid, topic, level)
  307 +
  308 + with self._db_session() as sess:
  309 + student_topic = sess.query(StudentTopic) \
  310 + .filter_by(student_id=uid, topic_id=topic)\
  311 + .one_or_none()
284 312
285 - with self.db_session() as s:  
286 - a = s.query(StudentTopic) \  
287 - .filter_by(student_id=uid, topic_id=topic) \  
288 - .one_or_none()  
289 - if a is None: 313 + if student_topic is None:
290 # insert new studenttopic into database 314 # insert new studenttopic into database
291 logger.debug('db insert studenttopic') 315 logger.debug('db insert studenttopic')
292 - t = s.query(Topic).get(topic)  
293 - u = s.query(Student).get(uid) 316 + tid = sess.query(Topic).get(topic)
  317 + uid = sess.query(Student).get(uid)
294 # association object 318 # association object
295 - a = StudentTopic(level=level, date=date, topic=t,  
296 - student=u)  
297 - u.topics.append(a) 319 + student_topic = StudentTopic(level=level, date=date,
  320 + topic=tid, student=uid)
  321 + uid.topics.append(student_topic)
298 else: 322 else:
299 # update studenttopic in database 323 # update studenttopic in database
300 - logger.debug(f'db update studenttopic to level {level}')  
301 - a.level = level  
302 - a.date = date 324 + logger.debug('db update studenttopic to level %f', level)
  325 + student_topic.level = level
  326 + student_topic.date = date
303 327
304 - s.add(a) 328 + sess.add(student_topic)
305 329
306 - return q 330 + return question
307 331
308 # ------------------------------------------------------------------------ 332 # ------------------------------------------------------------------------
309 - # Start course  
310 - # ------------------------------------------------------------------------  
311 def start_course(self, uid: str, course_id: str) -> None: 333 def start_course(self, uid: str, course_id: str) -> None:
  334 + '''Start course'''
  335 +
312 student = self.online[uid]['state'] 336 student = self.online[uid]['state']
313 try: 337 try:
314 student.start_course(course_id) 338 student.start_course(course_id)
315 except Exception: 339 except Exception:
316 - logger.warning(f'"{uid}" could not start course "{course_id}"')  
317 - raise 340 + logger.warning('"%s" could not start course "%s"', uid, course_id)
  341 + raise LearnException()
318 else: 342 else:
319 - logger.info(f'User "{uid}" started course "{course_id}"') 343 + logger.info('User "%s" started course "%s"', uid, course_id)
320 344
321 # ------------------------------------------------------------------------ 345 # ------------------------------------------------------------------------
322 - # Start new topic 346 + #
323 # ------------------------------------------------------------------------ 347 # ------------------------------------------------------------------------
324 async def start_topic(self, uid: str, topic: str) -> None: 348 async def start_topic(self, uid: str, topic: str) -> None:
  349 + '''Start new topic'''
  350 +
325 student = self.online[uid]['state'] 351 student = self.online[uid]['state']
326 - if uid == '0':  
327 - logger.warning(f'Reloading "{topic}"') # FIXME should be an option  
328 - self.factory.update(self.factory_for(topic)) 352 + # if uid == '0':
  353 + # logger.warning('Reloading "%s"', topic) # FIXME should be an option
  354 + # self.factory.update(self._factory_for(topic))
329 355
330 try: 356 try:
331 await student.start_topic(topic) 357 await student.start_topic(topic)
332 - except Exception as e:  
333 - logger.warning(f'User "{uid}" could not start "{topic}": {e}') 358 + except Exception as exc:
  359 + logger.warning('User "%s" could not start "%s": %s',
  360 + uid, topic, str(exc))
334 else: 361 else:
335 - logger.info(f'User "{uid}" started topic "{topic}"') 362 + logger.info('User "%s" started topic "%s"', uid, topic)
336 363
337 # ------------------------------------------------------------------------ 364 # ------------------------------------------------------------------------
338 - # Fill db table 'Topic' with topics from the graph if not already there. 365 + #
339 # ------------------------------------------------------------------------ 366 # ------------------------------------------------------------------------
340 - def add_missing_topics(self, topics: List[str]) -> None:  
341 - with self.db_session() as s:  
342 - new_topics = [Topic(id=t) for t in topics  
343 - if (t,) not in s.query(Topic.id)] 367 + def _add_missing_topics(self, topics: List[str]) -> None:
  368 + '''
  369 + Fill db table 'Topic' with topics from the graph, if new
  370 + '''
  371 + with self._db_session() as sess:
  372 + new = [Topic(id=t) for t in topics
  373 + if (t,) not in sess.query(Topic.id)]
344 374
345 - if new_topics:  
346 - s.add_all(new_topics)  
347 - logger.info(f'Added {len(new_topics)} new topic(s) to the '  
348 - f'database') 375 + if new:
  376 + sess.add_all(new)
  377 + logger.info('Added %d new topic(s) to the database', len(new))
349 378
350 # ------------------------------------------------------------------------ 379 # ------------------------------------------------------------------------
351 - # setup and check database contents  
352 - # ------------------------------------------------------------------------  
353 - def db_setup(self, db: str) -> None: 380 + def _db_setup(self, database: str) -> None:
  381 + '''setup and check database contents'''
354 382
355 - logger.info(f'Checking database "{db}":')  
356 - if not path.exists(db): 383 + logger.info('Checking database "%s":', database)
  384 + if not exists(database):
357 raise LearnException('Database does not exist. ' 385 raise LearnException('Database does not exist. '
358 'Use "initdb-aprendizations" to create') 386 'Use "initdb-aprendizations" to create')
359 387
360 - engine = sa.create_engine(f'sqlite:///{db}', echo=False) 388 + engine = sa.create_engine(f'sqlite:///{database}', echo=False)
361 self.Session = sa.orm.sessionmaker(bind=engine) 389 self.Session = sa.orm.sessionmaker(bind=engine)
362 try: 390 try:
363 - with self.db_session() as s:  
364 - n: int = s.query(Student).count()  
365 - m: int = s.query(Topic).count()  
366 - q: int = s.query(Answer).count() 391 + with self._db_session() as sess:
  392 + count_students: int = sess.query(Student).count()
  393 + count_topics: int = sess.query(Topic).count()
  394 + count_answers: int = sess.query(Answer).count()
367 except Exception: 395 except Exception:
368 - logger.error(f'Database "{db}" not usable!') 396 + logger.error('Database "%s" not usable!', database)
369 raise DatabaseUnusableError() 397 raise DatabaseUnusableError()
370 else: 398 else:
371 - logger.info(f'{n:6} students')  
372 - logger.info(f'{m:6} topics')  
373 - logger.info(f'{q:6} answers') 399 + logger.info('%6d students', count_students)
  400 + logger.info('%6d topics', count_topics)
  401 + logger.info('%6d answers', count_answers)
374 402
375 - # ========================================================================  
376 - # Populates a digraph.  
377 - #  
378 - # Nodes are the topic references e.g. 'my/topic'  
379 - # g.nodes['my/topic']['name'] name of the topic  
380 - # g.nodes['my/topic']['questions'] list of question refs  
381 - #  
382 - # Edges are obtained from the deps defined in the YAML file for each topic.  
383 # ------------------------------------------------------------------------ 403 # ------------------------------------------------------------------------
384 - def populate_graph(self, config: Dict[str, Any]) -> None:  
385 - g = self.deps # dependency graph 404 + def _populate_graph(self, config: Dict[str, Any]) -> None:
  405 + '''
  406 + Populates a digraph.
  407 +
  408 + Nodes are the topic references e.g. 'my/topic'
  409 + g.nodes['my/topic']['name'] name of the topic
  410 + g.nodes['my/topic']['questions'] list of question refs
  411 +
  412 + Edges are obtained from the deps defined in the YAML file for each topic.
  413 + '''
  414 +
386 defaults = { 415 defaults = {
387 'type': 'topic', # chapter 416 'type': 'topic', # chapter
388 - # 'file': 'questions.yaml', # deprecated  
389 - 'learn_file': 'learn.yaml',  
390 - 'practice_file': 'questions.yaml',  
391 - 417 + 'file': 'questions.yaml',
392 'shuffle_questions': True, 418 'shuffle_questions': True,
393 'choose': 9999, 419 'choose': 9999,
394 'forgetting_factor': 1.0, # no forgetting 420 'forgetting_factor': 1.0, # no forgetting
@@ -400,20 +426,21 @@ class LearnApp(object): @@ -400,20 +426,21 @@ class LearnApp(object):
400 426
401 # iterate over topics and populate graph 427 # iterate over topics and populate graph
402 topics: Dict[str, Dict] = config.get('topics', {}) 428 topics: Dict[str, Dict] = config.get('topics', {})
403 - g.add_nodes_from(topics.keys()) 429 + self.deps.add_nodes_from(topics.keys())
404 for tref, attr in topics.items(): 430 for tref, attr in topics.items():
405 - logger.debug(f' + {tref}')  
406 - for d in attr.get('deps', []):  
407 - g.add_edge(d, tref) 431 + logger.debug(' + %s', tref)
  432 + for dep in attr.get('deps', []):
  433 + self.deps.add_edge(dep, tref)
408 434
409 - t = g.nodes[tref] # get current topic node  
410 - t['name'] = attr.get('name', tref)  
411 - t['questions'] = attr.get('questions', []) # FIXME unused?? 435 + topic = self.deps.nodes[tref] # get current topic node
  436 + topic['name'] = attr.get('name', tref)
  437 + topic['questions'] = attr.get('questions', []) # FIXME unused??
412 438
413 for k, default in defaults.items(): 439 for k, default in defaults.items():
414 - t[k] = attr.get(k, default) 440 + topic[k] = attr.get(k, default)
415 441
416 - t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic 442 + # prefix/topic
  443 + topic['path'] = join(self.deps.graph['prefix'], tref)
417 444
418 445
419 # ======================================================================== 446 # ========================================================================
@@ -421,47 +448,46 @@ class LearnApp(object): @@ -421,47 +448,46 @@ class LearnApp(object):
421 # ======================================================================== 448 # ========================================================================
422 449
423 # ------------------------------------------------------------------------ 450 # ------------------------------------------------------------------------
424 - # Buils dictionary of question factories  
425 - # - visits each topic in the graph,  
426 - # - adds factory for each topic.  
427 - # ------------------------------------------------------------------------  
428 - def make_factory(self) -> Dict[str, QFactory]: 451 + def _make_factory(self) -> Dict[str, QFactory]:
  452 + '''
  453 + Buils dictionary of question factories
  454 + - visits each topic in the graph,
  455 + - adds factory for each topic.
  456 + '''
429 457
430 logger.info('Building questions factory:') 458 logger.info('Building questions factory:')
431 factory = dict() 459 factory = dict()
432 - g = self.deps  
433 - for tref in g.nodes():  
434 - factory.update(self.factory_for(tref)) 460 + for tref in self.deps.nodes():
  461 + factory.update(self._factory_for(tref))
435 462
436 - logger.info(f'Factory has {len(factory)} questions') 463 + logger.info('Factory has %s questions', len(factory))
437 return factory 464 return factory
438 465
439 # ------------------------------------------------------------------------ 466 # ------------------------------------------------------------------------
440 # makes factory for a single topic 467 # makes factory for a single topic
441 # ------------------------------------------------------------------------ 468 # ------------------------------------------------------------------------
442 - def factory_for(self, tref: str) -> Dict[str, QFactory]: 469 + def _factory_for(self, tref: str) -> Dict[str, QFactory]:
443 factory: Dict[str, QFactory] = dict() 470 factory: Dict[str, QFactory] = dict()
444 - g = self.deps  
445 - t = g.nodes[tref] # get node 471 + topic = self.deps.nodes[tref] # get node
446 # load questions as list of dicts 472 # load questions as list of dicts
447 try: 473 try:
448 - fullpath: str = path.join(t['path'], t['file']) 474 + fullpath: str = join(topic['path'], topic['file'])
449 except Exception: 475 except Exception:
450 msg1 = f'Invalid topic "{tref}"' 476 msg1 = f'Invalid topic "{tref}"'
451 - msg2 = f'Check dependencies of: {", ".join(g.successors(tref))}' 477 + msg2 = 'Check dependencies of: ' + \
  478 + ', '.join(self.deps.successors(tref))
452 msg = f'{msg1}. {msg2}' 479 msg = f'{msg1}. {msg2}'
453 logger.error(msg) 480 logger.error(msg)
454 raise LearnException(msg) 481 raise LearnException(msg)
455 - logger.debug(f' Loading {fullpath}') 482 + logger.debug(' Loading %s', fullpath)
456 try: 483 try:
457 questions: List[QDict] = load_yaml(fullpath) 484 questions: List[QDict] = load_yaml(fullpath)
458 except Exception: 485 except Exception:
459 - if t['type'] == 'chapter': 486 + if topic['type'] == 'chapter':
460 return factory # chapters may have no "questions" 487 return factory # chapters may have no "questions"
461 - else:  
462 - msg = f'Failed to load "{fullpath}"'  
463 - logger.error(msg)  
464 - raise LearnException(msg) 488 + msg = f'Failed to load "{fullpath}"'
  489 + logger.error(msg)
  490 + raise LearnException(msg)
465 491
466 if not isinstance(questions, list): 492 if not isinstance(questions, list):
467 msg = f'File "{fullpath}" must be a list of questions' 493 msg = f'File "{fullpath}" must be a list of questions'
@@ -473,134 +499,162 @@ class LearnApp(object): @@ -473,134 +499,162 @@ class LearnApp(object):
473 # undefined are set to topic:n, where n is the question number 499 # undefined are set to topic:n, where n is the question number
474 # within the file 500 # within the file
475 localrefs: Set[str] = set() # refs in current file 501 localrefs: Set[str] = set() # refs in current file
476 - for i, q in enumerate(questions):  
477 - qref = q.get('ref', str(i)) # ref or number 502 + for i, question in enumerate(questions):
  503 + qref = question.get('ref', str(i)) # ref or number
478 if qref in localrefs: 504 if qref in localrefs:
479 - msg = f'Duplicate ref "{qref}" in "{t["path"]}"' 505 + msg = f'Duplicate ref "{qref}" in "{topic["path"]}"'
480 raise LearnException(msg) 506 raise LearnException(msg)
481 localrefs.add(qref) 507 localrefs.add(qref)
482 508
483 - q['ref'] = f'{tref}:{qref}'  
484 - q['path'] = t['path']  
485 - q.setdefault('append_wrong', t['append_wrong']) 509 + question['ref'] = f'{tref}:{qref}'
  510 + question['path'] = topic['path']
  511 + question.setdefault('append_wrong', topic['append_wrong'])
486 512
487 # if questions are left undefined, include all. 513 # if questions are left undefined, include all.
488 - if not t['questions']:  
489 - t['questions'] = [q['ref'] for q in questions] 514 + if not topic['questions']:
  515 + topic['questions'] = [q['ref'] for q in questions]
490 516
491 - t['choose'] = min(t['choose'], len(t['questions'])) 517 + topic['choose'] = min(topic['choose'], len(topic['questions']))
492 518
493 - for q in questions:  
494 - if q['ref'] in t['questions']:  
495 - factory[q['ref']] = QFactory(q)  
496 - logger.debug(f' + {q["ref"]}') 519 + for question in questions:
  520 + if question['ref'] in topic['questions']:
  521 + factory[question['ref']] = QFactory(question)
  522 + logger.debug(' + %s', question["ref"])
497 523
498 - logger.info(f'{len(t["questions"]):6} questions in {tref}') 524 + logger.info('%6d questions in %s', len(topic["questions"]), tref)
499 525
500 return factory 526 return factory
501 527
502 # ------------------------------------------------------------------------ 528 # ------------------------------------------------------------------------
503 def get_login_counter(self, uid: str) -> int: 529 def get_login_counter(self, uid: str) -> int:
  530 + '''login counter''' # FIXME
504 return int(self.online[uid]['counter']) 531 return int(self.online[uid]['counter'])
505 532
506 # ------------------------------------------------------------------------ 533 # ------------------------------------------------------------------------
507 def get_student_name(self, uid: str) -> str: 534 def get_student_name(self, uid: str) -> str:
  535 + '''Get the username'''
508 return self.online[uid].get('name', '') 536 return self.online[uid].get('name', '')
509 537
510 # ------------------------------------------------------------------------ 538 # ------------------------------------------------------------------------
511 def get_student_state(self, uid: str) -> List[Dict[str, Any]]: 539 def get_student_state(self, uid: str) -> List[Dict[str, Any]]:
  540 + '''Get the knowledge state of a given user'''
512 return self.online[uid]['state'].get_knowledge_state() 541 return self.online[uid]['state'].get_knowledge_state()
513 542
514 # ------------------------------------------------------------------------ 543 # ------------------------------------------------------------------------
515 def get_student_progress(self, uid: str) -> float: 544 def get_student_progress(self, uid: str) -> float:
  545 + '''Get the current topic progress of a given user'''
516 return float(self.online[uid]['state'].get_topic_progress()) 546 return float(self.online[uid]['state'].get_topic_progress())
517 547
518 # ------------------------------------------------------------------------ 548 # ------------------------------------------------------------------------
519 def get_current_question(self, uid: str) -> Optional[Question]: 549 def get_current_question(self, uid: str) -> Optional[Question]:
  550 + '''Get the current question of a given user'''
520 q: Optional[Question] = self.online[uid]['state'].get_current_question() 551 q: Optional[Question] = self.online[uid]['state'].get_current_question()
521 return q 552 return q
522 553
523 # ------------------------------------------------------------------------ 554 # ------------------------------------------------------------------------
524 def get_current_question_id(self, uid: str) -> str: 555 def get_current_question_id(self, uid: str) -> str:
  556 + '''Get id of the current question for a given user'''
525 return str(self.online[uid]['state'].get_current_question()['qid']) 557 return str(self.online[uid]['state'].get_current_question()['qid'])
526 558
527 # ------------------------------------------------------------------------ 559 # ------------------------------------------------------------------------
528 def get_student_question_type(self, uid: str) -> str: 560 def get_student_question_type(self, uid: str) -> str:
  561 + '''Get type of the current question for a given user'''
529 return str(self.online[uid]['state'].get_current_question()['type']) 562 return str(self.online[uid]['state'].get_current_question()['type'])
530 563
531 # ------------------------------------------------------------------------ 564 # ------------------------------------------------------------------------
532 - def get_student_topic(self, uid: str) -> str:  
533 - return str(self.online[uid]['state'].get_current_topic()) 565 + # def get_student_topic(self, uid: str) -> str:
  566 + # return str(self.online[uid]['state'].get_current_topic())
534 567
535 # ------------------------------------------------------------------------ 568 # ------------------------------------------------------------------------
536 def get_student_course_title(self, uid: str) -> str: 569 def get_student_course_title(self, uid: str) -> str:
  570 + '''get the title of the current course for a given user'''
537 return str(self.online[uid]['state'].get_current_course_title()) 571 return str(self.online[uid]['state'].get_current_course_title())
538 572
539 # ------------------------------------------------------------------------ 573 # ------------------------------------------------------------------------
540 def get_current_course_id(self, uid: str) -> Optional[str]: 574 def get_current_course_id(self, uid: str) -> Optional[str]:
  575 + '''get the current course (id) of a given user'''
541 cid: Optional[str] = self.online[uid]['state'].get_current_course_id() 576 cid: Optional[str] = self.online[uid]['state'].get_current_course_id()
542 return cid 577 return cid
543 578
544 # ------------------------------------------------------------------------ 579 # ------------------------------------------------------------------------
545 - def get_topic_name(self, ref: str) -> str:  
546 - return str(self.deps.nodes[ref]['name']) 580 + # def get_topic_name(self, ref: str) -> str:
  581 + # return str(self.deps.nodes[ref]['name'])
547 582
548 # ------------------------------------------------------------------------ 583 # ------------------------------------------------------------------------
549 def get_current_public_dir(self, uid: str) -> str: 584 def get_current_public_dir(self, uid: str) -> str:
  585 + '''
  586 + Get the path for the 'public' directory of the current topic of the
  587 + given user.
  588 + E.g. if the user has the active topic 'xpto',
  589 + then returns 'path/to/xpto/public'.
  590 + '''
550 topic: str = self.online[uid]['state'].get_current_topic() 591 topic: str = self.online[uid]['state'].get_current_topic()
551 prefix: str = self.deps.graph['prefix'] 592 prefix: str = self.deps.graph['prefix']
552 - return path.join(prefix, topic, 'public') 593 + return join(prefix, topic, 'public')
553 594
554 # ------------------------------------------------------------------------ 595 # ------------------------------------------------------------------------
555 def get_courses(self) -> Dict[str, Dict[str, Any]]: 596 def get_courses(self) -> Dict[str, Dict[str, Any]]:
  597 + '''
  598 + Get dictionary with all courses {'course1': {...}, 'course2': {...}}
  599 + '''
556 return self.courses 600 return self.courses
557 601
558 # ------------------------------------------------------------------------ 602 # ------------------------------------------------------------------------
559 def get_course(self, course_id: str) -> Dict[str, Any]: 603 def get_course(self, course_id: str) -> Dict[str, Any]:
  604 + '''
  605 + Get dictionary {'title': ..., 'description':..., 'goals':...}
  606 + '''
560 return self.courses[course_id] 607 return self.courses[course_id]
561 608
562 # ------------------------------------------------------------------------ 609 # ------------------------------------------------------------------------
563 def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]: 610 def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]:
564 -  
565 - logger.info(f'User "{uid}" get rankings for {course_id}')  
566 - with self.db_session() as s:  
567 - students = s.query(Student.id, Student.name).all()  
568 -  
569 - # topic progress  
570 - student_topics = s.query(StudentTopic.student_id,  
571 - StudentTopic.topic_id,  
572 - StudentTopic.level,  
573 - StudentTopic.date).all() 611 + '''
  612 + Returns rankings for a certain course_id.
  613 + User where uid have <=2 chars are considered ghosts are hidden from
  614 + the rankings. This is so that there can be users for development or
  615 + testing purposes, which are not real users.
  616 + The user_id of real students must have >2 chars.
  617 + '''
  618 +
  619 + logger.info('User "%s" get rankings for %s', uid, course_id)
  620 + with self._db_session() as sess:
  621 + # all students in the database FIXME only with answers of this course
  622 + students = sess.query(Student.id, Student.name).all()
  623 +
  624 + # topic levels FIXME only topics of this course
  625 + student_topics = sess.query(StudentTopic.student_id,
  626 + StudentTopic.topic_id,
  627 + StudentTopic.level,
  628 + StudentTopic.date).all()
574 629
575 # answer performance 630 # answer performance
576 - total = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)).  
577 - group_by(Answer.student_id).  
578 - all())  
579 - right = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)).  
580 - filter(Answer.grade == 1.0).  
581 - group_by(Answer.student_id).  
582 - all()) 631 + total = dict(sess.query(Answer.student_id,
  632 + sa.func.count(Answer.ref)) \
  633 + .group_by(Answer.student_id) \
  634 + .all())
  635 + right = dict(sess.query(Answer.student_id,
  636 + sa.func.count(Answer.ref)) \
  637 + .filter(Answer.grade == 1.0) \
  638 + .group_by(Answer.student_id) \
  639 + .all())
583 640
584 # compute percentage of right answers 641 # compute percentage of right answers
585 - perf: Dict[str, float] = {u: right.get(u, 0.0)/total[u] 642 + perf: Dict[str, float] = {u: right.get(u, 0.0) / total[u]
586 for u in total} 643 for u in total}
587 644
588 # compute topic progress 645 # compute topic progress
589 now = datetime.now() 646 now = datetime.now()
590 goals = self.courses[course_id]['goals'] 647 goals = self.courses[course_id]['goals']
591 - prog: DefaultDict[str, float] = defaultdict(int) 648 + progress: DefaultDict[str, float] = defaultdict(int)
592 649
593 - for u, topic, level, date in student_topics: 650 + for student, topic, level, date in student_topics:
594 if topic in goals: 651 if topic in goals:
595 date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f") 652 date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f")
596 - prog[u] += level**(now - date).days / len(goals)  
597 -  
598 - ghostuser = len(uid) <= 2 # ghosts are invisible to students  
599 - rankings = [(u, name, prog[u], perf.get(u, 0.0))  
600 - for u, name in students  
601 - if u in prog  
602 - and (len(u) > 2 or ghostuser) and u != '0' ]  
603 - rankings.sort(key=lambda x: x[2], reverse=True)  
604 - return rankings 653 + progress[student] += level**(now - date).days / len(goals)
  654 +
  655 + return sorted(((u, name, progress[u], perf.get(u, 0.0))
  656 + for u, name in students
  657 + if u in progress and (len(u) > 2 or len(uid) <= 2)),
  658 + key=lambda x: x[2], reverse=True)
605 659
606 # ------------------------------------------------------------------------ 660 # ------------------------------------------------------------------------
aprendizations/serve.py
  1 +'''
  2 +Webserver
  3 +'''
  4 +
1 5
2 # python standard library 6 # python standard library
3 import asyncio 7 import asyncio
@@ -5,7 +9,7 @@ import base64 @@ -5,7 +9,7 @@ import base64
5 import functools 9 import functools
6 import logging.config 10 import logging.config
7 import mimetypes 11 import mimetypes
8 -from os import path 12 +from os.path import join, dirname, expanduser
9 import signal 13 import signal
10 import sys 14 import sys
11 from typing import List, Optional, Union 15 from typing import List, Optional, Union
@@ -16,8 +20,9 @@ import tornado.web @@ -16,8 +20,9 @@ import tornado.web
16 from tornado.escape import to_unicode 20 from tornado.escape import to_unicode
17 21
18 # this project 22 # this project
19 -from .tools import md_to_html  
20 -from . import APP_NAME 23 +from aprendizations.tools import md_to_html
  24 +from aprendizations.learnapp import LearnException
  25 +from aprendizations import APP_NAME
21 26
22 27
23 # setup logger for this module 28 # setup logger for this module
@@ -25,39 +30,39 @@ logger = logging.getLogger(__name__) @@ -25,39 +30,39 @@ logger = logging.getLogger(__name__)
25 30
26 31
27 # ---------------------------------------------------------------------------- 32 # ----------------------------------------------------------------------------
28 -# Decorator used to restrict access to the administrator  
29 -# ----------------------------------------------------------------------------  
30 def admin_only(func): 33 def admin_only(func):
  34 + '''
  35 + Decorator used to restrict access to the administrator
  36 + '''
31 @functools.wraps(func) 37 @functools.wraps(func)
32 def wrapper(self, *args, **kwargs): 38 def wrapper(self, *args, **kwargs):
33 if self.current_user != '0': 39 if self.current_user != '0':
34 raise tornado.web.HTTPError(403) # forbidden 40 raise tornado.web.HTTPError(403) # forbidden
35 - else:  
36 - func(self, *args, **kwargs) 41 + func(self, *args, **kwargs)
37 return wrapper 42 return wrapper
38 43
39 44
40 # ============================================================================ 45 # ============================================================================
41 -# WebApplication - Tornado Web Server  
42 -# ============================================================================  
43 class WebApplication(tornado.web.Application): 46 class WebApplication(tornado.web.Application):
44 - 47 + '''
  48 + WebApplication - Tornado Web Server
  49 + '''
45 def __init__(self, learnapp, debug=False): 50 def __init__(self, learnapp, debug=False):
46 handlers = [ 51 handlers = [
47 - (r'/login', LoginHandler),  
48 - (r'/logout', LogoutHandler), 52 + (r'/login', LoginHandler),
  53 + (r'/logout', LogoutHandler),
49 (r'/change_password', ChangePasswordHandler), 54 (r'/change_password', ChangePasswordHandler),
50 - (r'/question', QuestionHandler), # render question  
51 - (r'/rankings', RankingsHandler), # rankings table  
52 - (r'/topic/(.+)', TopicHandler), # start topic  
53 - (r'/file/(.+)', FileHandler), # serve file  
54 - (r'/courses', CoursesHandler), # show list of courses  
55 - (r'/course/(.*)', CourseHandler), # show course topics  
56 - (r'/', RootHandler), # redirects 55 + (r'/question', QuestionHandler), # render question
  56 + (r'/rankings', RankingsHandler), # rankings table
  57 + (r'/topic/(.+)', TopicHandler), # start topic
  58 + (r'/file/(.+)', FileHandler), # serve file
  59 + (r'/courses', CoursesHandler), # show list of courses
  60 + (r'/course/(.*)', CourseHandler), # show course topics
  61 + (r'/', RootHandler), # redirects
57 ] 62 ]
58 settings = { 63 settings = {
59 - 'template_path': path.join(path.dirname(__file__), 'templates'),  
60 - 'static_path': path.join(path.dirname(__file__), 'static'), 64 + 'template_path': join(dirname(__file__), 'templates'),
  65 + 'static_path': join(dirname(__file__), 'static'),
61 'static_url_prefix': '/static/', 66 'static_url_prefix': '/static/',
62 'xsrf_cookies': True, 67 'xsrf_cookies': True,
63 'cookie_secret': base64.b64encode(uuid.uuid4().bytes), 68 'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
@@ -71,30 +76,37 @@ class WebApplication(tornado.web.Application): @@ -71,30 +76,37 @@ class WebApplication(tornado.web.Application):
71 # ============================================================================ 76 # ============================================================================
72 # Handlers 77 # Handlers
73 # ============================================================================ 78 # ============================================================================
74 -  
75 -# ----------------------------------------------------------------------------  
76 -# Base handler common to all handlers.  
77 -# ---------------------------------------------------------------------------- 79 +# pylint: disable=abstract-method
78 class BaseHandler(tornado.web.RequestHandler): 80 class BaseHandler(tornado.web.RequestHandler):
  81 + '''
  82 + Base handler common to all handlers.
  83 + '''
79 @property 84 @property
80 def learn(self): 85 def learn(self):
  86 + '''easier access to learnapp'''
81 return self.application.learn 87 return self.application.learn
82 88
83 def get_current_user(self): 89 def get_current_user(self):
84 - cookie = self.get_secure_cookie('user')  
85 - if cookie:  
86 - uid = cookie.decode('utf-8') 90 + '''called on every method decorated with @tornado.web.authenticated'''
  91 + user_cookie = self.get_secure_cookie('user')
  92 + if user_cookie is not None:
  93 + uid = user_cookie.decode('utf-8')
87 counter = self.get_secure_cookie('counter').decode('utf-8') 94 counter = self.get_secure_cookie('counter').decode('utf-8')
88 if counter == str(self.learn.get_login_counter(uid)): 95 if counter == str(self.learn.get_login_counter(uid)):
89 return uid 96 return uid
  97 + return None
90 98
91 99
92 # ---------------------------------------------------------------------------- 100 # ----------------------------------------------------------------------------
93 -# /rankings  
94 -# ----------------------------------------------------------------------------  
95 class RankingsHandler(BaseHandler): 101 class RankingsHandler(BaseHandler):
  102 + '''
  103 + Handles rankings page
  104 + '''
96 @tornado.web.authenticated 105 @tornado.web.authenticated
97 def get(self): 106 def get(self):
  107 + '''
  108 + Renders list of students that have answers in this course.
  109 + '''
98 uid = self.current_user 110 uid = self.current_user
99 current_course = self.learn.get_current_course_id(uid) 111 current_course = self.learn.get_current_course_id(uid)
100 course_id = self.get_query_argument('course', default=current_course) 112 course_id = self.get_query_argument('course', default=current_course)
@@ -110,23 +122,33 @@ class RankingsHandler(BaseHandler): @@ -110,23 +122,33 @@ class RankingsHandler(BaseHandler):
110 122
111 123
112 # ---------------------------------------------------------------------------- 124 # ----------------------------------------------------------------------------
113 -# /auth/login 125 +#
114 # ---------------------------------------------------------------------------- 126 # ----------------------------------------------------------------------------
115 class LoginHandler(BaseHandler): 127 class LoginHandler(BaseHandler):
  128 + '''
  129 + Handles /login
  130 + '''
116 def get(self): 131 def get(self):
  132 + '''
  133 + Renders login page
  134 + '''
117 self.render('login.html', 135 self.render('login.html',
118 appname=APP_NAME, 136 appname=APP_NAME,
119 error='') 137 error='')
120 138
121 async def post(self): 139 async def post(self):
122 - uid = self.get_body_argument('uid').lstrip('l')  
123 - pw = self.get_body_argument('pw') 140 + '''
  141 + Perform authentication and redirects to application if successful
  142 + '''
124 143
125 - login_ok = await self.learn.login(uid, pw) 144 + userid = self.get_body_argument('uid').lstrip('l')
  145 + passwd = self.get_body_argument('pw')
  146 +
  147 + login_ok = await self.learn.login(userid, passwd)
126 148
127 if login_ok: 149 if login_ok:
128 - counter = str(self.learn.get_login_counter(uid))  
129 - self.set_secure_cookie('user', uid) 150 + counter = str(self.learn.get_login_counter(userid))
  151 + self.set_secure_cookie('user', userid)
130 self.set_secure_cookie('counter', counter) 152 self.set_secure_cookie('counter', counter)
131 self.redirect('/') 153 self.redirect('/')
132 else: 154 else:
@@ -136,11 +158,15 @@ class LoginHandler(BaseHandler): @@ -136,11 +158,15 @@ class LoginHandler(BaseHandler):
136 158
137 159
138 # ---------------------------------------------------------------------------- 160 # ----------------------------------------------------------------------------
139 -# /auth/logout  
140 -# ----------------------------------------------------------------------------  
141 class LogoutHandler(BaseHandler): 161 class LogoutHandler(BaseHandler):
  162 + '''
  163 + Handles /logout
  164 + '''
142 @tornado.web.authenticated 165 @tornado.web.authenticated
143 def get(self): 166 def get(self):
  167 + '''
  168 + clears cookies and removes user session
  169 + '''
144 self.clear_cookie('user') 170 self.clear_cookie('user')
145 self.clear_cookie('counter') 171 self.clear_cookie('counter')
146 self.redirect('/') 172 self.redirect('/')
@@ -151,12 +177,18 @@ class LogoutHandler(BaseHandler): @@ -151,12 +177,18 @@ class LogoutHandler(BaseHandler):
151 177
152 # ---------------------------------------------------------------------------- 178 # ----------------------------------------------------------------------------
153 class ChangePasswordHandler(BaseHandler): 179 class ChangePasswordHandler(BaseHandler):
  180 + '''
  181 + Handles password change
  182 + '''
154 @tornado.web.authenticated 183 @tornado.web.authenticated
155 async def post(self): 184 async def post(self):
156 - uid = self.current_user  
157 - pw = self.get_body_arguments('new_password')[0] 185 + '''
  186 + Tries to perform password change and then replies success/fail status
  187 + '''
  188 + userid = self.current_user
  189 + passwd = self.get_body_arguments('new_password')[0]
158 190
159 - changed_ok = await self.learn.change_password(uid, pw) 191 + changed_ok = await self.learn.change_password(userid, passwd)
160 if changed_ok: 192 if changed_ok:
161 notification = self.render_string( 193 notification = self.render_string(
162 'notification.html', 194 'notification.html',
@@ -174,45 +206,53 @@ class ChangePasswordHandler(BaseHandler): @@ -174,45 +206,53 @@ class ChangePasswordHandler(BaseHandler):
174 206
175 207
176 # ---------------------------------------------------------------------------- 208 # ----------------------------------------------------------------------------
177 -# /  
178 -# redirects to appropriate place  
179 -# ----------------------------------------------------------------------------  
180 class RootHandler(BaseHandler): 209 class RootHandler(BaseHandler):
  210 + '''
  211 + Handles root /
  212 + '''
181 @tornado.web.authenticated 213 @tornado.web.authenticated
182 def get(self): 214 def get(self):
  215 + '''Simply redirects to the main entrypoint'''
183 self.redirect('/courses') 216 self.redirect('/courses')
184 217
185 218
186 # ---------------------------------------------------------------------------- 219 # ----------------------------------------------------------------------------
187 -# /courses  
188 -# Shows a list of available courses  
189 -# ----------------------------------------------------------------------------  
190 class CoursesHandler(BaseHandler): 220 class CoursesHandler(BaseHandler):
  221 + '''
  222 + Handles /courses
  223 + '''
191 @tornado.web.authenticated 224 @tornado.web.authenticated
192 def get(self): 225 def get(self):
  226 + '''Renders list of available courses'''
193 uid = self.current_user 227 uid = self.current_user
194 self.render('courses.html', 228 self.render('courses.html',
195 appname=APP_NAME, 229 appname=APP_NAME,
196 uid=uid, 230 uid=uid,
197 name=self.learn.get_student_name(uid), 231 name=self.learn.get_student_name(uid),
198 courses=self.learn.get_courses(), 232 courses=self.learn.get_courses(),
  233 + # courses_progress=
199 ) 234 )
200 235
201 236
202 -# ----------------------------------------------------------------------------  
203 -# /course/...  
204 -# Start a given course and show list of topics  
205 -# ---------------------------------------------------------------------------- 237 +# ============================================================================
206 class CourseHandler(BaseHandler): 238 class CourseHandler(BaseHandler):
  239 + '''
  240 + Handles a particular course to show the topics table
  241 + '''
  242 +
207 @tornado.web.authenticated 243 @tornado.web.authenticated
208 def get(self, course_id): 244 def get(self, course_id):
  245 + '''
  246 + Handles get /course/...
  247 + Starts a given course and show list of topics
  248 + '''
209 uid = self.current_user 249 uid = self.current_user
210 if course_id == '': 250 if course_id == '':
211 course_id = self.learn.get_current_course_id(uid) 251 course_id = self.learn.get_current_course_id(uid)
212 252
213 try: 253 try:
214 self.learn.start_course(uid, course_id) 254 self.learn.start_course(uid, course_id)
215 - except KeyError: 255 + except LearnException:
216 self.redirect('/courses') 256 self.redirect('/courses')
217 257
218 self.render('maintopics-table.html', 258 self.render('maintopics-table.html',
@@ -225,17 +265,21 @@ class CourseHandler(BaseHandler): @@ -225,17 +265,21 @@ class CourseHandler(BaseHandler):
225 ) 265 )
226 266
227 267
228 -# ----------------------------------------------------------------------------  
229 -# /topic/...  
230 -# Start a given topic  
231 -# ---------------------------------------------------------------------------- 268 +# ============================================================================
232 class TopicHandler(BaseHandler): 269 class TopicHandler(BaseHandler):
  270 + '''
  271 + Handles a topic
  272 + '''
233 @tornado.web.authenticated 273 @tornado.web.authenticated
234 async def get(self, topic): 274 async def get(self, topic):
  275 + '''
  276 + Handles get /topic/...
  277 + Starts a given topic
  278 + '''
235 uid = self.current_user 279 uid = self.current_user
236 280
237 try: 281 try:
238 - await self.learn.start_topic(uid, topic) 282 + await self.learn.start_topic(uid, topic) # FIXME GET should not modify state...
239 except KeyError: 283 except KeyError:
240 self.redirect('/topics') 284 self.redirect('/topics')
241 285
@@ -243,31 +287,34 @@ class TopicHandler(BaseHandler): @@ -243,31 +287,34 @@ class TopicHandler(BaseHandler):
243 appname=APP_NAME, 287 appname=APP_NAME,
244 uid=uid, 288 uid=uid,
245 name=self.learn.get_student_name(uid), 289 name=self.learn.get_student_name(uid),
246 - # course_title=self.learn.get_student_course_title(uid),  
247 course_id=self.learn.get_current_course_id(uid), 290 course_id=self.learn.get_current_course_id(uid),
248 ) 291 )
249 292
250 293
251 -# ----------------------------------------------------------------------------  
252 -# Serves files from the /public subdir of the topics.  
253 -# ---------------------------------------------------------------------------- 294 +# ============================================================================
254 class FileHandler(BaseHandler): 295 class FileHandler(BaseHandler):
  296 + '''
  297 + Serves files from the /public subdir of the topics.
  298 + '''
255 @tornado.web.authenticated 299 @tornado.web.authenticated
256 async def get(self, filename): 300 async def get(self, filename):
  301 + '''
  302 + Serves files from /public subdirectories of a particular topic
  303 + '''
257 uid = self.current_user 304 uid = self.current_user
258 public_dir = self.learn.get_current_public_dir(uid) 305 public_dir = self.learn.get_current_public_dir(uid)
259 - filepath = path.expanduser(path.join(public_dir, filename)) 306 + filepath = expanduser(join(public_dir, filename))
260 content_type = mimetypes.guess_type(filename)[0] 307 content_type = mimetypes.guess_type(filename)[0]
261 308
262 try: 309 try:
263 - with open(filepath, 'rb') as f:  
264 - data = f.read() 310 + with open(filepath, 'rb') as file:
  311 + data = file.read()
265 except FileNotFoundError: 312 except FileNotFoundError:
266 - logger.error(f'File not found: {filepath}') 313 + logger.error('File not found: %s', filepath)
267 except PermissionError: 314 except PermissionError:
268 - logger.error(f'No permission: {filepath}') 315 + logger.error('No permission: %s', filepath)
269 except Exception: 316 except Exception:
270 - logger.error(f'Error reading: {filepath}') 317 + logger.error('Error reading: %s', filepath)
271 raise 318 raise
272 else: 319 else:
273 self.set_header("Content-Type", content_type) 320 self.set_header("Content-Type", content_type)
@@ -275,10 +322,11 @@ class FileHandler(BaseHandler): @@ -275,10 +322,11 @@ class FileHandler(BaseHandler):
275 await self.flush() 322 await self.flush()
276 323
277 324
278 -# ----------------------------------------------------------------------------  
279 -# respond to AJAX to get a JSON question  
280 -# ---------------------------------------------------------------------------- 325 +# ============================================================================
281 class QuestionHandler(BaseHandler): 326 class QuestionHandler(BaseHandler):
  327 + '''
  328 + Responds to AJAX to get a JSON question
  329 + '''
282 templates = { 330 templates = {
283 'checkbox': 'question-checkbox.html', 331 'checkbox': 'question-checkbox.html',
284 'radio': 'question-radio.html', 332 'radio': 'question-radio.html',
@@ -294,27 +342,27 @@ class QuestionHandler(BaseHandler): @@ -294,27 +342,27 @@ class QuestionHandler(BaseHandler):
294 } 342 }
295 343
296 # ------------------------------------------------------------------------ 344 # ------------------------------------------------------------------------
297 - # GET  
298 - # gets question to render. If there are no more questions in the topic  
299 - # shows an animated trophy  
300 - # ------------------------------------------------------------------------  
301 @tornado.web.authenticated 345 @tornado.web.authenticated
302 async def get(self): 346 async def get(self):
  347 + '''
  348 + Gets question to render.
  349 + Shows an animated trophy if there are no more questions in the topic.
  350 + '''
303 logger.debug('[QuestionHandler]') 351 logger.debug('[QuestionHandler]')
304 user = self.current_user 352 user = self.current_user
305 - q = await self.learn.get_question(user) 353 + question = await self.learn.get_question(user)
306 354
307 # show current question 355 # show current question
308 - if q is not None:  
309 - qhtml = self.render_string(self.templates[q['type']],  
310 - question=q, md=md_to_html) 356 + if question is not None:
  357 + qhtml = self.render_string(self.templates[question['type']],
  358 + question=question, md=md_to_html)
311 response = { 359 response = {
312 'method': 'new_question', 360 'method': 'new_question',
313 'params': { 361 'params': {
314 - 'type': q['type'], 362 + 'type': question['type'],
315 'question': to_unicode(qhtml), 363 'question': to_unicode(qhtml),
316 'progress': self.learn.get_student_progress(user), 364 'progress': self.learn.get_student_progress(user),
317 - 'tries': q['tries'], 365 + 'tries': question['tries'],
318 } 366 }
319 } 367 }
320 368
@@ -331,20 +379,20 @@ class QuestionHandler(BaseHandler): @@ -331,20 +379,20 @@ class QuestionHandler(BaseHandler):
331 self.write(response) 379 self.write(response)
332 380
333 # ------------------------------------------------------------------------ 381 # ------------------------------------------------------------------------
334 - # POST  
335 - # corrects answer and returns status: right, wrong, try_again  
336 - # does not move to next question.  
337 - # ------------------------------------------------------------------------  
338 @tornado.web.authenticated 382 @tornado.web.authenticated
339 async def post(self) -> None: 383 async def post(self) -> None:
  384 + '''
  385 + Corrects answer and returns status: right, wrong, try_again
  386 + Does not move to next question.
  387 + '''
340 user = self.current_user 388 user = self.current_user
341 answer = self.get_body_arguments('answer') # list 389 answer = self.get_body_arguments('answer') # list
342 qid = self.get_body_arguments('qid')[0] 390 qid = self.get_body_arguments('qid')[0]
343 - logger.debug(f'[QuestionHandler] answer={answer}') 391 + # logger.debug('[QuestionHandler] answer=%s', answer)
344 392
345 # --- check if browser opened different questions simultaneously 393 # --- check if browser opened different questions simultaneously
346 if qid != self.learn.get_current_question_id(user): 394 if qid != self.learn.get_current_question_id(user):
347 - logger.info(f'User {user} desynchronized questions') 395 + logger.warning('User %s desynchronized questions', user)
348 self.write({ 396 self.write({
349 'method': 'invalid', 397 'method': 'invalid',
350 'params': { 398 'params': {
@@ -370,51 +418,55 @@ class QuestionHandler(BaseHandler): @@ -370,51 +418,55 @@ class QuestionHandler(BaseHandler):
370 ans = answer 418 ans = answer
371 419
372 # --- check answer (nonblocking) and get corrected question and action 420 # --- check answer (nonblocking) and get corrected question and action
373 - q = await self.learn.check_answer(user, ans) 421 + question = await self.learn.check_answer(user, ans)
374 422
375 # --- built response to return 423 # --- built response to return
376 - response = {'method': q['status'], 'params': {}} 424 + response = {'method': question['status'], 'params': {}}
377 425
378 - if q['status'] == 'right': # get next question in the topic  
379 - comments_html = self.render_string(  
380 - 'comments-right.html', comments=q['comments'], md=md_to_html) 426 + if question['status'] == 'right': # get next question in the topic
  427 + comments = self.render_string('comments-right.html',
  428 + comments=question['comments'],
  429 + md=md_to_html)
381 430
382 - solution_html = self.render_string(  
383 - 'solution.html', solution=q['solution'], md=md_to_html) 431 + solution = self.render_string('solution.html',
  432 + solution=question['solution'],
  433 + md=md_to_html)
384 434
385 response['params'] = { 435 response['params'] = {
386 - 'type': q['type'], 436 + 'type': question['type'],
387 'progress': self.learn.get_student_progress(user), 437 'progress': self.learn.get_student_progress(user),
388 - 'comments': to_unicode(comments_html),  
389 - 'solution': to_unicode(solution_html),  
390 - 'tries': q['tries'], 438 + 'comments': to_unicode(comments),
  439 + 'solution': to_unicode(solution),
  440 + 'tries': question['tries'],
391 } 441 }
392 - elif q['status'] == 'try_again':  
393 - comments_html = self.render_string(  
394 - 'comments.html', comments=q['comments'], md=md_to_html) 442 + elif question['status'] == 'try_again':
  443 + comments = self.render_string('comments.html',
  444 + comments=question['comments'],
  445 + md=md_to_html)
395 446
396 response['params'] = { 447 response['params'] = {
397 - 'type': q['type'], 448 + 'type': question['type'],
398 'progress': self.learn.get_student_progress(user), 449 'progress': self.learn.get_student_progress(user),
399 - 'comments': to_unicode(comments_html),  
400 - 'tries': q['tries'], 450 + 'comments': to_unicode(comments),
  451 + 'tries': question['tries'],
401 } 452 }
402 - elif q['status'] == 'wrong': # no more tries  
403 - comments_html = self.render_string(  
404 - 'comments.html', comments=q['comments'], md=md_to_html) 453 + elif question['status'] == 'wrong': # no more tries
  454 + comments = self.render_string('comments.html',
  455 + comments=question['comments'],
  456 + md=md_to_html)
405 457
406 - solution_html = self.render_string(  
407 - 'solution.html', solution=q['solution'], md=md_to_html) 458 + solution = self.render_string(
  459 + 'solution.html', solution=question['solution'], md=md_to_html)
408 460
409 response['params'] = { 461 response['params'] = {
410 - 'type': q['type'], 462 + 'type': question['type'],
411 'progress': self.learn.get_student_progress(user), 463 'progress': self.learn.get_student_progress(user),
412 - 'comments': to_unicode(comments_html),  
413 - 'solution': to_unicode(solution_html),  
414 - 'tries': q['tries'], 464 + 'comments': to_unicode(comments),
  465 + 'solution': to_unicode(solution),
  466 + 'tries': question['tries'],
415 } 467 }
416 else: 468 else:
417 - logger.error(f'Unknown question status: {q["status"]}') 469 + logger.error('Unknown question status: %s', question["status"])
418 470
419 self.write(response) 471 self.write(response)
420 472
@@ -422,29 +474,29 @@ class QuestionHandler(BaseHandler): @@ -422,29 +474,29 @@ class QuestionHandler(BaseHandler):
422 # ---------------------------------------------------------------------------- 474 # ----------------------------------------------------------------------------
423 # Signal handler to catch Ctrl-C and abort server 475 # Signal handler to catch Ctrl-C and abort server
424 # ---------------------------------------------------------------------------- 476 # ----------------------------------------------------------------------------
425 -def signal_handler(signal, frame) -> None:  
426 - r = input(' --> Stop webserver? (yes/no) ').lower()  
427 - if r == 'yes': 477 +def signal_handler(*_) -> None:
  478 + '''
  479 + Catches Ctrl-C and stops webserver
  480 + '''
  481 + reply = input(' --> Stop webserver? (yes/no) ')
  482 + if reply.lower() == 'yes':
428 tornado.ioloop.IOLoop.current().stop() 483 tornado.ioloop.IOLoop.current().stop()
429 - logger.critical('Webserver stopped.') 484 + logging.critical('Webserver stopped.')
430 sys.exit(0) 485 sys.exit(0)
431 - else:  
432 - logger.info('Abort canceled...')  
433 486
434 487
435 # ---------------------------------------------------------------------------- 488 # ----------------------------------------------------------------------------
436 -def run_webserver(app,  
437 - ssl,  
438 - port: int = 8443,  
439 - debug: bool = False) -> None: 489 +def run_webserver(app, ssl, port: int = 8443, debug: bool = False) -> None:
  490 + '''
  491 + Starts and runs webserver until a SIGINT signal (Ctrl-C) is received.
  492 + '''
440 493
441 # --- create web application 494 # --- create web application
442 try: 495 try:
443 webapp = WebApplication(app, debug=debug) 496 webapp = WebApplication(app, debug=debug)
444 except Exception: 497 except Exception:
445 logger.critical('Failed to start web application.') 498 logger.critical('Failed to start web application.')
446 - raise  
447 - # sys.exit(1) 499 + sys.exit(1)
448 else: 500 else:
449 logger.info('Web application started (tornado.web.Application)') 501 logger.info('Web application started (tornado.web.Application)')
450 502
@@ -460,14 +512,12 @@ def run_webserver(app, @@ -460,14 +512,12 @@ def run_webserver(app,
460 try: 512 try:
461 httpserver.listen(port) 513 httpserver.listen(port)
462 except OSError: 514 except OSError:
463 - logger.critical(f'Cannot bind port {port}. Already in use?') 515 + logger.critical('Cannot bind port %d. Already in use?', port)
464 sys.exit(1) 516 sys.exit(1)
465 - else:  
466 - logger.info(f'HTTP server listening on port {port}')  
467 517
468 # --- run webserver 518 # --- run webserver
  519 + logger.info('Webserver listening on %d... (Ctrl-C to stop)', port)
469 signal.signal(signal.SIGINT, signal_handler) 520 signal.signal(signal.SIGINT, signal_handler)
470 - logger.info('Webserver running... (Ctrl-C to stop)')  
471 521
472 try: 522 try:
473 tornado.ioloop.IOLoop.current().start() # running... 523 tornado.ioloop.IOLoop.current().start() # running...
aprendizations/static/css/topic.css
1 -.progress {  
2 - /*position: fixed;*/  
3 - top: 0;  
4 - height: 70px;  
5 - border-radius: 0px;  
6 -}  
7 body { 1 body {
8 - margin: 0;  
9 - padding-top: 0px;  
10 margin-bottom: 120px; /* Margin bottom by footer height */ 2 margin-bottom: 120px; /* Margin bottom by footer height */
11 } 3 }
12 4
@@ -19,10 +11,6 @@ body { @@ -19,10 +11,6 @@ body {
19 /*background-color: #f5f5f5;*/ 11 /*background-color: #f5f5f5;*/
20 } 12 }
21 13
22 -html {  
23 - position: relative;  
24 - min-height: 100%;  
25 -}  
26 .CodeMirror { 14 .CodeMirror {
27 border: 1px solid #eee; 15 border: 1px solid #eee;
28 height: auto; 16 height: auto;
aprendizations/templates/topic.html
1 -<!doctype html>  
2 -<html>  
3 - 1 +<!DOCTYPE html>
  2 +<html lang="pt-PT">
4 <head> 3 <head>
5 <title>{{appname}}</title> 4 <title>{{appname}}</title>
6 - <link rel="icon" href="/static/favicon.ico">  
7 -  
8 <meta charset="utf-8"> 5 <meta charset="utf-8">
9 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 6 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
10 <meta name="author" content="Miguel Barão"> 7 <meta name="author" content="Miguel Barão">
  8 + <link rel="icon" href="/static/favicon.ico">
11 9
12 <!-- MathJax3 --> 10 <!-- MathJax3 -->
13 <script> 11 <script>
14 MathJax = { 12 MathJax = {
15 tex: { 13 tex: {
16 - inlineMath: [  
17 - ['$$$', '$$$'],  
18 - ['\\(', '\\)']  
19 - ] 14 + inlineMath: [['$$$', '$$$'], ['\\(', '\\)']]
20 }, 15 },
21 svg: { 16 svg: {
22 fontCache: 'global' 17 fontCache: 'global'
23 } 18 }
24 }; 19 };
25 </script> 20 </script>
26 - <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>  
27 - <!-- Styles -->  
28 - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">  
29 - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">  
30 - <link rel="stylesheet" href="/static/codemirror/lib/codemirror.css">  
31 - <link rel="stylesheet" href="/static/css/animate.min.css">  
32 - <link rel="stylesheet" href="/static/css/github.css">  
33 - <link rel="stylesheet" href="/static/css/topic.css">  
34 <!-- 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>
  23 + <!-- <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg-full.js"></script> -->
35 <script defer src="/static/mdbootstrap/js/jquery.min.js"></script> 24 <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>
36 <script defer src="/static/mdbootstrap/js/popper.min.js"></script> 25 <script defer src="/static/mdbootstrap/js/popper.min.js"></script>
37 <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script> 26 <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>
@@ -39,10 +28,23 @@ @@ -39,10 +28,23 @@
39 <script defer src="/static/fontawesome-free/js/all.min.js"></script> 28 <script defer src="/static/fontawesome-free/js/all.min.js"></script>
40 <script defer src="/static/codemirror/lib/codemirror.js"></script> 29 <script defer src="/static/codemirror/lib/codemirror.js"></script>
41 <script defer src="/static/js/topic.js"></script> 30 <script defer src="/static/js/topic.js"></script>
  31 +
  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">
42 </head> 39 </head>
43 <!-- ===================================================================== --> 40 <!-- ===================================================================== -->
44 41
45 <body> 42 <body>
  43 + <!-- Progress bar -->
  44 + <div class="progress fixed-top" style="height: 70px; border-radius: 0px;">
  45 + <div class="progress-bar bg-warning" id="topic_progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="min-width: 1em;width: 0%"></div>
  46 + </div>
  47 +
46 <!-- Navbar --> 48 <!-- Navbar -->
47 <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">
48 <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora"> 50 <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora">
@@ -70,12 +72,8 @@ @@ -70,12 +72,8 @@
70 </div> 72 </div>
71 </nav> 73 </nav>
72 <!-- ===================================================================== --> 74 <!-- ===================================================================== -->
73 - <div class="progress">  
74 - <div class="progress-bar bg-warning" id="topic_progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="min-width: 1em;width: 0%"></div>  
75 - </div>  
76 - <!-- ===================================================================== -->  
77 <!-- main panel with questions --> 75 <!-- main panel with questions -->
78 - <div class="container" id="container"> 76 + <div class="container" id="container" style="padding-top: 100px;">
79 <div id="notifications"></div> 77 <div id="notifications"></div>
80 <div class="my-5" id="content"> 78 <div class="my-5" id="content">
81 <form action="/question" method="post" id="question_form" autocomplete="off"> 79 <form action="/question" method="post" id="question_form" autocomplete="off">
@@ -101,5 +99,4 @@ @@ -101,5 +99,4 @@
101 <!-- title="Shift-Enter" --> 99 <!-- title="Shift-Enter" -->
102 </div> 100 </div>
103 </body> 101 </body>
104 -  
105 </html> 102 </html>
106 \ No newline at end of file 103 \ No newline at end of file