Commit 45744cc0856d8f38a4debe4c383ed469a2fb15b3
1 parent
33af3401
Exists in
master
and in
1 other branch
- adds docstrings in functions and classes
- fix pylint warnings - fix math not rendering: repaced tex-svg.js -> tex-mml-chtml.js - fix progress bar in topic so that it doesn't scroll with the page.
Showing
6 changed files
with
519 additions
and
425 deletions
Show diff stats
BUGS.md
1 | 1 | ||
2 | # BUGS | 2 | # BUGS |
3 | 3 | ||
4 | +- internal server error 500... experimentar cenario: aluno tem login efectuado, prof muda pw e faz login/logout. aluno obtem erro 500. | ||
5 | +- chapters deviam ser mostrados unlocked, antes de mostrar a medalha. alunos pensam que já terminaram e não conseguem progredir por causa das dependencias. | ||
6 | +- if topic deps on invalid ref terminates server with "Unknown error". | ||
7 | +- warning nos topics que não são usados em nenhum curso | ||
4 | - nao esta a seguir o max_tries definido no ficheiro de dependencias. | 8 | - nao esta a seguir o max_tries definido no ficheiro de dependencias. |
5 | - devia mostrar timeout para o aluno saber a razao. | 9 | - devia mostrar timeout para o aluno saber a razao. |
6 | - permitir configuracao para escolher entre static files locais ou remotos | 10 | - permitir configuracao para escolher entre static files locais ou remotos |
README.md
@@ -141,7 +141,7 @@ sudo pkg install py36-certbot # FreeBSD | @@ -141,7 +141,7 @@ sudo pkg install py36-certbot # FreeBSD | ||
141 | sudo apt install certbot # Ubuntu | 141 | sudo apt install certbot # Ubuntu |
142 | ``` | 142 | ``` |
143 | 143 | ||
144 | -To generate or renew the certificates, ports 80 and 443 have to be accessible. The firewall and webserver have to be stopped. | 144 | +To generate or renew the certificates, ports 80 and 443 have to be accessible. **The firewall and webserver have to be stopped**. |
145 | 145 | ||
146 | ```sh | 146 | ```sh |
147 | sudo certbot certonly --standalone -d www.example.com # first time | 147 | sudo certbot certonly --standalone -d www.example.com # first time |
@@ -151,6 +151,7 @@ sudo certbot renew # renew | @@ -151,6 +151,7 @@ sudo certbot renew # renew | ||
151 | Certificates are saved under `/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to `~/.local/share/certs` and change permissions to be readable: | 151 | Certificates are saved under `/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to `~/.local/share/certs` and change permissions to be readable: |
152 | 152 | ||
153 | ```sh | 153 | ```sh |
154 | +cd ~/.local/share/certs | ||
154 | sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem . | 155 | sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem . |
155 | sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem . | 156 | sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem . |
156 | chmod 400 cert.pem privkey.pem | 157 | chmod 400 cert.pem privkey.pem |
aprendizations/learnapp.py
1 | +''' | ||
2 | +Learn application. | ||
3 | +This is the main controller of the application. | ||
4 | +''' | ||
1 | 5 | ||
2 | # python standard library | 6 | # python standard library |
3 | import asyncio | 7 | import asyncio |
@@ -6,7 +10,7 @@ from contextlib import contextmanager # `with` statement in db sessions | @@ -6,7 +10,7 @@ from contextlib import contextmanager # `with` statement in db sessions | ||
6 | from datetime import datetime | 10 | from datetime import datetime |
7 | import logging | 11 | import logging |
8 | from random import random | 12 | from random import random |
9 | -from os import path | 13 | +from os.path import join, exists |
10 | from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict | 14 | from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict |
11 | 15 | ||
12 | # third party libraries | 16 | # third party libraries |
@@ -15,10 +19,10 @@ import networkx as nx | @@ -15,10 +19,10 @@ import networkx as nx | ||
15 | import sqlalchemy as sa | 19 | import sqlalchemy as sa |
16 | 20 | ||
17 | # this project | 21 | # this project |
18 | -from .models import Student, Answer, Topic, StudentTopic | ||
19 | -from .questions import Question, QFactory, QDict, QuestionException | ||
20 | -from .student import StudentState | ||
21 | -from .tools import load_yaml | 22 | +from aprendizations.models import Student, Answer, Topic, StudentTopic |
23 | +from aprendizations.questions import Question, QFactory, QDict, QuestionException | ||
24 | +from aprendizations.student import StudentState | ||
25 | +from aprendizations.tools import load_yaml | ||
22 | 26 | ||
23 | 27 | ||
24 | # setup logger for this module | 28 | # setup logger for this module |
@@ -27,33 +31,37 @@ logger = logging.getLogger(__name__) | @@ -27,33 +31,37 @@ logger = logging.getLogger(__name__) | ||
27 | 31 | ||
28 | # ============================================================================ | 32 | # ============================================================================ |
29 | class LearnException(Exception): | 33 | class LearnException(Exception): |
30 | - pass | 34 | + '''Exceptions raised from the LearnApp class''' |
31 | 35 | ||
32 | 36 | ||
33 | class DatabaseUnusableError(LearnException): | 37 | class DatabaseUnusableError(LearnException): |
34 | - pass | 38 | + '''Exception raised if the database fails in the initialization''' |
35 | 39 | ||
36 | 40 | ||
37 | # ============================================================================ | 41 | # ============================================================================ |
38 | -# LearnApp - application logic | ||
39 | -# | ||
40 | -# self.deps - networkx topic dependencies | ||
41 | -# self.courses - dict {course_id: {'title': ..., | ||
42 | -# 'description': ..., | ||
43 | -# 'goals': ...,}, ...} | ||
44 | -# self.factory = dict {qref: QFactory()} | ||
45 | -# self.online - dict {student_id: {'number': ..., | ||
46 | -# 'name': ..., | ||
47 | -# 'state': StudentState(), | ||
48 | -# 'counter': ...}, ...} | ||
49 | -# ============================================================================ | ||
50 | -class LearnApp(object): | ||
51 | - # ------------------------------------------------------------------------ | ||
52 | - # helper to manage db sessions using the `with` statement, for example | ||
53 | - # with self.db_session() as s: s.query(...) | 42 | +class LearnApp(): |
43 | + ''' | ||
44 | + LearnApp - application logic | ||
45 | + | ||
46 | + self.deps - networkx topic dependencies | ||
47 | + self.courses - dict {course_id: {'title': ..., | ||
48 | + 'description': ..., | ||
49 | + 'goals': ...,}, ...} | ||
50 | + self.factory = dict {qref: QFactory()} | ||
51 | + self.online - dict {student_id: {'number': ..., | ||
52 | + 'name': ..., | ||
53 | + 'state': StudentState(), | ||
54 | + 'counter': ...}, ...} | ||
55 | + ''' | ||
56 | + | ||
57 | + | ||
54 | # ------------------------------------------------------------------------ | 58 | # ------------------------------------------------------------------------ |
55 | @contextmanager | 59 | @contextmanager |
56 | - def db_session(self, **kw): | 60 | + def _db_session(self, **kw): |
61 | + ''' | ||
62 | + helper to manage db sessions using the `with` statement, for example | ||
63 | + with self._db_session() as s: s.query(...) | ||
64 | + ''' | ||
57 | session = self.Session(**kw) | 65 | session = self.Session(**kw) |
58 | try: | 66 | try: |
59 | yield session | 67 | yield session |
@@ -66,15 +74,13 @@ class LearnApp(object): | @@ -66,15 +74,13 @@ class LearnApp(object): | ||
66 | session.close() | 74 | session.close() |
67 | 75 | ||
68 | # ------------------------------------------------------------------------ | 76 | # ------------------------------------------------------------------------ |
69 | - # init | ||
70 | - # ------------------------------------------------------------------------ | ||
71 | def __init__(self, | 77 | def __init__(self, |
72 | courses: str, # filename with course configurations | 78 | courses: str, # filename with course configurations |
73 | prefix: str, # path to topics | 79 | prefix: str, # path to topics |
74 | db: str, # database filename | 80 | db: str, # database filename |
75 | check: bool = False) -> None: | 81 | check: bool = False) -> None: |
76 | 82 | ||
77 | - self.db_setup(db) # setup database and check students | 83 | + self._db_setup(db) # setup database and check students |
78 | self.online: Dict[str, Dict] = dict() # online students | 84 | self.online: Dict[str, Dict] = dict() # online students |
79 | 85 | ||
80 | try: | 86 | try: |
@@ -88,123 +94,130 @@ class LearnApp(object): | @@ -88,123 +94,130 @@ class LearnApp(object): | ||
88 | self.deps = nx.DiGraph(prefix=prefix) | 94 | self.deps = nx.DiGraph(prefix=prefix) |
89 | logger.info('Populating topic graph:') | 95 | logger.info('Populating topic graph:') |
90 | 96 | ||
91 | - t = config.get('topics', {}) # topics defined directly in courses file | ||
92 | - self.populate_graph(t) | ||
93 | - logger.info(f'{len(t):>6} topics in {courses}') | ||
94 | - for f in config.get('topics_from', []): | ||
95 | - c = load_yaml(f) # course configuration | 97 | + # topics defined directly in the courses file, usually empty |
98 | + base_topics = config.get('topics', {}) | ||
99 | + self._populate_graph(base_topics) | ||
100 | + logger.info('%6d topics in %s', len(base_topics), courses) | ||
101 | + | ||
102 | + # load other course files with the topics the their deps | ||
103 | + for course_file in config.get('topics_from', []): | ||
104 | + course_conf = load_yaml(course_file) # course configuration | ||
96 | # FIXME set defaults?? | 105 | # FIXME set defaults?? |
97 | - logger.info(f'{len(c["topics"]):>6} topics imported from {f}') | ||
98 | - self.populate_graph(c) | ||
99 | - logger.info(f'Graph has {len(self.deps)} topics') | 106 | + logger.info('%6d topics imported from %s', |
107 | + len(course_conf["topics"]), course_file) | ||
108 | + self._populate_graph(course_conf) | ||
109 | + logger.info('Graph has %d topics', len(self.deps)) | ||
100 | 110 | ||
101 | # --- courses dict | 111 | # --- courses dict |
102 | self.courses = config['courses'] | 112 | self.courses = config['courses'] |
103 | - logger.info(f'Courses: {", ".join(self.courses.keys())}') | ||
104 | - for c, d in self.courses.items(): | ||
105 | - d.setdefault('title', '') # course title undefined | ||
106 | - for goal in d['goals']: | 113 | + logger.info('Courses: %s', ', '.join(self.courses.keys())) |
114 | + for cid, course in self.courses.items(): | ||
115 | + course.setdefault('title', '') # course title undefined | ||
116 | + for goal in course['goals']: | ||
107 | if goal not in self.deps.nodes(): | 117 | if goal not in self.deps.nodes(): |
108 | - msg = f'Goal "{goal}" from course "{c}" does not exist' | 118 | + msg = f'Goal "{goal}" from course "{cid}" does not exist' |
109 | logger.error(msg) | 119 | logger.error(msg) |
110 | raise LearnException(msg) | 120 | raise LearnException(msg) |
111 | - elif self.deps.nodes[goal]['type'] == 'chapter': | ||
112 | - d['goals'] += [g for g in self.deps.predecessors(goal) | ||
113 | - if g not in d['goals']] | 121 | + if self.deps.nodes[goal]['type'] == 'chapter': |
122 | + course['goals'] += [g for g in self.deps.predecessors(goal) | ||
123 | + if g not in course['goals']] | ||
114 | 124 | ||
115 | # --- factory is a dict with question generators for all topics | 125 | # --- factory is a dict with question generators for all topics |
116 | - self.factory: Dict[str, QFactory] = self.make_factory() | 126 | + self.factory: Dict[str, QFactory] = self._make_factory() |
117 | 127 | ||
118 | # if graph has topics that are not in the database, add them | 128 | # if graph has topics that are not in the database, add them |
119 | - self.add_missing_topics(self.deps.nodes()) | 129 | + self._add_missing_topics(self.deps.nodes()) |
120 | 130 | ||
121 | if check: | 131 | if check: |
122 | - self.sanity_check_questions() | 132 | + self._sanity_check_questions() |
123 | 133 | ||
124 | # ------------------------------------------------------------------------ | 134 | # ------------------------------------------------------------------------ |
125 | - def sanity_check_questions(self) -> None: | 135 | + def _sanity_check_questions(self) -> None: |
136 | + ''' | ||
137 | + Unity tests for all questions | ||
138 | + | ||
139 | + Generates all questions, give right and wrong answers and corrects. | ||
140 | + ''' | ||
126 | logger.info('Starting sanity checks (may take a while...)') | 141 | logger.info('Starting sanity checks (may take a while...)') |
127 | 142 | ||
128 | errors: int = 0 | 143 | errors: int = 0 |
129 | for qref in self.factory: | 144 | for qref in self.factory: |
130 | - logger.debug(f'checking {qref}...') | 145 | + logger.debug('checking %s...', qref) |
131 | try: | 146 | try: |
132 | - q = self.factory[qref].generate() | ||
133 | - except QuestionException as e: | ||
134 | - logger.error(e) | 147 | + question = self.factory[qref].generate() |
148 | + except QuestionException as exc: | ||
149 | + logger.error(exc) | ||
135 | errors += 1 | 150 | errors += 1 |
136 | continue # to next question | 151 | continue # to next question |
137 | 152 | ||
138 | - if 'tests_right' in q: | ||
139 | - for t in q['tests_right']: | ||
140 | - q['answer'] = t | ||
141 | - q.correct() | ||
142 | - if q['grade'] < 1.0: | ||
143 | - logger.error(f'Failed right answer in "{qref}".') | 153 | + if 'tests_right' in question: |
154 | + for right_answer in question['tests_right']: | ||
155 | + question['answer'] = right_answer | ||
156 | + question.correct() | ||
157 | + if question['grade'] < 1.0: | ||
158 | + logger.error('Failed right answer in "%s".', qref) | ||
144 | errors += 1 | 159 | errors += 1 |
145 | continue # to next test | 160 | continue # to next test |
146 | - elif q['type'] == 'textarea': | ||
147 | - msg = f' consider adding tests to {q["ref"]}' | 161 | + elif question['type'] == 'textarea': |
162 | + msg = f'- consider adding tests to {question["ref"]}' | ||
148 | logger.warning(msg) | 163 | logger.warning(msg) |
149 | 164 | ||
150 | - if 'tests_wrong' in q: | ||
151 | - for t in q['tests_wrong']: | ||
152 | - q['answer'] = t | ||
153 | - q.correct() | ||
154 | - if q['grade'] >= 1.0: | ||
155 | - logger.error(f'Failed wrong answer in "{qref}".') | 165 | + if 'tests_wrong' in question: |
166 | + for wrong_answer in question['tests_wrong']: | ||
167 | + question['answer'] = wrong_answer | ||
168 | + question.correct() | ||
169 | + if question['grade'] >= 1.0: | ||
170 | + logger.error('Failed wrong answer in "%s".', qref) | ||
156 | errors += 1 | 171 | errors += 1 |
157 | continue # to next test | 172 | continue # to next test |
158 | 173 | ||
159 | if errors > 0: | 174 | if errors > 0: |
160 | - logger.error(f'{errors:>6} error(s) found.') | 175 | + logger.error('%6d error(s) found.', errors) # {errors:>6} |
161 | raise LearnException('Sanity checks') | 176 | raise LearnException('Sanity checks') |
162 | - else: | ||
163 | - logger.info(' 0 errors found.') | 177 | + logger.info(' 0 errors found.') |
164 | 178 | ||
165 | # ------------------------------------------------------------------------ | 179 | # ------------------------------------------------------------------------ |
166 | - # login | ||
167 | - # ------------------------------------------------------------------------ | ||
168 | - async def login(self, uid: str, pw: str) -> bool: | 180 | + async def login(self, uid: str, password: str) -> bool: |
181 | + '''user login''' | ||
169 | 182 | ||
170 | - with self.db_session() as s: | ||
171 | - found = s.query(Student.name, Student.password) \ | ||
172 | - .filter_by(id=uid) \ | ||
173 | - .one_or_none() | 183 | + with self._db_session() as sess: |
184 | + found = sess.query(Student.name, Student.password) \ | ||
185 | + .filter_by(id=uid) \ | ||
186 | + .one_or_none() | ||
174 | 187 | ||
175 | # wait random time to minimize timing attacks | 188 | # wait random time to minimize timing attacks |
176 | await asyncio.sleep(random()) | 189 | await asyncio.sleep(random()) |
177 | 190 | ||
178 | loop = asyncio.get_running_loop() | 191 | loop = asyncio.get_running_loop() |
179 | if found is None: | 192 | if found is None: |
180 | - logger.info(f'User "{uid}" does not exist') | 193 | + logger.info('User "%s" does not exist', uid) |
181 | await loop.run_in_executor(None, bcrypt.hashpw, b'', | 194 | await loop.run_in_executor(None, bcrypt.hashpw, b'', |
182 | bcrypt.gensalt()) # just spend time | 195 | bcrypt.gensalt()) # just spend time |
183 | return False | 196 | return False |
184 | 197 | ||
185 | - else: | ||
186 | - name, hashed_pw = found | ||
187 | - pw_ok: bool = await loop.run_in_executor(None, | ||
188 | - bcrypt.checkpw, | ||
189 | - pw.encode('utf-8'), | ||
190 | - hashed_pw) | 198 | + name, hashed_pw = found |
199 | + pw_ok: bool = await loop.run_in_executor(None, | ||
200 | + bcrypt.checkpw, | ||
201 | + password.encode('utf-8'), | ||
202 | + hashed_pw) | ||
191 | 203 | ||
192 | if pw_ok: | 204 | if pw_ok: |
193 | if uid in self.online: | 205 | if uid in self.online: |
194 | - logger.warning(f'User "{uid}" already logged in') | 206 | + logger.warning('User "%s" already logged in', uid) |
195 | counter = self.online[uid]['counter'] | 207 | counter = self.online[uid]['counter'] |
196 | else: | 208 | else: |
197 | - logger.info(f'User "{uid}" logged in') | 209 | + logger.info('User "%s" logged in', uid) |
198 | counter = 0 | 210 | counter = 0 |
199 | 211 | ||
200 | # get topics of this student and set its current state | 212 | # get topics of this student and set its current state |
201 | - with self.db_session() as s: | ||
202 | - tt = s.query(StudentTopic).filter_by(student_id=uid) | 213 | + with self._db_session() as sess: |
214 | + student_topics = sess.query(StudentTopic) \ | ||
215 | + .filter_by(student_id=uid) | ||
203 | 216 | ||
204 | state = {t.topic_id: { | 217 | state = {t.topic_id: { |
205 | 'level': t.level, | 218 | 'level': t.level, |
206 | 'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f") | 219 | 'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f") |
207 | - } for t in tt} | 220 | + } for t in student_topics} |
208 | 221 | ||
209 | self.online[uid] = { | 222 | self.online[uid] = { |
210 | 'number': uid, | 223 | 'number': uid, |
@@ -216,179 +229,192 @@ class LearnApp(object): | @@ -216,179 +229,192 @@ class LearnApp(object): | ||
216 | } | 229 | } |
217 | 230 | ||
218 | else: | 231 | else: |
219 | - logger.info(f'User "{uid}" wrong password') | 232 | + logger.info('User "%s" wrong password', uid) |
220 | 233 | ||
221 | return pw_ok | 234 | return pw_ok |
222 | 235 | ||
223 | # ------------------------------------------------------------------------ | 236 | # ------------------------------------------------------------------------ |
224 | - # logout | ||
225 | - # ------------------------------------------------------------------------ | ||
226 | def logout(self, uid: str) -> None: | 237 | def logout(self, uid: str) -> None: |
238 | + '''User logout''' | ||
227 | del self.online[uid] | 239 | del self.online[uid] |
228 | - logger.info(f'User "{uid}" logged out') | 240 | + logger.info('User "%s" logged out', uid) |
229 | 241 | ||
230 | # ------------------------------------------------------------------------ | 242 | # ------------------------------------------------------------------------ |
231 | - # change_password. returns True if password is successfully changed. | ||
232 | - # ------------------------------------------------------------------------ | ||
233 | - async def change_password(self, uid: str, pw: str) -> bool: | ||
234 | - if not pw: | 243 | + async def change_password(self, uid: str, password: str) -> bool: |
244 | + ''' | ||
245 | + Change user Password. | ||
246 | + Returns True if password is successfully changed | ||
247 | + ''' | ||
248 | + if not password: | ||
235 | return False | 249 | return False |
236 | 250 | ||
237 | loop = asyncio.get_running_loop() | 251 | loop = asyncio.get_running_loop() |
238 | - pw = await loop.run_in_executor(None, bcrypt.hashpw, | ||
239 | - pw.encode('utf-8'), bcrypt.gensalt()) | 252 | + password = await loop.run_in_executor(None, |
253 | + bcrypt.hashpw, | ||
254 | + password.encode('utf-8'), | ||
255 | + bcrypt.gensalt()) | ||
240 | 256 | ||
241 | - with self.db_session() as s: | ||
242 | - u = s.query(Student).get(uid) | ||
243 | - u.password = pw | 257 | + with self._db_session() as sess: |
258 | + user = sess.query(Student).get(uid) | ||
259 | + user.password = password | ||
244 | 260 | ||
245 | - logger.info(f'User "{uid}" changed password') | 261 | + logger.info('User "%s" changed password', uid) |
246 | return True | 262 | return True |
247 | 263 | ||
248 | # ------------------------------------------------------------------------ | 264 | # ------------------------------------------------------------------------ |
249 | - # Checks answer and update database. Returns corrected question. | ||
250 | - # ------------------------------------------------------------------------ | ||
251 | async def check_answer(self, uid: str, answer) -> Question: | 265 | async def check_answer(self, uid: str, answer) -> Question: |
266 | + ''' | ||
267 | + Checks answer and update database. | ||
268 | + Returns corrected question. | ||
269 | + ''' | ||
252 | student = self.online[uid]['state'] | 270 | student = self.online[uid]['state'] |
253 | await student.check_answer(answer) | 271 | await student.check_answer(answer) |
254 | - q: Question = student.get_current_question() | ||
255 | 272 | ||
256 | - logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"') | 273 | + topic_id = student.get_current_topic() |
274 | + question: Question = student.get_current_question() | ||
275 | + grade = question["grade"] | ||
276 | + ref = question["ref"] | ||
277 | + | ||
278 | + logger.info('User "%s" got %.2f in "%s"', uid, grade, ref) | ||
257 | 279 | ||
258 | # always save grade of answered question | 280 | # always save grade of answered question |
259 | - with self.db_session() as s: | ||
260 | - s.add(Answer( | ||
261 | - ref=q['ref'], | ||
262 | - grade=q['grade'], | ||
263 | - starttime=str(q['start_time']), | ||
264 | - finishtime=str(q['finish_time']), | ||
265 | - student_id=uid, | ||
266 | - topic_id=student.get_current_topic())) | 281 | + with self._db_session() as sess: |
282 | + sess.add(Answer(ref=ref, | ||
283 | + grade=grade, | ||
284 | + starttime=str(question['start_time']), | ||
285 | + finishtime=str(question['finish_time']), | ||
286 | + student_id=uid, | ||
287 | + topic_id=topic_id)) | ||
267 | 288 | ||
268 | - return q | 289 | + return question |
269 | 290 | ||
270 | # ------------------------------------------------------------------------ | 291 | # ------------------------------------------------------------------------ |
271 | - # get the question to show (current or new one) | ||
272 | - # if no more questions, save/update level in database | ||
273 | - # ------------------------------------------------------------------------ | ||
274 | async def get_question(self, uid: str) -> Optional[Question]: | 292 | async def get_question(self, uid: str) -> Optional[Question]: |
293 | + ''' | ||
294 | + Get the question to show (current or new one) | ||
295 | + If no more questions, save/update level in database | ||
296 | + ''' | ||
275 | student = self.online[uid]['state'] | 297 | student = self.online[uid]['state'] |
276 | - q: Optional[Question] = await student.get_question() | 298 | + question: Optional[Question] = await student.get_question() |
277 | 299 | ||
278 | # save topic to database if finished | 300 | # save topic to database if finished |
279 | if student.topic_has_finished(): | 301 | if student.topic_has_finished(): |
280 | topic: str = student.get_previous_topic() | 302 | topic: str = student.get_previous_topic() |
281 | level: float = student.get_topic_level(topic) | 303 | level: float = student.get_topic_level(topic) |
282 | date: str = str(student.get_topic_date(topic)) | 304 | date: str = str(student.get_topic_date(topic)) |
283 | - logger.info(f'User "{uid}" finished "{topic}" (level={level:.2})') | 305 | + logger.info('User "%s" finished "%s" (level=%.2f)', |
306 | + uid, topic, level) | ||
307 | + | ||
308 | + with self._db_session() as sess: | ||
309 | + student_topic = sess.query(StudentTopic) \ | ||
310 | + .filter_by(student_id=uid, topic_id=topic)\ | ||
311 | + .one_or_none() | ||
284 | 312 | ||
285 | - with self.db_session() as s: | ||
286 | - a = s.query(StudentTopic) \ | ||
287 | - .filter_by(student_id=uid, topic_id=topic) \ | ||
288 | - .one_or_none() | ||
289 | - if a is None: | 313 | + if student_topic is None: |
290 | # insert new studenttopic into database | 314 | # insert new studenttopic into database |
291 | logger.debug('db insert studenttopic') | 315 | logger.debug('db insert studenttopic') |
292 | - t = s.query(Topic).get(topic) | ||
293 | - u = s.query(Student).get(uid) | 316 | + tid = sess.query(Topic).get(topic) |
317 | + uid = sess.query(Student).get(uid) | ||
294 | # association object | 318 | # association object |
295 | - a = StudentTopic(level=level, date=date, topic=t, | ||
296 | - student=u) | ||
297 | - u.topics.append(a) | 319 | + student_topic = StudentTopic(level=level, date=date, |
320 | + topic=tid, student=uid) | ||
321 | + uid.topics.append(student_topic) | ||
298 | else: | 322 | else: |
299 | # update studenttopic in database | 323 | # update studenttopic in database |
300 | - logger.debug(f'db update studenttopic to level {level}') | ||
301 | - a.level = level | ||
302 | - a.date = date | 324 | + logger.debug('db update studenttopic to level %f', level) |
325 | + student_topic.level = level | ||
326 | + student_topic.date = date | ||
303 | 327 | ||
304 | - s.add(a) | 328 | + sess.add(student_topic) |
305 | 329 | ||
306 | - return q | 330 | + return question |
307 | 331 | ||
308 | # ------------------------------------------------------------------------ | 332 | # ------------------------------------------------------------------------ |
309 | - # Start course | ||
310 | - # ------------------------------------------------------------------------ | ||
311 | def start_course(self, uid: str, course_id: str) -> None: | 333 | def start_course(self, uid: str, course_id: str) -> None: |
334 | + '''Start course''' | ||
335 | + | ||
312 | student = self.online[uid]['state'] | 336 | student = self.online[uid]['state'] |
313 | try: | 337 | try: |
314 | student.start_course(course_id) | 338 | student.start_course(course_id) |
315 | except Exception: | 339 | except Exception: |
316 | - logger.warning(f'"{uid}" could not start course "{course_id}"') | ||
317 | - raise | 340 | + logger.warning('"%s" could not start course "%s"', uid, course_id) |
341 | + raise LearnException() | ||
318 | else: | 342 | else: |
319 | - logger.info(f'User "{uid}" started course "{course_id}"') | 343 | + logger.info('User "%s" started course "%s"', uid, course_id) |
320 | 344 | ||
321 | # ------------------------------------------------------------------------ | 345 | # ------------------------------------------------------------------------ |
322 | - # Start new topic | 346 | + # |
323 | # ------------------------------------------------------------------------ | 347 | # ------------------------------------------------------------------------ |
324 | async def start_topic(self, uid: str, topic: str) -> None: | 348 | async def start_topic(self, uid: str, topic: str) -> None: |
349 | + '''Start new topic''' | ||
350 | + | ||
325 | student = self.online[uid]['state'] | 351 | student = self.online[uid]['state'] |
326 | - if uid == '0': | ||
327 | - logger.warning(f'Reloading "{topic}"') # FIXME should be an option | ||
328 | - self.factory.update(self.factory_for(topic)) | 352 | + # if uid == '0': |
353 | + # logger.warning('Reloading "%s"', topic) # FIXME should be an option | ||
354 | + # self.factory.update(self._factory_for(topic)) | ||
329 | 355 | ||
330 | try: | 356 | try: |
331 | await student.start_topic(topic) | 357 | await student.start_topic(topic) |
332 | - except Exception as e: | ||
333 | - logger.warning(f'User "{uid}" could not start "{topic}": {e}') | 358 | + except Exception as exc: |
359 | + logger.warning('User "%s" could not start "%s": %s', | ||
360 | + uid, topic, str(exc)) | ||
334 | else: | 361 | else: |
335 | - logger.info(f'User "{uid}" started topic "{topic}"') | 362 | + logger.info('User "%s" started topic "%s"', uid, topic) |
336 | 363 | ||
337 | # ------------------------------------------------------------------------ | 364 | # ------------------------------------------------------------------------ |
338 | - # Fill db table 'Topic' with topics from the graph if not already there. | 365 | + # |
339 | # ------------------------------------------------------------------------ | 366 | # ------------------------------------------------------------------------ |
340 | - def add_missing_topics(self, topics: List[str]) -> None: | ||
341 | - with self.db_session() as s: | ||
342 | - new_topics = [Topic(id=t) for t in topics | ||
343 | - if (t,) not in s.query(Topic.id)] | 367 | + def _add_missing_topics(self, topics: List[str]) -> None: |
368 | + ''' | ||
369 | + Fill db table 'Topic' with topics from the graph, if new | ||
370 | + ''' | ||
371 | + with self._db_session() as sess: | ||
372 | + new = [Topic(id=t) for t in topics | ||
373 | + if (t,) not in sess.query(Topic.id)] | ||
344 | 374 | ||
345 | - if new_topics: | ||
346 | - s.add_all(new_topics) | ||
347 | - logger.info(f'Added {len(new_topics)} new topic(s) to the ' | ||
348 | - f'database') | 375 | + if new: |
376 | + sess.add_all(new) | ||
377 | + logger.info('Added %d new topic(s) to the database', len(new)) | ||
349 | 378 | ||
350 | # ------------------------------------------------------------------------ | 379 | # ------------------------------------------------------------------------ |
351 | - # setup and check database contents | ||
352 | - # ------------------------------------------------------------------------ | ||
353 | - def db_setup(self, db: str) -> None: | 380 | + def _db_setup(self, database: str) -> None: |
381 | + '''setup and check database contents''' | ||
354 | 382 | ||
355 | - logger.info(f'Checking database "{db}":') | ||
356 | - if not path.exists(db): | 383 | + logger.info('Checking database "%s":', database) |
384 | + if not exists(database): | ||
357 | raise LearnException('Database does not exist. ' | 385 | raise LearnException('Database does not exist. ' |
358 | 'Use "initdb-aprendizations" to create') | 386 | 'Use "initdb-aprendizations" to create') |
359 | 387 | ||
360 | - engine = sa.create_engine(f'sqlite:///{db}', echo=False) | 388 | + engine = sa.create_engine(f'sqlite:///{database}', echo=False) |
361 | self.Session = sa.orm.sessionmaker(bind=engine) | 389 | self.Session = sa.orm.sessionmaker(bind=engine) |
362 | try: | 390 | try: |
363 | - with self.db_session() as s: | ||
364 | - n: int = s.query(Student).count() | ||
365 | - m: int = s.query(Topic).count() | ||
366 | - q: int = s.query(Answer).count() | 391 | + with self._db_session() as sess: |
392 | + count_students: int = sess.query(Student).count() | ||
393 | + count_topics: int = sess.query(Topic).count() | ||
394 | + count_answers: int = sess.query(Answer).count() | ||
367 | except Exception: | 395 | except Exception: |
368 | - logger.error(f'Database "{db}" not usable!') | 396 | + logger.error('Database "%s" not usable!', database) |
369 | raise DatabaseUnusableError() | 397 | raise DatabaseUnusableError() |
370 | else: | 398 | else: |
371 | - logger.info(f'{n:6} students') | ||
372 | - logger.info(f'{m:6} topics') | ||
373 | - logger.info(f'{q:6} answers') | 399 | + logger.info('%6d students', count_students) |
400 | + logger.info('%6d topics', count_topics) | ||
401 | + logger.info('%6d answers', count_answers) | ||
374 | 402 | ||
375 | - # ======================================================================== | ||
376 | - # Populates a digraph. | ||
377 | - # | ||
378 | - # Nodes are the topic references e.g. 'my/topic' | ||
379 | - # g.nodes['my/topic']['name'] name of the topic | ||
380 | - # g.nodes['my/topic']['questions'] list of question refs | ||
381 | - # | ||
382 | - # Edges are obtained from the deps defined in the YAML file for each topic. | ||
383 | # ------------------------------------------------------------------------ | 403 | # ------------------------------------------------------------------------ |
384 | - def populate_graph(self, config: Dict[str, Any]) -> None: | ||
385 | - g = self.deps # dependency graph | 404 | + def _populate_graph(self, config: Dict[str, Any]) -> None: |
405 | + ''' | ||
406 | + Populates a digraph. | ||
407 | + | ||
408 | + Nodes are the topic references e.g. 'my/topic' | ||
409 | + g.nodes['my/topic']['name'] name of the topic | ||
410 | + g.nodes['my/topic']['questions'] list of question refs | ||
411 | + | ||
412 | + Edges are obtained from the deps defined in the YAML file for each topic. | ||
413 | + ''' | ||
414 | + | ||
386 | defaults = { | 415 | defaults = { |
387 | 'type': 'topic', # chapter | 416 | 'type': 'topic', # chapter |
388 | - # 'file': 'questions.yaml', # deprecated | ||
389 | - 'learn_file': 'learn.yaml', | ||
390 | - 'practice_file': 'questions.yaml', | ||
391 | - | 417 | + 'file': 'questions.yaml', |
392 | 'shuffle_questions': True, | 418 | 'shuffle_questions': True, |
393 | 'choose': 9999, | 419 | 'choose': 9999, |
394 | 'forgetting_factor': 1.0, # no forgetting | 420 | 'forgetting_factor': 1.0, # no forgetting |
@@ -400,20 +426,21 @@ class LearnApp(object): | @@ -400,20 +426,21 @@ class LearnApp(object): | ||
400 | 426 | ||
401 | # iterate over topics and populate graph | 427 | # iterate over topics and populate graph |
402 | topics: Dict[str, Dict] = config.get('topics', {}) | 428 | topics: Dict[str, Dict] = config.get('topics', {}) |
403 | - g.add_nodes_from(topics.keys()) | 429 | + self.deps.add_nodes_from(topics.keys()) |
404 | for tref, attr in topics.items(): | 430 | for tref, attr in topics.items(): |
405 | - logger.debug(f' + {tref}') | ||
406 | - for d in attr.get('deps', []): | ||
407 | - g.add_edge(d, tref) | 431 | + logger.debug(' + %s', tref) |
432 | + for dep in attr.get('deps', []): | ||
433 | + self.deps.add_edge(dep, tref) | ||
408 | 434 | ||
409 | - t = g.nodes[tref] # get current topic node | ||
410 | - t['name'] = attr.get('name', tref) | ||
411 | - t['questions'] = attr.get('questions', []) # FIXME unused?? | 435 | + topic = self.deps.nodes[tref] # get current topic node |
436 | + topic['name'] = attr.get('name', tref) | ||
437 | + topic['questions'] = attr.get('questions', []) # FIXME unused?? | ||
412 | 438 | ||
413 | for k, default in defaults.items(): | 439 | for k, default in defaults.items(): |
414 | - t[k] = attr.get(k, default) | 440 | + topic[k] = attr.get(k, default) |
415 | 441 | ||
416 | - t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic | 442 | + # prefix/topic |
443 | + topic['path'] = join(self.deps.graph['prefix'], tref) | ||
417 | 444 | ||
418 | 445 | ||
419 | # ======================================================================== | 446 | # ======================================================================== |
@@ -421,47 +448,46 @@ class LearnApp(object): | @@ -421,47 +448,46 @@ class LearnApp(object): | ||
421 | # ======================================================================== | 448 | # ======================================================================== |
422 | 449 | ||
423 | # ------------------------------------------------------------------------ | 450 | # ------------------------------------------------------------------------ |
424 | - # Buils dictionary of question factories | ||
425 | - # - visits each topic in the graph, | ||
426 | - # - adds factory for each topic. | ||
427 | - # ------------------------------------------------------------------------ | ||
428 | - def make_factory(self) -> Dict[str, QFactory]: | 451 | + def _make_factory(self) -> Dict[str, QFactory]: |
452 | + ''' | ||
453 | + Buils dictionary of question factories | ||
454 | + - visits each topic in the graph, | ||
455 | + - adds factory for each topic. | ||
456 | + ''' | ||
429 | 457 | ||
430 | logger.info('Building questions factory:') | 458 | logger.info('Building questions factory:') |
431 | factory = dict() | 459 | factory = dict() |
432 | - g = self.deps | ||
433 | - for tref in g.nodes(): | ||
434 | - factory.update(self.factory_for(tref)) | 460 | + for tref in self.deps.nodes(): |
461 | + factory.update(self._factory_for(tref)) | ||
435 | 462 | ||
436 | - logger.info(f'Factory has {len(factory)} questions') | 463 | + logger.info('Factory has %s questions', len(factory)) |
437 | return factory | 464 | return factory |
438 | 465 | ||
439 | # ------------------------------------------------------------------------ | 466 | # ------------------------------------------------------------------------ |
440 | # makes factory for a single topic | 467 | # makes factory for a single topic |
441 | # ------------------------------------------------------------------------ | 468 | # ------------------------------------------------------------------------ |
442 | - def factory_for(self, tref: str) -> Dict[str, QFactory]: | 469 | + def _factory_for(self, tref: str) -> Dict[str, QFactory]: |
443 | factory: Dict[str, QFactory] = dict() | 470 | factory: Dict[str, QFactory] = dict() |
444 | - g = self.deps | ||
445 | - t = g.nodes[tref] # get node | 471 | + topic = self.deps.nodes[tref] # get node |
446 | # load questions as list of dicts | 472 | # load questions as list of dicts |
447 | try: | 473 | try: |
448 | - fullpath: str = path.join(t['path'], t['file']) | 474 | + fullpath: str = join(topic['path'], topic['file']) |
449 | except Exception: | 475 | except Exception: |
450 | msg1 = f'Invalid topic "{tref}"' | 476 | msg1 = f'Invalid topic "{tref}"' |
451 | - msg2 = f'Check dependencies of: {", ".join(g.successors(tref))}' | 477 | + msg2 = 'Check dependencies of: ' + \ |
478 | + ', '.join(self.deps.successors(tref)) | ||
452 | msg = f'{msg1}. {msg2}' | 479 | msg = f'{msg1}. {msg2}' |
453 | logger.error(msg) | 480 | logger.error(msg) |
454 | raise LearnException(msg) | 481 | raise LearnException(msg) |
455 | - logger.debug(f' Loading {fullpath}') | 482 | + logger.debug(' Loading %s', fullpath) |
456 | try: | 483 | try: |
457 | questions: List[QDict] = load_yaml(fullpath) | 484 | questions: List[QDict] = load_yaml(fullpath) |
458 | except Exception: | 485 | except Exception: |
459 | - if t['type'] == 'chapter': | 486 | + if topic['type'] == 'chapter': |
460 | return factory # chapters may have no "questions" | 487 | return factory # chapters may have no "questions" |
461 | - else: | ||
462 | - msg = f'Failed to load "{fullpath}"' | ||
463 | - logger.error(msg) | ||
464 | - raise LearnException(msg) | 488 | + msg = f'Failed to load "{fullpath}"' |
489 | + logger.error(msg) | ||
490 | + raise LearnException(msg) | ||
465 | 491 | ||
466 | if not isinstance(questions, list): | 492 | if not isinstance(questions, list): |
467 | msg = f'File "{fullpath}" must be a list of questions' | 493 | msg = f'File "{fullpath}" must be a list of questions' |
@@ -473,134 +499,162 @@ class LearnApp(object): | @@ -473,134 +499,162 @@ class LearnApp(object): | ||
473 | # undefined are set to topic:n, where n is the question number | 499 | # undefined are set to topic:n, where n is the question number |
474 | # within the file | 500 | # within the file |
475 | localrefs: Set[str] = set() # refs in current file | 501 | localrefs: Set[str] = set() # refs in current file |
476 | - for i, q in enumerate(questions): | ||
477 | - qref = q.get('ref', str(i)) # ref or number | 502 | + for i, question in enumerate(questions): |
503 | + qref = question.get('ref', str(i)) # ref or number | ||
478 | if qref in localrefs: | 504 | if qref in localrefs: |
479 | - msg = f'Duplicate ref "{qref}" in "{t["path"]}"' | 505 | + msg = f'Duplicate ref "{qref}" in "{topic["path"]}"' |
480 | raise LearnException(msg) | 506 | raise LearnException(msg) |
481 | localrefs.add(qref) | 507 | localrefs.add(qref) |
482 | 508 | ||
483 | - q['ref'] = f'{tref}:{qref}' | ||
484 | - q['path'] = t['path'] | ||
485 | - q.setdefault('append_wrong', t['append_wrong']) | 509 | + question['ref'] = f'{tref}:{qref}' |
510 | + question['path'] = topic['path'] | ||
511 | + question.setdefault('append_wrong', topic['append_wrong']) | ||
486 | 512 | ||
487 | # if questions are left undefined, include all. | 513 | # if questions are left undefined, include all. |
488 | - if not t['questions']: | ||
489 | - t['questions'] = [q['ref'] for q in questions] | 514 | + if not topic['questions']: |
515 | + topic['questions'] = [q['ref'] for q in questions] | ||
490 | 516 | ||
491 | - t['choose'] = min(t['choose'], len(t['questions'])) | 517 | + topic['choose'] = min(topic['choose'], len(topic['questions'])) |
492 | 518 | ||
493 | - for q in questions: | ||
494 | - if q['ref'] in t['questions']: | ||
495 | - factory[q['ref']] = QFactory(q) | ||
496 | - logger.debug(f' + {q["ref"]}') | 519 | + for question in questions: |
520 | + if question['ref'] in topic['questions']: | ||
521 | + factory[question['ref']] = QFactory(question) | ||
522 | + logger.debug(' + %s', question["ref"]) | ||
497 | 523 | ||
498 | - logger.info(f'{len(t["questions"]):6} questions in {tref}') | 524 | + logger.info('%6d questions in %s', len(topic["questions"]), tref) |
499 | 525 | ||
500 | return factory | 526 | return factory |
501 | 527 | ||
502 | # ------------------------------------------------------------------------ | 528 | # ------------------------------------------------------------------------ |
503 | def get_login_counter(self, uid: str) -> int: | 529 | def get_login_counter(self, uid: str) -> int: |
530 | + '''login counter''' # FIXME | ||
504 | return int(self.online[uid]['counter']) | 531 | return int(self.online[uid]['counter']) |
505 | 532 | ||
506 | # ------------------------------------------------------------------------ | 533 | # ------------------------------------------------------------------------ |
507 | def get_student_name(self, uid: str) -> str: | 534 | def get_student_name(self, uid: str) -> str: |
535 | + '''Get the username''' | ||
508 | return self.online[uid].get('name', '') | 536 | return self.online[uid].get('name', '') |
509 | 537 | ||
510 | # ------------------------------------------------------------------------ | 538 | # ------------------------------------------------------------------------ |
511 | def get_student_state(self, uid: str) -> List[Dict[str, Any]]: | 539 | def get_student_state(self, uid: str) -> List[Dict[str, Any]]: |
540 | + '''Get the knowledge state of a given user''' | ||
512 | return self.online[uid]['state'].get_knowledge_state() | 541 | return self.online[uid]['state'].get_knowledge_state() |
513 | 542 | ||
514 | # ------------------------------------------------------------------------ | 543 | # ------------------------------------------------------------------------ |
515 | def get_student_progress(self, uid: str) -> float: | 544 | def get_student_progress(self, uid: str) -> float: |
545 | + '''Get the current topic progress of a given user''' | ||
516 | return float(self.online[uid]['state'].get_topic_progress()) | 546 | return float(self.online[uid]['state'].get_topic_progress()) |
517 | 547 | ||
518 | # ------------------------------------------------------------------------ | 548 | # ------------------------------------------------------------------------ |
519 | def get_current_question(self, uid: str) -> Optional[Question]: | 549 | def get_current_question(self, uid: str) -> Optional[Question]: |
550 | + '''Get the current question of a given user''' | ||
520 | q: Optional[Question] = self.online[uid]['state'].get_current_question() | 551 | q: Optional[Question] = self.online[uid]['state'].get_current_question() |
521 | return q | 552 | return q |
522 | 553 | ||
523 | # ------------------------------------------------------------------------ | 554 | # ------------------------------------------------------------------------ |
524 | def get_current_question_id(self, uid: str) -> str: | 555 | def get_current_question_id(self, uid: str) -> str: |
556 | + '''Get id of the current question for a given user''' | ||
525 | return str(self.online[uid]['state'].get_current_question()['qid']) | 557 | return str(self.online[uid]['state'].get_current_question()['qid']) |
526 | 558 | ||
527 | # ------------------------------------------------------------------------ | 559 | # ------------------------------------------------------------------------ |
528 | def get_student_question_type(self, uid: str) -> str: | 560 | def get_student_question_type(self, uid: str) -> str: |
561 | + '''Get type of the current question for a given user''' | ||
529 | return str(self.online[uid]['state'].get_current_question()['type']) | 562 | return str(self.online[uid]['state'].get_current_question()['type']) |
530 | 563 | ||
531 | # ------------------------------------------------------------------------ | 564 | # ------------------------------------------------------------------------ |
532 | - def get_student_topic(self, uid: str) -> str: | ||
533 | - return str(self.online[uid]['state'].get_current_topic()) | 565 | + # def get_student_topic(self, uid: str) -> str: |
566 | + # return str(self.online[uid]['state'].get_current_topic()) | ||
534 | 567 | ||
535 | # ------------------------------------------------------------------------ | 568 | # ------------------------------------------------------------------------ |
536 | def get_student_course_title(self, uid: str) -> str: | 569 | def get_student_course_title(self, uid: str) -> str: |
570 | + '''get the title of the current course for a given user''' | ||
537 | return str(self.online[uid]['state'].get_current_course_title()) | 571 | return str(self.online[uid]['state'].get_current_course_title()) |
538 | 572 | ||
539 | # ------------------------------------------------------------------------ | 573 | # ------------------------------------------------------------------------ |
540 | def get_current_course_id(self, uid: str) -> Optional[str]: | 574 | def get_current_course_id(self, uid: str) -> Optional[str]: |
575 | + '''get the current course (id) of a given user''' | ||
541 | cid: Optional[str] = self.online[uid]['state'].get_current_course_id() | 576 | cid: Optional[str] = self.online[uid]['state'].get_current_course_id() |
542 | return cid | 577 | return cid |
543 | 578 | ||
544 | # ------------------------------------------------------------------------ | 579 | # ------------------------------------------------------------------------ |
545 | - def get_topic_name(self, ref: str) -> str: | ||
546 | - return str(self.deps.nodes[ref]['name']) | 580 | + # def get_topic_name(self, ref: str) -> str: |
581 | + # return str(self.deps.nodes[ref]['name']) | ||
547 | 582 | ||
548 | # ------------------------------------------------------------------------ | 583 | # ------------------------------------------------------------------------ |
549 | def get_current_public_dir(self, uid: str) -> str: | 584 | def get_current_public_dir(self, uid: str) -> str: |
585 | + ''' | ||
586 | + Get the path for the 'public' directory of the current topic of the | ||
587 | + given user. | ||
588 | + E.g. if the user has the active topic 'xpto', | ||
589 | + then returns 'path/to/xpto/public'. | ||
590 | + ''' | ||
550 | topic: str = self.online[uid]['state'].get_current_topic() | 591 | topic: str = self.online[uid]['state'].get_current_topic() |
551 | prefix: str = self.deps.graph['prefix'] | 592 | prefix: str = self.deps.graph['prefix'] |
552 | - return path.join(prefix, topic, 'public') | 593 | + return join(prefix, topic, 'public') |
553 | 594 | ||
554 | # ------------------------------------------------------------------------ | 595 | # ------------------------------------------------------------------------ |
555 | def get_courses(self) -> Dict[str, Dict[str, Any]]: | 596 | def get_courses(self) -> Dict[str, Dict[str, Any]]: |
597 | + ''' | ||
598 | + Get dictionary with all courses {'course1': {...}, 'course2': {...}} | ||
599 | + ''' | ||
556 | return self.courses | 600 | return self.courses |
557 | 601 | ||
558 | # ------------------------------------------------------------------------ | 602 | # ------------------------------------------------------------------------ |
559 | def get_course(self, course_id: str) -> Dict[str, Any]: | 603 | def get_course(self, course_id: str) -> Dict[str, Any]: |
604 | + ''' | ||
605 | + Get dictionary {'title': ..., 'description':..., 'goals':...} | ||
606 | + ''' | ||
560 | return self.courses[course_id] | 607 | return self.courses[course_id] |
561 | 608 | ||
562 | # ------------------------------------------------------------------------ | 609 | # ------------------------------------------------------------------------ |
563 | def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]: | 610 | def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]: |
564 | - | ||
565 | - logger.info(f'User "{uid}" get rankings for {course_id}') | ||
566 | - with self.db_session() as s: | ||
567 | - students = s.query(Student.id, Student.name).all() | ||
568 | - | ||
569 | - # topic progress | ||
570 | - student_topics = s.query(StudentTopic.student_id, | ||
571 | - StudentTopic.topic_id, | ||
572 | - StudentTopic.level, | ||
573 | - StudentTopic.date).all() | 611 | + ''' |
612 | + Returns rankings for a certain course_id. | ||
613 | + User where uid have <=2 chars are considered ghosts are hidden from | ||
614 | + the rankings. This is so that there can be users for development or | ||
615 | + testing purposes, which are not real users. | ||
616 | + The user_id of real students must have >2 chars. | ||
617 | + ''' | ||
618 | + | ||
619 | + logger.info('User "%s" get rankings for %s', uid, course_id) | ||
620 | + with self._db_session() as sess: | ||
621 | + # all students in the database FIXME only with answers of this course | ||
622 | + students = sess.query(Student.id, Student.name).all() | ||
623 | + | ||
624 | + # topic levels FIXME only topics of this course | ||
625 | + student_topics = sess.query(StudentTopic.student_id, | ||
626 | + StudentTopic.topic_id, | ||
627 | + StudentTopic.level, | ||
628 | + StudentTopic.date).all() | ||
574 | 629 | ||
575 | # answer performance | 630 | # answer performance |
576 | - total = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)). | ||
577 | - group_by(Answer.student_id). | ||
578 | - all()) | ||
579 | - right = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)). | ||
580 | - filter(Answer.grade == 1.0). | ||
581 | - group_by(Answer.student_id). | ||
582 | - all()) | 631 | + total = dict(sess.query(Answer.student_id, |
632 | + sa.func.count(Answer.ref)) \ | ||
633 | + .group_by(Answer.student_id) \ | ||
634 | + .all()) | ||
635 | + right = dict(sess.query(Answer.student_id, | ||
636 | + sa.func.count(Answer.ref)) \ | ||
637 | + .filter(Answer.grade == 1.0) \ | ||
638 | + .group_by(Answer.student_id) \ | ||
639 | + .all()) | ||
583 | 640 | ||
584 | # compute percentage of right answers | 641 | # compute percentage of right answers |
585 | - perf: Dict[str, float] = {u: right.get(u, 0.0)/total[u] | 642 | + perf: Dict[str, float] = {u: right.get(u, 0.0) / total[u] |
586 | for u in total} | 643 | for u in total} |
587 | 644 | ||
588 | # compute topic progress | 645 | # compute topic progress |
589 | now = datetime.now() | 646 | now = datetime.now() |
590 | goals = self.courses[course_id]['goals'] | 647 | goals = self.courses[course_id]['goals'] |
591 | - prog: DefaultDict[str, float] = defaultdict(int) | 648 | + progress: DefaultDict[str, float] = defaultdict(int) |
592 | 649 | ||
593 | - for u, topic, level, date in student_topics: | 650 | + for student, topic, level, date in student_topics: |
594 | if topic in goals: | 651 | if topic in goals: |
595 | date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f") | 652 | date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f") |
596 | - prog[u] += level**(now - date).days / len(goals) | ||
597 | - | ||
598 | - ghostuser = len(uid) <= 2 # ghosts are invisible to students | ||
599 | - rankings = [(u, name, prog[u], perf.get(u, 0.0)) | ||
600 | - for u, name in students | ||
601 | - if u in prog | ||
602 | - and (len(u) > 2 or ghostuser) and u != '0' ] | ||
603 | - rankings.sort(key=lambda x: x[2], reverse=True) | ||
604 | - return rankings | 653 | + progress[student] += level**(now - date).days / len(goals) |
654 | + | ||
655 | + return sorted(((u, name, progress[u], perf.get(u, 0.0)) | ||
656 | + for u, name in students | ||
657 | + if u in progress and (len(u) > 2 or len(uid) <= 2)), | ||
658 | + key=lambda x: x[2], reverse=True) | ||
605 | 659 | ||
606 | # ------------------------------------------------------------------------ | 660 | # ------------------------------------------------------------------------ |
aprendizations/serve.py
1 | +''' | ||
2 | +Webserver | ||
3 | +''' | ||
4 | + | ||
1 | 5 | ||
2 | # python standard library | 6 | # python standard library |
3 | import asyncio | 7 | import asyncio |
@@ -5,7 +9,7 @@ import base64 | @@ -5,7 +9,7 @@ import base64 | ||
5 | import functools | 9 | import functools |
6 | import logging.config | 10 | import logging.config |
7 | import mimetypes | 11 | import mimetypes |
8 | -from os import path | 12 | +from os.path import join, dirname, expanduser |
9 | import signal | 13 | import signal |
10 | import sys | 14 | import sys |
11 | from typing import List, Optional, Union | 15 | from typing import List, Optional, Union |
@@ -16,8 +20,9 @@ import tornado.web | @@ -16,8 +20,9 @@ import tornado.web | ||
16 | from tornado.escape import to_unicode | 20 | from tornado.escape import to_unicode |
17 | 21 | ||
18 | # this project | 22 | # this project |
19 | -from .tools import md_to_html | ||
20 | -from . import APP_NAME | 23 | +from aprendizations.tools import md_to_html |
24 | +from aprendizations.learnapp import LearnException | ||
25 | +from aprendizations import APP_NAME | ||
21 | 26 | ||
22 | 27 | ||
23 | # setup logger for this module | 28 | # setup logger for this module |
@@ -25,39 +30,39 @@ logger = logging.getLogger(__name__) | @@ -25,39 +30,39 @@ logger = logging.getLogger(__name__) | ||
25 | 30 | ||
26 | 31 | ||
27 | # ---------------------------------------------------------------------------- | 32 | # ---------------------------------------------------------------------------- |
28 | -# Decorator used to restrict access to the administrator | ||
29 | -# ---------------------------------------------------------------------------- | ||
30 | def admin_only(func): | 33 | def admin_only(func): |
34 | + ''' | ||
35 | + Decorator used to restrict access to the administrator | ||
36 | + ''' | ||
31 | @functools.wraps(func) | 37 | @functools.wraps(func) |
32 | def wrapper(self, *args, **kwargs): | 38 | def wrapper(self, *args, **kwargs): |
33 | if self.current_user != '0': | 39 | if self.current_user != '0': |
34 | raise tornado.web.HTTPError(403) # forbidden | 40 | raise tornado.web.HTTPError(403) # forbidden |
35 | - else: | ||
36 | - func(self, *args, **kwargs) | 41 | + func(self, *args, **kwargs) |
37 | return wrapper | 42 | return wrapper |
38 | 43 | ||
39 | 44 | ||
40 | # ============================================================================ | 45 | # ============================================================================ |
41 | -# WebApplication - Tornado Web Server | ||
42 | -# ============================================================================ | ||
43 | class WebApplication(tornado.web.Application): | 46 | class WebApplication(tornado.web.Application): |
44 | - | 47 | + ''' |
48 | + WebApplication - Tornado Web Server | ||
49 | + ''' | ||
45 | def __init__(self, learnapp, debug=False): | 50 | def __init__(self, learnapp, debug=False): |
46 | handlers = [ | 51 | handlers = [ |
47 | - (r'/login', LoginHandler), | ||
48 | - (r'/logout', LogoutHandler), | 52 | + (r'/login', LoginHandler), |
53 | + (r'/logout', LogoutHandler), | ||
49 | (r'/change_password', ChangePasswordHandler), | 54 | (r'/change_password', ChangePasswordHandler), |
50 | - (r'/question', QuestionHandler), # render question | ||
51 | - (r'/rankings', RankingsHandler), # rankings table | ||
52 | - (r'/topic/(.+)', TopicHandler), # start topic | ||
53 | - (r'/file/(.+)', FileHandler), # serve file | ||
54 | - (r'/courses', CoursesHandler), # show list of courses | ||
55 | - (r'/course/(.*)', CourseHandler), # show course topics | ||
56 | - (r'/', RootHandler), # redirects | 55 | + (r'/question', QuestionHandler), # render question |
56 | + (r'/rankings', RankingsHandler), # rankings table | ||
57 | + (r'/topic/(.+)', TopicHandler), # start topic | ||
58 | + (r'/file/(.+)', FileHandler), # serve file | ||
59 | + (r'/courses', CoursesHandler), # show list of courses | ||
60 | + (r'/course/(.*)', CourseHandler), # show course topics | ||
61 | + (r'/', RootHandler), # redirects | ||
57 | ] | 62 | ] |
58 | settings = { | 63 | settings = { |
59 | - 'template_path': path.join(path.dirname(__file__), 'templates'), | ||
60 | - 'static_path': path.join(path.dirname(__file__), 'static'), | 64 | + 'template_path': join(dirname(__file__), 'templates'), |
65 | + 'static_path': join(dirname(__file__), 'static'), | ||
61 | 'static_url_prefix': '/static/', | 66 | 'static_url_prefix': '/static/', |
62 | 'xsrf_cookies': True, | 67 | 'xsrf_cookies': True, |
63 | 'cookie_secret': base64.b64encode(uuid.uuid4().bytes), | 68 | 'cookie_secret': base64.b64encode(uuid.uuid4().bytes), |
@@ -71,30 +76,37 @@ class WebApplication(tornado.web.Application): | @@ -71,30 +76,37 @@ class WebApplication(tornado.web.Application): | ||
71 | # ============================================================================ | 76 | # ============================================================================ |
72 | # Handlers | 77 | # Handlers |
73 | # ============================================================================ | 78 | # ============================================================================ |
74 | - | ||
75 | -# ---------------------------------------------------------------------------- | ||
76 | -# Base handler common to all handlers. | ||
77 | -# ---------------------------------------------------------------------------- | 79 | +# pylint: disable=abstract-method |
78 | class BaseHandler(tornado.web.RequestHandler): | 80 | class BaseHandler(tornado.web.RequestHandler): |
81 | + ''' | ||
82 | + Base handler common to all handlers. | ||
83 | + ''' | ||
79 | @property | 84 | @property |
80 | def learn(self): | 85 | def learn(self): |
86 | + '''easier access to learnapp''' | ||
81 | return self.application.learn | 87 | return self.application.learn |
82 | 88 | ||
83 | def get_current_user(self): | 89 | def get_current_user(self): |
84 | - cookie = self.get_secure_cookie('user') | ||
85 | - if cookie: | ||
86 | - uid = cookie.decode('utf-8') | 90 | + '''called on every method decorated with @tornado.web.authenticated''' |
91 | + user_cookie = self.get_secure_cookie('user') | ||
92 | + if user_cookie is not None: | ||
93 | + uid = user_cookie.decode('utf-8') | ||
87 | counter = self.get_secure_cookie('counter').decode('utf-8') | 94 | counter = self.get_secure_cookie('counter').decode('utf-8') |
88 | if counter == str(self.learn.get_login_counter(uid)): | 95 | if counter == str(self.learn.get_login_counter(uid)): |
89 | return uid | 96 | return uid |
97 | + return None | ||
90 | 98 | ||
91 | 99 | ||
92 | # ---------------------------------------------------------------------------- | 100 | # ---------------------------------------------------------------------------- |
93 | -# /rankings | ||
94 | -# ---------------------------------------------------------------------------- | ||
95 | class RankingsHandler(BaseHandler): | 101 | class RankingsHandler(BaseHandler): |
102 | + ''' | ||
103 | + Handles rankings page | ||
104 | + ''' | ||
96 | @tornado.web.authenticated | 105 | @tornado.web.authenticated |
97 | def get(self): | 106 | def get(self): |
107 | + ''' | ||
108 | + Renders list of students that have answers in this course. | ||
109 | + ''' | ||
98 | uid = self.current_user | 110 | uid = self.current_user |
99 | current_course = self.learn.get_current_course_id(uid) | 111 | current_course = self.learn.get_current_course_id(uid) |
100 | course_id = self.get_query_argument('course', default=current_course) | 112 | course_id = self.get_query_argument('course', default=current_course) |
@@ -110,23 +122,33 @@ class RankingsHandler(BaseHandler): | @@ -110,23 +122,33 @@ class RankingsHandler(BaseHandler): | ||
110 | 122 | ||
111 | 123 | ||
112 | # ---------------------------------------------------------------------------- | 124 | # ---------------------------------------------------------------------------- |
113 | -# /auth/login | 125 | +# |
114 | # ---------------------------------------------------------------------------- | 126 | # ---------------------------------------------------------------------------- |
115 | class LoginHandler(BaseHandler): | 127 | class LoginHandler(BaseHandler): |
128 | + ''' | ||
129 | + Handles /login | ||
130 | + ''' | ||
116 | def get(self): | 131 | def get(self): |
132 | + ''' | ||
133 | + Renders login page | ||
134 | + ''' | ||
117 | self.render('login.html', | 135 | self.render('login.html', |
118 | appname=APP_NAME, | 136 | appname=APP_NAME, |
119 | error='') | 137 | error='') |
120 | 138 | ||
121 | async def post(self): | 139 | async def post(self): |
122 | - uid = self.get_body_argument('uid').lstrip('l') | ||
123 | - pw = self.get_body_argument('pw') | 140 | + ''' |
141 | + Perform authentication and redirects to application if successful | ||
142 | + ''' | ||
124 | 143 | ||
125 | - login_ok = await self.learn.login(uid, pw) | 144 | + userid = self.get_body_argument('uid').lstrip('l') |
145 | + passwd = self.get_body_argument('pw') | ||
146 | + | ||
147 | + login_ok = await self.learn.login(userid, passwd) | ||
126 | 148 | ||
127 | if login_ok: | 149 | if login_ok: |
128 | - counter = str(self.learn.get_login_counter(uid)) | ||
129 | - self.set_secure_cookie('user', uid) | 150 | + counter = str(self.learn.get_login_counter(userid)) |
151 | + self.set_secure_cookie('user', userid) | ||
130 | self.set_secure_cookie('counter', counter) | 152 | self.set_secure_cookie('counter', counter) |
131 | self.redirect('/') | 153 | self.redirect('/') |
132 | else: | 154 | else: |
@@ -136,11 +158,15 @@ class LoginHandler(BaseHandler): | @@ -136,11 +158,15 @@ class LoginHandler(BaseHandler): | ||
136 | 158 | ||
137 | 159 | ||
138 | # ---------------------------------------------------------------------------- | 160 | # ---------------------------------------------------------------------------- |
139 | -# /auth/logout | ||
140 | -# ---------------------------------------------------------------------------- | ||
141 | class LogoutHandler(BaseHandler): | 161 | class LogoutHandler(BaseHandler): |
162 | + ''' | ||
163 | + Handles /logout | ||
164 | + ''' | ||
142 | @tornado.web.authenticated | 165 | @tornado.web.authenticated |
143 | def get(self): | 166 | def get(self): |
167 | + ''' | ||
168 | + clears cookies and removes user session | ||
169 | + ''' | ||
144 | self.clear_cookie('user') | 170 | self.clear_cookie('user') |
145 | self.clear_cookie('counter') | 171 | self.clear_cookie('counter') |
146 | self.redirect('/') | 172 | self.redirect('/') |
@@ -151,12 +177,18 @@ class LogoutHandler(BaseHandler): | @@ -151,12 +177,18 @@ class LogoutHandler(BaseHandler): | ||
151 | 177 | ||
152 | # ---------------------------------------------------------------------------- | 178 | # ---------------------------------------------------------------------------- |
153 | class ChangePasswordHandler(BaseHandler): | 179 | class ChangePasswordHandler(BaseHandler): |
180 | + ''' | ||
181 | + Handles password change | ||
182 | + ''' | ||
154 | @tornado.web.authenticated | 183 | @tornado.web.authenticated |
155 | async def post(self): | 184 | async def post(self): |
156 | - uid = self.current_user | ||
157 | - pw = self.get_body_arguments('new_password')[0] | 185 | + ''' |
186 | + Tries to perform password change and then replies success/fail status | ||
187 | + ''' | ||
188 | + userid = self.current_user | ||
189 | + passwd = self.get_body_arguments('new_password')[0] | ||
158 | 190 | ||
159 | - changed_ok = await self.learn.change_password(uid, pw) | 191 | + changed_ok = await self.learn.change_password(userid, passwd) |
160 | if changed_ok: | 192 | if changed_ok: |
161 | notification = self.render_string( | 193 | notification = self.render_string( |
162 | 'notification.html', | 194 | 'notification.html', |
@@ -174,45 +206,53 @@ class ChangePasswordHandler(BaseHandler): | @@ -174,45 +206,53 @@ class ChangePasswordHandler(BaseHandler): | ||
174 | 206 | ||
175 | 207 | ||
176 | # ---------------------------------------------------------------------------- | 208 | # ---------------------------------------------------------------------------- |
177 | -# / | ||
178 | -# redirects to appropriate place | ||
179 | -# ---------------------------------------------------------------------------- | ||
180 | class RootHandler(BaseHandler): | 209 | class RootHandler(BaseHandler): |
210 | + ''' | ||
211 | + Handles root / | ||
212 | + ''' | ||
181 | @tornado.web.authenticated | 213 | @tornado.web.authenticated |
182 | def get(self): | 214 | def get(self): |
215 | + '''Simply redirects to the main entrypoint''' | ||
183 | self.redirect('/courses') | 216 | self.redirect('/courses') |
184 | 217 | ||
185 | 218 | ||
186 | # ---------------------------------------------------------------------------- | 219 | # ---------------------------------------------------------------------------- |
187 | -# /courses | ||
188 | -# Shows a list of available courses | ||
189 | -# ---------------------------------------------------------------------------- | ||
190 | class CoursesHandler(BaseHandler): | 220 | class CoursesHandler(BaseHandler): |
221 | + ''' | ||
222 | + Handles /courses | ||
223 | + ''' | ||
191 | @tornado.web.authenticated | 224 | @tornado.web.authenticated |
192 | def get(self): | 225 | def get(self): |
226 | + '''Renders list of available courses''' | ||
193 | uid = self.current_user | 227 | uid = self.current_user |
194 | self.render('courses.html', | 228 | self.render('courses.html', |
195 | appname=APP_NAME, | 229 | appname=APP_NAME, |
196 | uid=uid, | 230 | uid=uid, |
197 | name=self.learn.get_student_name(uid), | 231 | name=self.learn.get_student_name(uid), |
198 | courses=self.learn.get_courses(), | 232 | courses=self.learn.get_courses(), |
233 | + # courses_progress= | ||
199 | ) | 234 | ) |
200 | 235 | ||
201 | 236 | ||
202 | -# ---------------------------------------------------------------------------- | ||
203 | -# /course/... | ||
204 | -# Start a given course and show list of topics | ||
205 | -# ---------------------------------------------------------------------------- | 237 | +# ============================================================================ |
206 | class CourseHandler(BaseHandler): | 238 | class CourseHandler(BaseHandler): |
239 | + ''' | ||
240 | + Handles a particular course to show the topics table | ||
241 | + ''' | ||
242 | + | ||
207 | @tornado.web.authenticated | 243 | @tornado.web.authenticated |
208 | def get(self, course_id): | 244 | def get(self, course_id): |
245 | + ''' | ||
246 | + Handles get /course/... | ||
247 | + Starts a given course and show list of topics | ||
248 | + ''' | ||
209 | uid = self.current_user | 249 | uid = self.current_user |
210 | if course_id == '': | 250 | if course_id == '': |
211 | course_id = self.learn.get_current_course_id(uid) | 251 | course_id = self.learn.get_current_course_id(uid) |
212 | 252 | ||
213 | try: | 253 | try: |
214 | self.learn.start_course(uid, course_id) | 254 | self.learn.start_course(uid, course_id) |
215 | - except KeyError: | 255 | + except LearnException: |
216 | self.redirect('/courses') | 256 | self.redirect('/courses') |
217 | 257 | ||
218 | self.render('maintopics-table.html', | 258 | self.render('maintopics-table.html', |
@@ -225,17 +265,21 @@ class CourseHandler(BaseHandler): | @@ -225,17 +265,21 @@ class CourseHandler(BaseHandler): | ||
225 | ) | 265 | ) |
226 | 266 | ||
227 | 267 | ||
228 | -# ---------------------------------------------------------------------------- | ||
229 | -# /topic/... | ||
230 | -# Start a given topic | ||
231 | -# ---------------------------------------------------------------------------- | 268 | +# ============================================================================ |
232 | class TopicHandler(BaseHandler): | 269 | class TopicHandler(BaseHandler): |
270 | + ''' | ||
271 | + Handles a topic | ||
272 | + ''' | ||
233 | @tornado.web.authenticated | 273 | @tornado.web.authenticated |
234 | async def get(self, topic): | 274 | async def get(self, topic): |
275 | + ''' | ||
276 | + Handles get /topic/... | ||
277 | + Starts a given topic | ||
278 | + ''' | ||
235 | uid = self.current_user | 279 | uid = self.current_user |
236 | 280 | ||
237 | try: | 281 | try: |
238 | - await self.learn.start_topic(uid, topic) | 282 | + await self.learn.start_topic(uid, topic) # FIXME GET should not modify state... |
239 | except KeyError: | 283 | except KeyError: |
240 | self.redirect('/topics') | 284 | self.redirect('/topics') |
241 | 285 | ||
@@ -243,31 +287,34 @@ class TopicHandler(BaseHandler): | @@ -243,31 +287,34 @@ class TopicHandler(BaseHandler): | ||
243 | appname=APP_NAME, | 287 | appname=APP_NAME, |
244 | uid=uid, | 288 | uid=uid, |
245 | name=self.learn.get_student_name(uid), | 289 | name=self.learn.get_student_name(uid), |
246 | - # course_title=self.learn.get_student_course_title(uid), | ||
247 | course_id=self.learn.get_current_course_id(uid), | 290 | course_id=self.learn.get_current_course_id(uid), |
248 | ) | 291 | ) |
249 | 292 | ||
250 | 293 | ||
251 | -# ---------------------------------------------------------------------------- | ||
252 | -# Serves files from the /public subdir of the topics. | ||
253 | -# ---------------------------------------------------------------------------- | 294 | +# ============================================================================ |
254 | class FileHandler(BaseHandler): | 295 | class FileHandler(BaseHandler): |
296 | + ''' | ||
297 | + Serves files from the /public subdir of the topics. | ||
298 | + ''' | ||
255 | @tornado.web.authenticated | 299 | @tornado.web.authenticated |
256 | async def get(self, filename): | 300 | async def get(self, filename): |
301 | + ''' | ||
302 | + Serves files from /public subdirectories of a particular topic | ||
303 | + ''' | ||
257 | uid = self.current_user | 304 | uid = self.current_user |
258 | public_dir = self.learn.get_current_public_dir(uid) | 305 | public_dir = self.learn.get_current_public_dir(uid) |
259 | - filepath = path.expanduser(path.join(public_dir, filename)) | 306 | + filepath = expanduser(join(public_dir, filename)) |
260 | content_type = mimetypes.guess_type(filename)[0] | 307 | content_type = mimetypes.guess_type(filename)[0] |
261 | 308 | ||
262 | try: | 309 | try: |
263 | - with open(filepath, 'rb') as f: | ||
264 | - data = f.read() | 310 | + with open(filepath, 'rb') as file: |
311 | + data = file.read() | ||
265 | except FileNotFoundError: | 312 | except FileNotFoundError: |
266 | - logger.error(f'File not found: {filepath}') | 313 | + logger.error('File not found: %s', filepath) |
267 | except PermissionError: | 314 | except PermissionError: |
268 | - logger.error(f'No permission: {filepath}') | 315 | + logger.error('No permission: %s', filepath) |
269 | except Exception: | 316 | except Exception: |
270 | - logger.error(f'Error reading: {filepath}') | 317 | + logger.error('Error reading: %s', filepath) |
271 | raise | 318 | raise |
272 | else: | 319 | else: |
273 | self.set_header("Content-Type", content_type) | 320 | self.set_header("Content-Type", content_type) |
@@ -275,10 +322,11 @@ class FileHandler(BaseHandler): | @@ -275,10 +322,11 @@ class FileHandler(BaseHandler): | ||
275 | await self.flush() | 322 | await self.flush() |
276 | 323 | ||
277 | 324 | ||
278 | -# ---------------------------------------------------------------------------- | ||
279 | -# respond to AJAX to get a JSON question | ||
280 | -# ---------------------------------------------------------------------------- | 325 | +# ============================================================================ |
281 | class QuestionHandler(BaseHandler): | 326 | class QuestionHandler(BaseHandler): |
327 | + ''' | ||
328 | + Responds to AJAX to get a JSON question | ||
329 | + ''' | ||
282 | templates = { | 330 | templates = { |
283 | 'checkbox': 'question-checkbox.html', | 331 | 'checkbox': 'question-checkbox.html', |
284 | 'radio': 'question-radio.html', | 332 | 'radio': 'question-radio.html', |
@@ -294,27 +342,27 @@ class QuestionHandler(BaseHandler): | @@ -294,27 +342,27 @@ class QuestionHandler(BaseHandler): | ||
294 | } | 342 | } |
295 | 343 | ||
296 | # ------------------------------------------------------------------------ | 344 | # ------------------------------------------------------------------------ |
297 | - # GET | ||
298 | - # gets question to render. If there are no more questions in the topic | ||
299 | - # shows an animated trophy | ||
300 | - # ------------------------------------------------------------------------ | ||
301 | @tornado.web.authenticated | 345 | @tornado.web.authenticated |
302 | async def get(self): | 346 | async def get(self): |
347 | + ''' | ||
348 | + Gets question to render. | ||
349 | + Shows an animated trophy if there are no more questions in the topic. | ||
350 | + ''' | ||
303 | logger.debug('[QuestionHandler]') | 351 | logger.debug('[QuestionHandler]') |
304 | user = self.current_user | 352 | user = self.current_user |
305 | - q = await self.learn.get_question(user) | 353 | + question = await self.learn.get_question(user) |
306 | 354 | ||
307 | # show current question | 355 | # show current question |
308 | - if q is not None: | ||
309 | - qhtml = self.render_string(self.templates[q['type']], | ||
310 | - question=q, md=md_to_html) | 356 | + if question is not None: |
357 | + qhtml = self.render_string(self.templates[question['type']], | ||
358 | + question=question, md=md_to_html) | ||
311 | response = { | 359 | response = { |
312 | 'method': 'new_question', | 360 | 'method': 'new_question', |
313 | 'params': { | 361 | 'params': { |
314 | - 'type': q['type'], | 362 | + 'type': question['type'], |
315 | 'question': to_unicode(qhtml), | 363 | 'question': to_unicode(qhtml), |
316 | 'progress': self.learn.get_student_progress(user), | 364 | 'progress': self.learn.get_student_progress(user), |
317 | - 'tries': q['tries'], | 365 | + 'tries': question['tries'], |
318 | } | 366 | } |
319 | } | 367 | } |
320 | 368 | ||
@@ -331,20 +379,20 @@ class QuestionHandler(BaseHandler): | @@ -331,20 +379,20 @@ class QuestionHandler(BaseHandler): | ||
331 | self.write(response) | 379 | self.write(response) |
332 | 380 | ||
333 | # ------------------------------------------------------------------------ | 381 | # ------------------------------------------------------------------------ |
334 | - # POST | ||
335 | - # corrects answer and returns status: right, wrong, try_again | ||
336 | - # does not move to next question. | ||
337 | - # ------------------------------------------------------------------------ | ||
338 | @tornado.web.authenticated | 382 | @tornado.web.authenticated |
339 | async def post(self) -> None: | 383 | async def post(self) -> None: |
384 | + ''' | ||
385 | + Corrects answer and returns status: right, wrong, try_again | ||
386 | + Does not move to next question. | ||
387 | + ''' | ||
340 | user = self.current_user | 388 | user = self.current_user |
341 | answer = self.get_body_arguments('answer') # list | 389 | answer = self.get_body_arguments('answer') # list |
342 | qid = self.get_body_arguments('qid')[0] | 390 | qid = self.get_body_arguments('qid')[0] |
343 | - logger.debug(f'[QuestionHandler] answer={answer}') | 391 | + # logger.debug('[QuestionHandler] answer=%s', answer) |
344 | 392 | ||
345 | # --- check if browser opened different questions simultaneously | 393 | # --- check if browser opened different questions simultaneously |
346 | if qid != self.learn.get_current_question_id(user): | 394 | if qid != self.learn.get_current_question_id(user): |
347 | - logger.info(f'User {user} desynchronized questions') | 395 | + logger.warning('User %s desynchronized questions', user) |
348 | self.write({ | 396 | self.write({ |
349 | 'method': 'invalid', | 397 | 'method': 'invalid', |
350 | 'params': { | 398 | 'params': { |
@@ -370,51 +418,55 @@ class QuestionHandler(BaseHandler): | @@ -370,51 +418,55 @@ class QuestionHandler(BaseHandler): | ||
370 | ans = answer | 418 | ans = answer |
371 | 419 | ||
372 | # --- check answer (nonblocking) and get corrected question and action | 420 | # --- check answer (nonblocking) and get corrected question and action |
373 | - q = await self.learn.check_answer(user, ans) | 421 | + question = await self.learn.check_answer(user, ans) |
374 | 422 | ||
375 | # --- built response to return | 423 | # --- built response to return |
376 | - response = {'method': q['status'], 'params': {}} | 424 | + response = {'method': question['status'], 'params': {}} |
377 | 425 | ||
378 | - if q['status'] == 'right': # get next question in the topic | ||
379 | - comments_html = self.render_string( | ||
380 | - 'comments-right.html', comments=q['comments'], md=md_to_html) | 426 | + if question['status'] == 'right': # get next question in the topic |
427 | + comments = self.render_string('comments-right.html', | ||
428 | + comments=question['comments'], | ||
429 | + md=md_to_html) | ||
381 | 430 | ||
382 | - solution_html = self.render_string( | ||
383 | - 'solution.html', solution=q['solution'], md=md_to_html) | 431 | + solution = self.render_string('solution.html', |
432 | + solution=question['solution'], | ||
433 | + md=md_to_html) | ||
384 | 434 | ||
385 | response['params'] = { | 435 | response['params'] = { |
386 | - 'type': q['type'], | 436 | + 'type': question['type'], |
387 | 'progress': self.learn.get_student_progress(user), | 437 | 'progress': self.learn.get_student_progress(user), |
388 | - 'comments': to_unicode(comments_html), | ||
389 | - 'solution': to_unicode(solution_html), | ||
390 | - 'tries': q['tries'], | 438 | + 'comments': to_unicode(comments), |
439 | + 'solution': to_unicode(solution), | ||
440 | + 'tries': question['tries'], | ||
391 | } | 441 | } |
392 | - elif q['status'] == 'try_again': | ||
393 | - comments_html = self.render_string( | ||
394 | - 'comments.html', comments=q['comments'], md=md_to_html) | 442 | + elif question['status'] == 'try_again': |
443 | + comments = self.render_string('comments.html', | ||
444 | + comments=question['comments'], | ||
445 | + md=md_to_html) | ||
395 | 446 | ||
396 | response['params'] = { | 447 | response['params'] = { |
397 | - 'type': q['type'], | 448 | + 'type': question['type'], |
398 | 'progress': self.learn.get_student_progress(user), | 449 | 'progress': self.learn.get_student_progress(user), |
399 | - 'comments': to_unicode(comments_html), | ||
400 | - 'tries': q['tries'], | 450 | + 'comments': to_unicode(comments), |
451 | + 'tries': question['tries'], | ||
401 | } | 452 | } |
402 | - elif q['status'] == 'wrong': # no more tries | ||
403 | - comments_html = self.render_string( | ||
404 | - 'comments.html', comments=q['comments'], md=md_to_html) | 453 | + elif question['status'] == 'wrong': # no more tries |
454 | + comments = self.render_string('comments.html', | ||
455 | + comments=question['comments'], | ||
456 | + md=md_to_html) | ||
405 | 457 | ||
406 | - solution_html = self.render_string( | ||
407 | - 'solution.html', solution=q['solution'], md=md_to_html) | 458 | + solution = self.render_string( |
459 | + 'solution.html', solution=question['solution'], md=md_to_html) | ||
408 | 460 | ||
409 | response['params'] = { | 461 | response['params'] = { |
410 | - 'type': q['type'], | 462 | + 'type': question['type'], |
411 | 'progress': self.learn.get_student_progress(user), | 463 | 'progress': self.learn.get_student_progress(user), |
412 | - 'comments': to_unicode(comments_html), | ||
413 | - 'solution': to_unicode(solution_html), | ||
414 | - 'tries': q['tries'], | 464 | + 'comments': to_unicode(comments), |
465 | + 'solution': to_unicode(solution), | ||
466 | + 'tries': question['tries'], | ||
415 | } | 467 | } |
416 | else: | 468 | else: |
417 | - logger.error(f'Unknown question status: {q["status"]}') | 469 | + logger.error('Unknown question status: %s', question["status"]) |
418 | 470 | ||
419 | self.write(response) | 471 | self.write(response) |
420 | 472 | ||
@@ -422,29 +474,29 @@ class QuestionHandler(BaseHandler): | @@ -422,29 +474,29 @@ class QuestionHandler(BaseHandler): | ||
422 | # ---------------------------------------------------------------------------- | 474 | # ---------------------------------------------------------------------------- |
423 | # Signal handler to catch Ctrl-C and abort server | 475 | # Signal handler to catch Ctrl-C and abort server |
424 | # ---------------------------------------------------------------------------- | 476 | # ---------------------------------------------------------------------------- |
425 | -def signal_handler(signal, frame) -> None: | ||
426 | - r = input(' --> Stop webserver? (yes/no) ').lower() | ||
427 | - if r == 'yes': | 477 | +def signal_handler(*_) -> None: |
478 | + ''' | ||
479 | + Catches Ctrl-C and stops webserver | ||
480 | + ''' | ||
481 | + reply = input(' --> Stop webserver? (yes/no) ') | ||
482 | + if reply.lower() == 'yes': | ||
428 | tornado.ioloop.IOLoop.current().stop() | 483 | tornado.ioloop.IOLoop.current().stop() |
429 | - logger.critical('Webserver stopped.') | 484 | + logging.critical('Webserver stopped.') |
430 | sys.exit(0) | 485 | sys.exit(0) |
431 | - else: | ||
432 | - logger.info('Abort canceled...') | ||
433 | 486 | ||
434 | 487 | ||
435 | # ---------------------------------------------------------------------------- | 488 | # ---------------------------------------------------------------------------- |
436 | -def run_webserver(app, | ||
437 | - ssl, | ||
438 | - port: int = 8443, | ||
439 | - debug: bool = False) -> None: | 489 | +def run_webserver(app, ssl, port: int = 8443, debug: bool = False) -> None: |
490 | + ''' | ||
491 | + Starts and runs webserver until a SIGINT signal (Ctrl-C) is received. | ||
492 | + ''' | ||
440 | 493 | ||
441 | # --- create web application | 494 | # --- create web application |
442 | try: | 495 | try: |
443 | webapp = WebApplication(app, debug=debug) | 496 | webapp = WebApplication(app, debug=debug) |
444 | except Exception: | 497 | except Exception: |
445 | logger.critical('Failed to start web application.') | 498 | logger.critical('Failed to start web application.') |
446 | - raise | ||
447 | - # sys.exit(1) | 499 | + sys.exit(1) |
448 | else: | 500 | else: |
449 | logger.info('Web application started (tornado.web.Application)') | 501 | logger.info('Web application started (tornado.web.Application)') |
450 | 502 | ||
@@ -460,14 +512,12 @@ def run_webserver(app, | @@ -460,14 +512,12 @@ def run_webserver(app, | ||
460 | try: | 512 | try: |
461 | httpserver.listen(port) | 513 | httpserver.listen(port) |
462 | except OSError: | 514 | except OSError: |
463 | - logger.critical(f'Cannot bind port {port}. Already in use?') | 515 | + logger.critical('Cannot bind port %d. Already in use?', port) |
464 | sys.exit(1) | 516 | sys.exit(1) |
465 | - else: | ||
466 | - logger.info(f'HTTP server listening on port {port}') | ||
467 | 517 | ||
468 | # --- run webserver | 518 | # --- run webserver |
519 | + logger.info('Webserver listening on %d... (Ctrl-C to stop)', port) | ||
469 | signal.signal(signal.SIGINT, signal_handler) | 520 | signal.signal(signal.SIGINT, signal_handler) |
470 | - logger.info('Webserver running... (Ctrl-C to stop)') | ||
471 | 521 | ||
472 | try: | 522 | try: |
473 | tornado.ioloop.IOLoop.current().start() # running... | 523 | tornado.ioloop.IOLoop.current().start() # running... |
aprendizations/static/css/topic.css
1 | -.progress { | ||
2 | - /*position: fixed;*/ | ||
3 | - top: 0; | ||
4 | - height: 70px; | ||
5 | - border-radius: 0px; | ||
6 | -} | ||
7 | body { | 1 | body { |
8 | - margin: 0; | ||
9 | - padding-top: 0px; | ||
10 | margin-bottom: 120px; /* Margin bottom by footer height */ | 2 | margin-bottom: 120px; /* Margin bottom by footer height */ |
11 | } | 3 | } |
12 | 4 | ||
@@ -19,10 +11,6 @@ body { | @@ -19,10 +11,6 @@ body { | ||
19 | /*background-color: #f5f5f5;*/ | 11 | /*background-color: #f5f5f5;*/ |
20 | } | 12 | } |
21 | 13 | ||
22 | -html { | ||
23 | - position: relative; | ||
24 | - min-height: 100%; | ||
25 | -} | ||
26 | .CodeMirror { | 14 | .CodeMirror { |
27 | border: 1px solid #eee; | 15 | border: 1px solid #eee; |
28 | height: auto; | 16 | height: auto; |
aprendizations/templates/topic.html
1 | -<!doctype html> | ||
2 | -<html> | ||
3 | - | 1 | +<!DOCTYPE html> |
2 | +<html lang="pt-PT"> | ||
4 | <head> | 3 | <head> |
5 | <title>{{appname}}</title> | 4 | <title>{{appname}}</title> |
6 | - <link rel="icon" href="/static/favicon.ico"> | ||
7 | - | ||
8 | <meta charset="utf-8"> | 5 | <meta charset="utf-8"> |
9 | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> | 6 | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> |
10 | <meta name="author" content="Miguel Barão"> | 7 | <meta name="author" content="Miguel Barão"> |
8 | + <link rel="icon" href="/static/favicon.ico"> | ||
11 | 9 | ||
12 | <!-- MathJax3 --> | 10 | <!-- MathJax3 --> |
13 | <script> | 11 | <script> |
14 | MathJax = { | 12 | MathJax = { |
15 | tex: { | 13 | tex: { |
16 | - inlineMath: [ | ||
17 | - ['$$$', '$$$'], | ||
18 | - ['\\(', '\\)'] | ||
19 | - ] | 14 | + inlineMath: [['$$$', '$$$'], ['\\(', '\\)']] |
20 | }, | 15 | }, |
21 | svg: { | 16 | svg: { |
22 | fontCache: 'global' | 17 | fontCache: 'global' |
23 | } | 18 | } |
24 | }; | 19 | }; |
25 | </script> | 20 | </script> |
26 | - <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script> | ||
27 | - <!-- Styles --> | ||
28 | - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css"> | ||
29 | - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css"> | ||
30 | - <link rel="stylesheet" href="/static/codemirror/lib/codemirror.css"> | ||
31 | - <link rel="stylesheet" href="/static/css/animate.min.css"> | ||
32 | - <link rel="stylesheet" href="/static/css/github.css"> | ||
33 | - <link rel="stylesheet" href="/static/css/topic.css"> | ||
34 | <!-- Scripts --> | 21 | <!-- Scripts --> |
22 | + <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script> | ||
23 | + <!-- <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg-full.js"></script> --> | ||
35 | <script defer src="/static/mdbootstrap/js/jquery.min.js"></script> | 24 | <script defer src="/static/mdbootstrap/js/jquery.min.js"></script> |
36 | <script defer src="/static/mdbootstrap/js/popper.min.js"></script> | 25 | <script defer src="/static/mdbootstrap/js/popper.min.js"></script> |
37 | <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script> | 26 | <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script> |
@@ -39,10 +28,23 @@ | @@ -39,10 +28,23 @@ | ||
39 | <script defer src="/static/fontawesome-free/js/all.min.js"></script> | 28 | <script defer src="/static/fontawesome-free/js/all.min.js"></script> |
40 | <script defer src="/static/codemirror/lib/codemirror.js"></script> | 29 | <script defer src="/static/codemirror/lib/codemirror.js"></script> |
41 | <script defer src="/static/js/topic.js"></script> | 30 | <script defer src="/static/js/topic.js"></script> |
31 | + | ||
32 | + <!-- Styles --> | ||
33 | + <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css"> | ||
34 | + <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css"> | ||
35 | + <link rel="stylesheet" href="/static/codemirror/lib/codemirror.css"> | ||
36 | + <link rel="stylesheet" href="/static/css/animate.min.css"> | ||
37 | + <link rel="stylesheet" href="/static/css/github.css"> | ||
38 | + <link rel="stylesheet" href="/static/css/topic.css"> | ||
42 | </head> | 39 | </head> |
43 | <!-- ===================================================================== --> | 40 | <!-- ===================================================================== --> |
44 | 41 | ||
45 | <body> | 42 | <body> |
43 | + <!-- Progress bar --> | ||
44 | + <div class="progress fixed-top" style="height: 70px; border-radius: 0px;"> | ||
45 | + <div class="progress-bar bg-warning" id="topic_progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="min-width: 1em;width: 0%"></div> | ||
46 | + </div> | ||
47 | + | ||
46 | <!-- Navbar --> | 48 | <!-- Navbar --> |
47 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> | 49 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> |
48 | <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora"> | 50 | <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora"> |
@@ -70,12 +72,8 @@ | @@ -70,12 +72,8 @@ | ||
70 | </div> | 72 | </div> |
71 | </nav> | 73 | </nav> |
72 | <!-- ===================================================================== --> | 74 | <!-- ===================================================================== --> |
73 | - <div class="progress"> | ||
74 | - <div class="progress-bar bg-warning" id="topic_progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="min-width: 1em;width: 0%"></div> | ||
75 | - </div> | ||
76 | - <!-- ===================================================================== --> | ||
77 | <!-- main panel with questions --> | 75 | <!-- main panel with questions --> |
78 | - <div class="container" id="container"> | 76 | + <div class="container" id="container" style="padding-top: 100px;"> |
79 | <div id="notifications"></div> | 77 | <div id="notifications"></div> |
80 | <div class="my-5" id="content"> | 78 | <div class="my-5" id="content"> |
81 | <form action="/question" method="post" id="question_form" autocomplete="off"> | 79 | <form action="/question" method="post" id="question_form" autocomplete="off"> |
@@ -101,5 +99,4 @@ | @@ -101,5 +99,4 @@ | ||
101 | <!-- title="Shift-Enter" --> | 99 | <!-- title="Shift-Enter" --> |
102 | </div> | 100 | </div> |
103 | </body> | 101 | </body> |
104 | - | ||
105 | </html> | 102 | </html> |
106 | \ No newline at end of file | 103 | \ No newline at end of file |