Commit 59bddbb8f9657992ef2a1a2e1a446d7869b11830

Authored by Miguel Barão
2 parents 729aaf0e 01676f46
Exists in dev

Merge branch 'dev' of https://git.xdi.uevora.pt/mjsb/aprendizations into dev

@@ -8,4 +8,5 @@ node_modules/ @@ -8,4 +8,5 @@ node_modules/
8 .DS_Store 8 .DS_Store
9 demo/.DS_Store 9 demo/.DS_Store
10 demo/solar_system/.DS_Store 10 demo/solar_system/.DS_Store
11 -.venv 11 +.venv/
  12 +build/
@@ -2,6 +2,15 @@ @@ -2,6 +2,15 @@
2 2
3 ## BUGS 3 ## BUGS
4 4
  5 +- usar [xsrf_cookie_name](https://www.tornadoweb.org/en/stable/releases/v6.3.0.html)
  6 +- usar [asyncio.loop.add_signal_handler](https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.loop.add_signal_handler)
  7 + em vez de `signal.signal`.
  8 +- fim do topico "servidor nao responde": /Users/mjsb/Repos/aprendizations/aprendizations/learnapp.py:296: SAWarning: Multiple rows returned with uselist=False for lazily-loaded attribute 'Topic.students' (This warning originated from the Session 'autoflush' process, which was invoked automatically in response to a user-initiated operation.)
  9 + student.topics.append(student_topic)
  10 + /Users/mjsb/Repos/aprendizations/aprendizations/learnapp.py:296: SAWarning: Object of type <StudentTopic> not in session, add operation along 'Topic.students' will not proceed (This warning originated from the Session 'autoflush' process, which was invoked automatically in response to a user-initiated operation.)
  11 + student.topics.append(student_topic)
  12 +- rendering das tabelas errado, e.g. computer/logic/operators:equivalence
  13 +- warning ao arrancar: could not start course "favicon.ico"
5 - nao esta a respeitar o numero de tentativas `max_tries`. 14 - nao esta a respeitar o numero de tentativas `max_tries`.
6 - se na especificacao de um curso, a referencia do topico nao existir como 15 - se na especificacao de um curso, a referencia do topico nao existir como
7 directorio, rebenta. 16 directorio, rebenta.
@@ -23,6 +32,8 @@ @@ -23,6 +32,8 @@
23 32
24 ## TODO 33 ## TODO
25 34
  35 +- RGPD: possibilidade ter alunos anonimos (sem numero e nome), poderem
  36 +unregister.
26 - ordenação que faça sentido, organizada por chapters. 37 - ordenação que faça sentido, organizada por chapters.
27 - chapters deviam ser automaticamente checkados... 38 - chapters deviam ser automaticamente checkados...
28 - hints dos topicos fechados com as dependencias desse topico que ainda faltam 39 - hints dos topicos fechados com as dependencias desse topico que ainda faltam
aprendizations/__init__.py
1 -# Copyright © 2022 Miguel Barão 1 +# Copyright © 2025 Miguel Barão
2 # 2 #
3 # THE MIT License 3 # THE MIT License
4 # 4 #
@@ -30,10 +30,10 @@ are progressively uncovered as the students progress. @@ -30,10 +30,10 @@ are progressively uncovered as the students progress.
30 ''' 30 '''
31 31
32 APP_NAME = 'aprendizations' 32 APP_NAME = 'aprendizations'
33 -APP_VERSION = '2023.2.dev1' 33 +APP_VERSION = '2025.1.dev1'
34 APP_DESCRIPTION = __doc__ 34 APP_DESCRIPTION = __doc__
35 35
36 __author__ = 'Miguel Barão' 36 __author__ = 'Miguel Barão'
37 -__copyright__ = 'Copyright © 2023, Miguel Barão' 37 +__copyright__ = 'Copyright © 2025, Miguel Barão'
38 __license__ = 'MIT license' 38 __license__ = 'MIT license'
39 __version__ = APP_VERSION 39 __version__ = APP_VERSION
aprendizations/initdb.py
@@ -5,8 +5,9 @@ Initializes or updates database @@ -5,8 +5,9 @@ Initializes or updates database
5 ''' 5 '''
6 6
7 # python standard libraries 7 # python standard libraries
8 -import csv  
9 import argparse 8 import argparse
  9 +import csv
  10 +from pandas import read_excel
10 import re 11 import re
11 from string import capwords 12 from string import capwords
12 13
@@ -32,11 +33,11 @@ def parse_commandline_arguments(): @@ -32,11 +33,11 @@ def parse_commandline_arguments():
32 'command line. If the database does not exist, a new one' 33 'command line. If the database does not exist, a new one'
33 ' is created.') 34 ' is created.')
34 35
35 - argparser.add_argument('csvfile', 36 + argparser.add_argument('files',
36 nargs='*', 37 nargs='*',
37 type=str, 38 type=str,
38 default='', 39 default='',
39 - help='CSV file to import (SIIUE)') 40 + help='CSV or Excel files to import (SIIUE)')
40 41
41 argparser.add_argument('--db', 42 argparser.add_argument('--db',
42 default='students.db', 43 default='students.db',
@@ -72,6 +73,30 @@ def parse_commandline_arguments(): @@ -72,6 +73,30 @@ def parse_commandline_arguments():
72 73
73 74
74 # =========================================================================== 75 # ===========================================================================
  76 +def get_students_from_xlsx(filename):
  77 + excel_settings = {
  78 + 'skiprows': 3,
  79 + 'converters': { "Número" : str }
  80 + }
  81 + students = []
  82 + try:
  83 + file = read_excel(filename, **excel_settings)
  84 + names = file['Aluno']
  85 + nums = file['Número']
  86 + students = [{
  87 + 'uid': num,
  88 + 'name': capwords(re.sub(r'\(.*\)', '', name).strip())
  89 + } for num, name in zip(nums, names)]
  90 + except FileNotFoundError as e:
  91 + print(f'!!! File {filename} not found !!!')
  92 + except ValueError as e:
  93 + print(f'!!! File {filename} has wrong format !!!')
  94 + except Exception as e:
  95 + raise e
  96 +
  97 + return students
  98 +
  99 +# ===========================================================================
75 def get_students_from_csv(filename): 100 def get_students_from_csv(filename):
76 '''Reads CSV file with enrolled students in SIIUE format. 101 '''Reads CSV file with enrolled students in SIIUE format.
77 SIIUE names can have suffixes like "(TE)" and are sometimes capitalized. 102 SIIUE names can have suffixes like "(TE)" and are sometimes capitalized.
@@ -138,8 +163,13 @@ def main(): @@ -138,8 +163,13 @@ def main():
138 # --- make list of students to insert/update 163 # --- make list of students to insert/update
139 students = [] 164 students = []
140 165
141 - for csvfile in args.csvfile:  
142 - students += get_students_from_csv(csvfile) 166 + for file in args.files:
  167 + if file.endswith('.csv'):
  168 + students += get_students_from_csv(file)
  169 + elif file.endswith('.xlsx'):
  170 + students += get_students_from_xlsx(file)
  171 + else:
  172 + print(f'!!! Ignoring file {file} !!!')
143 173
144 if args.admin: 174 if args.admin:
145 students.append({'uid': '0', 'name': 'Admin'}) 175 students.append({'uid': '0', 'name': 'Admin'})
aprendizations/learnapp.py
@@ -110,7 +110,7 @@ class Application(): @@ -110,7 +110,7 @@ class Application():
110 def _sanity_check_questions(self) -> None: 110 def _sanity_check_questions(self) -> None:
111 ''' 111 '''
112 Unit tests for all questions 112 Unit tests for all questions
113 - 113 +
114 Generates all questions, give right and wrong answers and corrects. 114 Generates all questions, give right and wrong answers and corrects.
115 ''' 115 '''
116 116
@@ -274,7 +274,7 @@ class Application(): @@ -274,7 +274,7 @@ class Application():
274 tid: str = student_state.get_previous_topic() 274 tid: str = student_state.get_previous_topic()
275 level: float = student_state.get_topic_level(tid) 275 level: float = student_state.get_topic_level(tid)
276 date: str = str(student_state.get_topic_date(tid)) 276 date: str = str(student_state.get_topic_date(tid))
277 - logger.info('"%s" finished "%s" (level=%.2f)', uid, tid, level) 277 + logger.info('User "%s" finished topic "%s" (level=%.2f)', uid, tid, level)
278 278
279 with Session(self._engine) as session: 279 with Session(self._engine) as session:
280 query = select(StudentTopic) \ 280 query = select(StudentTopic) \
aprendizations/main.py
@@ -168,9 +168,9 @@ def main(): @@ -168,9 +168,9 @@ def main():
168 logger.info('LearnApp started') 168 logger.info('LearnApp started')
169 169
170 # --- run webserver forever ---------------------------------------------- 170 # --- run webserver forever ----------------------------------------------
171 - asyncio.run(webserver(app=app,  
172 - ssl=ssl_ctx,  
173 - port=arg.port, 171 + asyncio.run(webserver(app=app,
  172 + ssl=ssl_ctx,
  173 + port=arg.port,
174 debug=arg.debug)) 174 debug=arg.debug))
175 logger.critical('Webserver stopped.') 175 logger.critical('Webserver stopped.')
176 176
aprendizations/renderer_markdown.py
@@ -10,6 +10,9 @@ from pygments.formatters import html @@ -10,6 +10,9 @@ from pygments.formatters import html
10 10
11 class Renderer(mistune.HTMLRenderer): 11 class Renderer(mistune.HTMLRenderer):
12 def block_code(self, code, info=None): 12 def block_code(self, code, info=None):
  13 + '''
  14 + Code syntax highlight using pygments
  15 + '''
13 if info is not None: 16 if info is not None:
14 lexer = get_lexer_by_name(info, stripall=True) 17 lexer = get_lexer_by_name(info, stripall=True)
15 formatter = html.HtmlFormatter() 18 formatter = html.HtmlFormatter()
@@ -17,6 +20,9 @@ class Renderer(mistune.HTMLRenderer): @@ -17,6 +20,9 @@ class Renderer(mistune.HTMLRenderer):
17 return f'<pre><code>{mistune.escape(code)}</code></pre>' 20 return f'<pre><code>{mistune.escape(code)}</code></pre>'
18 21
19 def image(self, text, url, title=None): 22 def image(self, text, url, title=None):
  23 + '''
  24 + Include image title and alternative text
  25 + '''
20 text = mistune.escape(text, quote=True) 26 text = mistune.escape(text, quote=True)
21 title = mistune.escape(title or '', quote=True) 27 title = mistune.escape(title or '', quote=True)
22 return (f'<img src="/file/{url}" alt="{text}" title="{title}"' 28 return (f'<img src="/file/{url}" alt="{text}" title="{title}"'
aprendizations/serve.py
@@ -6,18 +6,16 @@ Tornado Webserver @@ -6,18 +6,16 @@ Tornado Webserver
6 # python standard library 6 # python standard library
7 import asyncio 7 import asyncio
8 import base64 8 import base64
9 -from logging import getLogger  
10 import mimetypes 9 import mimetypes
11 -from os.path import join, dirname, expanduser  
12 import signal 10 import signal
13 import sys 11 import sys
14 -from typing import List, Optional, Union  
15 import uuid 12 import uuid
  13 +import logging
  14 +from os.path import join, dirname, expanduser
  15 +from typing import List, Optional, Union
16 16
17 # third party libraries 17 # third party libraries
18 -import tornado.httpserver  
19 -import tornado.ioloop  
20 -import tornado.web 18 +import tornado
21 from tornado.escape import to_unicode 19 from tornado.escape import to_unicode
22 20
23 # this project 21 # this project
@@ -26,7 +24,7 @@ from aprendizations.learnapp import LearnException @@ -26,7 +24,7 @@ from aprendizations.learnapp import LearnException
26 24
27 25
28 # setup logger for this module 26 # setup logger for this module
29 -logger = getLogger(__name__) 27 +logger = logging.getLogger(__name__)
30 28
31 29
32 # ============================================================================ 30 # ============================================================================
@@ -40,7 +38,7 @@ class BaseHandler(tornado.web.RequestHandler): @@ -40,7 +38,7 @@ class BaseHandler(tornado.web.RequestHandler):
40 38
41 def get_current_user(self): 39 def get_current_user(self):
42 '''called on every method decorated with @tornado.web.authenticated''' 40 '''called on every method decorated with @tornado.web.authenticated'''
43 - cookie = self.get_secure_cookie('aprendizations_user') 41 + cookie = self.get_signed_cookie('aprendizations_user')
44 return None if cookie is None else to_unicode(cookie) 42 return None if cookie is None else to_unicode(cookie)
45 43
46 44
@@ -59,7 +57,7 @@ class LoginHandler(BaseHandler): @@ -59,7 +57,7 @@ class LoginHandler(BaseHandler):
59 loop = tornado.ioloop.IOLoop.current() 57 loop = tornado.ioloop.IOLoop.current()
60 login_ok = await self.app.login(uid, pw, loop) 58 login_ok = await self.app.login(uid, pw, loop)
61 if login_ok: 59 if login_ok:
62 - self.set_secure_cookie('aprendizations_user', uid) 60 + self.set_signed_cookie('aprendizations_user', uid)
63 self.redirect('/') 61 self.redirect('/')
64 else: 62 else:
65 self.render('login.html', error='Número ou senha incorrectos') 63 self.render('login.html', error='Número ou senha incorrectos')
@@ -391,7 +389,7 @@ def signal_handler(*_) -&gt; None: @@ -391,7 +389,7 @@ def signal_handler(*_) -&gt; None:
391 ''' 389 '''
392 reply = input(' --> Stop webserver? (yes/no) ') 390 reply = input(' --> Stop webserver? (yes/no) ')
393 if reply.lower() == 'yes': 391 if reply.lower() == 'yes':
394 - tornado.ioloop.IOLoop.current().stop() 392 + tornado.ioloop.IOLoop.current().stop() # FIXME: is there a recent alternative?
395 logger.critical('Webserver stopped.') 393 logger.critical('Webserver stopped.')
396 sys.exit(0) 394 sys.exit(0)
397 395
@@ -403,23 +401,25 @@ async def webserver(app, ssl, port: int = 8443, debug: bool = False) -&gt; None: @@ -403,23 +401,25 @@ async def webserver(app, ssl, port: int = 8443, debug: bool = False) -&gt; None:
403 ''' 401 '''
404 402
405 # --- create web application 403 # --- create web application
  404 + opts = { 'app': app }
406 handlers = [ 405 handlers = [
407 - (r'/login', LoginHandler, dict(app=app)),  
408 - (r'/logout', LogoutHandler, dict(app=app)),  
409 - (r'/change_password', ChangePasswordHandler, dict(app=app)),  
410 - (r'/question', QuestionHandler, dict(app=app)), # render question  
411 - (r'/rankings', RankingsHandler, dict(app=app)), # rankings table  
412 - (r'/topic/(.+)', TopicHandler, dict(app=app)), # start topic  
413 - (r'/file/(.+)', FileHandler, dict(app=app)), # serve file  
414 - (r'/courses', CoursesHandler, dict(app=app)), # show available courses  
415 - (r'/course/(.*)', CourseHandler, dict(app=app)), # show topics from course  
416 - (r'/course2/(.*)', CourseHandler2, dict(app=app)), # show topics from course FIXME  
417 - (r'/', RootHandler, dict(app=app)), # redirects 406 + (r'/login', LoginHandler, opts),
  407 + (r'/logout', LogoutHandler, opts),
  408 + (r'/change_password', ChangePasswordHandler, opts),
  409 + (r'/question', QuestionHandler, opts), # render question
  410 + (r'/rankings', RankingsHandler, opts), # rankings table
  411 + (r'/topic/(.+)', TopicHandler, opts), # start topic
  412 + (r'/file/(.+)', FileHandler, opts), # serve file
  413 + (r'/courses', CoursesHandler, opts), # show available courses
  414 + (r'/course/(.*)', CourseHandler, opts), # show topics from course
  415 + (r'/course2/(.*)', CourseHandler2, opts), # show topics from course FIXME:
  416 + (r'/', RootHandler, opts), # redirects
418 ] 417 ]
419 settings = { 418 settings = {
420 'template_path': join(dirname(__file__), 'templates'), 419 'template_path': join(dirname(__file__), 'templates'),
421 'static_path': join(dirname(__file__), 'static'), 420 'static_path': join(dirname(__file__), 'static'),
422 'static_url_prefix': '/static/', 421 'static_url_prefix': '/static/',
  422 + # TODO: xsrf_cookie_name
423 'xsrf_cookies': True, 423 'xsrf_cookies': True,
424 'cookie_secret': base64.b64encode(uuid.uuid4().bytes), 424 'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
425 'login_url': '/login', 425 'login_url': '/login',
aprendizations/student.py
@@ -50,35 +50,34 @@ class StudentState(): @@ -50,35 +50,34 @@ class StudentState():
50 self.factory = factory # question factory 50 self.factory = factory # question factory
51 self.courses = courses # {'course': ['topic_id1', 'topic_id2',...]} 51 self.courses = courses # {'course': ['topic_id1', 'topic_id2',...]}
52 52
53 - # data of this student 53 + # student data
54 self.uid = uid # user id '12345' 54 self.uid = uid # user id '12345'
55 self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...} 55 self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...}
56 56
57 # prepare for running 57 # prepare for running
58 self.update_topic_levels() # applies forgetting factor 58 self.update_topic_levels() # applies forgetting factor
59 self.unlock_topics() # whose dependencies have been completed 59 self.unlock_topics() # whose dependencies have been completed
60 - self.start_course(None) 60 +
  61 + logger.debug('no active course')
  62 + self.current_course: Optional[str] = None
  63 + self.topic_sequence: List[str] = []
  64 + self.current_topic: Optional[str] = None
  65 + self.previous_topic: Optional[str] = None
61 66
62 # ------------------------------------------------------------------------ 67 # ------------------------------------------------------------------------
63 - def start_course(self, course: Optional[str]) -> None: 68 + def start_course(self, course: str) -> None:
64 ''' 69 '''
65 Tries to start a course. 70 Tries to start a course.
66 Finds the recommended sequence of topics for the student. 71 Finds the recommended sequence of topics for the student.
67 ''' 72 '''
68 - if course is None:  
69 - logger.debug('no active course')  
70 - self.current_course: Optional[str] = None  
71 - self.topic_sequence: List[str] = []  
72 - self.current_topic: Optional[str] = None  
73 - else:  
74 - try:  
75 - topics = self.courses[course]['goals']  
76 - except KeyError:  
77 - logger.debug('course "%s" does not exist', course)  
78 - raise  
79 - logger.debug('starting course "%s"', course)  
80 - self.current_course = course  
81 - self.topic_sequence = self._recommend_sequence(topics) 73 + try:
  74 + topics = self.courses[course]['goals']
  75 + except KeyError:
  76 + logger.debug('course "%s" does not exist', course)
  77 + raise
  78 + logger.debug('starting course "%s"', course)
  79 + self.current_course = course
  80 + self.topic_sequence = self._recommend_sequence(topics)
82 81
83 # ------------------------------------------------------------------------ 82 # ------------------------------------------------------------------------
84 async def start_topic(self, topic_ref: str) -> None: 83 async def start_topic(self, topic_ref: str) -> None:
aprendizations/templates/include-head.html
1 <meta charset="utf-8" /> 1 <meta charset="utf-8" />
2 <meta name="viewport" content="width=device-width, initial-scale=1"> 2 <meta name="viewport" content="width=device-width, initial-scale=1">
3 <meta name="author" content="Miguel Barão"> 3 <meta name="author" content="Miguel Barão">
4 -<link rel="icon" href="favicon.ico"> 4 +<!-- <link href="{{static_url('favicon.ico')}}" rel="icon" /> -->
5 <title>aprendizations</title> 5 <title>aprendizations</title>
aprendizations/templates/include-libs.html
1 <!-- jquery --> 1 <!-- jquery -->
2 -<script src="https://code.jquery.com/jquery-3.6.3.min.js" integrity="sha256-pvPw+upLPUjgMXY0G+8O0xUf+/Im1MZjXxxgOcBQBXU=" crossorigin="anonymous"></script> 2 +<script src="https://code.jquery.com/jquery-3.7.1.min.js" integrity="sha256-/JqT3SQfawRcv/BIHPThkBvs0OEvtFFmqPF/lYI/Cxo=" crossorigin="anonymous"></script>
  3 +<!-- <script src="https://code.jquery.com/jquery-3.7.0.min.js" integrity="sha256-2Pmvv0kuTBOenSvLm6bvfBSSHrUJ+3A7x6P5Ebd07/g=" crossorigin="anonymous"></script> -->
  4 +<!-- <script src="https://code.jquery.com/jquery-3.6.3.min.js" integrity="sha256-pvPw+upLPUjgMXY0G+8O0xUf+/Im1MZjXxxgOcBQBXU=" crossorigin="anonymous"></script> -->
3 5
4 <!-- bootstrap --> 6 <!-- bootstrap -->
5 -<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous">  
6 -<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script> 7 +<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
  8 +<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
  9 +<!-- <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ" crossorigin="anonymous"> -->
  10 +<!-- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ENjdO4Dr2bkBIFxQpeoTz1HIcje39Wm4jDKdf19U8gI4ddQ3GYNS7NTKfAdVQSZe" crossorigin="anonymous"></script> -->
  11 +<!-- <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-rbsA2VBKQhggwzxH7pPCaAqO46MgnOM80zW1RWuH61DGLwZJEdK2Kadq2F9CUG65" crossorigin="anonymous"> -->
  12 +<!-- <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-kenU1KFdBIe4zVF0s0G1M5b4hcpxyD9F7jL+jjXkk+Q2h455rYXK/7HAuoJl+0I4" crossorigin="anonymous"></script> -->
7 13
8 <!-- bootstrap icons --> 14 <!-- bootstrap icons -->
9 -<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.2/font/bootstrap-icons.css"> 15 +<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
  16 +<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css"> -->
  17 +<!-- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.2/font/bootstrap-icons.css"> -->
10 18
11 <!-- MathJax --> 19 <!-- MathJax -->
12 <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script> 20 <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
13 <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script> 21 <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
14 22
15 <!-- codemirror --> 23 <!-- codemirror -->
16 -<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.11/codemirror.min.css" integrity="sha512-uf06llspW44/LZpHzHT6qBOIVODjWtv4MxCricRxkzvopAlSWnTf6hpZTFxuuZcuNE9CBQhqE0Seu1CoRk84nQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />  
17 -<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.11/codemirror.min.js" integrity="sha512-rdFIN28+neM8H8zNsjRClhJb1fIYby2YCNmoqwnqBDEvZgpcp7MJiX8Wd+Oi6KcJOMOuvGztjrsI59rly9BsVQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> 24 +<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.css" integrity="sha512-uf06llspW44/LZpHzHT6qBOIVODjWtv4MxCricRxkzvopAlSWnTf6hpZTFxuuZcuNE9CBQhqE0Seu1CoRk84nQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
  25 +<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.13/codemirror.min.js" integrity="sha512-sSWQXoxIkE0G4/xqLngx5C53oOZCgFRxWE79CvMX2X0IKx14W3j9Dpz/2MpRh58xb2W/h+Y4WAHJQA0qMMuxJg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
  26 +<!-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.11/codemirror.min.css" integrity="sha512-uf06llspW44/LZpHzHT6qBOIVODjWtv4MxCricRxkzvopAlSWnTf6hpZTFxuuZcuNE9CBQhqE0Seu1CoRk84nQ==" crossorigin="anonymous" referrerpolicy="no-referrer" /> -->
  27 +<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.11/codemirror.min.js" integrity="sha512-rdFIN28+neM8H8zNsjRClhJb1fIYby2YCNmoqwnqBDEvZgpcp7MJiX8Wd+Oi6KcJOMOuvGztjrsI59rly9BsVQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> -->
18 28
19 <!-- animate --> 29 <!-- animate -->
20 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" integrity="sha512-c42qTSw/wPZ3/5LBzD+Bw5f7bSF2oxou6wEb+I/lqeaKV5FDIfMvvRp772y4jcJLKuGUOpbJMdg/BTl50fJYAw==" crossorigin="anonymous" referrerpolicy="no-referrer" /> 30 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" integrity="sha512-c42qTSw/wPZ3/5LBzD+Bw5f7bSF2oxou6wEb+I/lqeaKV5FDIfMvvRp772y4jcJLKuGUOpbJMdg/BTl50fJYAw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
aprendizations/templates/topic.html
@@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
5 {% include include-libs.html %} 5 {% include include-libs.html %}
6 6
7 <!-- local --> 7 <!-- local -->
  8 + <link rel="icon" href="{{static_url('favicon.ico')}}" />
8 <link rel="stylesheet" href="{{static_url('css/github.css')}}" /> 9 <link rel="stylesheet" href="{{static_url('css/github.css')}}" />
9 <link rel="stylesheet" href="{{static_url('css/topic.css')}}" /> 10 <link rel="stylesheet" href="{{static_url('css/topic.css')}}" />
10 <script defer src="{{static_url('js/topic.js')}}"></script> 11 <script defer src="{{static_url('js/topic.js')}}"></script>
aprendizations/tools.py
1 1
2 # python standard library 2 # python standard library
3 -import asyncio 3 +# import asyncio
4 import logging 4 import logging
5 from os import path 5 from os import path
6 # import re 6 # import re
7 # import subprocess 7 # import subprocess
8 -from typing import Any, List 8 +from typing import Any
9 9
10 # third party libraries 10 # third party libraries
11 # import mistune 11 # import mistune
12 # from pygments import highlight 12 # from pygments import highlight
13 # from pygments.lexers import get_lexer_by_name 13 # from pygments.lexers import get_lexer_by_name
14 # from pygments.formatters import HtmlFormatter 14 # from pygments.formatters import HtmlFormatter
15 -import yaml  
16 15
17 16
18 # setup logger for this module 17 # setup logger for this module
19 logger = logging.getLogger(__name__) 18 logger = logging.getLogger(__name__)
20 19
21 20
22 -# -------------------------------------------------------------------------  
23 -# Block math:  
24 -# $$x$$ or \begin{equation}x\end{equation}  
25 -# ------------------------------------------------------------------------- 21 +# # -------------------------------------------------------------------------
  22 +# # Block math:
  23 +# # $$x$$ or \begin{equation}x\end{equation}
  24 +# # -------------------------------------------------------------------------
26 # class MathBlockGrammar(mistune.BlockGrammar): 25 # class MathBlockGrammar(mistune.BlockGrammar):
27 # block_math = re.compile(r'^\$\$(.*?)\$\$', re.DOTALL) 26 # block_math = re.compile(r'^\$\$(.*?)\$\$', re.DOTALL)
28 # latex_environment = re.compile(r'^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}', 27 # latex_environment = re.compile(r'^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}',
@@ -53,7 +52,6 @@ logger = logging.getLogger(__name__) @@ -53,7 +52,6 @@ logger = logging.getLogger(__name__)
53 # 'text': m.group(2) 52 # 'text': m.group(2)
54 # }) 53 # })
55 # 54 #
56 -#  
57 # # ------------------------------------------------------------------------- 55 # # -------------------------------------------------------------------------
58 # # Inline math: $x$ 56 # # Inline math: $x$
59 # # ------------------------------------------------------------------------- 57 # # -------------------------------------------------------------------------
@@ -94,6 +92,7 @@ logger = logging.getLogger(__name__) @@ -94,6 +92,7 @@ logger = logging.getLogger(__name__)
94 # self.token['text']) 92 # self.token['text'])
95 # 93 #
96 # 94 #
  95 +#
97 # class HighlightRenderer(mistune.Renderer): 96 # class HighlightRenderer(mistune.Renderer):
98 # def block_code(self, code, lang='text'): 97 # def block_code(self, code, lang='text'):
99 # try: 98 # try:
@@ -136,118 +135,32 @@ logger = logging.getLogger(__name__) @@ -136,118 +135,32 @@ logger = logging.getLogger(__name__)
136 # else: 135 # else:
137 # return md 136 # return md
138 # 137 #
  138 +#
139 139
140 -# ---------------------------------------------------------------------------  
141 -# load data from yaml file  
142 -# ---------------------------------------------------------------------------  
143 -def load_yaml(filename: str, default: Any = None) -> Any:  
144 - filename = path.expanduser(filename)  
145 - try:  
146 - f = open(filename, 'r', encoding='utf-8')  
147 - except Exception as e:  
148 - logger.error(e)  
149 - if default is not None:  
150 - return default  
151 - else:  
152 - raise  
153 -  
154 - with f:  
155 - try:  
156 - return yaml.safe_load(f)  
157 - except yaml.YAMLError as e:  
158 - logger.error(str(e).replace('\n', ' '))  
159 - if default is not None:  
160 - return default  
161 - else:  
162 - raise  
163 140
164 141
165 # --------------------------------------------------------------------------- 142 # ---------------------------------------------------------------------------
166 -# Runs a script and returns its stdout parsed as yaml, or None on error.  
167 -# The script is run in another process but this function blocks waiting  
168 -# for its termination.  
169 -# ---------------------------------------------------------------------------  
170 -# def run_script(script: str,  
171 -# args: List[str] = [],  
172 -# stdin: str = '',  
173 -# timeout: int = 2) -> Any:  
174 -#  
175 -# script = path.expanduser(script) 143 +# TODO: unused, to be removed
  144 +# def load_yaml(filename: str, default: Any = None) -> Any:
  145 +# '''
  146 +# Load data from yaml file
  147 +# '''
  148 +# filename = path.expanduser(filename)
176 # try: 149 # try:
177 -# cmd = [script] + [str(a) for a in args]  
178 -# p = subprocess.run(cmd,  
179 -# input=stdin,  
180 -# stdout=subprocess.PIPE,  
181 -# stderr=subprocess.STDOUT,  
182 -# text=True, # same as universal_newlines=True  
183 -# timeout=timeout,  
184 -# )  
185 -# except FileNotFoundError:  
186 -# logger.error(f'Can not execute script "{script}": not found.')  
187 -# except PermissionError:  
188 -# logger.error(f'Can not execute script "{script}": wrong permissions.')  
189 -# except OSError:  
190 -# logger.error(f'Can not execute script "{script}": unknown reason.')  
191 -# except subprocess.TimeoutExpired:  
192 -# logger.error(f'Timeout {timeout}s exceeded while running "{script}".')  
193 -# except Exception:  
194 -# logger.error(f'An Exception ocurred running {script}.')  
195 -# else:  
196 -# if p.returncode != 0:  
197 -# logger.error(f'Return code {p.returncode} running "{script}".') 150 +# f = open(filename, 'r', encoding='utf-8')
  151 +# except Exception as e:
  152 +# logger.error(e)
  153 +# if default is not None:
  154 +# return default
198 # else: 155 # else:
199 -# try:  
200 -# output = yaml.safe_load(p.stdout)  
201 -# except Exception:  
202 -# logger.error(f'Error parsing yaml output of "{script}"')  
203 -# else:  
204 -# return output  
205 -#  
206 -  
207 -# def run_script(script: str,  
208 -# args: List[str] = [],  
209 -# stdin: str = '',  
210 -# timeout: int = 2) -> Any:  
211 -# asyncio.run(run_script_async(script, args, stdin, timeout))  
212 -# ----------------------------------------------------------------------------  
213 -# Same as above, but asynchronous  
214 -# ----------------------------------------------------------------------------  
215 -# async def run_script(script: str,  
216 -# args: List[str] = [],  
217 -# stdin: str = '',  
218 -# timeout: int = 5) -> Any:  
219 -#  
220 -# # normalize args  
221 -# script = path.expanduser(script)  
222 -# input_bytes = stdin.encode('utf-8')  
223 -# args = [str(a) for a in args] 156 +# raise
224 # 157 #
225 -# try:  
226 -# p = await asyncio.create_subprocess_exec(  
227 -# script, *args,  
228 -# stdin=asyncio.subprocess.PIPE,  
229 -# stdout=asyncio.subprocess.PIPE,  
230 -# stderr=asyncio.subprocess.DEVNULL,  
231 -# )  
232 -# except FileNotFoundError:  
233 -# logger.error(f'Can not execute script "{script}": not found.')  
234 -# except PermissionError:  
235 -# logger.error(f'Can not execute script "{script}": wrong permissions.')  
236 -# except OSError:  
237 -# logger.error(f'Can not execute script "{script}": unknown reason.')  
238 -# else: 158 +# with f:
239 # try: 159 # try:
240 -# stdout, _ = await asyncio.wait_for(p.communicate(input_bytes), timeout)  
241 -# except asyncio.TimeoutError:  
242 -# logger.warning(f'Timeout {timeout}s exceeded running "{script}".')  
243 -# return  
244 -#  
245 -# if p.returncode != 0:  
246 -# logger.error(f'Return code {p.returncode} running "{script}".')  
247 -# else:  
248 -# try:  
249 -# output = yaml.safe_load(stdout.decode('utf-8', 'ignore'))  
250 -# except Exception:  
251 -# logger.error(f'Error parsing yaml output of "{script}"') 160 +# return yaml.safe_load(f)
  161 +# except yaml.YAMLError as e:
  162 +# logger.error(str(e).replace('\n', ' '))
  163 +# if default is not None:
  164 +# return default
252 # else: 165 # else:
253 -# return output 166 +# raise
demo/astronomy.yaml
@@ -17,8 +17,14 @@ @@ -17,8 +17,14 @@
17 # ---------------------------------------------------------------------------- 17 # ----------------------------------------------------------------------------
18 18
19 topics: 19 topics:
20 - astronomy/solar-system:  
21 - name: Sistema solar 20 + astronomy/solar-system/planets:
  21 + name: Planets
  22 +
  23 + astronomy/solar-system/natural_satellites:
  24 + name: Natural satellites
  25 + deps:
  26 + - astronomy/solar-system/planets
  27 +
22 28
23 # astronomy/milky-way: 29 # astronomy/milky-way:
24 # name: Via Láctea 30 # name: Via Láctea
demo/astronomy/solar-system/correct-first_3_planets.py
@@ -1,28 +0,0 @@ @@ -1,28 +0,0 @@
1 -#!/usr/bin/env python3  
2 -  
3 -import re  
4 -import sys  
5 -import time  
6 -  
7 -s = sys.stdin.read()  
8 -  
9 -ans = set(re.findall(r'[\w]+', s.lower())) # convert answer to lowercase  
10 -ans.difference_update({'e', 'a', 'o', 'planeta', 'planetas'}) # ignore words  
11 -  
12 -# correct set of planets  
13 -planets = {'mercúrio', 'vénus', 'terra'}  
14 -  
15 -correct = set.intersection(ans, planets) # the ones I got right  
16 -wrong = set.difference(ans, planets) # the ones I got wrong  
17 -  
18 -grade = (len(correct) - len(wrong)) / len(planets)  
19 -  
20 -comments = 'Certo' if grade == 1.0 else 'as iniciais dos planetas são M, V e T'  
21 -  
22 -out = f'''---  
23 -grade: {grade}  
24 -comments: {comments}'''  
25 -  
26 -time.sleep(2) # simulate computation time (may generate timeout)  
27 -  
28 -print(out)  
demo/astronomy/solar-system/public/earth.jpg

1.02 MB

demo/astronomy/solar-system/public/jupiter.gif

2.96 MB

demo/astronomy/solar-system/public/planets.png

419 KB

demo/astronomy/solar-system/public/saturn.jpg

117 KB

demo/astronomy/solar-system/questions.yaml
@@ -1,67 +0,0 @@ @@ -1,67 +0,0 @@
1 ----  
2 -# ----------------------------------------------------------------------------  
3 -- type: text  
4 - ref: planet-earth  
5 - title: Sistema solar  
6 - text: O nosso planeta chama-se planeta...  
7 - correct: ['Terra', 'terra']  
8 - # opcional  
9 - answer: dos macacos?  
10 - solution: |  
11 - O nosso planeta é o planeta **Terra**.  
12 -  
13 - ![Terra](earth.jpg)  
14 -  
15 -# ----------------------------------------------------------------------------  
16 -- type: radio  
17 - ref: largest-planet  
18 - title: Sistema solar  
19 - text: |  
20 - ![planetas](planets.png "Planetas do Sistema Solar")  
21 -  
22 - Qual é o maior planeta do Sistema Solar?  
23 - options:  
24 - - Mercúrio  
25 - - Marte  
26 - - Júpiter  
27 - - Saturno  
28 - - Têm todos o mesmo tamanho  
29 - # opcional  
30 - correct: 2  
31 - # discount: true  
32 - shuffle: false  
33 - solution: |  
34 - O maior planeta é Júpiter. Tem uma massa 1000 vezes inferior ao Sol, mas  
35 - ainda assim 2.5 vezes maior que a massa de todos os outros planetas juntos.  
36 - É um gigante gasoso maioritariamente composto por hidrogénio.  
37 -  
38 - ![Júpiter](jupiter.gif)  
39 -  
40 -# ----------------------------------------------------------------------------  
41 -- type: text-regex  
42 - ref: saturn  
43 - title: Sistema solar  
44 - text: O planeta do sistema solar conhecido pelos seus aneis é o planeta...  
45 - correct: '[Ss]aturno'  
46 - solution: |  
47 - O planeta Saturno é famoso pelos seus anéis.  
48 - É o segundo maior planeta do Sistema Solar.  
49 - Tal como Júpiter, é um gigante gasoso.  
50 - Os seus anéis são formados por partículas de gelo.  
51 -  
52 - ![Saturno](saturn.jpg)  
53 -  
54 -# ----------------------------------------------------------------------------  
55 -- type: textarea  
56 - ref: first_3_planets  
57 - title: Sistema solar  
58 - text: |  
59 - Qual o nome dos três planetas mais próximos do Sol?  
60 - Exemplo `Ceres, Krypton e Vulcano`  
61 - correct: correct-first_3_planets.py  
62 - # correct: correct-timeout.py  
63 - # opcional  
64 - answer: Ceres, Krypton e Vulcano  
65 - timeout: 3  
66 - solution: |  
67 - Os 3 planetas mais perto do Sol são Mercúrio, Vénus e Terra.  
demo/courses.yaml
@@ -11,19 +11,20 @@ topics_from: @@ -11,19 +11,20 @@ topics_from:
11 # ---------------------------------------------------------------------------- 11 # ----------------------------------------------------------------------------
12 courses: 12 courses:
13 math: 13 math:
14 - title: Matemática 14 + title: Elementary math
15 description: | 15 description: |
16 - Adição, multiplicação e números primos. 16 + Arithmetic operations: add, subtract, mulitply and divide numbers
17 goals: 17 goals:
18 - - math  
19 - math/addition 18 - math/addition
  19 + - math/subtraction
20 - math/multiplication 20 - math/multiplication
21 - - math/prime-numbers 21 + - math/division
22 22
23 astronomy: 23 astronomy:
24 - title: Astronomia 24 + title: Astronomy
25 description: | 25 description: |
26 - Sistema Solar e Via Láctea. 26 + This course is about the planets and natural satellites in our Solar
  27 + System.
27 goals: 28 goals:
28 - - astronomy/solar-system  
29 - # - astronomy/milky-way 29 + - astronomy/solar-system/planets
  30 + - astronomy/solar-system/natural_satellites
demo/math.yaml
@@ -18,30 +18,20 @@ @@ -18,30 +18,20 @@
18 18
19 topics: 19 topics:
20 math/addition: 20 math/addition:
21 - name: Adição 21 + name: Addition
22 22
23 - math/multiplication:  
24 - name: Multiplicação 23 + math/subtraction:
  24 + name: Subtraction
25 deps: 25 deps:
26 - math/addition 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 -  
35 - math/prime-numbers:  
36 - name: Números primos 28 + math/multiplication:
  29 + name: Multiplication
37 deps: 30 deps:
38 - - math/learn-prime-numbers 31 + - math/addition
39 32
40 - math:  
41 - name: Números e operações aritméticas  
42 - type: chapter 33 + math/division:
  34 + name: Division
43 deps: 35 deps:
44 - - math/addition 36 + - math/subtraction
45 - math/multiplication 37 - math/multiplication
46 - - math/learn-prime-numbers  
47 - - math/prime-numbers  
demo/math/multiplication/multiplication-table.py
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
  3 +import json
3 import random 4 import random
4 5
5 x, y = random.sample(range(2,10), k=2) 6 x, y = random.sample(range(2,10), k=2)
@@ -8,15 +9,38 @@ r = x * y @@ -8,15 +9,38 @@ r = x * y
8 yy = '+'.join([str(y)]*x) 9 yy = '+'.join([str(y)]*x)
9 xx = '+'.join([str(x)]*y) 10 xx = '+'.join([str(x)]*y)
10 11
11 -print(f'''---  
12 -type: text  
13 -title: Multiplicação (tabuada)  
14 -text: |  
15 - Qual o resultado da multiplicação ${x}\\times {y}$?  
16 -transform: ['trim']  
17 -correct: ['{r}']  
18 -solution: |  
19 - A multiplicação é a repetição da soma. Podemos fazer de duas maneiras:  
20 - $$ {x}\\times {y} = {yy} = {r} $$  
21 - ou  
22 - $$ {y}\\times {x} = {xx} = {r}. $$''') 12 +xbullets = x * '*' # ⏺
  13 +xxbullets = '\n'.join(y * [xbullets])
  14 +
  15 +ybullets = y * '*'
  16 +yybullets = '\n'.join(x * [ybullets])
  17 +
  18 +solution = f'''
  19 +Multiplying is essentially repeating the addition multiple times.
  20 +We can do it in two ways:
  21 +
  22 +$$
  23 +{x}\\times {y} = {yy} = {r}
  24 +$$
  25 +or
  26 +$$
  27 +{y}\\times {x} = {xx} = {r}.
  28 +$$
  29 +
  30 +Multiplication can also be seen as the number of dots organised in rectangular
  31 +shape:
  32 +
  33 +{xxbullets}
  34 +
  35 +{yybullets}
  36 +'''
  37 +
  38 +question = {
  39 + 'type': 'text',
  40 + 'title': 'Multiplication (table)',
  41 + 'text': f'What is the result of ${x}\\times {y}$?',
  42 + 'transform': ['trim'],
  43 + 'correct': [f'{r}'],
  44 + 'solution': solution,
  45 +}
  46 +print(json.dumps(question))
demo/math/multiplication/questions.yaml
1 --- 1 ---
2 # ---------------------------------------------------------------------------- 2 # ----------------------------------------------------------------------------
3 -- type: generator  
4 - ref: multiplication-table  
5 - script: multiplication-table.py 3 +# - type: generator
  4 +# ref: multiplication-table
  5 +# script: multiplication-table.py
6 6
7 # ---------------------------------------------------------------------------- 7 # ----------------------------------------------------------------------------
8 - type: checkbox 8 - type: checkbox
9 - ref: multiplication-properties  
10 - title: Propriedades da multiplicação  
11 - text: Indique quais as propriedades que a multiplicação satisfaz. 9 + ref: multiplication
  10 + title: Multiplication
  11 + text: |
  12 + Which of the following multiplications have the result $12$?
12 options: 13 options:
13 - # right  
14 - - Existência de elemento neutro, $1x=x$.  
15 - - Propriedade associativa, $(xy)z = x(yz)$.  
16 - - Propriedade comutativa, $xy=yx$.  
17 - - Existência de elemento absorvente, $0x=0$.  
18 - # wrong  
19 - - Existência de inverso, todos os números $x$ tem um inverso $1/x$ tal que  
20 - $x(1/x)=1$.  
21 - correct: [1, 1, 1, 1, 0] 14 + - $6\times 2$
  15 + - $2\times 6$
  16 + - $3\times 4$
  17 + - $12\times 0$
  18 + correct: [1, 1, 1, 0]
22 solution: | 19 solution: |
23 - Na multiplicação nem todos os números têm inverso. Só têm inverso os números  
24 - diferentes de zero.  
25 - As outras propriedades são satisfeitas para todos os números. 20 + Multiplying by zero is always zero.
  21 + The others are correct.
  22 +
  23 +
  24 +# # ----------------------------------------------------------------------------
  25 +# - type: checkbox
  26 +# ref: multiplication-properties
  27 +# title: Multiplication Properties
  28 +# text: Which properties does the multiplication have?
  29 +# options:
  30 +# # right
  31 +# - 'Existence of an identity element: $1 x = x$.'
  32 +# - 'Associative property: $(x y) z = x (y z)$.'
  33 +# - 'Commutative property: $x y = y x$.'
  34 +# - 'Existence of an absorbing element: $0 x = 0$.'
  35 +# # wrong
  36 +# - 'Inverse property: all numbers have an inverse $1/x$ such that
  37 +# $x(1/x)=1$.'
  38 +# correct: [1, 1, 1, 1, 0]
  39 +# solution: |
  40 +# Not all numbers have an inverse: *zero* does not have an inverse.
  41 +# The other properties are true.
demo/math/questions.yaml
1 - type: info 1 - type: info
2 - title: Fim do capítulo 2 + title: End of chapter
  3 + text: |
  4 + You should now be able to add, subtract, multiply and divide whole numbers.
  5 + With these operations, prime numbers where also introduced.
1 -; [mypy]  
2 -; python_version = 3.10 1 +[mypy]
  2 +python_version = 3.11
3 ; plugins = sqlalchemy.ext.mypy.plugin 3 ; plugins = sqlalchemy.ext.mypy.plugin
4 4
5 -; [mypy-pygments.*]  
6 -; ignore_missing_imports = True 5 +[mypy-networkx.*]
  6 +ignore_missing_imports = True
7 7
8 -; [mypy-networkx.*]  
9 -; ignore_missing_imports = True  
10 -  
11 -; [mypy-bcrypt.*]  
12 -; ignore_missing_imports = True  
13 -  
14 -; [mypy-mistune.*]  
15 -; ignore_missing_imports = True 8 +[mypy-mistune.*]
  9 +ignore_missing_imports = True
@@ -27,6 +27,8 @@ setup( @@ -27,6 +27,8 @@ setup(
27 'sqlalchemy>=2.0.37', 27 'sqlalchemy>=2.0.37',
28 'bcrypt>=4.2.1', 28 'bcrypt>=4.2.1',
29 'networkx>=3.4.2' 29 'networkx>=3.4.2'
  30 + 'pandas>=2.3',
  31 + 'openpyxl'
30 ], 32 ],
31 entry_points = { 33 entry_points = {
32 'console_scripts': [ 34 'console_scripts': [