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.
BUGS.md
1 1  
2 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 8 - nao esta a seguir o max_tries definido no ficheiro de dependencias.
5 9 - devia mostrar timeout para o aluno saber a razao.
6 10 - permitir configuracao para escolher entre static files locais ou remotos
... ...
README.md
... ... @@ -141,7 +141,7 @@ sudo pkg install py36-certbot # FreeBSD
141 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 146 ```sh
147 147 sudo certbot certonly --standalone -d www.example.com # first time
... ... @@ -151,6 +151,7 @@ sudo certbot renew # renew
151 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 153 ```sh
  154 +cd ~/.local/share/certs
154 155 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem .
155 156 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem .
156 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 6 # python standard library
3 7 import asyncio
... ... @@ -6,7 +10,7 @@ from contextlib import contextmanager # `with` statement in db sessions
6 10 from datetime import datetime
7 11 import logging
8 12 from random import random
9   -from os import path
  13 +from os.path import join, exists
10 14 from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict
11 15  
12 16 # third party libraries
... ... @@ -15,10 +19,10 @@ import networkx as nx
15 19 import sqlalchemy as sa
16 20  
17 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 28 # setup logger for this module
... ... @@ -27,33 +31,37 @@ logger = logging.getLogger(__name__)
27 31  
28 32 # ============================================================================
29 33 class LearnException(Exception):
30   - pass
  34 + '''Exceptions raised from the LearnApp class'''
31 35  
32 36  
33 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 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 65 session = self.Session(**kw)
58 66 try:
59 67 yield session
... ... @@ -66,15 +74,13 @@ class LearnApp(object):
66 74 session.close()
67 75  
68 76 # ------------------------------------------------------------------------
69   - # init
70   - # ------------------------------------------------------------------------
71 77 def __init__(self,
72 78 courses: str, # filename with course configurations
73 79 prefix: str, # path to topics
74 80 db: str, # database filename
75 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 84 self.online: Dict[str, Dict] = dict() # online students
79 85  
80 86 try:
... ... @@ -88,123 +94,130 @@ class LearnApp(object):
88 94 self.deps = nx.DiGraph(prefix=prefix)
89 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 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 111 # --- courses dict
102 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 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 119 logger.error(msg)
110 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 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 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 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 141 logger.info('Starting sanity checks (may take a while...)')
127 142  
128 143 errors: int = 0
129 144 for qref in self.factory:
130   - logger.debug(f'checking {qref}...')
  145 + logger.debug('checking %s...', qref)
131 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 150 errors += 1
136 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 159 errors += 1
145 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 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 171 errors += 1
157 172 continue # to next test
158 173  
159 174 if errors > 0:
160   - logger.error(f'{errors:>6} error(s) found.')
  175 + logger.error('%6d error(s) found.', errors) # {errors:>6}
161 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 188 # wait random time to minimize timing attacks
176 189 await asyncio.sleep(random())
177 190  
178 191 loop = asyncio.get_running_loop()
179 192 if found is None:
180   - logger.info(f'User "{uid}" does not exist')
  193 + logger.info('User "%s" does not exist', uid)
181 194 await loop.run_in_executor(None, bcrypt.hashpw, b'',
182 195 bcrypt.gensalt()) # just spend time
183 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 204 if pw_ok:
193 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 207 counter = self.online[uid]['counter']
196 208 else:
197   - logger.info(f'User "{uid}" logged in')
  209 + logger.info('User "%s" logged in', uid)
198 210 counter = 0
199 211  
200 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 217 state = {t.topic_id: {
205 218 'level': t.level,
206 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 222 self.online[uid] = {
210 223 'number': uid,
... ... @@ -216,179 +229,192 @@ class LearnApp(object):
216 229 }
217 230  
218 231 else:
219   - logger.info(f'User "{uid}" wrong password')
  232 + logger.info('User "%s" wrong password', uid)
220 233  
221 234 return pw_ok
222 235  
223 236 # ------------------------------------------------------------------------
224   - # logout
225   - # ------------------------------------------------------------------------
226 237 def logout(self, uid: str) -> None:
  238 + '''User logout'''
227 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 249 return False
236 250  
237 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 262 return True
247 263  
248 264 # ------------------------------------------------------------------------
249   - # Checks answer and update database. Returns corrected question.
250   - # ------------------------------------------------------------------------
251 265 async def check_answer(self, uid: str, answer) -> Question:
  266 + '''
  267 + Checks answer and update database.
  268 + Returns corrected question.
  269 + '''
252 270 student = self.online[uid]['state']
253 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 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 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 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 300 # save topic to database if finished
279 301 if student.topic_has_finished():
280 302 topic: str = student.get_previous_topic()
281 303 level: float = student.get_topic_level(topic)
282 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 314 # insert new studenttopic into database
291 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 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 322 else:
299 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 333 def start_course(self, uid: str, course_id: str) -> None:
  334 + '''Start course'''
  335 +
312 336 student = self.online[uid]['state']
313 337 try:
314 338 student.start_course(course_id)
315 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 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 348 async def start_topic(self, uid: str, topic: str) -> None:
  349 + '''Start new topic'''
  350 +
325 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 356 try:
331 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 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 385 raise LearnException('Database does not exist. '
358 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 389 self.Session = sa.orm.sessionmaker(bind=engine)
362 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 395 except Exception:
368   - logger.error(f'Database "{db}" not usable!')
  396 + logger.error('Database "%s" not usable!', database)
369 397 raise DatabaseUnusableError()
370 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 415 defaults = {
387 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 418 'shuffle_questions': True,
393 419 'choose': 9999,
394 420 'forgetting_factor': 1.0, # no forgetting
... ... @@ -400,20 +426,21 @@ class LearnApp(object):
400 426  
401 427 # iterate over topics and populate graph
402 428 topics: Dict[str, Dict] = config.get('topics', {})
403   - g.add_nodes_from(topics.keys())
  429 + self.deps.add_nodes_from(topics.keys())
404 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 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 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 458 logger.info('Building questions factory:')
431 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 464 return factory
438 465  
439 466 # ------------------------------------------------------------------------
440 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 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 472 # load questions as list of dicts
447 473 try:
448   - fullpath: str = path.join(t['path'], t['file'])
  474 + fullpath: str = join(topic['path'], topic['file'])
449 475 except Exception:
450 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 479 msg = f'{msg1}. {msg2}'
453 480 logger.error(msg)
454 481 raise LearnException(msg)
455   - logger.debug(f' Loading {fullpath}')
  482 + logger.debug(' Loading %s', fullpath)
456 483 try:
457 484 questions: List[QDict] = load_yaml(fullpath)
458 485 except Exception:
459   - if t['type'] == 'chapter':
  486 + if topic['type'] == 'chapter':
460 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 492 if not isinstance(questions, list):
467 493 msg = f'File "{fullpath}" must be a list of questions'
... ... @@ -473,134 +499,162 @@ class LearnApp(object):
473 499 # undefined are set to topic:n, where n is the question number
474 500 # within the file
475 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 504 if qref in localrefs:
479   - msg = f'Duplicate ref "{qref}" in "{t["path"]}"'
  505 + msg = f'Duplicate ref "{qref}" in "{topic["path"]}"'
480 506 raise LearnException(msg)
481 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 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 526 return factory
501 527  
502 528 # ------------------------------------------------------------------------
503 529 def get_login_counter(self, uid: str) -> int:
  530 + '''login counter''' # FIXME
504 531 return int(self.online[uid]['counter'])
505 532  
506 533 # ------------------------------------------------------------------------
507 534 def get_student_name(self, uid: str) -> str:
  535 + '''Get the username'''
508 536 return self.online[uid].get('name', '')
509 537  
510 538 # ------------------------------------------------------------------------
511 539 def get_student_state(self, uid: str) -> List[Dict[str, Any]]:
  540 + '''Get the knowledge state of a given user'''
512 541 return self.online[uid]['state'].get_knowledge_state()
513 542  
514 543 # ------------------------------------------------------------------------
515 544 def get_student_progress(self, uid: str) -> float:
  545 + '''Get the current topic progress of a given user'''
516 546 return float(self.online[uid]['state'].get_topic_progress())
517 547  
518 548 # ------------------------------------------------------------------------
519 549 def get_current_question(self, uid: str) -> Optional[Question]:
  550 + '''Get the current question of a given user'''
520 551 q: Optional[Question] = self.online[uid]['state'].get_current_question()
521 552 return q
522 553  
523 554 # ------------------------------------------------------------------------
524 555 def get_current_question_id(self, uid: str) -> str:
  556 + '''Get id of the current question for a given user'''
525 557 return str(self.online[uid]['state'].get_current_question()['qid'])
526 558  
527 559 # ------------------------------------------------------------------------
528 560 def get_student_question_type(self, uid: str) -> str:
  561 + '''Get type of the current question for a given user'''
529 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 569 def get_student_course_title(self, uid: str) -> str:
  570 + '''get the title of the current course for a given user'''
537 571 return str(self.online[uid]['state'].get_current_course_title())
538 572  
539 573 # ------------------------------------------------------------------------
540 574 def get_current_course_id(self, uid: str) -> Optional[str]:
  575 + '''get the current course (id) of a given user'''
541 576 cid: Optional[str] = self.online[uid]['state'].get_current_course_id()
542 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 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 591 topic: str = self.online[uid]['state'].get_current_topic()
551 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 596 def get_courses(self) -> Dict[str, Dict[str, Any]]:
  597 + '''
  598 + Get dictionary with all courses {'course1': {...}, 'course2': {...}}
  599 + '''
556 600 return self.courses
557 601  
558 602 # ------------------------------------------------------------------------
559 603 def get_course(self, course_id: str) -> Dict[str, Any]:
  604 + '''
  605 + Get dictionary {'title': ..., 'description':..., 'goals':...}
  606 + '''
560 607 return self.courses[course_id]
561 608  
562 609 # ------------------------------------------------------------------------
563 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 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 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 643 for u in total}
587 644  
588 645 # compute topic progress
589 646 now = datetime.now()
590 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 651 if topic in goals:
595 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 6 # python standard library
3 7 import asyncio
... ... @@ -5,7 +9,7 @@ import base64
5 9 import functools
6 10 import logging.config
7 11 import mimetypes
8   -from os import path
  12 +from os.path import join, dirname, expanduser
9 13 import signal
10 14 import sys
11 15 from typing import List, Optional, Union
... ... @@ -16,8 +20,9 @@ import tornado.web
16 20 from tornado.escape import to_unicode
17 21  
18 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 28 # setup logger for this module
... ... @@ -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 33 def admin_only(func):
  34 + '''
  35 + Decorator used to restrict access to the administrator
  36 + '''
31 37 @functools.wraps(func)
32 38 def wrapper(self, *args, **kwargs):
33 39 if self.current_user != '0':
34 40 raise tornado.web.HTTPError(403) # forbidden
35   - else:
36   - func(self, *args, **kwargs)
  41 + func(self, *args, **kwargs)
37 42 return wrapper
38 43  
39 44  
40 45 # ============================================================================
41   -# WebApplication - Tornado Web Server
42   -# ============================================================================
43 46 class WebApplication(tornado.web.Application):
44   -
  47 + '''
  48 + WebApplication - Tornado Web Server
  49 + '''
45 50 def __init__(self, learnapp, debug=False):
46 51 handlers = [
47   - (r'/login', LoginHandler),
48   - (r'/logout', LogoutHandler),
  52 + (r'/login', LoginHandler),
  53 + (r'/logout', LogoutHandler),
49 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 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 66 'static_url_prefix': '/static/',
62 67 'xsrf_cookies': True,
63 68 'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
... ... @@ -71,30 +76,37 @@ class WebApplication(tornado.web.Application):
71 76 # ============================================================================
72 77 # Handlers
73 78 # ============================================================================
74   -
75   -# ----------------------------------------------------------------------------
76   -# Base handler common to all handlers.
77   -# ----------------------------------------------------------------------------
  79 +# pylint: disable=abstract-method
78 80 class BaseHandler(tornado.web.RequestHandler):
  81 + '''
  82 + Base handler common to all handlers.
  83 + '''
79 84 @property
80 85 def learn(self):
  86 + '''easier access to learnapp'''
81 87 return self.application.learn
82 88  
83 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 94 counter = self.get_secure_cookie('counter').decode('utf-8')
88 95 if counter == str(self.learn.get_login_counter(uid)):
89 96 return uid
  97 + return None
90 98  
91 99  
92 100 # ----------------------------------------------------------------------------
93   -# /rankings
94   -# ----------------------------------------------------------------------------
95 101 class RankingsHandler(BaseHandler):
  102 + '''
  103 + Handles rankings page
  104 + '''
96 105 @tornado.web.authenticated
97 106 def get(self):
  107 + '''
  108 + Renders list of students that have answers in this course.
  109 + '''
98 110 uid = self.current_user
99 111 current_course = self.learn.get_current_course_id(uid)
100 112 course_id = self.get_query_argument('course', default=current_course)
... ... @@ -110,23 +122,33 @@ class RankingsHandler(BaseHandler):
110 122  
111 123  
112 124 # ----------------------------------------------------------------------------
113   -# /auth/login
  125 +#
114 126 # ----------------------------------------------------------------------------
115 127 class LoginHandler(BaseHandler):
  128 + '''
  129 + Handles /login
  130 + '''
116 131 def get(self):
  132 + '''
  133 + Renders login page
  134 + '''
117 135 self.render('login.html',
118 136 appname=APP_NAME,
119 137 error='')
120 138  
121 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 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 152 self.set_secure_cookie('counter', counter)
131 153 self.redirect('/')
132 154 else:
... ... @@ -136,11 +158,15 @@ class LoginHandler(BaseHandler):
136 158  
137 159  
138 160 # ----------------------------------------------------------------------------
139   -# /auth/logout
140   -# ----------------------------------------------------------------------------
141 161 class LogoutHandler(BaseHandler):
  162 + '''
  163 + Handles /logout
  164 + '''
142 165 @tornado.web.authenticated
143 166 def get(self):
  167 + '''
  168 + clears cookies and removes user session
  169 + '''
144 170 self.clear_cookie('user')
145 171 self.clear_cookie('counter')
146 172 self.redirect('/')
... ... @@ -151,12 +177,18 @@ class LogoutHandler(BaseHandler):
151 177  
152 178 # ----------------------------------------------------------------------------
153 179 class ChangePasswordHandler(BaseHandler):
  180 + '''
  181 + Handles password change
  182 + '''
154 183 @tornado.web.authenticated
155 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 192 if changed_ok:
161 193 notification = self.render_string(
162 194 'notification.html',
... ... @@ -174,45 +206,53 @@ class ChangePasswordHandler(BaseHandler):
174 206  
175 207  
176 208 # ----------------------------------------------------------------------------
177   -# /
178   -# redirects to appropriate place
179   -# ----------------------------------------------------------------------------
180 209 class RootHandler(BaseHandler):
  210 + '''
  211 + Handles root /
  212 + '''
181 213 @tornado.web.authenticated
182 214 def get(self):
  215 + '''Simply redirects to the main entrypoint'''
183 216 self.redirect('/courses')
184 217  
185 218  
186 219 # ----------------------------------------------------------------------------
187   -# /courses
188   -# Shows a list of available courses
189   -# ----------------------------------------------------------------------------
190 220 class CoursesHandler(BaseHandler):
  221 + '''
  222 + Handles /courses
  223 + '''
191 224 @tornado.web.authenticated
192 225 def get(self):
  226 + '''Renders list of available courses'''
193 227 uid = self.current_user
194 228 self.render('courses.html',
195 229 appname=APP_NAME,
196 230 uid=uid,
197 231 name=self.learn.get_student_name(uid),
198 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 238 class CourseHandler(BaseHandler):
  239 + '''
  240 + Handles a particular course to show the topics table
  241 + '''
  242 +
207 243 @tornado.web.authenticated
208 244 def get(self, course_id):
  245 + '''
  246 + Handles get /course/...
  247 + Starts a given course and show list of topics
  248 + '''
209 249 uid = self.current_user
210 250 if course_id == '':
211 251 course_id = self.learn.get_current_course_id(uid)
212 252  
213 253 try:
214 254 self.learn.start_course(uid, course_id)
215   - except KeyError:
  255 + except LearnException:
216 256 self.redirect('/courses')
217 257  
218 258 self.render('maintopics-table.html',
... ... @@ -225,17 +265,21 @@ class CourseHandler(BaseHandler):
225 265 )
226 266  
227 267  
228   -# ----------------------------------------------------------------------------
229   -# /topic/...
230   -# Start a given topic
231   -# ----------------------------------------------------------------------------
  268 +# ============================================================================
232 269 class TopicHandler(BaseHandler):
  270 + '''
  271 + Handles a topic
  272 + '''
233 273 @tornado.web.authenticated
234 274 async def get(self, topic):
  275 + '''
  276 + Handles get /topic/...
  277 + Starts a given topic
  278 + '''
235 279 uid = self.current_user
236 280  
237 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 283 except KeyError:
240 284 self.redirect('/topics')
241 285  
... ... @@ -243,31 +287,34 @@ class TopicHandler(BaseHandler):
243 287 appname=APP_NAME,
244 288 uid=uid,
245 289 name=self.learn.get_student_name(uid),
246   - # course_title=self.learn.get_student_course_title(uid),
247 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 295 class FileHandler(BaseHandler):
  296 + '''
  297 + Serves files from the /public subdir of the topics.
  298 + '''
255 299 @tornado.web.authenticated
256 300 async def get(self, filename):
  301 + '''
  302 + Serves files from /public subdirectories of a particular topic
  303 + '''
257 304 uid = self.current_user
258 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 307 content_type = mimetypes.guess_type(filename)[0]
261 308  
262 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 312 except FileNotFoundError:
266   - logger.error(f'File not found: {filepath}')
  313 + logger.error('File not found: %s', filepath)
267 314 except PermissionError:
268   - logger.error(f'No permission: {filepath}')
  315 + logger.error('No permission: %s', filepath)
269 316 except Exception:
270   - logger.error(f'Error reading: {filepath}')
  317 + logger.error('Error reading: %s', filepath)
271 318 raise
272 319 else:
273 320 self.set_header("Content-Type", content_type)
... ... @@ -275,10 +322,11 @@ class FileHandler(BaseHandler):
275 322 await self.flush()
276 323  
277 324  
278   -# ----------------------------------------------------------------------------
279   -# respond to AJAX to get a JSON question
280   -# ----------------------------------------------------------------------------
  325 +# ============================================================================
281 326 class QuestionHandler(BaseHandler):
  327 + '''
  328 + Responds to AJAX to get a JSON question
  329 + '''
282 330 templates = {
283 331 'checkbox': 'question-checkbox.html',
284 332 'radio': 'question-radio.html',
... ... @@ -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 345 @tornado.web.authenticated
302 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 351 logger.debug('[QuestionHandler]')
304 352 user = self.current_user
305   - q = await self.learn.get_question(user)
  353 + question = await self.learn.get_question(user)
306 354  
307 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 359 response = {
312 360 'method': 'new_question',
313 361 'params': {
314   - 'type': q['type'],
  362 + 'type': question['type'],
315 363 'question': to_unicode(qhtml),
316 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 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 382 @tornado.web.authenticated
339 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 388 user = self.current_user
341 389 answer = self.get_body_arguments('answer') # list
342 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 393 # --- check if browser opened different questions simultaneously
346 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 396 self.write({
349 397 'method': 'invalid',
350 398 'params': {
... ... @@ -370,51 +418,55 @@ class QuestionHandler(BaseHandler):
370 418 ans = answer
371 419  
372 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 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 435 response['params'] = {
386   - 'type': q['type'],
  436 + 'type': question['type'],
387 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 447 response['params'] = {
397   - 'type': q['type'],
  448 + 'type': question['type'],
398 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 461 response['params'] = {
410   - 'type': q['type'],
  462 + 'type': question['type'],
411 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 468 else:
417   - logger.error(f'Unknown question status: {q["status"]}')
  469 + logger.error('Unknown question status: %s', question["status"])
418 470  
419 471 self.write(response)
420 472  
... ... @@ -422,29 +474,29 @@ class QuestionHandler(BaseHandler):
422 474 # ----------------------------------------------------------------------------
423 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 483 tornado.ioloop.IOLoop.current().stop()
429   - logger.critical('Webserver stopped.')
  484 + logging.critical('Webserver stopped.')
430 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 494 # --- create web application
442 495 try:
443 496 webapp = WebApplication(app, debug=debug)
444 497 except Exception:
445 498 logger.critical('Failed to start web application.')
446   - raise
447   - # sys.exit(1)
  499 + sys.exit(1)
448 500 else:
449 501 logger.info('Web application started (tornado.web.Application)')
450 502  
... ... @@ -460,14 +512,12 @@ def run_webserver(app,
460 512 try:
461 513 httpserver.listen(port)
462 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 516 sys.exit(1)
465   - else:
466   - logger.info(f'HTTP server listening on port {port}')
467 517  
468 518 # --- run webserver
  519 + logger.info('Webserver listening on %d... (Ctrl-C to stop)', port)
469 520 signal.signal(signal.SIGINT, signal_handler)
470   - logger.info('Webserver running... (Ctrl-C to stop)')
471 521  
472 522 try:
473 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 1 body {
8   - margin: 0;
9   - padding-top: 0px;
10 2 margin-bottom: 120px; /* Margin bottom by footer height */
11 3 }
12 4  
... ... @@ -19,10 +11,6 @@ body {
19 11 /*background-color: #f5f5f5;*/
20 12 }
21 13  
22   -html {
23   - position: relative;
24   - min-height: 100%;
25   -}
26 14 .CodeMirror {
27 15 border: 1px solid #eee;
28 16 height: auto;
... ...
aprendizations/templates/topic.html
1   -<!doctype html>
2   -<html>
3   -
  1 +<!DOCTYPE html>
  2 +<html lang="pt-PT">
4 3 <head>
5 4 <title>{{appname}}</title>
6   - <link rel="icon" href="/static/favicon.ico">
7   -
8 5 <meta charset="utf-8">
9 6 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
10 7 <meta name="author" content="Miguel Barão">
  8 + <link rel="icon" href="/static/favicon.ico">
11 9  
12 10 <!-- MathJax3 -->
13 11 <script>
14 12 MathJax = {
15 13 tex: {
16   - inlineMath: [
17   - ['$$$', '$$$'],
18   - ['\\(', '\\)']
19   - ]
  14 + inlineMath: [['$$$', '$$$'], ['\\(', '\\)']]
20 15 },
21 16 svg: {
22 17 fontCache: 'global'
23 18 }
24 19 };
25 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 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 24 <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>
36 25 <script defer src="/static/mdbootstrap/js/popper.min.js"></script>
37 26 <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>
... ... @@ -39,10 +28,23 @@
39 28 <script defer src="/static/fontawesome-free/js/all.min.js"></script>
40 29 <script defer src="/static/codemirror/lib/codemirror.js"></script>
41 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 39 </head>
43 40 <!-- ===================================================================== -->
44 41  
45 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 48 <!-- Navbar -->
47 49 <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">
48 50 <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora">
... ... @@ -70,12 +72,8 @@
70 72 </div>
71 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 75 <!-- main panel with questions -->
78   - <div class="container" id="container">
  76 + <div class="container" id="container" style="padding-top: 100px;">
79 77 <div id="notifications"></div>
80 78 <div class="my-5" id="content">
81 79 <form action="/question" method="post" id="question_form" autocomplete="off">
... ... @@ -101,5 +99,4 @@
101 99 <!-- title="Shift-Enter" -->
102 100 </div>
103 101 </body>
104   -
105 102 </html>
106 103 \ No newline at end of file
... ...