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 | 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 | 8 | - nao esta a seguir o max_tries definido no ficheiro de dependencias. |
5 | 9 | - devia mostrar timeout para o aluno saber a razao. |
6 | 10 | - permitir configuracao para escolher entre static files locais ou remotos | ... | ... |
README.md
... | ... | @@ -141,7 +141,7 @@ sudo pkg install py36-certbot # FreeBSD |
141 | 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 | 146 | ```sh |
147 | 147 | sudo certbot certonly --standalone -d www.example.com # first time |
... | ... | @@ -151,6 +151,7 @@ sudo certbot renew # renew |
151 | 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 | 153 | ```sh |
154 | +cd ~/.local/share/certs | |
154 | 155 | sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem . |
155 | 156 | sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem . |
156 | 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 | 6 | # python standard library |
3 | 7 | import asyncio |
... | ... | @@ -6,7 +10,7 @@ from contextlib import contextmanager # `with` statement in db sessions |
6 | 10 | from datetime import datetime |
7 | 11 | import logging |
8 | 12 | from random import random |
9 | -from os import path | |
13 | +from os.path import join, exists | |
10 | 14 | from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict |
11 | 15 | |
12 | 16 | # third party libraries |
... | ... | @@ -15,10 +19,10 @@ import networkx as nx |
15 | 19 | import sqlalchemy as sa |
16 | 20 | |
17 | 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 | 28 | # setup logger for this module |
... | ... | @@ -27,33 +31,37 @@ logger = logging.getLogger(__name__) |
27 | 31 | |
28 | 32 | # ============================================================================ |
29 | 33 | class LearnException(Exception): |
30 | - pass | |
34 | + '''Exceptions raised from the LearnApp class''' | |
31 | 35 | |
32 | 36 | |
33 | 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 | 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 | 65 | session = self.Session(**kw) |
58 | 66 | try: |
59 | 67 | yield session |
... | ... | @@ -66,15 +74,13 @@ class LearnApp(object): |
66 | 74 | session.close() |
67 | 75 | |
68 | 76 | # ------------------------------------------------------------------------ |
69 | - # init | |
70 | - # ------------------------------------------------------------------------ | |
71 | 77 | def __init__(self, |
72 | 78 | courses: str, # filename with course configurations |
73 | 79 | prefix: str, # path to topics |
74 | 80 | db: str, # database filename |
75 | 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 | 84 | self.online: Dict[str, Dict] = dict() # online students |
79 | 85 | |
80 | 86 | try: |
... | ... | @@ -88,123 +94,130 @@ class LearnApp(object): |
88 | 94 | self.deps = nx.DiGraph(prefix=prefix) |
89 | 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 | 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 | 111 | # --- courses dict |
102 | 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 | 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 | 119 | logger.error(msg) |
110 | 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 | 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 | 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 | 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 | 141 | logger.info('Starting sanity checks (may take a while...)') |
127 | 142 | |
128 | 143 | errors: int = 0 |
129 | 144 | for qref in self.factory: |
130 | - logger.debug(f'checking {qref}...') | |
145 | + logger.debug('checking %s...', qref) | |
131 | 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 | 150 | errors += 1 |
136 | 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 | 159 | errors += 1 |
145 | 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 | 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 | 171 | errors += 1 |
157 | 172 | continue # to next test |
158 | 173 | |
159 | 174 | if errors > 0: |
160 | - logger.error(f'{errors:>6} error(s) found.') | |
175 | + logger.error('%6d error(s) found.', errors) # {errors:>6} | |
161 | 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 | 188 | # wait random time to minimize timing attacks |
176 | 189 | await asyncio.sleep(random()) |
177 | 190 | |
178 | 191 | loop = asyncio.get_running_loop() |
179 | 192 | if found is None: |
180 | - logger.info(f'User "{uid}" does not exist') | |
193 | + logger.info('User "%s" does not exist', uid) | |
181 | 194 | await loop.run_in_executor(None, bcrypt.hashpw, b'', |
182 | 195 | bcrypt.gensalt()) # just spend time |
183 | 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 | 204 | if pw_ok: |
193 | 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 | 207 | counter = self.online[uid]['counter'] |
196 | 208 | else: |
197 | - logger.info(f'User "{uid}" logged in') | |
209 | + logger.info('User "%s" logged in', uid) | |
198 | 210 | counter = 0 |
199 | 211 | |
200 | 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 | 217 | state = {t.topic_id: { |
205 | 218 | 'level': t.level, |
206 | 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 | 222 | self.online[uid] = { |
210 | 223 | 'number': uid, |
... | ... | @@ -216,179 +229,192 @@ class LearnApp(object): |
216 | 229 | } |
217 | 230 | |
218 | 231 | else: |
219 | - logger.info(f'User "{uid}" wrong password') | |
232 | + logger.info('User "%s" wrong password', uid) | |
220 | 233 | |
221 | 234 | return pw_ok |
222 | 235 | |
223 | 236 | # ------------------------------------------------------------------------ |
224 | - # logout | |
225 | - # ------------------------------------------------------------------------ | |
226 | 237 | def logout(self, uid: str) -> None: |
238 | + '''User logout''' | |
227 | 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 | 249 | return False |
236 | 250 | |
237 | 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 | 262 | return True |
247 | 263 | |
248 | 264 | # ------------------------------------------------------------------------ |
249 | - # Checks answer and update database. Returns corrected question. | |
250 | - # ------------------------------------------------------------------------ | |
251 | 265 | async def check_answer(self, uid: str, answer) -> Question: |
266 | + ''' | |
267 | + Checks answer and update database. | |
268 | + Returns corrected question. | |
269 | + ''' | |
252 | 270 | student = self.online[uid]['state'] |
253 | 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 | 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 | 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 | 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 | 300 | # save topic to database if finished |
279 | 301 | if student.topic_has_finished(): |
280 | 302 | topic: str = student.get_previous_topic() |
281 | 303 | level: float = student.get_topic_level(topic) |
282 | 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 | 314 | # insert new studenttopic into database |
291 | 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 | 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 | 322 | else: |
299 | 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 | 333 | def start_course(self, uid: str, course_id: str) -> None: |
334 | + '''Start course''' | |
335 | + | |
312 | 336 | student = self.online[uid]['state'] |
313 | 337 | try: |
314 | 338 | student.start_course(course_id) |
315 | 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 | 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 | 348 | async def start_topic(self, uid: str, topic: str) -> None: |
349 | + '''Start new topic''' | |
350 | + | |
325 | 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 | 356 | try: |
331 | 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 | 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 | 385 | raise LearnException('Database does not exist. ' |
358 | 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 | 389 | self.Session = sa.orm.sessionmaker(bind=engine) |
362 | 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 | 395 | except Exception: |
368 | - logger.error(f'Database "{db}" not usable!') | |
396 | + logger.error('Database "%s" not usable!', database) | |
369 | 397 | raise DatabaseUnusableError() |
370 | 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 | 415 | defaults = { |
387 | 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 | 418 | 'shuffle_questions': True, |
393 | 419 | 'choose': 9999, |
394 | 420 | 'forgetting_factor': 1.0, # no forgetting |
... | ... | @@ -400,20 +426,21 @@ class LearnApp(object): |
400 | 426 | |
401 | 427 | # iterate over topics and populate graph |
402 | 428 | topics: Dict[str, Dict] = config.get('topics', {}) |
403 | - g.add_nodes_from(topics.keys()) | |
429 | + self.deps.add_nodes_from(topics.keys()) | |
404 | 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 | 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 | 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 | 458 | logger.info('Building questions factory:') |
431 | 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 | 464 | return factory |
438 | 465 | |
439 | 466 | # ------------------------------------------------------------------------ |
440 | 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 | 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 | 472 | # load questions as list of dicts |
447 | 473 | try: |
448 | - fullpath: str = path.join(t['path'], t['file']) | |
474 | + fullpath: str = join(topic['path'], topic['file']) | |
449 | 475 | except Exception: |
450 | 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 | 479 | msg = f'{msg1}. {msg2}' |
453 | 480 | logger.error(msg) |
454 | 481 | raise LearnException(msg) |
455 | - logger.debug(f' Loading {fullpath}') | |
482 | + logger.debug(' Loading %s', fullpath) | |
456 | 483 | try: |
457 | 484 | questions: List[QDict] = load_yaml(fullpath) |
458 | 485 | except Exception: |
459 | - if t['type'] == 'chapter': | |
486 | + if topic['type'] == 'chapter': | |
460 | 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 | 492 | if not isinstance(questions, list): |
467 | 493 | msg = f'File "{fullpath}" must be a list of questions' |
... | ... | @@ -473,134 +499,162 @@ class LearnApp(object): |
473 | 499 | # undefined are set to topic:n, where n is the question number |
474 | 500 | # within the file |
475 | 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 | 504 | if qref in localrefs: |
479 | - msg = f'Duplicate ref "{qref}" in "{t["path"]}"' | |
505 | + msg = f'Duplicate ref "{qref}" in "{topic["path"]}"' | |
480 | 506 | raise LearnException(msg) |
481 | 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 | 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 | 526 | return factory |
501 | 527 | |
502 | 528 | # ------------------------------------------------------------------------ |
503 | 529 | def get_login_counter(self, uid: str) -> int: |
530 | + '''login counter''' # FIXME | |
504 | 531 | return int(self.online[uid]['counter']) |
505 | 532 | |
506 | 533 | # ------------------------------------------------------------------------ |
507 | 534 | def get_student_name(self, uid: str) -> str: |
535 | + '''Get the username''' | |
508 | 536 | return self.online[uid].get('name', '') |
509 | 537 | |
510 | 538 | # ------------------------------------------------------------------------ |
511 | 539 | def get_student_state(self, uid: str) -> List[Dict[str, Any]]: |
540 | + '''Get the knowledge state of a given user''' | |
512 | 541 | return self.online[uid]['state'].get_knowledge_state() |
513 | 542 | |
514 | 543 | # ------------------------------------------------------------------------ |
515 | 544 | def get_student_progress(self, uid: str) -> float: |
545 | + '''Get the current topic progress of a given user''' | |
516 | 546 | return float(self.online[uid]['state'].get_topic_progress()) |
517 | 547 | |
518 | 548 | # ------------------------------------------------------------------------ |
519 | 549 | def get_current_question(self, uid: str) -> Optional[Question]: |
550 | + '''Get the current question of a given user''' | |
520 | 551 | q: Optional[Question] = self.online[uid]['state'].get_current_question() |
521 | 552 | return q |
522 | 553 | |
523 | 554 | # ------------------------------------------------------------------------ |
524 | 555 | def get_current_question_id(self, uid: str) -> str: |
556 | + '''Get id of the current question for a given user''' | |
525 | 557 | return str(self.online[uid]['state'].get_current_question()['qid']) |
526 | 558 | |
527 | 559 | # ------------------------------------------------------------------------ |
528 | 560 | def get_student_question_type(self, uid: str) -> str: |
561 | + '''Get type of the current question for a given user''' | |
529 | 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 | 569 | def get_student_course_title(self, uid: str) -> str: |
570 | + '''get the title of the current course for a given user''' | |
537 | 571 | return str(self.online[uid]['state'].get_current_course_title()) |
538 | 572 | |
539 | 573 | # ------------------------------------------------------------------------ |
540 | 574 | def get_current_course_id(self, uid: str) -> Optional[str]: |
575 | + '''get the current course (id) of a given user''' | |
541 | 576 | cid: Optional[str] = self.online[uid]['state'].get_current_course_id() |
542 | 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 | 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 | 591 | topic: str = self.online[uid]['state'].get_current_topic() |
551 | 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 | 596 | def get_courses(self) -> Dict[str, Dict[str, Any]]: |
597 | + ''' | |
598 | + Get dictionary with all courses {'course1': {...}, 'course2': {...}} | |
599 | + ''' | |
556 | 600 | return self.courses |
557 | 601 | |
558 | 602 | # ------------------------------------------------------------------------ |
559 | 603 | def get_course(self, course_id: str) -> Dict[str, Any]: |
604 | + ''' | |
605 | + Get dictionary {'title': ..., 'description':..., 'goals':...} | |
606 | + ''' | |
560 | 607 | return self.courses[course_id] |
561 | 608 | |
562 | 609 | # ------------------------------------------------------------------------ |
563 | 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 | 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 | 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 | 643 | for u in total} |
587 | 644 | |
588 | 645 | # compute topic progress |
589 | 646 | now = datetime.now() |
590 | 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 | 651 | if topic in goals: |
595 | 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 | 6 | # python standard library |
3 | 7 | import asyncio |
... | ... | @@ -5,7 +9,7 @@ import base64 |
5 | 9 | import functools |
6 | 10 | import logging.config |
7 | 11 | import mimetypes |
8 | -from os import path | |
12 | +from os.path import join, dirname, expanduser | |
9 | 13 | import signal |
10 | 14 | import sys |
11 | 15 | from typing import List, Optional, Union |
... | ... | @@ -16,8 +20,9 @@ import tornado.web |
16 | 20 | from tornado.escape import to_unicode |
17 | 21 | |
18 | 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 | 28 | # setup logger for this module |
... | ... | @@ -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 | 33 | def admin_only(func): |
34 | + ''' | |
35 | + Decorator used to restrict access to the administrator | |
36 | + ''' | |
31 | 37 | @functools.wraps(func) |
32 | 38 | def wrapper(self, *args, **kwargs): |
33 | 39 | if self.current_user != '0': |
34 | 40 | raise tornado.web.HTTPError(403) # forbidden |
35 | - else: | |
36 | - func(self, *args, **kwargs) | |
41 | + func(self, *args, **kwargs) | |
37 | 42 | return wrapper |
38 | 43 | |
39 | 44 | |
40 | 45 | # ============================================================================ |
41 | -# WebApplication - Tornado Web Server | |
42 | -# ============================================================================ | |
43 | 46 | class WebApplication(tornado.web.Application): |
44 | - | |
47 | + ''' | |
48 | + WebApplication - Tornado Web Server | |
49 | + ''' | |
45 | 50 | def __init__(self, learnapp, debug=False): |
46 | 51 | handlers = [ |
47 | - (r'/login', LoginHandler), | |
48 | - (r'/logout', LogoutHandler), | |
52 | + (r'/login', LoginHandler), | |
53 | + (r'/logout', LogoutHandler), | |
49 | 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 | 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 | 66 | 'static_url_prefix': '/static/', |
62 | 67 | 'xsrf_cookies': True, |
63 | 68 | 'cookie_secret': base64.b64encode(uuid.uuid4().bytes), |
... | ... | @@ -71,30 +76,37 @@ class WebApplication(tornado.web.Application): |
71 | 76 | # ============================================================================ |
72 | 77 | # Handlers |
73 | 78 | # ============================================================================ |
74 | - | |
75 | -# ---------------------------------------------------------------------------- | |
76 | -# Base handler common to all handlers. | |
77 | -# ---------------------------------------------------------------------------- | |
79 | +# pylint: disable=abstract-method | |
78 | 80 | class BaseHandler(tornado.web.RequestHandler): |
81 | + ''' | |
82 | + Base handler common to all handlers. | |
83 | + ''' | |
79 | 84 | @property |
80 | 85 | def learn(self): |
86 | + '''easier access to learnapp''' | |
81 | 87 | return self.application.learn |
82 | 88 | |
83 | 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 | 94 | counter = self.get_secure_cookie('counter').decode('utf-8') |
88 | 95 | if counter == str(self.learn.get_login_counter(uid)): |
89 | 96 | return uid |
97 | + return None | |
90 | 98 | |
91 | 99 | |
92 | 100 | # ---------------------------------------------------------------------------- |
93 | -# /rankings | |
94 | -# ---------------------------------------------------------------------------- | |
95 | 101 | class RankingsHandler(BaseHandler): |
102 | + ''' | |
103 | + Handles rankings page | |
104 | + ''' | |
96 | 105 | @tornado.web.authenticated |
97 | 106 | def get(self): |
107 | + ''' | |
108 | + Renders list of students that have answers in this course. | |
109 | + ''' | |
98 | 110 | uid = self.current_user |
99 | 111 | current_course = self.learn.get_current_course_id(uid) |
100 | 112 | course_id = self.get_query_argument('course', default=current_course) |
... | ... | @@ -110,23 +122,33 @@ class RankingsHandler(BaseHandler): |
110 | 122 | |
111 | 123 | |
112 | 124 | # ---------------------------------------------------------------------------- |
113 | -# /auth/login | |
125 | +# | |
114 | 126 | # ---------------------------------------------------------------------------- |
115 | 127 | class LoginHandler(BaseHandler): |
128 | + ''' | |
129 | + Handles /login | |
130 | + ''' | |
116 | 131 | def get(self): |
132 | + ''' | |
133 | + Renders login page | |
134 | + ''' | |
117 | 135 | self.render('login.html', |
118 | 136 | appname=APP_NAME, |
119 | 137 | error='') |
120 | 138 | |
121 | 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 | 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 | 152 | self.set_secure_cookie('counter', counter) |
131 | 153 | self.redirect('/') |
132 | 154 | else: |
... | ... | @@ -136,11 +158,15 @@ class LoginHandler(BaseHandler): |
136 | 158 | |
137 | 159 | |
138 | 160 | # ---------------------------------------------------------------------------- |
139 | -# /auth/logout | |
140 | -# ---------------------------------------------------------------------------- | |
141 | 161 | class LogoutHandler(BaseHandler): |
162 | + ''' | |
163 | + Handles /logout | |
164 | + ''' | |
142 | 165 | @tornado.web.authenticated |
143 | 166 | def get(self): |
167 | + ''' | |
168 | + clears cookies and removes user session | |
169 | + ''' | |
144 | 170 | self.clear_cookie('user') |
145 | 171 | self.clear_cookie('counter') |
146 | 172 | self.redirect('/') |
... | ... | @@ -151,12 +177,18 @@ class LogoutHandler(BaseHandler): |
151 | 177 | |
152 | 178 | # ---------------------------------------------------------------------------- |
153 | 179 | class ChangePasswordHandler(BaseHandler): |
180 | + ''' | |
181 | + Handles password change | |
182 | + ''' | |
154 | 183 | @tornado.web.authenticated |
155 | 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 | 192 | if changed_ok: |
161 | 193 | notification = self.render_string( |
162 | 194 | 'notification.html', |
... | ... | @@ -174,45 +206,53 @@ class ChangePasswordHandler(BaseHandler): |
174 | 206 | |
175 | 207 | |
176 | 208 | # ---------------------------------------------------------------------------- |
177 | -# / | |
178 | -# redirects to appropriate place | |
179 | -# ---------------------------------------------------------------------------- | |
180 | 209 | class RootHandler(BaseHandler): |
210 | + ''' | |
211 | + Handles root / | |
212 | + ''' | |
181 | 213 | @tornado.web.authenticated |
182 | 214 | def get(self): |
215 | + '''Simply redirects to the main entrypoint''' | |
183 | 216 | self.redirect('/courses') |
184 | 217 | |
185 | 218 | |
186 | 219 | # ---------------------------------------------------------------------------- |
187 | -# /courses | |
188 | -# Shows a list of available courses | |
189 | -# ---------------------------------------------------------------------------- | |
190 | 220 | class CoursesHandler(BaseHandler): |
221 | + ''' | |
222 | + Handles /courses | |
223 | + ''' | |
191 | 224 | @tornado.web.authenticated |
192 | 225 | def get(self): |
226 | + '''Renders list of available courses''' | |
193 | 227 | uid = self.current_user |
194 | 228 | self.render('courses.html', |
195 | 229 | appname=APP_NAME, |
196 | 230 | uid=uid, |
197 | 231 | name=self.learn.get_student_name(uid), |
198 | 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 | 238 | class CourseHandler(BaseHandler): |
239 | + ''' | |
240 | + Handles a particular course to show the topics table | |
241 | + ''' | |
242 | + | |
207 | 243 | @tornado.web.authenticated |
208 | 244 | def get(self, course_id): |
245 | + ''' | |
246 | + Handles get /course/... | |
247 | + Starts a given course and show list of topics | |
248 | + ''' | |
209 | 249 | uid = self.current_user |
210 | 250 | if course_id == '': |
211 | 251 | course_id = self.learn.get_current_course_id(uid) |
212 | 252 | |
213 | 253 | try: |
214 | 254 | self.learn.start_course(uid, course_id) |
215 | - except KeyError: | |
255 | + except LearnException: | |
216 | 256 | self.redirect('/courses') |
217 | 257 | |
218 | 258 | self.render('maintopics-table.html', |
... | ... | @@ -225,17 +265,21 @@ class CourseHandler(BaseHandler): |
225 | 265 | ) |
226 | 266 | |
227 | 267 | |
228 | -# ---------------------------------------------------------------------------- | |
229 | -# /topic/... | |
230 | -# Start a given topic | |
231 | -# ---------------------------------------------------------------------------- | |
268 | +# ============================================================================ | |
232 | 269 | class TopicHandler(BaseHandler): |
270 | + ''' | |
271 | + Handles a topic | |
272 | + ''' | |
233 | 273 | @tornado.web.authenticated |
234 | 274 | async def get(self, topic): |
275 | + ''' | |
276 | + Handles get /topic/... | |
277 | + Starts a given topic | |
278 | + ''' | |
235 | 279 | uid = self.current_user |
236 | 280 | |
237 | 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 | 283 | except KeyError: |
240 | 284 | self.redirect('/topics') |
241 | 285 | |
... | ... | @@ -243,31 +287,34 @@ class TopicHandler(BaseHandler): |
243 | 287 | appname=APP_NAME, |
244 | 288 | uid=uid, |
245 | 289 | name=self.learn.get_student_name(uid), |
246 | - # course_title=self.learn.get_student_course_title(uid), | |
247 | 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 | 295 | class FileHandler(BaseHandler): |
296 | + ''' | |
297 | + Serves files from the /public subdir of the topics. | |
298 | + ''' | |
255 | 299 | @tornado.web.authenticated |
256 | 300 | async def get(self, filename): |
301 | + ''' | |
302 | + Serves files from /public subdirectories of a particular topic | |
303 | + ''' | |
257 | 304 | uid = self.current_user |
258 | 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 | 307 | content_type = mimetypes.guess_type(filename)[0] |
261 | 308 | |
262 | 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 | 312 | except FileNotFoundError: |
266 | - logger.error(f'File not found: {filepath}') | |
313 | + logger.error('File not found: %s', filepath) | |
267 | 314 | except PermissionError: |
268 | - logger.error(f'No permission: {filepath}') | |
315 | + logger.error('No permission: %s', filepath) | |
269 | 316 | except Exception: |
270 | - logger.error(f'Error reading: {filepath}') | |
317 | + logger.error('Error reading: %s', filepath) | |
271 | 318 | raise |
272 | 319 | else: |
273 | 320 | self.set_header("Content-Type", content_type) |
... | ... | @@ -275,10 +322,11 @@ class FileHandler(BaseHandler): |
275 | 322 | await self.flush() |
276 | 323 | |
277 | 324 | |
278 | -# ---------------------------------------------------------------------------- | |
279 | -# respond to AJAX to get a JSON question | |
280 | -# ---------------------------------------------------------------------------- | |
325 | +# ============================================================================ | |
281 | 326 | class QuestionHandler(BaseHandler): |
327 | + ''' | |
328 | + Responds to AJAX to get a JSON question | |
329 | + ''' | |
282 | 330 | templates = { |
283 | 331 | 'checkbox': 'question-checkbox.html', |
284 | 332 | 'radio': 'question-radio.html', |
... | ... | @@ -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 | 345 | @tornado.web.authenticated |
302 | 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 | 351 | logger.debug('[QuestionHandler]') |
304 | 352 | user = self.current_user |
305 | - q = await self.learn.get_question(user) | |
353 | + question = await self.learn.get_question(user) | |
306 | 354 | |
307 | 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 | 359 | response = { |
312 | 360 | 'method': 'new_question', |
313 | 361 | 'params': { |
314 | - 'type': q['type'], | |
362 | + 'type': question['type'], | |
315 | 363 | 'question': to_unicode(qhtml), |
316 | 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 | 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 | 382 | @tornado.web.authenticated |
339 | 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 | 388 | user = self.current_user |
341 | 389 | answer = self.get_body_arguments('answer') # list |
342 | 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 | 393 | # --- check if browser opened different questions simultaneously |
346 | 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 | 396 | self.write({ |
349 | 397 | 'method': 'invalid', |
350 | 398 | 'params': { |
... | ... | @@ -370,51 +418,55 @@ class QuestionHandler(BaseHandler): |
370 | 418 | ans = answer |
371 | 419 | |
372 | 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 | 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 | 435 | response['params'] = { |
386 | - 'type': q['type'], | |
436 | + 'type': question['type'], | |
387 | 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 | 447 | response['params'] = { |
397 | - 'type': q['type'], | |
448 | + 'type': question['type'], | |
398 | 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 | 461 | response['params'] = { |
410 | - 'type': q['type'], | |
462 | + 'type': question['type'], | |
411 | 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 | 468 | else: |
417 | - logger.error(f'Unknown question status: {q["status"]}') | |
469 | + logger.error('Unknown question status: %s', question["status"]) | |
418 | 470 | |
419 | 471 | self.write(response) |
420 | 472 | |
... | ... | @@ -422,29 +474,29 @@ class QuestionHandler(BaseHandler): |
422 | 474 | # ---------------------------------------------------------------------------- |
423 | 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 | 483 | tornado.ioloop.IOLoop.current().stop() |
429 | - logger.critical('Webserver stopped.') | |
484 | + logging.critical('Webserver stopped.') | |
430 | 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 | 494 | # --- create web application |
442 | 495 | try: |
443 | 496 | webapp = WebApplication(app, debug=debug) |
444 | 497 | except Exception: |
445 | 498 | logger.critical('Failed to start web application.') |
446 | - raise | |
447 | - # sys.exit(1) | |
499 | + sys.exit(1) | |
448 | 500 | else: |
449 | 501 | logger.info('Web application started (tornado.web.Application)') |
450 | 502 | |
... | ... | @@ -460,14 +512,12 @@ def run_webserver(app, |
460 | 512 | try: |
461 | 513 | httpserver.listen(port) |
462 | 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 | 516 | sys.exit(1) |
465 | - else: | |
466 | - logger.info(f'HTTP server listening on port {port}') | |
467 | 517 | |
468 | 518 | # --- run webserver |
519 | + logger.info('Webserver listening on %d... (Ctrl-C to stop)', port) | |
469 | 520 | signal.signal(signal.SIGINT, signal_handler) |
470 | - logger.info('Webserver running... (Ctrl-C to stop)') | |
471 | 521 | |
472 | 522 | try: |
473 | 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 | 1 | body { |
8 | - margin: 0; | |
9 | - padding-top: 0px; | |
10 | 2 | margin-bottom: 120px; /* Margin bottom by footer height */ |
11 | 3 | } |
12 | 4 | |
... | ... | @@ -19,10 +11,6 @@ body { |
19 | 11 | /*background-color: #f5f5f5;*/ |
20 | 12 | } |
21 | 13 | |
22 | -html { | |
23 | - position: relative; | |
24 | - min-height: 100%; | |
25 | -} | |
26 | 14 | .CodeMirror { |
27 | 15 | border: 1px solid #eee; |
28 | 16 | height: auto; | ... | ... |
aprendizations/templates/topic.html
1 | -<!doctype html> | |
2 | -<html> | |
3 | - | |
1 | +<!DOCTYPE html> | |
2 | +<html lang="pt-PT"> | |
4 | 3 | <head> |
5 | 4 | <title>{{appname}}</title> |
6 | - <link rel="icon" href="/static/favicon.ico"> | |
7 | - | |
8 | 5 | <meta charset="utf-8"> |
9 | 6 | <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> |
10 | 7 | <meta name="author" content="Miguel Barão"> |
8 | + <link rel="icon" href="/static/favicon.ico"> | |
11 | 9 | |
12 | 10 | <!-- MathJax3 --> |
13 | 11 | <script> |
14 | 12 | MathJax = { |
15 | 13 | tex: { |
16 | - inlineMath: [ | |
17 | - ['$$$', '$$$'], | |
18 | - ['\\(', '\\)'] | |
19 | - ] | |
14 | + inlineMath: [['$$$', '$$$'], ['\\(', '\\)']] | |
20 | 15 | }, |
21 | 16 | svg: { |
22 | 17 | fontCache: 'global' |
23 | 18 | } |
24 | 19 | }; |
25 | 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 | 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 | 24 | <script defer src="/static/mdbootstrap/js/jquery.min.js"></script> |
36 | 25 | <script defer src="/static/mdbootstrap/js/popper.min.js"></script> |
37 | 26 | <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script> |
... | ... | @@ -39,10 +28,23 @@ |
39 | 28 | <script defer src="/static/fontawesome-free/js/all.min.js"></script> |
40 | 29 | <script defer src="/static/codemirror/lib/codemirror.js"></script> |
41 | 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 | 39 | </head> |
43 | 40 | <!-- ===================================================================== --> |
44 | 41 | |
45 | 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 | 48 | <!-- Navbar --> |
47 | 49 | <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> |
48 | 50 | <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora"> |
... | ... | @@ -70,12 +72,8 @@ |
70 | 72 | </div> |
71 | 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 | 75 | <!-- main panel with questions --> |
78 | - <div class="container" id="container"> | |
76 | + <div class="container" id="container" style="padding-top: 100px;"> | |
79 | 77 | <div id="notifications"></div> |
80 | 78 | <div class="my-5" id="content"> |
81 | 79 | <form action="/question" method="post" id="question_form" autocomplete="off"> |
... | ... | @@ -101,5 +99,4 @@ |
101 | 99 | <!-- title="Shift-Enter" --> |
102 | 100 | </div> |
103 | 101 | </body> |
104 | - | |
105 | 102 | </html> |
106 | 103 | \ No newline at end of file | ... | ... |