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

.gitignore
... ... @@ -8,4 +8,5 @@ node_modules/
8 8 .DS_Store
9 9 demo/.DS_Store
10 10 demo/solar_system/.DS_Store
11   -.venv
  11 +.venv/
  12 +build/
... ...
BUGS.md
... ... @@ -2,6 +2,15 @@
2 2  
3 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 14 - nao esta a respeitar o numero de tentativas `max_tries`.
6 15 - se na especificacao de um curso, a referencia do topico nao existir como
7 16 directorio, rebenta.
... ... @@ -23,6 +32,8 @@
23 32  
24 33 ## TODO
25 34  
  35 +- RGPD: possibilidade ter alunos anonimos (sem numero e nome), poderem
  36 +unregister.
26 37 - ordenação que faça sentido, organizada por chapters.
27 38 - chapters deviam ser automaticamente checkados...
28 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 3 # THE MIT License
4 4 #
... ... @@ -30,10 +30,10 @@ are progressively uncovered as the students progress.
30 30 '''
31 31  
32 32 APP_NAME = 'aprendizations'
33   -APP_VERSION = '2023.2.dev1'
  33 +APP_VERSION = '2025.1.dev1'
34 34 APP_DESCRIPTION = __doc__
35 35  
36 36 __author__ = 'Miguel Barão'
37   -__copyright__ = 'Copyright © 2023, Miguel Barão'
  37 +__copyright__ = 'Copyright © 2025, Miguel Barão'
38 38 __license__ = 'MIT license'
39 39 __version__ = APP_VERSION
... ...
aprendizations/initdb.py
... ... @@ -5,8 +5,9 @@ Initializes or updates database
5 5 '''
6 6  
7 7 # python standard libraries
8   -import csv
9 8 import argparse
  9 +import csv
  10 +from pandas import read_excel
10 11 import re
11 12 from string import capwords
12 13  
... ... @@ -32,11 +33,11 @@ def parse_commandline_arguments():
32 33 'command line. If the database does not exist, a new one'
33 34 ' is created.')
34 35  
35   - argparser.add_argument('csvfile',
  36 + argparser.add_argument('files',
36 37 nargs='*',
37 38 type=str,
38 39 default='',
39   - help='CSV file to import (SIIUE)')
  40 + help='CSV or Excel files to import (SIIUE)')
40 41  
41 42 argparser.add_argument('--db',
42 43 default='students.db',
... ... @@ -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 100 def get_students_from_csv(filename):
76 101 '''Reads CSV file with enrolled students in SIIUE format.
77 102 SIIUE names can have suffixes like "(TE)" and are sometimes capitalized.
... ... @@ -138,8 +163,13 @@ def main():
138 163 # --- make list of students to insert/update
139 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 174 if args.admin:
145 175 students.append({'uid': '0', 'name': 'Admin'})
... ...
aprendizations/learnapp.py
... ... @@ -110,7 +110,7 @@ class Application():
110 110 def _sanity_check_questions(self) -> None:
111 111 '''
112 112 Unit tests for all questions
113   -
  113 +
114 114 Generates all questions, give right and wrong answers and corrects.
115 115 '''
116 116  
... ... @@ -274,7 +274,7 @@ class Application():
274 274 tid: str = student_state.get_previous_topic()
275 275 level: float = student_state.get_topic_level(tid)
276 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 279 with Session(self._engine) as session:
280 280 query = select(StudentTopic) \
... ...
aprendizations/main.py
... ... @@ -168,9 +168,9 @@ def main():
168 168 logger.info('LearnApp started')
169 169  
170 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 174 debug=arg.debug))
175 175 logger.critical('Webserver stopped.')
176 176  
... ...
aprendizations/renderer_markdown.py
... ... @@ -10,6 +10,9 @@ from pygments.formatters import html
10 10  
11 11 class Renderer(mistune.HTMLRenderer):
12 12 def block_code(self, code, info=None):
  13 + '''
  14 + Code syntax highlight using pygments
  15 + '''
13 16 if info is not None:
14 17 lexer = get_lexer_by_name(info, stripall=True)
15 18 formatter = html.HtmlFormatter()
... ... @@ -17,6 +20,9 @@ class Renderer(mistune.HTMLRenderer):
17 20 return f'<pre><code>{mistune.escape(code)}</code></pre>'
18 21  
19 22 def image(self, text, url, title=None):
  23 + '''
  24 + Include image title and alternative text
  25 + '''
20 26 text = mistune.escape(text, quote=True)
21 27 title = mistune.escape(title or '', quote=True)
22 28 return (f'<img src="/file/{url}" alt="{text}" title="{title}"'
... ...
aprendizations/serve.py
... ... @@ -6,18 +6,16 @@ Tornado Webserver
6 6 # python standard library
7 7 import asyncio
8 8 import base64
9   -from logging import getLogger
10 9 import mimetypes
11   -from os.path import join, dirname, expanduser
12 10 import signal
13 11 import sys
14   -from typing import List, Optional, Union
15 12 import uuid
  13 +import logging
  14 +from os.path import join, dirname, expanduser
  15 +from typing import List, Optional, Union
16 16  
17 17 # third party libraries
18   -import tornado.httpserver
19   -import tornado.ioloop
20   -import tornado.web
  18 +import tornado
21 19 from tornado.escape import to_unicode
22 20  
23 21 # this project
... ... @@ -26,7 +24,7 @@ from aprendizations.learnapp import LearnException
26 24  
27 25  
28 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 38  
41 39 def get_current_user(self):
42 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 42 return None if cookie is None else to_unicode(cookie)
45 43  
46 44  
... ... @@ -59,7 +57,7 @@ class LoginHandler(BaseHandler):
59 57 loop = tornado.ioloop.IOLoop.current()
60 58 login_ok = await self.app.login(uid, pw, loop)
61 59 if login_ok:
62   - self.set_secure_cookie('aprendizations_user', uid)
  60 + self.set_signed_cookie('aprendizations_user', uid)
63 61 self.redirect('/')
64 62 else:
65 63 self.render('login.html', error='Número ou senha incorrectos')
... ... @@ -391,7 +389,7 @@ def signal_handler(*_) -&gt; None:
391 389 '''
392 390 reply = input(' --> Stop webserver? (yes/no) ')
393 391 if reply.lower() == 'yes':
394   - tornado.ioloop.IOLoop.current().stop()
  392 + tornado.ioloop.IOLoop.current().stop() # FIXME: is there a recent alternative?
395 393 logger.critical('Webserver stopped.')
396 394 sys.exit(0)
397 395  
... ... @@ -403,23 +401,25 @@ async def webserver(app, ssl, port: int = 8443, debug: bool = False) -&gt; None:
403 401 '''
404 402  
405 403 # --- create web application
  404 + opts = { 'app': app }
406 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 418 settings = {
420 419 'template_path': join(dirname(__file__), 'templates'),
421 420 'static_path': join(dirname(__file__), 'static'),
422 421 'static_url_prefix': '/static/',
  422 + # TODO: xsrf_cookie_name
423 423 'xsrf_cookies': True,
424 424 'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
425 425 'login_url': '/login',
... ...
aprendizations/student.py
... ... @@ -50,35 +50,34 @@ class StudentState():
50 50 self.factory = factory # question factory
51 51 self.courses = courses # {'course': ['topic_id1', 'topic_id2',...]}
52 52  
53   - # data of this student
  53 + # student data
54 54 self.uid = uid # user id '12345'
55 55 self.state = state # {'topic': {'level': 0.5, 'date': datetime}, ...}
56 56  
57 57 # prepare for running
58 58 self.update_topic_levels() # applies forgetting factor
59 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 70 Tries to start a course.
66 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 83 async def start_topic(self, topic_ref: str) -> None:
... ...
aprendizations/templates/include-head.html
1 1 <meta charset="utf-8" />
2 2 <meta name="viewport" content="width=device-width, initial-scale=1">
3 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 5 <title>aprendizations</title>
... ...
aprendizations/templates/include-libs.html
1 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 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 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 19 <!-- MathJax -->
12 20 <script src="https://polyfill.io/v3/polyfill.min.js?features=es6"></script>
13 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 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 29 <!-- animate -->
20 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 5 {% include include-libs.html %}
6 6  
7 7 <!-- local -->
  8 + <link rel="icon" href="{{static_url('favicon.ico')}}" />
8 9 <link rel="stylesheet" href="{{static_url('css/github.css')}}" />
9 10 <link rel="stylesheet" href="{{static_url('css/topic.css')}}" />
10 11 <script defer src="{{static_url('js/topic.js')}}"></script>
... ...
aprendizations/tools.py
1 1  
2 2 # python standard library
3   -import asyncio
  3 +# import asyncio
4 4 import logging
5 5 from os import path
6 6 # import re
7 7 # import subprocess
8   -from typing import Any, List
  8 +from typing import Any
9 9  
10 10 # third party libraries
11 11 # import mistune
12 12 # from pygments import highlight
13 13 # from pygments.lexers import get_lexer_by_name
14 14 # from pygments.formatters import HtmlFormatter
15   -import yaml
16 15  
17 16  
18 17 # setup logger for this module
19 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 25 # class MathBlockGrammar(mistune.BlockGrammar):
27 26 # block_math = re.compile(r'^\$\$(.*?)\$\$', re.DOTALL)
28 27 # latex_environment = re.compile(r'^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}',
... ... @@ -53,7 +52,6 @@ logger = logging.getLogger(__name__)
53 52 # 'text': m.group(2)
54 53 # })
55 54 #
56   -#
57 55 # # -------------------------------------------------------------------------
58 56 # # Inline math: $x$
59 57 # # -------------------------------------------------------------------------
... ... @@ -94,6 +92,7 @@ logger = logging.getLogger(__name__)
94 92 # self.token['text'])
95 93 #
96 94 #
  95 +#
97 96 # class HighlightRenderer(mistune.Renderer):
98 97 # def block_code(self, code, lang='text'):
99 98 # try:
... ... @@ -136,118 +135,32 @@ logger = logging.getLogger(__name__)
136 135 # else:
137 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 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 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 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 165 # else:
253   -# return output
  166 +# raise
... ...
demo/astronomy.yaml
... ... @@ -17,8 +17,14 @@
17 17 # ----------------------------------------------------------------------------
18 18  
19 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 29 # astronomy/milky-way:
24 30 # name: Via Láctea
... ...
demo/astronomy/solar-system/correct-first_3_planets.py
... ... @@ -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   ----
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 11 # ----------------------------------------------------------------------------
12 12 courses:
13 13 math:
14   - title: Matemática
  14 + title: Elementary math
15 15 description: |
16   - Adição, multiplicação e números primos.
  16 + Arithmetic operations: add, subtract, mulitply and divide numbers
17 17 goals:
18   - - math
19 18 - math/addition
  19 + - math/subtraction
20 20 - math/multiplication
21   - - math/prime-numbers
  21 + - math/division
22 22  
23 23 astronomy:
24   - title: Astronomia
  24 + title: Astronomy
25 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 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 18  
19 19 topics:
20 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 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   -
35   - math/prime-numbers:
36   - name: Números primos
  28 + math/multiplication:
  29 + name: Multiplication
37 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 35 deps:
44   - - math/addition
  36 + - math/subtraction
45 37 - math/multiplication
46   - - math/learn-prime-numbers
47   - - math/prime-numbers
... ...
demo/math/multiplication/multiplication-table.py
1 1 #!/usr/bin/env python3
2 2  
  3 +import json
3 4 import random
4 5  
5 6 x, y = random.sample(range(2,10), k=2)
... ... @@ -8,15 +9,38 @@ r = x * y
8 9 yy = '+'.join([str(y)]*x)
9 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 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 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 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 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.
... ...
mypy.ini
1   -; [mypy]
2   -; python_version = 3.10
  1 +[mypy]
  2 +python_version = 3.11
3 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
... ...
setup.py
... ... @@ -27,6 +27,8 @@ setup(
27 27 'sqlalchemy>=2.0.37',
28 28 'bcrypt>=4.2.1',
29 29 'networkx>=3.4.2'
  30 + 'pandas>=2.3',
  31 + 'openpyxl'
30 32 ],
31 33 entry_points = {
32 34 'console_scripts': [
... ...