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 | ... | ... |