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