Commit d173a54ce9b33e8ff1ddcae4e53f9f64961e37ca
1 parent
f8d971df
Exists in
master
and in
1 other branch
several fixes
Showing
11 changed files
with
232 additions
and
176 deletions
Show diff stats
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) -> 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 | ... | ... |