Commit 60be4ab62ff720dfc95572f99b6503a67610cf68

Authored by Miguel Barão
2 parents 2fa34906 dde72423
Exists in master and in 1 other branch dev

Merge branch 'dev'

BUGS.md
... ... @@ -26,6 +26,9 @@
26 26  
27 27 ## TODO
28 28  
  29 +- ordenação que faça sentido, organizada por chapters.
  30 +- chapters deviam ser automaticamente checkados...
  31 +- hints dos topicos fechados com as dependencias desse topico que ainda faltam
29 32 - alterar tabelas para incluir email de recuperacao de password (e outros
30 33 avisos)
31 34 - shuffle das perguntas dentro de um topico
... ...
README.md
1 1 # Getting Started
2 2  
3   -Latest review: 2021-11-08
  3 +Latest review: 2022-04-04
4 4  
5 5 ## Installation
6 6  
... ... @@ -27,7 +27,7 @@ operating system default.
27 27 ```sh
28 28 sudo pkg install python3 py38-sqlite3 # FreeBSD
29 29 sudo apt install python3 # Linux (Ubuntu)
30   -sudo port install python39 # MacOS
  30 +sudo port install python310 # MacOS
31 31 ```
32 32  
33 33 ### Install pip
... ... @@ -37,15 +37,18 @@ Install `pip` from the system package manager:
37 37 ```sh
38 38 sudo pkg install py38-pip # FreeBSD
39 39 sudo apt install python3-pip # Linux (Ubuntu)
40   -sudo port install py39-pip # MacOS
  40 +sudo port install py310-pip # MacOS
41 41 ```
42 42  
43 43 In the end you should be able to run `pip --version` and `python3 -c "import
44 44 sqlite3"` without errors.
45 45 In some systems, `pip` can be named `pip3`, `pip3.8` or `pip-3.8`, etc.
46 46  
47   -Packages should **not** be installed system-wide. To install locally in the user
48   -area, edit the configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or
  47 +Packages should **not** be installed system-wide. Either install them to a
  48 +python virtual environment or in the user area.
  49 +
  50 +To install in the user area use `pip install --user some_package` or edit the
  51 +configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or
49 52 `Library/Application Support/pip/pip.conf` (MacOS) and add the lines
50 53  
51 54 ```ini
... ... @@ -62,7 +65,7 @@ pip install git+https://git.xdi.uevora.pt/mjsb/aprendizations.git
62 65 Python packages are usually installed in:
63 66  
64 67 * `~/.local/lib/python3.8/site-packages/` in Linux/FreeBSD.
65   -* `~/Library/python/3.9/lib/python/site-packages/` in MacOS.
  68 +* `~/Library/python/3.10/lib/python/site-packages/` in MacOS.
66 69  
67 70 When aprendizations is installed with pip, all the dependencies are also
68 71 installed.
... ... @@ -72,7 +75,7 @@ installed in
72 75  
73 76 ```sh
74 77 ~/.local/bin # Linux/FreeBSD
75   -~/Library/Python/3.9/bin # MacOS
  78 +~/Library/Python/3.10/bin # MacOS
76 79 ```
77 80  
78 81 and can be run from the terminal:
... ...
aprendizations/__init__.py
... ... @@ -30,7 +30,7 @@ are progressively uncovered as the students progress.
30 30 '''
31 31  
32 32 APP_NAME = 'aprendizations'
33   -APP_VERSION = '2021.11.dev1'
  33 +APP_VERSION = '2022.08.dev1'
34 34 APP_DESCRIPTION = __doc__
35 35  
36 36 __author__ = 'Miguel Barão'
... ...
aprendizations/initdb.py
... ... @@ -84,25 +84,24 @@ def get_students_from_csv(filename):
84 84 'skipinitialspace': True,
85 85 }
86 86  
  87 + students = []
87 88 try:
88 89 with open(filename, encoding='iso-8859-1') as file:
89 90 csvreader = csv.DictReader(file, **csv_settings)
90 91 students = [{
91 92 'uid': s['N.º'],
92 93 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())
93   - } for s in csvreader]
  94 + } for s in csvreader]
94 95 except OSError:
95 96 print(f'!!! Error reading file "{filename}" !!!')
96   - students = []
97 97 except csv.Error:
98 98 print(f'!!! Error parsing CSV from "{filename}" !!!')
99   - students = []
100 99  
101 100 return students
102 101  
103 102  
104 103 # ===========================================================================
105   -def show_students_in_database(session, verbose=False):
  104 +def show_students_in_database(session, verbose=False) -> None:
106 105 '''shows students in the database'''
107 106 users = session.execute(select(Student)).scalars().all()
108 107 total = len(users)
... ... @@ -136,7 +135,7 @@ def main():
136 135 Base.metadata.create_all(engine) # Creates schema if needed
137 136 session = Session(engine, future=True)
138 137  
139   - # --- build list of students to insert/update
  138 + # --- make list of students to insert/update
140 139 students = []
141 140  
142 141 for csvfile in args.csvfile:
... ... @@ -145,24 +144,22 @@ def main():
145 144 if args.admin:
146 145 students.append({'uid': '0', 'name': 'Admin'})
147 146  
148   - if args.add:
  147 + if args.add is not None:
149 148 for uid, name in args.add:
150 149 students.append({'uid': uid, 'name': name})
151 150  
152 151 # --- only insert students that are not yet in the database
153 152 print('\nInserting new students:')
  153 + db_students = session.execute(select(Student.id)).scalars().all()
  154 + new_students = [s for s in students if s['uid'] not in set(db_students)]
  155 + for student in new_students:
  156 + print(f' {student["uid"]}, {student["name"]}')
154 157  
155   - db_students = set(session.execute(select(Student.id)).scalars().all())
156   - new_students = (s for s in students if s['uid'] not in db_students)
157   - count = 0
158   - for s in new_students:
159   - print(f' {s["uid"]}, {s["name"]}')
160   -
161   - pw = args.pw or s['uid']
162   - hashed_pw = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt())
163   -
164   - session.add(Student(id=s['uid'], name=s['name'], password=hashed_pw))
165   - count += 1
  158 + passwd = args.pw or student['uid']
  159 + hashed_pw = bcrypt.hashpw(passwd.encode('utf-8'), bcrypt.gensalt())
  160 + session.add(Student(id=student['uid'],
  161 + name=student['name'],
  162 + password=hashed_pw))
166 163  
167 164 try:
168 165 session.commit()
... ... @@ -170,23 +167,24 @@ def main():
170 167 print('!!! Integrity error. Aborted !!!\n')
171 168 session.rollback()
172 169 else:
173   - print(f'Total {count} new student(s).')
  170 + print(f'Total {len(new_students)} new student(s).')
174 171  
175   - # --- update data for student in the database
  172 + # --- update data for students in the database
176 173 if args.update:
177 174 print('\nUpdating passwords of students:')
178 175 count = 0
179 176 for sid in args.update:
180 177 try:
181   - s = session.execute(select(Student).filter_by(id=sid)).scalar_one()
  178 + query = select(Student).filter_by(id=sid)
  179 + student = session.execute(query).scalar_one()
182 180 except NoResultFound:
183 181 print(f' -> student {sid} does not exist!')
184 182 continue
185   - else:
186   - print(f' {sid}, {s.name}')
187   - count += 1
188   - pw = args.pw or sid
189   - s.password = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt())
  183 + count += 1
  184 + print(f' {sid}, {student.name}')
  185 + passwd = (args.pw or sid).encode('utf-8')
  186 + student.password = bcrypt.hashpw(passwd, bcrypt.gensalt())
  187 +
190 188 session.commit()
191 189 print(f'Total {count} password(s) updated.')
192 190  
... ...
aprendizations/learnapp.py
... ... @@ -6,7 +6,6 @@ This is the main controller of the application.
6 6 # python standard library
7 7 import asyncio
8 8 from collections import defaultdict
9   -# from contextlib import contextmanager # `with` statement in db sessions
10 9 from datetime import datetime
11 10 import logging
12 11 from random import random
... ... @@ -45,26 +44,33 @@ class LearnApp():
45 44 '''
46 45 LearnApp - application logic
47 46  
48   - self.deps - networkx topic dependencies
49   - self.courses - dict {course_id: {'title': ...,
50   - 'description': ...,
51   - 'goals': ...,}, ...}
52   - self.factory = dict {qref: QFactory()}
53   - self.online - dict {student_id: {'number': ...,
54   - 'name': ...,
55   - 'state': StudentState(),
56   - 'counter': ...}, ...}
  47 + self.deps = networkx topic dependencies
  48 + self.courses = {
  49 + course_id: {
  50 + 'title': ...,
  51 + 'description': ...,
  52 + 'goals': ...,
  53 + }, ...
  54 + }
  55 + self.factory = { qref: QFactory() }
  56 + self.online = {
  57 + student_id: {
  58 + 'number': ...,
  59 + 'name': ...,
  60 + 'state': StudentState(),
  61 + 'counter': ...
  62 + }, ...
  63 + }
57 64 '''
58 65  
59   - # ------------------------------------------------------------------------
60 66 def __init__(self,
61 67 courses: str, # filename with course configurations
62 68 prefix: str, # path to topics
63   - db: str, # database filename
  69 + dbase: str, # database filename
64 70 check: bool = False) -> None:
65 71  
66   - self._db_setup(db) # setup database and check students
67   - self.online: Dict[str, Dict] = dict() # online students
  72 + self._db_setup(dbase) # setup database and check students
  73 + self.online: Dict[str, Dict] = {} # online students
68 74  
69 75 try:
70 76 config: Dict[str, Any] = load_yaml(courses)
... ... @@ -85,7 +91,6 @@ class LearnApp():
85 91 # load other course files with the topics the their deps
86 92 for course_file in config.get('topics_from', []):
87 93 course_conf = load_yaml(course_file) # course configuration
88   - # FIXME set defaults??
89 94 logger.info('%6d topics from %s',
90 95 len(course_conf["topics"]), course_file)
91 96 self._populate_graph(course_conf)
... ... @@ -114,7 +119,6 @@ class LearnApp():
114 119 if check:
115 120 self._sanity_check_questions()
116 121  
117   - # ------------------------------------------------------------------------
118 122 def _sanity_check_questions(self) -> None:
119 123 '''
120 124 Unit tests for all questions
... ... @@ -124,10 +128,10 @@ class LearnApp():
124 128 logger.info('Starting sanity checks (may take a while...)')
125 129  
126 130 errors: int = 0
127   - for qref in self.factory:
  131 + for qref, qfactory in self.factory.items():
128 132 logger.debug('checking %s...', qref)
129 133 try:
130   - question = self.factory[qref].generate()
  134 + question = qfactory.generate()
131 135 except QuestionException as exc:
132 136 logger.error(exc)
133 137 errors += 1
... ... @@ -159,7 +163,6 @@ class LearnApp():
159 163 raise LearnException('Sanity checks')
160 164 logger.info(' 0 errors found.')
161 165  
162   - # ------------------------------------------------------------------------
163 166 async def login(self, uid: str, password: str) -> bool:
164 167 '''user login'''
165 168  
... ... @@ -212,13 +215,11 @@ class LearnApp():
212 215  
213 216 return pw_ok
214 217  
215   - # ------------------------------------------------------------------------
216 218 def logout(self, uid: str) -> None:
217 219 '''User logout'''
218 220 del self.online[uid]
219 221 logger.info('User "%s" logged out', uid)
220 222  
221   - # ------------------------------------------------------------------------
222 223 async def change_password(self, uid: str, password: str) -> bool:
223 224 '''
224 225 Change user Password.
... ... @@ -242,7 +243,6 @@ class LearnApp():
242 243 logger.info('User "%s" changed password', uid)
243 244 return True
244 245  
245   - # ------------------------------------------------------------------------
246 246 async def check_answer(self, uid: str, answer) -> Question:
247 247 '''
248 248 Checks answer and update database.
... ... @@ -271,7 +271,6 @@ class LearnApp():
271 271  
272 272 return question
273 273  
274   - # ------------------------------------------------------------------------
275 274 async def get_question(self, uid: str) -> Optional[Question]:
276 275 '''
277 276 Get the question to show (current or new one)
... ... @@ -318,7 +317,6 @@ class LearnApp():
318 317  
319 318 return question
320 319  
321   - # ------------------------------------------------------------------------
322 320 def start_course(self, uid: str, course_id: str) -> None:
323 321 '''Start course'''
324 322  
... ... @@ -331,16 +329,10 @@ class LearnApp():
331 329 else:
332 330 logger.info('User "%s" course "%s"', uid, course_id)
333 331  
334   - # ------------------------------------------------------------------------
335   - #
336   - # ------------------------------------------------------------------------
337 332 async def start_topic(self, uid: str, topic: str) -> None:
338 333 '''Start new topic'''
339 334  
340 335 student = self.online[uid]['state']
341   - # if uid == '0':
342   - # logger.warning('Reloading "%s"', topic) # FIXME should be an option
343   - # self.factory.update(self._factory_for(topic))
344 336  
345 337 try:
346 338 await student.start_topic(topic)
... ... @@ -350,9 +342,6 @@ class LearnApp():
350 342 else:
351 343 logger.info('User "%s" started topic "%s"', uid, topic)
352 344  
353   - # ------------------------------------------------------------------------
354   - #
355   - # ------------------------------------------------------------------------
356 345 def _add_missing_topics(self, topics: Iterable[str]) -> None:
357 346 '''
358 347 Fill db table 'Topic' with topics from the graph, if new
... ... @@ -365,7 +354,6 @@ class LearnApp():
365 354 session.commit()
366 355 logger.info('Added %d new topic(s) to the database', len(new))
367 356  
368   - # ------------------------------------------------------------------------
369 357 def _db_setup(self, database: str) -> None:
370 358 '''
371 359 Setup and check database contents
... ... @@ -373,12 +361,12 @@ class LearnApp():
373 361  
374 362 logger.info('Checking database "%s":', database)
375 363 if not exists(database):
376   - raise LearnException('Database does not exist. '
377   - 'Use "initdb-aprendizations" to create')
  364 + msg = 'Database does not exist.'
  365 + logger.error(msg)
  366 + raise LearnException(msg)
378 367  
379 368 self._engine = create_engine(f'sqlite:///{database}', future=True)
380 369  
381   -
382 370 try:
383 371 query_students = select(func.count(Student.id))
384 372 query_topics = select(func.count(Topic.id))
... ... @@ -395,7 +383,6 @@ class LearnApp():
395 383 logger.info('%6d topics', count_topics)
396 384 logger.info('%6d answers', count_answers)
397 385  
398   - # ------------------------------------------------------------------------
399 386 def _populate_graph(self, config: Dict[str, Any]) -> None:
400 387 '''
401 388 Populates a digraph.
... ... @@ -429,7 +416,7 @@ class LearnApp():
429 416  
430 417 topic = self.deps.nodes[tref] # get current topic node
431 418 topic['name'] = attr.get('name', tref)
432   - topic['questions'] = attr.get('questions', []) # FIXME unused??
  419 + topic['questions'] = attr.get('questions', [])
433 420  
434 421 for k, default in defaults.items():
435 422 topic[k] = attr.get(k, default)
... ... @@ -437,12 +424,10 @@ class LearnApp():
437 424 # prefix/topic
438 425 topic['path'] = join(self.deps.graph['prefix'], tref)
439 426  
440   -
441   - # ========================================================================
  427 + # ------------------------------------------------------------------------
442 428 # methods that do not change state (pure functions)
443   - # ========================================================================
444   -
445 429 # ------------------------------------------------------------------------
  430 +
446 431 def _make_factory(self) -> Dict[str, QFactory]:
447 432 '''
448 433 Buils dictionary of question factories
... ... @@ -451,7 +436,7 @@ class LearnApp():
451 436 '''
452 437  
453 438 logger.info('Building questions factory:')
454   - factory = dict()
  439 + factory = {}
455 440 for tref in self.deps.nodes:
456 441 factory.update(self._factory_for(tref))
457 442  
... ... @@ -462,7 +447,7 @@ class LearnApp():
462 447 # makes factory for a single topic
463 448 # ------------------------------------------------------------------------
464 449 def _factory_for(self, tref: str) -> Dict[str, QFactory]:
465   - factory: Dict[str, QFactory] = dict()
  450 + factory: Dict[str, QFactory] = {}
466 451 topic = self.deps.nodes[tref] # get node
467 452 # load questions as list of dicts
468 453 try:
... ... @@ -519,38 +504,31 @@ class LearnApp():
519 504  
520 505 return factory
521 506  
522   - # ------------------------------------------------------------------------
523 507 def get_login_counter(self, uid: str) -> int:
524   - '''login counter''' # FIXME
  508 + '''login counter'''
525 509 return int(self.online[uid]['counter'])
526 510  
527   - # ------------------------------------------------------------------------
528 511 def get_student_name(self, uid: str) -> str:
529 512 '''Get the username'''
530 513 return self.online[uid].get('name', '')
531 514  
532   - # ------------------------------------------------------------------------
533 515 def get_student_state(self, uid: str) -> List[Dict[str, Any]]:
534 516 '''Get the knowledge state of a given user'''
535 517 return self.online[uid]['state'].get_knowledge_state()
536 518  
537   - # ------------------------------------------------------------------------
538 519 def get_student_progress(self, uid: str) -> float:
539 520 '''Get the current topic progress of a given user'''
540 521 return float(self.online[uid]['state'].get_topic_progress())
541 522  
542   - # ------------------------------------------------------------------------
543 523 def get_current_question(self, uid: str) -> Optional[Question]:
544 524 '''Get the current question of a given user'''
545 525 question: Optional[Question] = self.online[uid]['state'].get_current_question()
546 526 return question
547 527  
548   - # ------------------------------------------------------------------------
549 528 def get_current_question_id(self, uid: str) -> str:
550 529 '''Get id of the current question for a given user'''
551 530 return str(self.online[uid]['state'].get_current_question()['qid'])
552 531  
553   - # ------------------------------------------------------------------------
554 532 def get_student_question_type(self, uid: str) -> str:
555 533 '''Get type of the current question for a given user'''
556 534 return str(self.online[uid]['state'].get_current_question()['type'])
... ... @@ -559,12 +537,10 @@ class LearnApp():
559 537 # def get_student_topic(self, uid: str) -> str:
560 538 # return str(self.online[uid]['state'].get_current_topic())
561 539  
562   - # ------------------------------------------------------------------------
563 540 def get_student_course_title(self, uid: str) -> str:
564 541 '''get the title of the current course for a given user'''
565 542 return str(self.online[uid]['state'].get_current_course_title())
566 543  
567   - # ------------------------------------------------------------------------
568 544 def get_current_course_id(self, uid: str) -> Optional[str]:
569 545 '''get the current course (id) of a given user'''
570 546 cid: Optional[str] = self.online[uid]['state'].get_current_course_id()
... ... @@ -574,7 +550,6 @@ class LearnApp():
574 550 # def get_topic_name(self, ref: str) -> str:
575 551 # return str(self.deps.nodes[ref]['name'])
576 552  
577   - # ------------------------------------------------------------------------
578 553 def get_current_public_dir(self, uid: str) -> str:
579 554 '''
580 555 Get the path for the 'public' directory of the current topic of the
... ... @@ -586,21 +561,18 @@ class LearnApp():
586 561 prefix: str = self.deps.graph['prefix']
587 562 return join(prefix, topic, 'public')
588 563  
589   - # ------------------------------------------------------------------------
590 564 def get_courses(self) -> Dict[str, Dict[str, Any]]:
591 565 '''
592 566 Get dictionary with all courses {'course1': {...}, 'course2': {...}}
593 567 '''
594 568 return self.courses
595 569  
596   - # ------------------------------------------------------------------------
597 570 def get_course(self, course_id: str) -> Dict[str, Any]:
598 571 '''
599 572 Get dictionary {'title': ..., 'description':..., 'goals':...}
600 573 '''
601 574 return self.courses[course_id]
602 575  
603   - # ------------------------------------------------------------------------
604 576 def get_rankings(self, uid: str, cid: str) -> List[Tuple[str, str, float]]:
605 577 '''
606 578 Returns rankings for a certain cid (course_id).
... ... @@ -626,18 +598,18 @@ class LearnApp():
626 598 student_topics = session.execute(query_student_topics).all()
627 599  
628 600 # compute topic progress
629   - now = datetime.now()
630   - goals = self.courses[cid]['goals']
631 601 progress: DefaultDict[str, float] = defaultdict(int)
  602 + goals = self.courses[cid]['goals']
  603 + num_goals = len(goals)
  604 + now = datetime.now()
632 605  
633 606 for student, topic, level, datestr in student_topics:
634 607 if topic in goals:
635 608 date = datetime.strptime(datestr, "%Y-%m-%d %H:%M:%S.%f")
636   - progress[student] += level**(now - date).days / len(goals)
  609 + elapsed_days = (now - date).days
  610 + progress[student] += level**elapsed_days / num_goals
637 611  
638 612 return sorted(((u, name, progress[u])
639 613 for u, name in students
640 614 if u in progress and (len(u) > 2 or len(uid) <= 2)),
641 615 key=lambda x: x[2], reverse=True)
642   -
643   - # ------------------------------------------------------------------------
... ...
aprendizations/main.py
... ... @@ -7,6 +7,7 @@ Setup configurations and then runs the application.
7 7  
8 8 # python standard library
9 9 import argparse
  10 +import logging
10 11 import logging.config
11 12 from os import environ, path
12 13 import ssl
... ... @@ -14,10 +15,10 @@ import sys
14 15 from typing import Any, Dict
15 16  
16 17 # this project
17   -from aprendizations.learnapp import LearnApp, DatabaseUnusableError, LearnException
18   -from aprendizations.serve import run_webserver
19   -from aprendizations.tools import load_yaml
20   -from aprendizations import APP_NAME, APP_VERSION
  18 +from .learnapp import LearnApp, DatabaseUnusableError, LearnException
  19 +from .serve import run_webserver
  20 +from .tools import load_yaml
  21 +from . import APP_NAME, APP_VERSION
21 22  
22 23  
23 24 # ----------------------------------------------------------------------------
... ... @@ -115,7 +116,7 @@ def get_logger_config(debug: bool = False) -&gt; Any:
115 116 'level': level,
116 117 'propagate': False,
117 118 } for module in ['learnapp', 'models', 'factory', 'tools', 'serve',
118   - 'questions', 'student']})
  119 + 'questions', 'student', 'main']})
119 120  
120 121 return load_yaml(config_file, default=default_config)
121 122  
... ... @@ -181,7 +182,7 @@ def main():
181 182 try:
182 183 learnapp = LearnApp(courses=arg.courses,
183 184 prefix=arg.prefix,
184   - db=arg.db,
  185 + dbase=arg.db,
185 186 check=arg.check)
186 187 except DatabaseUnusableError:
187 188 logging.critical('Failed to start application.')
... ... @@ -196,13 +197,8 @@ def main():
196 197 sep='\n')
197 198 sys.exit(1)
198 199 except LearnException as exc:
199   - logging.critical('Failed to start backend')
200   - # sys.exit(1)
201   - raise
202   - except Exception:
203   - logging.critical('Unknown error')
204   - # sys.exit(1)
205   - raise
  200 + logging.critical('Failed to start backend: %s', str(exc))
  201 + sys.exit(1)
206 202 else:
207 203 logging.info('LearnApp started')
208 204  
... ...
aprendizations/serve.py
... ... @@ -4,10 +4,9 @@ Webserver
4 4  
5 5  
6 6 # python standard library
7   -import asyncio
8 7 import base64
9 8 import functools
10   -import logging
  9 +from logging import getLogger
11 10 import mimetypes
12 11 from os.path import join, dirname, expanduser
13 12 import signal
... ... @@ -28,7 +27,7 @@ from aprendizations import APP_NAME
28 27  
29 28  
30 29 # setup logger for this module
31   -logger = logging.getLogger(__name__)
  30 +logger = getLogger(__name__)
32 31  
33 32  
34 33 # ----------------------------------------------------------------------------
... ... @@ -37,7 +36,7 @@ def admin_only(func):
37 36 Decorator used to restrict access to the administrator
38 37 '''
39 38 @functools.wraps(func)
40   - def wrapper(self, *args, **kwargs):
  39 + def wrapper(self, *args, **kwargs) -> None:
41 40 if self.current_user != '0':
42 41 raise tornado.web.HTTPError(403) # forbidden
43 42 func(self, *args, **kwargs)
... ... @@ -49,7 +48,7 @@ class WebApplication(tornado.web.Application):
49 48 '''
50 49 WebApplication - Tornado Web Server
51 50 '''
52   - def __init__(self, learnapp, debug=False):
  51 + def __init__(self, learnapp, debug=False) -> None:
53 52 handlers = [
54 53 (r'/login', LoginHandler),
55 54 (r'/logout', LogoutHandler),
... ... @@ -94,7 +93,6 @@ class BaseHandler(tornado.web.RequestHandler):
94 93 counter_cookie = self.get_secure_cookie('counter')
95 94 if user_cookie is not None:
96 95 uid = user_cookie.decode('utf-8')
97   -
98 96 if counter_cookie is not None:
99 97 counter = counter_cookie.decode('utf-8')
100 98 if counter == str(self.learn.get_login_counter(uid)):
... ... @@ -108,7 +106,7 @@ class RankingsHandler(BaseHandler):
108 106 Handles rankings page
109 107 '''
110 108 @tornado.web.authenticated
111   - def get(self):
  109 + def get(self) -> None:
112 110 '''
113 111 Renders list of students that have answers in this course.
114 112 '''
... ... @@ -122,18 +120,17 @@ class RankingsHandler(BaseHandler):
122 120 name=self.learn.get_student_name(uid),
123 121 rankings=rankings,
124 122 course_id=course_id,
125   - course_title=self.learn.get_student_course_title(uid), # FIXME get from course var
  123 + course_title=self.learn.get_student_course_title(uid),
  124 + # FIXME get from course var
126 125 )
127 126  
128 127  
129 128 # ----------------------------------------------------------------------------
130   -#
131   -# ----------------------------------------------------------------------------
132 129 class LoginHandler(BaseHandler):
133 130 '''
134 131 Handles /login
135 132 '''
136   - def get(self):
  133 + def get(self) -> None:
137 134 '''
138 135 Renders login page
139 136 '''
... ... @@ -168,7 +165,7 @@ class LogoutHandler(BaseHandler):
168 165 Handles /logout
169 166 '''
170 167 @tornado.web.authenticated
171   - def get(self):
  168 + def get(self) -> None:
172 169 '''
173 170 clears cookies and removes user session
174 171 '''
... ... @@ -176,7 +173,7 @@ class LogoutHandler(BaseHandler):
176 173 self.clear_cookie('counter')
177 174 self.redirect('/')
178 175  
179   - def on_finish(self):
  176 + def on_finish(self) -> None:
180 177 self.learn.logout(self.current_user)
181 178  
182 179  
... ... @@ -186,7 +183,7 @@ class ChangePasswordHandler(BaseHandler):
186 183 Handles password change
187 184 '''
188 185 @tornado.web.authenticated
189   - async def post(self):
  186 + async def post(self) -> None:
190 187 '''
191 188 Tries to perform password change and then replies success/fail status
192 189 '''
... ... @@ -216,7 +213,7 @@ class RootHandler(BaseHandler):
216 213 Handles root /
217 214 '''
218 215 @tornado.web.authenticated
219   - def get(self):
  216 + def get(self) -> None:
220 217 '''Simply redirects to the main entrypoint'''
221 218 self.redirect('/courses')
222 219  
... ... @@ -226,11 +223,11 @@ class CoursesHandler(BaseHandler):
226 223 '''
227 224 Handles /courses
228 225 '''
229   - def set_default_headers(self, *args, **kwargs):
  226 + def set_default_headers(self, *_) -> None:
230 227 self.set_header('Cache-Control', 'no-cache')
231 228  
232 229 @tornado.web.authenticated
233   - def get(self):
  230 + def get(self) -> None:
234 231 '''Renders list of available courses'''
235 232 uid = self.current_user
236 233 self.render('courses.html',
... ... @@ -249,7 +246,7 @@ class CourseHandler(BaseHandler):
249 246 '''
250 247  
251 248 @tornado.web.authenticated
252   - def get(self, course_id):
  249 + def get(self, course_id) -> None:
253 250 '''
254 251 Handles get /course/...
255 252 Starts a given course and show list of topics
... ... @@ -278,11 +275,11 @@ class TopicHandler(BaseHandler):
278 275 '''
279 276 Handles a topic
280 277 '''
281   - def set_default_headers(self, *args, **kwargs):
  278 + def set_default_headers(self, *_) -> None:
282 279 self.set_header('Cache-Control', 'no-cache')
283 280  
284 281 @tornado.web.authenticated
285   - async def get(self, topic):
  282 + async def get(self, topic) -> None:
286 283 '''
287 284 Handles get /topic/...
288 285 Starts a given topic
... ... @@ -308,7 +305,7 @@ class FileHandler(BaseHandler):
308 305 Serves files from the /public subdir of the topics.
309 306 '''
310 307 @tornado.web.authenticated
311   - async def get(self, filename):
  308 + async def get(self, filename) -> None:
312 309 '''
313 310 Serves files from /public subdirectories of a particular topic
314 311 '''
... ... @@ -351,7 +348,7 @@ class QuestionHandler(BaseHandler):
351 348  
352 349 # ------------------------------------------------------------------------
353 350 @tornado.web.authenticated
354   - async def get(self):
  351 + async def get(self) -> None:
355 352 '''
356 353 Gets question to render.
357 354 Shows an animated trophy if there are no more questions in the topic.
... ... @@ -410,9 +407,6 @@ class QuestionHandler(BaseHandler):
410 407 })
411 408 return
412 409  
413   - # --- brain hacking ;)
414   - # await asyncio.sleep(1.5)
415   -
416 410 # --- answers are in a list. fix depending on question type
417 411 qtype = self.learn.get_student_question_type(user)
418 412 ans: Optional[Union[List, str]]
... ... @@ -489,7 +483,7 @@ def signal_handler(*_) -&gt; None:
489 483 reply = input(' --> Stop webserver? (yes/no) ')
490 484 if reply.lower() == 'yes':
491 485 tornado.ioloop.IOLoop.current().stop()
492   - logging.critical('Webserver stopped.')
  486 + logger.critical('Webserver stopped.')
493 487 sys.exit(0)
494 488  
495 489  
... ... @@ -503,10 +497,9 @@ def run_webserver(app, ssl, port: int = 8443, debug: bool = False) -&gt; None:
503 497 try:
504 498 webapp = WebApplication(app, debug=debug)
505 499 except Exception:
506   - logger.critical('Failed to start web application.')
  500 + logger.critical('Failed to start web application.', exc_info=True)
507 501 sys.exit(1)
508   - else:
509   - logger.info('Web application started (tornado.web.Application)')
  502 + logger.info('Web application started (tornado.web.Application)')
510 503  
511 504 # --- create tornado webserver
512 505 try:
... ... @@ -514,19 +507,17 @@ def run_webserver(app, ssl, port: int = 8443, debug: bool = False) -&gt; None:
514 507 except ValueError:
515 508 logger.critical('Certificates cert.pem and privkey.pem not found')
516 509 sys.exit(1)
517   - else:
518   - logger.debug('HTTP server started')
  510 + logger.debug('HTTP server started')
519 511  
520 512 try:
521 513 httpserver.listen(port)
522 514 except OSError:
523 515 logger.critical('Cannot bind port %d. Already in use?', port)
524 516 sys.exit(1)
525   -
526   - # --- run webserver
527 517 logger.info('Webserver listening on %d... (Ctrl-C to stop)', port)
528 518 signal.signal(signal.SIGINT, signal_handler)
529 519  
  520 + # --- run webserver
530 521 try:
531 522 tornado.ioloop.IOLoop.current().start() # running...
532 523 except Exception:
... ...
aprendizations/templates/courses.html
... ... @@ -7,18 +7,12 @@
7 7 <meta name="viewport" content="width=device-width, initial-scale=1">
8 8 <meta name="author" content="Miguel Barão">
9 9 <link rel="icon" href="favicon.ico">
10   - <!-- Styles -->
11   - <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
12   - <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" rel="stylesheet">
  10 +
  11 + {% include include-libs.html %}
13 12  
14 13 <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
15 14 <link rel="stylesheet" href="{{static_url('css/sticky-footer-navbar.css')}}">
16   - <!-- Scripts -->
17   - <script src="https://code.jquery.com/jquery-3.6.0.min.js"
18   - integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
19   - <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
20   - integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
21   -
  15 +
22 16 <script defer src="{{static_url('js/maintopics.js')}}"></script>
23 17  
24 18 <title>{{appname}}</title>
... ... @@ -42,8 +36,7 @@
42 36 <ul class="navbar-nav ms-auto">
43 37 <li class="nav-item dropdown">
44 38 <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
45   - <i class="fas fa-user-graduate" aria-hidden="true"></i>
46   - &nbsp;
  39 + <i class="bi bi-person-circle"></i>&nbsp;
47 40 <span id="name">{{ escape(name) }}</span>
48 41 <span class="caret"></span>
49 42 </a>
... ...
aprendizations/templates/include-libs.html 0 → 100644
... ... @@ -0,0 +1,9 @@
  1 +<!-- jquery -->
  2 +<script src="https://code.jquery.com/jquery-3.6.1.min.js" integrity="sha256-o88AwQnZB+VDvE9tvIXrMQaPlFFSUTR+nldQm1LuPXQ=" crossorigin="anonymous"></script>
  3 +
  4 +<!-- bootstrap -->
  5 +<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous">
  6 +<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script>
  7 +
  8 +<!-- bootstrap icons -->
  9 +<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
... ...
aprendizations/templates/login.html
... ... @@ -5,11 +5,7 @@
5 5 <meta name="viewport" content="width=device-width, initial-scale=1" />
6 6 <meta name="author" content="Miguel Barão">
7 7  
8   - <!-- <link rel="canonical" href="https://getbootstrap.com/docs/5.1/examples/sign-in/"> -->
9   -
10   - <!-- Bootstrap core CSS -->
11   - <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
12   - <!-- <link href="../assets/dist/css/bootstrap.min.css" rel="stylesheet"> -->
  8 + {% include include-libs.html %}
13 9  
14 10 <style>
15 11 .bd-placeholder-img {
... ...
aprendizations/templates/maintopics-table.html
... ... @@ -8,16 +8,9 @@
8 8 <meta name="author" content="Miguel Barão">
9 9 <link rel="icon" href="/static/favicon.ico">
10 10  
11   -<!-- Styles -->
12   - <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
13   - <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" rel="stylesheet">
14   -
  11 + {% include include-libs.html %}
  12 +
15 13 <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
16   -
17   -<!-- Scripts -->
18   - <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
19   - <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
20   -
21 14 <script defer src="{{static_url('js/maintopics.js')}}"></script>
22 15  
23 16 <title>{{appname}}</title>
... ... @@ -40,8 +33,7 @@
40 33 <ul class="navbar-nav ms-auto">
41 34 <li class="nav-item dropdown">
42 35 <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
43   - <i class="fas fa-user-graduate" aria-hidden="true"></i>
44   - &nbsp;
  36 + <i class="bi bi-person-circle"></i>&nbsp;
45 37 <span id="name">{{ escape(name) }}</span>
46 38 <span class="caret"></span>
47 39 </a>
... ... @@ -104,58 +96,71 @@
104 96 <!-- ------------------------------------------------------------- -->
105 97 {% if t['level'] is None %}
106 98 <tr>
107   - <th scope="row" class="text-muted text-center">
108   - {% if t['type']=='chapter' %}
109   - <i class="fas fa-flag"></i>
110   - {% elif t['type']=='learn' %}
111   - <i class="fas fa-book"></i>&nbsp;
112   - {% else %}
113   - <i class="fas fa-pencil-alt"></i>&nbsp;
114   - {% end %}
115   - </th>
116   - <td>
117   - <div class="text-muted">
118   - {% if t['ref'] not in course['goals'] %}
119   - <i class="fas fa-puzzle-piece"></i>
  99 + <td scope="row" class="text-muted text-center">
  100 + <h5>
  101 + {% if t['type']=='chapter' %}
  102 + <i class="bi bi-flag-fill"></i>
  103 + {% elif t['type']=='learn' %}
  104 + <i class="bi bi-book"></i>
  105 + {% else %}
  106 + <i class="bi bi-pencil-square"></i>
120 107 {% end %}
121   -
122   -
  108 + </h5>
  109 + </td>
  110 + <td class="text-muted">
  111 + {% if t['ref'] not in course['goals'] %}
  112 + <span class="text-nowrap" data-toggle="tooltip" data-placement="bottom" title="Este tópico é um pré-requisito">
  113 + <i class="bi bi-puzzle-fill"></i>
  114 + {{ t['name'] }}
  115 + </span>
  116 + {% else %}
123 117 {{ t['name'] }}
124   - </div>
  118 + {% end %}
125 119 </td>
126 120 <td class="text-center">
127   - <i class="fas fa-lock text-muted"></i>
  121 + <h5><i class="bi bi-lock-fill text-muted"></i></h5>
128 122 </td>
129 123 </tr>
130 124  
131 125 {% else %}
132 126  
133 127 <tr class="clickable-row " data-href="/topic/{{t['ref']}}">
134   - <th scope="row" class="text-primary text-center">
135   - {% if t['type']=='chapter' %}
136   - <i class="fas fa-flag-checkered"></i>&nbsp;
137   - {% elif t['type']=='learn' %}
138   - <i class="fas fa-book"></i>&nbsp;
139   - {% else %}
140   - <i class="fas fa-pencil-alt"></i>&nbsp;
141   - {% end %}
142   - </th>
  128 + <td scope="row" class="text-primary text-center">
  129 + <h5>
  130 + {% if t['type']=='chapter' %}
  131 + <span class="text-nowrap" data-toggle="tooltip" data-placement="bottom" title="Fim do capítulo">
  132 + <i class="bi bi-flag-fill"></i>
  133 + </span>
  134 + {% elif t['type']=='learn' %}
  135 + <span class="text-nowrap" data-toggle="tooltip" data-placement="bottom" title="Texto com matéria">
  136 + <i class="bi bi-book"></i>
  137 + </span>
  138 + {% else %}
  139 + <span class="text-nowrap" data-toggle="tooltip" data-placement="bottom" title="Exercícios">
  140 + <i class="bi bi-pencil-square"></i>
  141 + </span>
  142 + {% end %}
  143 + </h5>
  144 + </td>
143 145 <td class="text-primary">
144 146 {% if t['ref'] not in course['goals'] %}
145   - <i class="fas fa-puzzle-piece"></i>
  147 + <span class="text-nowrap" data-toggle="tooltip" data-placement="bottom" title="Este tópico é um pré-requisito">
  148 + <i class="bi bi-puzzle-fill"></i>
  149 + {{ t['name'] }}
  150 + </span>
  151 + {% else %}
  152 + {{ t['name'] }}
146 153 {% end %}
147   -
148   - {{ t['name'] }}
149 154 </td>
150 155  
151 156 <td class="text-center">
152 157 {% if t['level'] < 0.01 %}
153   - <i class="fas fa-lock-open text-success"></i>
  158 + <h5><i class="bi bi-unlock-fill text-success"></i></h5>
154 159 {% elif t['type']=='chapter' %}
155   - <h5><i class="fas fa-award text-warning"></i></h5>
  160 + <h5><i class="bi bi-award-fill"></i></h5>
156 161 {% else %}
157   - <span class="text-nowrap text-warning" data-toggle="tooltip" data-placement="bottom" title="{{round(t['level']*5, 2)}}">
158   - {{int(t['level']*5+0.25)*'<i class="fas fa-star"></i>'}}{% if int(t['level']*5+0.25) < 5 %}{{'<i class="fas fa-star-half-alt"></i>' if 0.25 <= t['level']*5-int(t['level']*5) < 0.75 else '<i class="far fa-star"></i>'}}{% end %}{{(4-int(t['level']*5+0.25))*'<i class="far fa-star"></i>' }}
  162 + <span class="text-nowrap text-warning" data-toggle="tooltip" data-placement="bottom" title="{{round(t['level']*5, 3)}}">
  163 + <h5>{{ int(t['level']*5+0.25)*'<i class="bi bi-star-fill"></i>' }}{% if int(t['level']*5+0.25) < 5 %}{{'<i class="bi bi-star-half"></i>' if 0.25 <= t['level']*5-int(t['level']*5) < 0.75 else '<i class="bi bi-star"></i>'}}{% end %}{{ (4-int(t['level']*5+0.25))*'<i class="bi bi-star"></i>' }}</h5>
159 164 </span>
160 165 {% end %} <!-- if -->
161 166 </td>
... ...
aprendizations/templates/rankings.html
... ... @@ -7,14 +7,10 @@
7 7 <meta name="viewport" content="width=device-width, initial-scale=1" />
8 8 <meta name="author" content="Miguel Barão">
9 9 <link rel="icon" href="/static/favicon.ico">
10   - <!-- Styles -->
11   - <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
12   - <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" rel="stylesheet">
13 10  
14   - <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
15   - <!-- Scripts -->
16   - <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
  11 + {% include include-libs.html %}
17 12  
  13 + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
18 14 <script defer src="{{static_url('js/maintopics.js')}}"></script>
19 15  
20 16 <title>{{appname}}</title>
... ... @@ -37,8 +33,7 @@
37 33 <ul class="navbar-nav ms-auto">
38 34 <li class="nav-item dropdown">
39 35 <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
40   - <i class="fas fa-user-graduate" aria-hidden="true"></i>
41   - &nbsp;
  36 + <i class="bi bi-person-circle"></i>&nbsp;
42 37 <span id="name">{{ escape(name) }}</span>
43 38 <span class="caret"></span>
44 39 </a>
... ... @@ -56,11 +51,10 @@
56 51 <h1 class="display-6">{{ course_title }}</h1>
57 52  
58 53 <table class="table table-sm table-hover">
59   - <!-- <col width="100"> -->
60 54 <thead>
61 55 <tr>
62 56 <th scope="col" class="text-center">Posição</th>
63   - <th scope="col">Aluno</th>
  57 + <th scope="col">Nome</th>
64 58 <th scope="col">Progresso</th>
65 59 </tr>
66 60 </thead>
... ... @@ -69,10 +63,10 @@
69 63 <tr class="{{ 'table-secondary' if r[0] == uid else '' }}">
70 64 <td class="text-center"> <!-- rank -->
71 65 <strong>
72   - {{ '<i class="fas fa-crown fa-lg text-warning"></i>' if i==0 else i+1 }}
  66 + {{ '<h3><i class="bi bi-trophy-fill text-warning"></i></h3>' if i==0 else i+1 }}
73 67 </strong>
74 68 </td>
75   - <td> <!-- student name -->
  69 + <td> <!-- first and last name -->
76 70 {{ ' '.join(r[1].split()[n] for n in (0,-1)) }}
77 71 </td>
78 72 <td> <!-- progress -->
... ...
aprendizations/templates/topic.html
... ... @@ -6,17 +6,9 @@
6 6 <meta name="author" content="Miguel Barão" />
7 7 <link rel="icon" href="/static/favicon.ico">
8 8  
9   - <!-- Styles ---------------------------------------------------------- -->
10   - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
11   - integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous" />
12   - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
13   - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" />
14   - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.63.3/codemirror.min.css" />
15   - <!-- local styles -->
16   - <link rel="stylesheet" href="{{static_url('css/github.css')}}" />
17   - <link rel="stylesheet" href="{{static_url('css/topic.css')}}" />
  9 + {% include include-libs.html %}
18 10  
19   - <!-- Scripts --------------------------------------------------------- -->
  11 + <!-- mathjax -->
20 12 <script>
21 13 MathJax = {
22 14 tex: {
... ... @@ -27,14 +19,19 @@
27 19 }
28 20 };
29 21 </script>
  22 + <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
30 23 <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
31   - <script defer src="https://code.jquery.com/jquery-3.6.0.min.js"
32   - integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
33   - <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
34   - integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>
  24 +
  25 + <!-- codemirror -->
  26 + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.63.3/codemirror.min.css" />
35 27 <script defer src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.63.3/codemirror.min.js"></script>
36 28  
37   - <!-- local scripts -->
  29 + <!-- animate -->
  30 + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" />
  31 +
  32 + <!-- local -->
  33 + <link rel="stylesheet" href="{{static_url('css/github.css')}}" />
  34 + <link rel="stylesheet" href="{{static_url('css/topic.css')}}" />
38 35 <script defer src="{{static_url('js/topic.js')}}"></script>
39 36  
40 37 <title>{{appname}}</title>
... ... @@ -58,13 +55,12 @@
58 55 <ul class="navbar-nav">
59 56 <li class="nav-item"><a class="nav-link" href="/courses">Cursos</a></li>
60 57 <li class="nav-item"><a class="nav-link active" aria-current="page" href="/course/{{course_id}}">Tópicos</a></li>
61   - <!-- <li class="nav-item"><a class="nav-link disabled" href="#">Classificação</a></li> -->
  58 + <li class="nav-item"><a class="nav-link disabled" href="#">Classificação</a></li>
62 59 </ul>
63 60 <ul class="navbar-nav ms-auto">
64 61 <li class="nav-item dropdown">
65 62 <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
66   - <i class="fas fa-user-graduate" aria-hidden="true"></i>
67   - &nbsp;
  63 + <i class="bi bi-person-circle"></i>&nbsp;
68 64 <span id="name">{{ escape(name) }}</span>
69 65 <span class="caret"></span>
70 66 </a>
... ...
demo/courses.yaml
... ... @@ -15,6 +15,7 @@ courses:
15 15 description: |
16 16 Adição, multiplicação e números primos.
17 17 goals:
  18 + - math
18 19 - math/addition
19 20 - math/multiplication
20 21 - math/prime-numbers
... ...
demo/math.yaml
... ... @@ -25,7 +25,23 @@ topics:
25 25 deps:
26 26 - math/addition
27 27  
  28 + math/learn-prime-numbers:
  29 + name: O que são números primos?
  30 + type: learn
  31 + file: learn.yaml
  32 + deps:
  33 + - math/multiplication
  34 +
28 35 math/prime-numbers:
29 36 name: Números primos
30 37 deps:
  38 + - math/learn-prime-numbers
  39 +
  40 + math:
  41 + name: Números e operações aritméticas
  42 + type: chapter
  43 + deps:
  44 + - math/addition
31 45 - math/multiplication
  46 + - math/learn-prime-numbers
  47 + - math/prime-numbers
... ...
demo/math/learn-prime-numbers/learn.yaml 0 → 100644
... ... @@ -0,0 +1,17 @@
  1 +- type: radio
  2 + title: O que são números primos?
  3 + text: |
  4 + Um número $n \ge 2$ é primo se os únicos divisores forem $1$ e $n$.
  5 +
  6 + Por exemplo, o número $5$ é primo, pois $2,3,4$ não são divisores de $5$.
  7 +
  8 + ---
  9 +
  10 + O número $1$ é primo?
  11 + options:
  12 + - Não
  13 + - Sim
  14 + max_tries: 1
  15 + solution: |
  16 + O número $1$ não é primo. Apenas podem ser primos os números a partir do
  17 + $2$.
... ...
demo/math/questions.yaml 0 → 100644
... ... @@ -0,0 +1,2 @@
  1 +- type: info
  2 + title: Fim do capítulo
... ...
mypy.ini
1 1 [mypy]
2   -python_version = 3.9
  2 +python_version = 3.10
3 3 plugins = sqlalchemy.ext.mypy.plugin
4 4  
5 5 ; [mypy-pygments.*]
... ...
setup.py
... ... @@ -18,10 +18,10 @@ setup(
18 18 url="https://git.xdi.uevora.pt/mjsb/aprendizations.git",
19 19 packages=find_packages(),
20 20 include_package_data=True, # install files from MANIFEST.in
21   - python_requires='>=3.8.*',
  21 + python_requires='>=3.9.*',
22 22 install_requires=[
23 23 'tornado>=6.0',
24   - 'mistune',
  24 + 'mistune<2',
25 25 'pyyaml>=5.1',
26 26 'pygments',
27 27 'sqlalchemy>=1.4',
... ... @@ -32,7 +32,6 @@ setup(
32 32 'console_scripts': [
33 33 'aprendizations = aprendizations.main:main',
34 34 'initdb-aprendizations = aprendizations.initdb:main',
35   - # 'redirect = aprendizations.redirect:main',
36 35 ]
37 36 },
38 37 classifiers=[
... ...