Commit d173a54ce9b33e8ff1ddcae4e53f9f64961e37ca

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

several fixes

BUGS.md
1 1  
2 2 # BUGS
3 3  
  4 +- duplo clicks no botao "responder" dessincroniza as questões, ver debounce em https://stackoverflow.com/questions/20281546/how-to-prevent-calling-of-en-event-handler-twice-on-fast-clicks
4 5 - quando termina topico devia apagar as perguntas todas (se falhar a gerar novo topico, aparecem perguntas do antigo)
5 6 - apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
6 7 - devia mostrar timeout para o aluno saber a razao.
... ... @@ -40,6 +41,7 @@ sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in
40 41  
41 42 # FIXED
42 43  
  44 +- forgetting factor is hardcoded in student.py
43 45 - add aprendizatons --version
44 46 - se aluno abre dois tabs no browser, conseque navegar em simultaneo para perguntas diferentes. quando submete uma delas dá asneira. Tem de haver um campo hidden que tenha um céodigo único que indique qual a pergunta. do lado do servidor apnas há o codigo da pergunta corrente, se forem diferentes faz redirect para /.
45 47 - nos topicos learn.yaml, qd falha acrescenta no fim. nao faz sentido.
... ...
README.md
1 1 # Getting Started
2 2  
  3 +
  4 +## Installation
  5 +
3 6 To complete the installation we will need to perform the following steps:
4 7  
5 8 1. install python3.7, pip and npm
6 9 1. download aprendizations from the repository
7 10 1. install javascript libraries (with npm)
8 11 1. install aprendizations (with pip)
9   -1. initialize database
10 12 1. generate SSL certificates
11 13 1. configure the firewall (optional)
12   -1. try running `aprendizations demo.yaml`
13 14  
14   -These steps are explained next in detail.
  15 +To use the software we need to:
  16 +
  17 +1. initialize database
  18 +1. go to the demo directory (or an existing course)
  19 +1. run `aprendizations demo.yaml`
  20 +
  21 +Each of these steps is explained below.
15 22  
16 23 ### Install python3.7 with sqlite3 support and npm
17 24  
... ... @@ -47,16 +54,21 @@ This will install python locally under `~/.local/bin`. Make sure to add it to yo
47 54  
48 55 ### Install pip
49 56  
50   -Python usually includes pip which is accessible through `python -m pip install something`, but it's also convenient to have the `pip` command installed.
51   -If the `pip` command is not yet installed, run one of these:
  57 +Python usually includes pip which is accessible through `python -m pip install something`, but it's also convenient to have the `pip` command directly available in the terminal.
  58 +To install `pip` from the system package manager:
52 59  
53 60 ```sh
54 61 sudo apt install python3.7-pip # Ubuntu 19.04+
55   -python3.7 -m pip install pip # Ubuntu 18.04
56 62 sudo pkg py37-pip # FreeBSD
57 63 sudo port install py37-pip # MacOS
58 64 ```
59 65  
  66 +otherwise run:
  67 +
  68 +```sh
  69 +python3.7 -m pip install pip # install in user area
  70 +```
  71 +
60 72 The latter will install `pip` in your user account under `~/.local/bin`.
61 73 In the end you should be able to run `pip --version` and
62 74 `python3 -c "import sqlite3"` without errors.
... ... @@ -73,7 +85,7 @@ user = yes
73 85  
74 86 This will set pip to install modules in the user area (recommended).
75 87  
76   -### Install aprendizations and dependencies:
  88 +### Download and install aprendizations:
77 89  
78 90 ```sh
79 91 git clone https://git.xdi.uevora.pt/mjsb/aprendizations.git
... ... @@ -82,8 +94,7 @@ npm install # install javascript libraries
82 94 pip install . # install aprendizations and dependencies
83 95 ```
84 96  
85   -Javascript libraries are initially installed in `aprendizations/node_modules` directory.
86   -These libraries already have symbolic links from `aprendizations/aprendizations/static`.
  97 +Javascript libraries are installed in `aprendizations/node_modules` and are linked from `aprendizations/aprendizations/static`.
87 98  
88 99 Python packages are usually installed in:
89 100  
... ... @@ -92,7 +103,7 @@ Python packages are usually installed in:
92 103  
93 104 When aprendizations is installed with pip, all the dependencies are also installed. The javascript libraries previously installed with npm are copied to the above directory and the cloned repository is no longer needed.
94 105  
95   -At this point, aprendizations is installed in
  106 +At this point `aprendizations` is installed in
96 107  
97 108 ```sh
98 109 ~/.local/bin # Linux/FreeBSD
... ... @@ -106,29 +117,6 @@ aprendizations --version
106 117 aprendizations --help
107 118 ```
108 119  
109   -## Configuration
110   -
111   -### Database
112   -
113   -User data is maintained in a sqlite3 database which has to be created manually using the command `initdb-aprendizations`.
114   -The database file should be located in the same directory as the main
115   -YAML configuration file.
116   -
117   -For example, to run the included demo do:
118   -
119   -```sh
120   -cd demo # contains a small example
121   -initdb-aprendizations # show or initialize database
122   -initdb-aprendizations --admin # add admin user
123   -initdb-aprendizations inscricoes.csv # add students from CSV
124   -initdb-aprendizations --add 1184 "Aladino da Silva" # add new user
125   -initdb-aprendizations --update 1184 --pw alibaba # update password
126   -initdb-aprendizations --help # for available options
127   -```
128   -
129   -The default password is equal to the user name, if left undefined.
130   -
131   -
132 120 ### SSL Certificates
133 121  
134 122 We need certificates for https. Certificates can be self-signed or validated by a trusted authority.
... ... @@ -150,7 +138,7 @@ Install the certbot from LetsEncrypt:
150 138  
151 139 ```sh
152 140 sudo pkg install py36-certbot # FreeBSD
153   -sudo apt install certbot # Linux Ubuntu
  141 +sudo apt install certbot # Ubuntu
154 142 ```
155 143  
156 144 To generate or renew the certificates, ports 80 and 443 have to be accessible. The firewall and webserver have to be stopped.
... ... @@ -168,6 +156,30 @@ sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem .
168 156 chmod 400 cert.pem privkey.pem
169 157 ```
170 158  
  159 +
  160 +## Configuration
  161 +
  162 +### Database
  163 +
  164 +User data is maintained in a sqlite3 database which has to be created manually using the `initdb-aprendizations` command.
  165 +The database file should be located in the same directory as the main
  166 +YAML configuration file.
  167 +
  168 +For example, to run the included demo do:
  169 +
  170 +```sh
  171 +cd demo # contains a small example
  172 +initdb-aprendizations # show or initialize database
  173 +initdb-aprendizations --admin # add admin user
  174 +initdb-aprendizations inscricoes.csv # add students from CSV
  175 +initdb-aprendizations --add 1184 "Aladino da Silva" # add new user
  176 +initdb-aprendizations --update 1184 --pw alibaba # update password
  177 +initdb-aprendizations --help # for available options
  178 +```
  179 +
  180 +The default password is equal to the user name, if left undefined.
  181 +
  182 +
171 183 ### Running the demo
172 184  
173 185 The application includes a small example in `demo/demo.yaml` that can be used for initial testing. Run it with
... ... @@ -178,7 +190,7 @@ aprendizations demo.yaml
178 190 ```
179 191  
180 192 Open the browser at [https://127.0.0.1:8443](https://127.0.0.1:8443).
181   -If it everything looks good, check at the correct address
  193 +If everything looks good, check at the correct address
182 194 `https://www.example.com:8443`.
183 195 The option `--debug` provides more verbose logging and might
184 196 be useful during testing.
... ... @@ -198,7 +210,7 @@ rdr on $ext_if proto tcp from any to any port 80 -> 127.0.0.1 port 8080
198 210 rdr on $ext_if proto tcp from any to any port 443 -> 127.0.0.1 port 8443
199 211 ```
200 212  
201   -Under virtualbox with guest additions use `ext_if="vtnet0"`.
  213 +Virtualbox with guest additions uses `ext_if="vtnet0"`.
202 214  
203 215 Edit `/etc/rc.conf`
204 216  
... ... @@ -228,6 +240,17 @@ Make sure the following steps have been done:
228 240 - (optional) configure the firewall to do port forwarding
229 241 - run `aprendizations demo.yaml --check`
230 242  
  243 +## Keeping aprendizations updated
  244 +
  245 +To update aprendizations to the latest version do:
  246 +
  247 +```sh
  248 +cd aprendizations
  249 +git pull # get latest version
  250 +npm update # update javascript libraries
  251 +pip install -U . # updates installed version to latest
  252 +```
  253 +
231 254 ## Troubleshooting
232 255  
233 256 To help with troubleshooting, use the option `--debug` when running the server.
... ...
aprendizations/learnapp.py
... ... @@ -79,7 +79,7 @@ class LearnApp(object):
79 79  
80 80 errors = 0
81 81 for qref in self.factory:
82   - logger.debug(f'[sanity_check_questions] Checking "{qref}"...')
  82 + logger.debug(f'checking {qref}...')
83 83 try:
84 84 q = self.factory[qref].generate()
85 85 except Exception:
... ... @@ -195,9 +195,9 @@ class LearnApp(object):
195 195 # ------------------------------------------------------------------------
196 196 async def check_answer(self, uid, answer):
197 197 knowledge = self.online[uid]['state']
  198 + topic = knowledge.get_current_topic()
198 199 q, action = await knowledge.check_answer(answer) # may move questions
199 200 logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"')
200   - topic = knowledge.get_current_topic()
201 201  
202 202 # always save grade of answered question
203 203 with self.db_session() as s:
... ... @@ -208,7 +208,7 @@ class LearnApp(object):
208 208 finishtime=str(q['finish_time']),
209 209 student_id=uid,
210 210 topic_id=topic))
211   - logger.debug(f'[check_answer] Saved "{q["ref"]}" into database')
  211 + logger.debug(f'db insert answer of {q["ref"]}')
212 212  
213 213 if knowledge.topic_has_finished():
214 214 # finished topic, save into database
... ... @@ -222,7 +222,7 @@ class LearnApp(object):
222 222 .one_or_none()
223 223 if a is None:
224 224 # insert new studenttopic into database
225   - logger.debug('[check_answer] Database insert studenttopic')
  225 + logger.debug('db insert studenttopic')
226 226 t = s.query(Topic).get(topic)
227 227 u = s.query(Student).get(uid)
228 228 # association object
... ... @@ -231,14 +231,12 @@ class LearnApp(object):
231 231 u.topics.append(a)
232 232 else:
233 233 # update studenttopic in database
234   - logger.debug('[check_answer] Database update studenttopic')
  234 + logger.debug(f'db update studenttopic to level {level}')
235 235 a.level = level
236 236 a.date = date
237 237  
238 238 s.add(a)
239 239  
240   - logger.debug(f'[check_answer] Saved topic "{topic}" into database')
241   -
242 240 return q, action
243 241  
244 242 # ------------------------------------------------------------------------
... ... @@ -347,7 +345,7 @@ class LearnApp(object):
347 345 # Buils dictionary of question factories
348 346 # ------------------------------------------------------------------------
349 347 def make_factory(self) -> Dict[str, QFactory]:
350   - logger.info('Building questions factory...')
  348 + logger.info('Building questions factory:')
351 349 factory = {} # {'qref': QFactory()}
352 350 g = self.deps
353 351 for tref in g.nodes():
... ...
aprendizations/main.py
... ... @@ -4,34 +4,17 @@
4 4 import argparse
5 5 import logging
6 6 from os import environ, path
7   -import signal
8 7 import ssl
9 8 import sys
10 9  
11   -# third party libraries
12   -import tornado
13   -
14 10 # this project
15 11 from .learnapp import LearnApp, DatabaseUnusableError
16   -from .serve import WebApplication
  12 +from .serve import run_webserver
17 13 from .tools import load_yaml
18 14 from . import APP_NAME, APP_VERSION
19 15  
20 16  
21 17 # ----------------------------------------------------------------------------
22   -# Signal handler to catch Ctrl-C and abort server
23   -# ----------------------------------------------------------------------------
24   -def signal_handler(signal, frame):
25   - r = input(' --> Stop webserver? (yes/no) ').lower()
26   - if r == 'yes':
27   - tornado.ioloop.IOLoop.current().stop()
28   - logging.critical('Webserver stopped.')
29   - sys.exit(0)
30   - else:
31   - logging.info('Abort canceled...')
32   -
33   -
34   -# ----------------------------------------------------------------------------
35 18 def parse_cmdline_arguments():
36 19 argparser = argparse.ArgumentParser(
37 20 description='Server for online learning. Students and topics '
... ... @@ -91,7 +74,7 @@ def get_logger_config(debug=False):
91 74 'version': 1,
92 75 'formatters': {
93 76 'standard': {
94   - 'format': '%(asctime)s | %(levelname)-10s | %(message)s',
  77 + 'format': '%(asctime)s | %(levelname)-8s | %(message)s',
95 78 'datefmt': '%Y-%m-%d %H:%M:%S',
96 79 },
97 80 },
... ... @@ -115,14 +98,14 @@ def get_logger_config(debug=False):
115 98 'handlers': ['default'],
116 99 'level': level,
117 100 'propagate': False,
118   - } for module in ['learnapp', 'models', 'factory', 'questions',
119   - 'knowledge', 'tools']})
  101 + } for module in ['learnapp', 'models', 'factory', 'tools', 'serve',
  102 + 'questions', 'student']})
120 103  
121 104 return load_yaml(config_file, default=default_config)
122 105  
123 106  
124 107 # ----------------------------------------------------------------------------
125   -# Tornado web server
  108 +# Start application and webserver
126 109 # ----------------------------------------------------------------------------
127 110 def main():
128 111 # --- Commandline argument parsing
... ... @@ -144,34 +127,6 @@ def main():
144 127  
145 128 logging.info('====================== Start Logging ======================')
146 129  
147   - # --- start application
148   - logging.info('Starting App...')
149   - try:
150   - learnapp = LearnApp(arg.conffile, prefix=arg.prefix, db=arg.db,
151   - check=arg.check)
152   - except DatabaseUnusableError:
153   - logging.critical('Failed to start application.')
154   - print('--------------------------------------------------------------')
155   - print('Could not find a usable database. Use one of the follwing ')
156   - print('commands to initialize: ')
157   - print(' ')
158   - print(' initdb-aprendizations --admin # add admin ')
159   - print(' initdb-aprendizations -a 86 "Max Smart" # add student ')
160   - print(' initdb-aprendizations students.csv # add many students')
161   - print('--------------------------------------------------------------')
162   - sys.exit(1)
163   - except Exception:
164   - logging.critical('Failed to start application.')
165   - sys.exit(1)
166   -
167   - # --- create web application
168   - logging.info('Starting Web App (tornado)...')
169   - try:
170   - webapp = WebApplication(learnapp, debug=arg.debug)
171   - except Exception:
172   - logging.critical('Failed to start web application.')
173   - sys.exit(1)
174   -
175 130 # --- get SSL certificates
176 131 if 'XDG_DATA_HOME' in environ:
177 132 certs_dir = path.join(environ['XDG_DATA_HOME'], 'certs')
... ... @@ -186,44 +141,46 @@ def main():
186 141 logging.critical(f'SSL certificates missing in {certs_dir}')
187 142 print('--------------------------------------------------------------')
188 143 print('Certificates should be issued by a certificate authority (CA),')
189   - print('such as https://letsencrypt.org, and then copied to: ')
190   - print(' ')
191   - print(f' {certs_dir:<62}')
192   - print(' ')
  144 + print('such as https://letsencrypt.org. ')
193 145 print('For testing purposes a selfsigned certificate can be generated')
194 146 print('locally by running: ')
195 147 print(' ')
196 148 print(' openssl req -x509 -newkey rsa:4096 -keyout privkey.pem \\ ')
197 149 print(' -out cert.pem -days 365 -nodes ')
198 150 print(' ')
  151 + print('Copy the cert.pem and privkey.pem files to: ')
  152 + print(' ')
  153 + print(f' {certs_dir:<62}')
  154 + print(' ')
  155 + print('(See README.md for more information) ')
199 156 print('--------------------------------------------------------------')
200 157 sys.exit(1)
  158 + else:
  159 + logging.info('SSL certificates loaded')
201 160  
202   - # --- create webserver
  161 + # --- start application
203 162 try:
204   - httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_ctx)
205   - except ValueError:
206   - logging.critical('Certificates cert.pem and privkey.pem not found')
  163 + learnapp = LearnApp(arg.conffile, prefix=arg.prefix, db=arg.db,
  164 + check=arg.check)
  165 + except DatabaseUnusableError:
  166 + logging.critical('Failed to start application.')
  167 + print('--------------------------------------------------------------')
  168 + print('Could not find a usable database. Use one of the follwing ')
  169 + print('commands to initialize: ')
  170 + print(' ')
  171 + print(' initdb-aprendizations --admin # add admin ')
  172 + print(' initdb-aprendizations -a 86 "Max Smart" # add student ')
  173 + print(' initdb-aprendizations students.csv # add many students')
  174 + print('--------------------------------------------------------------')
207 175 sys.exit(1)
208   -
209   - try:
210   - httpserver.listen(arg.port)
211   - except OSError:
212   - logging.critical(f'Cannot bind port {arg.port}. Already in use?')
  176 + except Exception:
  177 + logging.critical('Failed to start application.')
213 178 sys.exit(1)
  179 + else:
  180 + logging.info('Backend application started')
214 181  
215   - logging.info(f'Listening on port {arg.port}.')
216   -
217   - # --- run webserver
218   - signal.signal(signal.SIGINT, signal_handler)
219   - logging.info('Webserver running. (Ctrl-C to stop)')
220   -
221   - try:
222   - tornado.ioloop.IOLoop.current().start() # running...
223   - except Exception:
224   - logging.critical('Webserver stopped.')
225   - tornado.ioloop.IOLoop.current().stop()
226   - raise
  182 + # --- run webserver forever
  183 + run_webserver(app=learnapp, ssl=ssl_ctx, port=arg.port, debug=arg.debug)
227 184  
228 185  
229 186 # ----------------------------------------------------------------------------
... ...
aprendizations/questions.py
... ... @@ -461,7 +461,7 @@ class QFactory(object):
461 461 # i.e. a question object (radio, checkbox, ...).
462 462 # -----------------------------------------------------------------------
463 463 def generate(self) -> Question:
464   - logger.debug(f'[QFactory.generate] "{self.question["ref"]}"...')
  464 + logger.debug(f'generating {self.question["ref"]}...')
465 465 # Shallow copy so that script generated questions will not replace
466 466 # the original generators
467 467 q = self.question.copy()
... ... @@ -492,7 +492,7 @@ class QFactory(object):
492 492  
493 493 # -----------------------------------------------------------------------
494 494 async def generate_async(self) -> Question:
495   - logger.debug(f'[QFactory.generate_async] "{self.question["ref"]}"...')
  495 + logger.debug(f'generating {self.question["ref"]}...')
496 496 # Shallow copy so that script generated questions will not replace
497 497 # the original generators
498 498 q = self.question.copy()
... ... @@ -520,5 +520,5 @@ class QFactory(object):
520 520 logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
521 521 raise
522 522 else:
523   - logger.debug(f'[generate_async] Done instance of {q["ref"]}')
  523 + logger.debug('ok')
524 524 return qinstance
... ...
aprendizations/serve.py
... ... @@ -6,6 +6,8 @@ import functools
6 6 import logging.config
7 7 import mimetypes
8 8 from os import path
  9 +import signal
  10 +import sys
9 11 import uuid
10 12  
11 13 # third party libraries
... ... @@ -16,6 +18,9 @@ from tornado.escape import to_unicode
16 18 from .tools import md_to_html
17 19 from . import APP_NAME
18 20  
  21 +# setup logger for this module
  22 +logger = logging.getLogger(__name__)
  23 +
19 24  
20 25 # ----------------------------------------------------------------------------
21 26 # Decorator used to restrict access to the administrator only
... ... @@ -211,11 +216,11 @@ class FileHandler(BaseHandler):
211 216 with open(filepath, 'rb') as f:
212 217 data = f.read()
213 218 except FileNotFoundError:
214   - logging.error(f'File not found: {filepath}')
  219 + logger.error(f'File not found: {filepath}')
215 220 except PermissionError:
216   - logging.error(f'No permission: {filepath}')
  221 + logger.error(f'No permission: {filepath}')
217 222 except Exception:
218   - logging.error(f'Error reading: {filepath}')
  223 + logger.error(f'Error reading: {filepath}')
219 224 raise
220 225 else:
221 226 self.set_header("Content-Type", content_type)
... ... @@ -244,7 +249,7 @@ class QuestionHandler(BaseHandler):
244 249 # --- get question to render
245 250 @tornado.web.authenticated
246 251 def get(self):
247   - logging.debug('[QuestionHandler.get]')
  252 + logger.debug('[QuestionHandler.get]')
248 253 user = self.current_user
249 254 q = self.learn.get_current_question(user)
250 255  
... ... @@ -275,7 +280,7 @@ class QuestionHandler(BaseHandler):
275 280 # --- post answer, returns what to do next: shake, new_question, finished
276 281 @tornado.web.authenticated
277 282 async def post(self) -> None:
278   - logging.debug('[QuestionHandler.post]')
  283 + logger.debug('[QuestionHandler.post]')
279 284 user = self.current_user
280 285 answer = self.get_body_arguments('answer') # list
281 286  
... ... @@ -283,7 +288,7 @@ class QuestionHandler(BaseHandler):
283 288 answer_qid = self.get_body_arguments('qid')[0]
284 289 current_qid = self.learn.get_current_question_id(user)
285 290 if answer_qid != current_qid:
286   - logging.info(f'User {user} desynchronized questions')
  291 + logger.info(f'User {user} desynchronized questions')
287 292 self.write({
288 293 'method': 'invalid',
289 294 'params': {
... ... @@ -299,14 +304,14 @@ class QuestionHandler(BaseHandler):
299 304 # --- answers are in a list. fix depending on question type
300 305 qtype = self.learn.get_student_question_type(user)
301 306 if qtype in ('success', 'information', 'info'):
302   - answer = None
  307 + ans = None
303 308 elif qtype == 'radio' and not answer:
304   - answer = None
  309 + ans = None
305 310 elif qtype != 'checkbox': # radio, text, textarea, ...
306   - answer = answer[0]
  311 + ans = answer[0]
307 312  
308 313 # --- check answer (nonblocking) and get corrected question and action
309   - q, action = await self.learn.check_answer(user, answer)
  314 + q, action = await self.learn.check_answer(user, ans)
310 315  
311 316 # --- built response to return
312 317 response = {'method': action, 'params': {}}
... ... @@ -349,6 +354,59 @@ class QuestionHandler(BaseHandler):
349 354 'tries': q['tries'],
350 355 }
351 356 else:
352   - logging.error(f'Unknown action: {action}')
  357 + logger.error(f'Unknown action: {action}')
353 358  
354 359 self.write(response)
  360 +
  361 +
  362 +# ----------------------------------------------------------------------------
  363 +# Signal handler to catch Ctrl-C and abort server
  364 +# ----------------------------------------------------------------------------
  365 +def signal_handler(signal, frame):
  366 + r = input(' --> Stop webserver? (yes/no) ').lower()
  367 + if r == 'yes':
  368 + tornado.ioloop.IOLoop.current().stop()
  369 + logger.critical('Webserver stopped.')
  370 + sys.exit(0)
  371 + else:
  372 + logger.info('Abort canceled...')
  373 +
  374 +
  375 +# ----------------------------------------------------------------------------
  376 +def run_webserver(app, ssl, port=8443, debug=False):
  377 + # --- create web application
  378 + try:
  379 + webapp = WebApplication(app, debug=debug)
  380 + except Exception:
  381 + logger.critical('Failed to start web application.')
  382 + sys.exit(1)
  383 + else:
  384 + logger.info('Web application started (tornado.web.Application)')
  385 +
  386 + # --- create tornado webserver
  387 + try:
  388 + httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl)
  389 + except ValueError:
  390 + logger.critical('Certificates cert.pem and privkey.pem not found')
  391 + sys.exit(1)
  392 + else:
  393 + logger.debug('HTTP server started')
  394 +
  395 + try:
  396 + httpserver.listen(port)
  397 + except OSError:
  398 + logger.critical(f'Cannot bind port {port}. Already in use?')
  399 + sys.exit(1)
  400 + else:
  401 + logger.info(f'HTTP server listening on port {port}')
  402 +
  403 + # --- run webserver
  404 + signal.signal(signal.SIGINT, signal_handler)
  405 + logger.info('Webserver running. (Ctrl-C to stop)')
  406 +
  407 + try:
  408 + tornado.ioloop.IOLoop.current().start() # running...
  409 + except Exception:
  410 + logger.critical('Webserver stopped.')
  411 + tornado.ioloop.IOLoop.current().stop()
  412 + raise
... ...
aprendizations/student.py
... ... @@ -3,20 +3,26 @@
3 3 import random
4 4 from datetime import datetime
5 5 import logging
  6 +from typing import List, Optional
6 7  
7 8 # third party libraries
8 9 import networkx as nx
9 10  
  11 +# this project
  12 +from .questions import Question
  13 +
  14 +
10 15 # setup logger for this module
11 16 logger = logging.getLogger(__name__)
12 17  
13 18  
14 19 # ----------------------------------------------------------------------------
15   -# kowledge state of each student....??
  20 +# kowledge state of a student
16 21 # Contains:
17   -# state - dict of topics with state of unlocked topics
  22 +# state - dict of unlocked topics and their levels
18 23 # deps - access to dependency graph shared between students
19   -# topic_sequence - list with the order of recommended topics
  24 +# topic_sequence - list with the recommended topic sequence
  25 +# current_topic - nameref of the current topic
20 26 # ----------------------------------------------------------------------------
21 27 class StudentState(object):
22 28 # =======================================================================
... ... @@ -34,13 +40,13 @@ class StudentState(object):
34 40  
35 41 # ------------------------------------------------------------------------
36 42 # Updates the proficiency levels of the topics, with forgetting factor
37   - # FIXME no dependencies are considered yet...
38 43 # ------------------------------------------------------------------------
39 44 def update_topic_levels(self):
40 45 now = datetime.now()
41 46 for tref, s in self.state.items():
42 47 dt = now - s['date']
43   - s['level'] *= 0.98 ** dt.days # forgetting factor
  48 + forgetting_factor = self.deps.node[tref]['forgetting_factor']
  49 + s['level'] *= forgetting_factor ** dt.days # forgetting factor
44 50  
45 51 # ------------------------------------------------------------------------
46 52 # Unlock topics whose dependencies are satisfied (> min_level)
... ... @@ -51,13 +57,13 @@ class StudentState(object):
51 57 pred = self.deps.predecessors(topic)
52 58 min_level = self.deps.node[topic]['min_level']
53 59 if all(d in self.state and self.state[d]['level'] > min_level
54   - for d in pred): # all deps are greater than min_level
  60 + for d in pred): # all deps are greater than min_level
55 61  
56 62 self.state[topic] = {
57 63 'level': 0.0, # unlocked
58 64 'date': datetime.now()
59 65 }
60   - logger.debug(f'[unlock_topics] Unlocked "{topic}".')
  66 + logger.debug(f'unlocked "{topic}"')
61 67 # else: # lock this topic if deps do not satisfy min_level
62 68 # del self.state[topic]
63 69  
... ... @@ -66,16 +72,17 @@ class StudentState(object):
66 72 # questions: list of generated questions to do in the topic
67 73 # current_question: the current question to be presented
68 74 # ------------------------------------------------------------------------
69   - async def start_topic(self, topic):
70   - logger.debug(f'[start_topic] topic "{topic}"')
  75 + async def start_topic(self, topic: str):
  76 + logger.debug(f'starting "{topic}"')
71 77  
  78 + # avoid regenerating questions in the middle of the current topic
72 79 if self.current_topic == topic:
73 80 logger.info('Restarting current topic is not allowed.')
74 81 return
75 82  
76 83 # do not allow locked topics
77 84 if self.is_locked(topic):
78   - logger.debug(f'[start_topic] topic "{topic}" is locked')
  85 + logger.debug(f'is locked "{topic}"')
79 86 return
80 87  
81 88 # starting new topic
... ... @@ -89,18 +96,13 @@ class StudentState(object):
89 96 questions = random.sample(t['questions'], k=k)
90 97 else:
91 98 questions = t['questions'][:k]
92   - logger.debug(f'[start_topic] questions: {", ".join(questions)}')
  99 + logger.debug(f'selected questions: {", ".join(questions)}')
93 100  
94   - # synchronous
95   - # self.questions = [self.factory[ref].generate()
96   - # for ref in questions]
97   -
98   - # asynchronous:
99 101 self.questions = [await self.factory[ref].generate_async()
100 102 for ref in questions]
101 103  
102 104 n = len(self.questions)
103   - logger.debug(f'[start_topic] generated {n} questions')
  105 + logger.debug(f'generated {n} questions')
104 106  
105 107 # get first question
106 108 self.next_question()
... ... @@ -111,14 +113,14 @@ class StudentState(object):
111 113 # The current topic is unchanged.
112 114 # ------------------------------------------------------------------------
113 115 def finish_topic(self):
114   - logger.debug(f'[finish_topic] current_topic {self.current_topic}')
  116 + logger.debug(f'finished {self.current_topic}')
115 117  
116 118 self.state[self.current_topic] = {
117 119 'date': datetime.now(),
118 120 'level': self.correct_answers / (self.correct_answers +
119 121 self.wrong_answers)
120 122 }
121   - # self.current_topic = None
  123 + self.current_topic = None
122 124 self.unlock_topics()
123 125  
124 126 # ------------------------------------------------------------------------
... ... @@ -128,13 +130,12 @@ class StudentState(object):
128 130 # - if wrong, counts number of tries. If exceeded, moves on.
129 131 # ------------------------------------------------------------------------
130 132 async def check_answer(self, answer):
131   - logger.debug('[check_answer]')
132   -
133 133 q = self.current_question
134 134 q['answer'] = answer
135 135 q['finish_time'] = datetime.now()
  136 + logger.debug(f'checking answer of {q["ref"]}...')
136 137 await q.correct_async()
137   - logger.debug(f'[check_answer] Grade {q["grade"]:.2} in {q["ref"]}')
  138 + logger.debug(f'grade = {q["grade"]:.2}')
138 139  
139 140 if q['grade'] > 0.999:
140 141 self.correct_answers += 1
... ... @@ -150,7 +151,7 @@ class StudentState(object):
150 151 else:
151 152 action = 'wrong'
152 153 if self.current_question['append_wrong']:
153   - logger.debug('[check_answer] Wrong, append new instance')
  154 + logger.debug('wrong answer, append new question')
154 155 self.questions.append(self.factory[q['ref']].generate())
155 156 self.next_question()
156 157  
... ... @@ -158,9 +159,9 @@ class StudentState(object):
158 159 return q, action
159 160  
160 161 # ------------------------------------------------------------------------
161   - # Move to next question
  162 + # Move to next question, or None
162 163 # ------------------------------------------------------------------------
163   - def next_question(self):
  164 + def next_question(self) -> Optional[Question]:
164 165 try:
165 166 self.current_question = self.questions.pop(0)
166 167 except IndexError:
... ... @@ -171,7 +172,7 @@ class StudentState(object):
171 172 default_maxtries = self.deps.nodes[self.current_topic]['max_tries']
172 173 maxtries = self.current_question.get('max_tries', default_maxtries)
173 174 self.current_question['tries'] = maxtries
174   - logger.debug(f'[next_question] "{self.current_question["ref"]}"')
  175 + logger.debug(f'current_question = {self.current_question["ref"]}')
175 176  
176 177 return self.current_question # question or None
177 178  
... ... @@ -179,28 +180,35 @@ class StudentState(object):
179 180 # pure functions of the state (no side effects)
180 181 # ========================================================================
181 182  
182   - def topic_has_finished(self):
183   - return self.current_question is None
  183 + def topic_has_finished(self) -> bool:
  184 + return self.current_topic is None
184 185  
185 186 # ------------------------------------------------------------------------
186 187 # compute recommended sequence of topics ['a', 'b', ...]
187 188 # ------------------------------------------------------------------------
188   - def recommend_topic_sequence(self, target=None):
  189 + def recommend_topic_sequence(self, target: str = '') -> List[str]:
189 190 tt = list(nx.topological_sort(self.deps))
  191 + try:
  192 + idx = tt.index(target)
  193 + except ValueError:
  194 + pass
  195 + else:
  196 + del tt[idx:]
  197 +
190 198 unlocked = [t for t in tt if t in self.state]
191 199 locked = [t for t in tt if t not in unlocked]
192 200 return unlocked + locked
193 201  
194 202 # ------------------------------------------------------------------------
195   - def get_current_question(self):
  203 + def get_current_question(self) -> Optional[Question]:
196 204 return self.current_question
197 205  
198 206 # ------------------------------------------------------------------------
199   - def get_current_topic(self):
  207 + def get_current_topic(self) -> Optional[str]:
200 208 return self.current_topic
201 209  
202 210 # ------------------------------------------------------------------------
203   - def is_locked(self, topic):
  211 + def is_locked(self, topic: str) -> bool:
204 212 return topic not in self.state
205 213  
206 214 # ------------------------------------------------------------------------
... ... @@ -217,16 +225,16 @@ class StudentState(object):
217 225 } for ref in self.topic_sequence]
218 226  
219 227 # ------------------------------------------------------------------------
220   - def get_topic_progress(self):
  228 + def get_topic_progress(self) -> float:
221 229 return self.correct_answers / (1 + self.correct_answers +
222 230 len(self.questions))
223 231  
224 232 # ------------------------------------------------------------------------
225   - def get_topic_level(self, topic):
  233 + def get_topic_level(self, topic: str) -> float:
226 234 return self.state[topic]['level']
227 235  
228 236 # ------------------------------------------------------------------------
229   - def get_topic_date(self, topic):
  237 + def get_topic_date(self, topic: str):
230 238 return self.state[topic]['date']
231 239  
232 240 # ------------------------------------------------------------------------
... ...
aprendizations/tools.py
... ... @@ -154,7 +154,7 @@ def load_yaml(filename: str, default: Any = None) -&gt; Any:
154 154 except yaml.YAMLError as e:
155 155 if hasattr(e, 'problem_mark'):
156 156 mark = e.problem_mark
157   - logger.error(f'File "{filename}" near line {mark.line}, '
  157 + logger.error(f'File "{filename}" near line {mark.line+1}, '
158 158 f'column {mark.column+1}')
159 159 else:
160 160 logger.error(f'File "{filename}"')
... ...
config/logger-debug.yaml
... ... @@ -5,8 +5,8 @@ formatters:
5 5 void:
6 6 format: ''
7 7 standard:
8   - format: '%(asctime)s | %(levelname)-9s | %(name)-24s | %(thread)-15d | %(message)s'
9   - datefmt: '%H:%M:%S'
  8 + format: '%(asctime)s | %(thread)-15d | %(levelname)-8s | %(module)-10s | %(funcName)-20s | %(message)s'
  9 + # datefmt: '%H:%M:%S'
10 10  
11 11 handlers:
12 12 default:
... ... @@ -25,7 +25,7 @@ loggers:
25 25 level: 'DEBUG'
26 26 propagate: false
27 27  
28   - 'aprendizations.knowledge':
  28 + 'aprendizations.student':
29 29 handlers: ['default']
30 30 level: 'DEBUG'
31 31 propagate: false
... ... @@ -44,3 +44,8 @@ loggers:
44 44 handlers: ['default']
45 45 level: 'DEBUG'
46 46 propagate: false
  47 +
  48 + 'aprendizations.serve':
  49 + handlers: ['default']
  50 + level: 'DEBUG'
  51 + propagate: false
... ...
config/logger.yaml
... ... @@ -5,7 +5,7 @@ formatters:
5 5 void:
6 6 format: ''
7 7 standard:
8   - format: '%(asctime)s | %(thread)-15d | %(levelname)-9s | %(message)s'
  8 + format: '%(asctime)s | %(levelname)-8s | %(module)-10s | %(message)s'
9 9 datefmt: '%Y-%m-%d %H:%M:%S'
10 10  
11 11 handlers:
... ... @@ -25,7 +25,7 @@ loggers:
25 25 level: 'INFO'
26 26 propagate: false
27 27  
28   - 'aprendizations.knowledge':
  28 + 'aprendizations.student':
29 29 handlers: ['default']
30 30 level: 'INFO'
31 31 propagate: false
... ... @@ -44,3 +44,8 @@ loggers:
44 44 handlers: ['default']
45 45 level: 'INFO'
46 46 propagate: false
  47 +
  48 + 'aprendizations.serve':
  49 + handlers: ['default']
  50 + level: 'INFO'
  51 + propagate: false
... ...
demo/demo.yaml
... ... @@ -3,8 +3,7 @@
3 3 title: Example
4 4 database: students.db
5 5  
6   -
7   -# values applie to each topic, if undefined there
  6 +# values applied to each topic, if undefined there
8 7 file: questions.yaml
9 8 shuffle_questions: true
10 9 choose: 6
... ... @@ -27,3 +26,4 @@ topics:
27 26 name: Sistema solar
28 27 deps:
29 28 - math
  29 + forgetting_factor: 0.1
... ...