Commit c37266817994065159840b680208d2f3493af2c7
1 parent
094837eb
Exists in
master
and in
1 other branch
- adds button to /admin page to download CSV file with grades.
- button to close the test after the grades. - replace (scale_points, scale_min, scale_max) by "scale: [0, 20]" - updates demo.yaml - correct-question.py changed to count the number of correct colors - catch exception during loading test configuration - fix exception handling of sqlalchemy - change default logging to include seconds - update BUGS.md
Showing
12 changed files
with
190 additions
and
131 deletions
Show diff stats
BUGS.md
| @@ -2,8 +2,6 @@ | @@ -2,8 +2,6 @@ | ||
| 2 | # BUGS | 2 | # BUGS |
| 3 | 3 | ||
| 4 | - quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20 | 4 | - quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20 |
| 5 | -- servidor ntpd para configurar a data/hora dos portateis dell | ||
| 6 | -- link na pagina com a nota para voltar ao principio. | ||
| 7 | - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?) | 5 | - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?) |
| 8 | - na pagina grade.html as barras estao normalizadas para os limites scale_min e max do teste actual e nao do realizado. | 6 | - na pagina grade.html as barras estao normalizadas para os limites scale_min e max do teste actual e nao do realizado. |
| 9 | - codigo `hello world` nao esta a preservar o whitespace. O renderer de markdown gera a tag <code> que não preserva whitespace. Necessario adicionar <pre>. | 7 | - codigo `hello world` nao esta a preservar o whitespace. O renderer de markdown gera a tag <code> que não preserva whitespace. Necessario adicionar <pre>. |
| @@ -22,9 +20,11 @@ ou usar push (websockets?) | @@ -22,9 +20,11 @@ ou usar push (websockets?) | ||
| 22 | 20 | ||
| 23 | # TODO | 21 | # TODO |
| 24 | 22 | ||
| 23 | +- servidor ntpd para configurar a data/hora dos portateis dell | ||
| 24 | +- autorização dada, mas teste não disponível até que seja dada ordem para começar. | ||
| 25 | +- alunos com necessidades especiais nao podem ter autosubmit. ter um autosubmit_exceptions: ['123', '456'] | ||
| 25 | - mostrar unfocus e window area em /admin | 26 | - mostrar unfocus e window area em /admin |
| 26 | - testar as perguntas todas no início do teste. | 27 | - testar as perguntas todas no início do teste. |
| 27 | -- test: mostrar duração do teste com progressbar no navbar. | ||
| 28 | - submissao fazer um post ajax? | 28 | - submissao fazer um post ajax? |
| 29 | - adicionar opcao para eliminar um teste em curso. | 29 | - adicionar opcao para eliminar um teste em curso. |
| 30 | - enviar resposta de cada pergunta individualmente. | 30 | - enviar resposta de cada pergunta individualmente. |
| @@ -63,6 +63,9 @@ ou usar push (websockets?) | @@ -63,6 +63,9 @@ ou usar push (websockets?) | ||
| 63 | 63 | ||
| 64 | # FIXED | 64 | # FIXED |
| 65 | 65 | ||
| 66 | +- link na pagina com a nota para voltar ao principio. | ||
| 67 | +- default logger config mostrar horas com segundos | ||
| 68 | +- test: mostrar duração do teste com progressbar no navbar. | ||
| 66 | - lidar com eventos unfocus. | 69 | - lidar com eventos unfocus. |
| 67 | - servidor nao esta a lidar com eventos resize. | 70 | - servidor nao esta a lidar com eventos resize. |
| 68 | - sock.bind(sockaddr) OSError: [Errno 48] Address already in use | 71 | - sock.bind(sockaddr) OSError: [Errno 48] Address already in use |
demo/demo.yaml
| 1 | --- | 1 | --- |
| 2 | # ============================================================================ | 2 | # ============================================================================ |
| 3 | -# The test reference should be a unique identifier. It is saved in the database | ||
| 4 | -# so that queries can be done in the terminal like | ||
| 5 | -# sqlite3 students.db "select * from tests where ref='demo'" | 3 | +# Unique identifier of the test. |
| 4 | +# Database queries can be done in the terminal with | ||
| 5 | +# sqlite3 students.db "select * from tests where ref='tutorial'" | ||
| 6 | ref: tutorial | 6 | ref: tutorial |
| 7 | 7 | ||
| 8 | -# Database with student credentials and grades of all questions and tests done | ||
| 9 | -# The database is an sqlite3 file generate with the command initdb | 8 | +# Database file that includes student credentials, tests and questions grades. |
| 9 | +# It's a sqlite3 database generated with the command 'initdb' | ||
| 10 | database: students.db | 10 | database: students.db |
| 11 | 11 | ||
| 12 | -# Directory where the tests including submitted answers and grades are stored. | ||
| 13 | -# The submitted tests and their corrections can be reviewed later. | 12 | +# Directory where the submitted and corrected test are stored for later review. |
| 14 | answers_dir: ans | 13 | answers_dir: ans |
| 15 | 14 | ||
| 16 | # --- optional settings: ----------------------------------------------------- | 15 | # --- optional settings: ----------------------------------------------------- |
| 17 | 16 | ||
| 18 | -# You may wish to refer the course, year or kind of test | 17 | +# Title of this test, e.g. course name, year or test number |
| 19 | # (default: '') | 18 | # (default: '') |
| 20 | title: Teste de demonstração (tutorial) | 19 | title: Teste de demonstração (tutorial) |
| 21 | 20 | ||
| 22 | # Duration in minutes. | 21 | # Duration in minutes. |
| 23 | # (0 or undefined means infinite time) | 22 | # (0 or undefined means infinite time) |
| 24 | duration: 2 | 23 | duration: 2 |
| 25 | -autosubmit: true | ||
| 26 | 24 | ||
| 27 | -# Show points for each question, scale 0-20. | 25 | +# Automatic test submission after the timeout 'duration'? |
| 28 | # (default: false) | 26 | # (default: false) |
| 27 | +autosubmit: true | ||
| 28 | + | ||
| 29 | +# Show points for each question (min and max). | ||
| 30 | +# (default: true) | ||
| 29 | show_points: true | 31 | show_points: true |
| 30 | 32 | ||
| 31 | -# scale final grade to the interval [scale_min, scale_max] | ||
| 32 | -# (default: scale to [0,20]) | ||
| 33 | -scale_max: 20 | ||
| 34 | -scale_min: 0 | ||
| 35 | -scale_points: true | 33 | +# scale final grade to an interval, e.g. [0, 20], keeping the relative weight |
| 34 | +# of the points declared in the questions below. | ||
| 35 | +# (default: no scaling, just use question points) | ||
| 36 | +scale: [0, 5] | ||
| 37 | + | ||
| 38 | +# DEPRECATED: old version, to be removed | ||
| 39 | +# scale_max: 20 | ||
| 40 | +# scale_min: 0 | ||
| 41 | +# scale_points: true | ||
| 36 | 42 | ||
| 37 | # ---------------------------------------------------------------------------- | 43 | # ---------------------------------------------------------------------------- |
| 38 | # Base path applied to the questions files and all the scripts | 44 | # Base path applied to the questions files and all the scripts |
| @@ -50,7 +56,7 @@ files: | @@ -50,7 +56,7 @@ files: | ||
| 50 | # The order is preserved. | 56 | # The order is preserved. |
| 51 | # There are several ways to define each question (explained below). | 57 | # There are several ways to define each question (explained below). |
| 52 | questions: | 58 | questions: |
| 53 | - - tut-test | 59 | + - ref: tut-test |
| 54 | - tut-questions | 60 | - tut-questions |
| 55 | 61 | ||
| 56 | - tut-radio | 62 | - tut-radio |
demo/questions/correct/correct-question.py
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | +''' | ||
| 4 | +Demonstação de um script de correcção | ||
| 5 | +''' | ||
| 6 | + | ||
| 3 | import re | 7 | import re |
| 4 | import sys | 8 | import sys |
| 5 | 9 | ||
| 6 | -msg1 = '''--- | ||
| 7 | -grade: 1.0 | ||
| 8 | -comments: Muito bem! | ||
| 9 | -''' | 10 | +s = sys.stdin.read() |
| 10 | 11 | ||
| 11 | -msg0 = ''' | ||
| 12 | -grade: 0.0 | ||
| 13 | -comments: A resposta correcta é "red green blue". | ||
| 14 | -''' | 12 | +ans = set(re.findall(r'[\w]+', s.lower())) # get words in lowercase |
| 13 | +rgb = set(['red', 'green', 'blue']) # the correct answer | ||
| 15 | 14 | ||
| 16 | -s = sys.stdin.read() | 15 | +# a nota é o número de cores certas menos o número de erradas |
| 16 | +grade = max(0, | ||
| 17 | + len(rgb.intersection(ans)) - len(ans.difference(rgb))) / 3 | ||
| 17 | 18 | ||
| 18 | -answer = set(re.findall(r'[\w]+', s.lower())) # get words in lowercase | ||
| 19 | -rgb_colors = set(['red', 'green', 'blue']) # the correct answer | 19 | +if ans == rgb: |
| 20 | + print('---\n' | ||
| 21 | + 'grade: 1.0\n' | ||
| 22 | + 'comments: Muito bem!') | ||
| 20 | 23 | ||
| 21 | -if answer == rgb_colors: | ||
| 22 | - print(msg1) | ||
| 23 | else: | 24 | else: |
| 24 | - print(msg0) | 25 | + print('---\n' |
| 26 | + f'grade: {grade}\n' | ||
| 27 | + 'comments: A resposta correcta é "red green blue".') |
demo/questions/generators/generate-question.py
| @@ -18,10 +18,11 @@ print(f"""--- | @@ -18,10 +18,11 @@ print(f"""--- | ||
| 18 | type: text | 18 | type: text |
| 19 | title: Geradores de perguntas | 19 | title: Geradores de perguntas |
| 20 | text: | | 20 | text: | |
| 21 | - Existe a possibilidade da pergunta ser gerada por um programa externo. | ||
| 22 | - Este programa deve escrever no `stdout` uma pergunta em formato `yaml` como | ||
| 23 | - os anteriores. Pode também receber argumentos para parametrizar a geração da | ||
| 24 | - pergunta. Aqui está um exemplo de uma pergunta gerada por um script python: | 21 | + Existe a possibilidade da pergunta ser gerada por um programa externo. Este |
| 22 | + programa deve escrever no `stdout` uma pergunta em formato `yaml` como nos | ||
| 23 | + exemplos anteriores. Pode também receber argumentos para parametrizar a | ||
| 24 | + geração da pergunta. Aqui está um exemplo de uma pergunta gerada por um | ||
| 25 | + script python: | ||
| 25 | 26 | ||
| 26 | ```python | 27 | ```python |
| 27 | #!/usr/bin/env python3 | 28 | #!/usr/bin/env python3 |
perguntations/__init__.py
| @@ -32,7 +32,7 @@ proof of submission and for review. | @@ -32,7 +32,7 @@ proof of submission and for review. | ||
| 32 | ''' | 32 | ''' |
| 33 | 33 | ||
| 34 | APP_NAME = 'perguntations' | 34 | APP_NAME = 'perguntations' |
| 35 | -APP_VERSION = '2020.04.dev5' | 35 | +APP_VERSION = '2020.05.dev1' |
| 36 | APP_DESCRIPTION = __doc__ | 36 | APP_DESCRIPTION = __doc__ |
| 37 | 37 | ||
| 38 | __author__ = 'Miguel Barão' | 38 | __author__ = 'Miguel Barão' |
perguntations/app.py
| @@ -6,13 +6,15 @@ Main application module | @@ -6,13 +6,15 @@ Main application module | ||
| 6 | # python standard libraries | 6 | # python standard libraries |
| 7 | import asyncio | 7 | import asyncio |
| 8 | from contextlib import contextmanager # `with` statement in db sessions | 8 | from contextlib import contextmanager # `with` statement in db sessions |
| 9 | +import csv | ||
| 10 | +import io | ||
| 9 | import json | 11 | import json |
| 10 | import logging | 12 | import logging |
| 11 | from os import path | 13 | from os import path |
| 12 | 14 | ||
| 13 | -# user installed packages | 15 | +# installed packages |
| 14 | import bcrypt | 16 | import bcrypt |
| 15 | -from sqlalchemy import create_engine | 17 | +from sqlalchemy import create_engine, exc |
| 16 | from sqlalchemy.orm import sessionmaker | 18 | from sqlalchemy.orm import sessionmaker |
| 17 | 19 | ||
| 18 | # this project | 20 | # this project |
| @@ -73,9 +75,10 @@ class App(): | @@ -73,9 +75,10 @@ class App(): | ||
| 73 | try: | 75 | try: |
| 74 | yield session | 76 | yield session |
| 75 | session.commit() | 77 | session.commit() |
| 76 | - except Exception: | 78 | + except exc.SQLAlchemyError: |
| 77 | logger.error('DB rollback!!!') | 79 | logger.error('DB rollback!!!') |
| 78 | session.rollback() | 80 | session.rollback() |
| 81 | + raise | ||
| 79 | finally: | 82 | finally: |
| 80 | session.close() | 83 | session.close() |
| 81 | 84 | ||
| @@ -85,7 +88,12 @@ class App(): | @@ -85,7 +88,12 @@ class App(): | ||
| 85 | self.allowed = set([]) # '0' is hardcoded to allowed elsewhere | 88 | self.allowed = set([]) # '0' is hardcoded to allowed elsewhere |
| 86 | 89 | ||
| 87 | logger.info('Loading test configuration "%s".', conf["testfile"]) | 90 | logger.info('Loading test configuration "%s".', conf["testfile"]) |
| 88 | - testconf = load_yaml(conf['testfile']) | 91 | + try: |
| 92 | + testconf = load_yaml(conf['testfile']) | ||
| 93 | + except Exception as exc: | ||
| 94 | + logger.critical('Error loading test configuration YAML.') | ||
| 95 | + raise AppException(exc) | ||
| 96 | + | ||
| 89 | testconf.update(conf) # command line options override configuration | 97 | testconf.update(conf) # command line options override configuration |
| 90 | 98 | ||
| 91 | # start test factory | 99 | # start test factory |
| @@ -258,7 +266,7 @@ class App(): | @@ -258,7 +266,7 @@ class App(): | ||
| 258 | 266 | ||
| 259 | # ------------------------------------------------------------------------ | 267 | # ------------------------------------------------------------------------ |
| 260 | def event_test(self, uid, cmd, value): | 268 | def event_test(self, uid, cmd, value): |
| 261 | - '''handle browser events the occur during the test''' | 269 | + '''handles browser events the occur during the test''' |
| 262 | if cmd == 'focus': | 270 | if cmd == 'focus': |
| 263 | logger.info('Student %s: focus %s', uid, value) | 271 | logger.info('Student %s: focus %s', uid, value) |
| 264 | elif cmd == 'size': | 272 | elif cmd == 'size': |
| @@ -273,6 +281,21 @@ class App(): | @@ -273,6 +281,21 @@ class App(): | ||
| 273 | # def get_student_name(self, uid): | 281 | # def get_student_name(self, uid): |
| 274 | # return self.online[uid]['student']['name'] | 282 | # return self.online[uid]['student']['name'] |
| 275 | 283 | ||
| 284 | + def get_test_csv(self): | ||
| 285 | + '''generates a CSV with the grades of the test''' | ||
| 286 | + with self.db_session() as sess: | ||
| 287 | + grades = sess.query(Test.student_id, Test.grade, | ||
| 288 | + Test.starttime, Test.finishtime)\ | ||
| 289 | + .filter(Test.ref == self.testfactory['ref'])\ | ||
| 290 | + .order_by(Test.student_id)\ | ||
| 291 | + .all() | ||
| 292 | + | ||
| 293 | + csvstr = io.StringIO() | ||
| 294 | + writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) | ||
| 295 | + writer.writerow(('Número', 'Nota', 'Início', 'Fim')) | ||
| 296 | + writer.writerows(grades) | ||
| 297 | + return csvstr.getvalue() | ||
| 298 | + | ||
| 276 | def get_student_test(self, uid, default=None): | 299 | def get_student_test(self, uid, default=None): |
| 277 | '''get test from online student''' | 300 | '''get test from online student''' |
| 278 | return self.online[uid].get('test', default) | 301 | return self.online[uid].get('test', default) |
| @@ -320,11 +343,7 @@ class App(): | @@ -320,11 +343,7 @@ class App(): | ||
| 320 | .get('start_time', ''), | 343 | .get('start_time', ''), |
| 321 | 'password_defined': pw != '', | 344 | 'password_defined': pw != '', |
| 322 | 'grades': self.get_student_grades_from_test( | 345 | 'grades': self.get_student_grades_from_test( |
| 323 | - uid, | ||
| 324 | - self.testfactory['ref'] | ||
| 325 | - ), | ||
| 326 | - | ||
| 327 | - # 'focus': self.online.get(uid, {}).get('student', {}).get('focus', True), # FIXME | 346 | + uid, self.testfactory['ref']) |
| 328 | } for uid, name, pw in self.get_all_students()] | 347 | } for uid, name, pw in self.get_all_students()] |
| 329 | 348 | ||
| 330 | # def get_allowed_students(self): | 349 | # def get_allowed_students(self): |
| @@ -363,7 +382,7 @@ class App(): | @@ -363,7 +382,7 @@ class App(): | ||
| 363 | try: | 382 | try: |
| 364 | with self.db_session() as sess: | 383 | with self.db_session() as sess: |
| 365 | sess.add(Student(id=uid, name=name, password='')) | 384 | sess.add(Student(id=uid, name=name, password='')) |
| 366 | - except Exception: | ||
| 367 | - logger.error('Insert failed: student %s already exists.', uid) | 385 | + except exc.SQLAlchemyError: |
| 386 | + logger.error('Insert failed: student %s already exists?', uid) | ||
| 368 | else: | 387 | else: |
| 369 | logger.info('New student inserted: %s, %s', uid, name) | 388 | logger.info('New student inserted: %s, %s', uid, name) |
perguntations/main.py
| @@ -74,7 +74,7 @@ def get_logger_config(debug=False): | @@ -74,7 +74,7 @@ def get_logger_config(debug=False): | ||
| 74 | 'formatters': { | 74 | 'formatters': { |
| 75 | 'standard': { | 75 | 'standard': { |
| 76 | 'format': '%(asctime)s %(levelname)-8s %(message)s', | 76 | 'format': '%(asctime)s %(levelname)-8s %(message)s', |
| 77 | - 'datefmt': '%H:%M', | 77 | + 'datefmt': '%H:%M:%S', |
| 78 | }, | 78 | }, |
| 79 | }, | 79 | }, |
| 80 | 'handlers': { | 80 | 'handlers': { |
perguntations/serve.py
| @@ -42,6 +42,7 @@ class WebApplication(tornado.web.Application): | @@ -42,6 +42,7 @@ class WebApplication(tornado.web.Application): | ||
| 42 | (r'/file', FileHandler), | 42 | (r'/file', FileHandler), |
| 43 | # (r'/root', MainHandler), # FIXME | 43 | # (r'/root', MainHandler), # FIXME |
| 44 | # (r'/ws', AdminSocketHandler), | 44 | # (r'/ws', AdminSocketHandler), |
| 45 | + (r'/adminwebservice', AdminWebservice), | ||
| 45 | (r'/studentwebservice', StudentWebservice), | 46 | (r'/studentwebservice', StudentWebservice), |
| 46 | (r'/', RootHandler), | 47 | (r'/', RootHandler), |
| 47 | ] | 48 | ] |
| @@ -62,7 +63,10 @@ class WebApplication(tornado.web.Application): | @@ -62,7 +63,10 @@ class WebApplication(tornado.web.Application): | ||
| 62 | # ---------------------------------------------------------------------------- | 63 | # ---------------------------------------------------------------------------- |
| 63 | def admin_only(func): | 64 | def admin_only(func): |
| 64 | ''' | 65 | ''' |
| 65 | - Decorator used to restrict access to the administrator | 66 | + Decorator used to restrict access to the administrator. For example: |
| 67 | + | ||
| 68 | + @admin_only() | ||
| 69 | + def get(self): ... | ||
| 66 | ''' | 70 | ''' |
| 67 | @functools.wraps(func) | 71 | @functools.wraps(func) |
| 68 | async def wrapper(self, *args, **kwargs): | 72 | async def wrapper(self, *args, **kwargs): |
| @@ -75,14 +79,14 @@ def admin_only(func): | @@ -75,14 +79,14 @@ def admin_only(func): | ||
| 75 | # ---------------------------------------------------------------------------- | 79 | # ---------------------------------------------------------------------------- |
| 76 | class BaseHandler(tornado.web.RequestHandler): | 80 | class BaseHandler(tornado.web.RequestHandler): |
| 77 | ''' | 81 | ''' |
| 78 | - Base handler. Other handlers will inherit this one. | 82 | + Handlers should inherit this one instead of tornado.web.RequestHandler. |
| 83 | + It automatically gets the user cookie, which is required to identify the | ||
| 84 | + user in most handlers. | ||
| 79 | ''' | 85 | ''' |
| 80 | 86 | ||
| 81 | @property | 87 | @property |
| 82 | def testapp(self): | 88 | def testapp(self): |
| 83 | - ''' | ||
| 84 | - simplifies access to the application | ||
| 85 | - ''' | 89 | + '''simplifies access to the application''' |
| 86 | return self.application.testapp | 90 | return self.application.testapp |
| 87 | 91 | ||
| 88 | def get_current_user(self): | 92 | def get_current_user(self): |
| @@ -155,8 +159,8 @@ class BaseHandler(tornado.web.RequestHandler): | @@ -155,8 +159,8 @@ class BaseHandler(tornado.web.RequestHandler): | ||
| 155 | 159 | ||
| 156 | class StudentWebservice(BaseHandler): | 160 | class StudentWebservice(BaseHandler): |
| 157 | ''' | 161 | ''' |
| 158 | - Receive ajax from students in the test: | ||
| 159 | - focus, unfocus | 162 | + Receive ajax from students in the test in response from focus, unfocus and |
| 163 | + resize events. | ||
| 160 | ''' | 164 | ''' |
| 161 | 165 | ||
| 162 | @tornado.web.authenticated | 166 | @tornado.web.authenticated |
| @@ -167,6 +171,25 @@ class StudentWebservice(BaseHandler): | @@ -167,6 +171,25 @@ class StudentWebservice(BaseHandler): | ||
| 167 | value = json.loads(self.get_body_argument('value', None)) | 171 | value = json.loads(self.get_body_argument('value', None)) |
| 168 | self.testapp.event_test(uid, cmd, value) | 172 | self.testapp.event_test(uid, cmd, value) |
| 169 | 173 | ||
| 174 | + | ||
| 175 | +# ---------------------------------------------------------------------------- | ||
| 176 | +class AdminWebservice(BaseHandler): | ||
| 177 | + ''' | ||
| 178 | + Receive ajax requests from admin | ||
| 179 | + ''' | ||
| 180 | + | ||
| 181 | + @tornado.web.authenticated | ||
| 182 | + @admin_only | ||
| 183 | + async def get(self): | ||
| 184 | + '''admin webservices that do not change state''' | ||
| 185 | + cmd = self.get_query_argument('cmd') | ||
| 186 | + if cmd == 'testcsv': | ||
| 187 | + self.set_header('Content-Type', 'text/csv') | ||
| 188 | + self.set_header('content-Disposition', | ||
| 189 | + 'attachment; filename=notas.csv') | ||
| 190 | + self.write(self.testapp.get_test_csv()) | ||
| 191 | + await self.flush() | ||
| 192 | + | ||
| 170 | # ---------------------------------------------------------------------------- | 193 | # ---------------------------------------------------------------------------- |
| 171 | class AdminHandler(BaseHandler): | 194 | class AdminHandler(BaseHandler): |
| 172 | '''Handle /admin''' | 195 | '''Handle /admin''' |
perguntations/static/js/admin.js
| @@ -14,35 +14,27 @@ jQuery.postJSON = function(url, args) { | @@ -14,35 +14,27 @@ jQuery.postJSON = function(url, args) { | ||
| 14 | $(document).ready(function() { | 14 | $(document).ready(function() { |
| 15 | function button_handlers() { | 15 | function button_handlers() { |
| 16 | // button handlers (runs once) | 16 | // button handlers (runs once) |
| 17 | - $("#allow_all").click( | ||
| 18 | - function() { | ||
| 19 | - $(":checkbox").prop("checked", true).trigger('change'); | ||
| 20 | - } | ||
| 21 | - ); | ||
| 22 | - $("#deny_all").click( | ||
| 23 | - function() { | ||
| 24 | - $(":checkbox").prop("checked", false).trigger('change'); | ||
| 25 | - } | ||
| 26 | - ); | ||
| 27 | - $("#reset_password").click( | ||
| 28 | - function () { | ||
| 29 | - $.postJSON("/admin", { | ||
| 30 | - "cmd": "reset_password", | ||
| 31 | - "value": $("#reset_number").val() | ||
| 32 | - }); | ||
| 33 | - } | ||
| 34 | - ); | ||
| 35 | - $("#inserir_novo_aluno").click( | ||
| 36 | - function () { | ||
| 37 | - $.postJSON("/admin", { | ||
| 38 | - "cmd": "insert_student", | ||
| 39 | - "value": JSON.stringify({ | ||
| 40 | - "number": $("#novo_numero").val(), | ||
| 41 | - "name": $("#novo_nome").val() | ||
| 42 | - }) | ||
| 43 | - }); | ||
| 44 | - } | ||
| 45 | - ); | 17 | + $("#allow_all").click(function() { |
| 18 | + $(":checkbox").prop("checked", true).trigger('change'); | ||
| 19 | + }); | ||
| 20 | + $("#deny_all").click(function() { | ||
| 21 | + $(":checkbox").prop("checked", false).trigger('change'); | ||
| 22 | + }); | ||
| 23 | + $("#reset_password").click(function () { | ||
| 24 | + $.postJSON("/admin", { | ||
| 25 | + "cmd": "reset_password", | ||
| 26 | + "value": $("#reset_number").val() | ||
| 27 | + }); | ||
| 28 | + }); | ||
| 29 | + $("#inserir_novo_aluno").click(function () { | ||
| 30 | + $.postJSON("/admin", { | ||
| 31 | + "cmd": "insert_student", | ||
| 32 | + "value": JSON.stringify({ | ||
| 33 | + "number": $("#novo_numero").val(), | ||
| 34 | + "name": $("#novo_nome").val() | ||
| 35 | + }) | ||
| 36 | + }); | ||
| 37 | + }); | ||
| 46 | // authorization checkboxes in the students_table: | 38 | // authorization checkboxes in the students_table: |
| 47 | $("tbody", "#students_table").on("change", "input", autorizeStudent); | 39 | $("tbody", "#students_table").on("change", "input", autorizeStudent); |
| 48 | } | 40 | } |
perguntations/templates/admin.html
| @@ -68,24 +68,29 @@ | @@ -68,24 +68,29 @@ | ||
| 68 | <div class="container-fluid"> | 68 | <div class="container-fluid"> |
| 69 | 69 | ||
| 70 | <div class="jumbotron"> | 70 | <div class="jumbotron"> |
| 71 | - <h3 id="title"></h3> | ||
| 72 | - Ref: <span id="ref"></span><br> | ||
| 73 | - Enunciado: <span id="filename"></span><br> | ||
| 74 | - Base de dados: <span id="database"></span><br> | ||
| 75 | - Testes submetidos: <span id="answers_dir"></span> | 71 | + <h3 id="title"></h3> |
| 72 | + <p> | ||
| 73 | + Referência: <code id="ref">--</code><br> | ||
| 74 | + Ficheiro de configuração do teste: <code id="filename">--</code><br> | ||
| 75 | + Testes em formato JSON no directório: <code id="answers_dir">--</code><br> | ||
| 76 | + Base de dados: <code id="database">--</code><br> | ||
| 77 | + </p> | ||
| 78 | + <p> | ||
| 79 | + <a href="/adminwebservice?cmd=testcsv" class="btn btn-primary">Obter CSV com as notas</a> | ||
| 80 | + </p> | ||
| 76 | </div> <!-- jumbotron --> | 81 | </div> <!-- jumbotron --> |
| 77 | 82 | ||
| 78 | <table class="table table-sm table-striped" style="width:100%" id="students_table"> | 83 | <table class="table table-sm table-striped" style="width:100%" id="students_table"> |
| 79 | - <thead class="thead thead-light"> | ||
| 80 | - <tr> | ||
| 81 | - <th>#</th> | ||
| 82 | - <th>Ok</th> | ||
| 83 | - <th>Número</th> | ||
| 84 | - <th>Nome</th> | ||
| 85 | - <th>Estado</th> | ||
| 86 | - <th>Nota</th> | ||
| 87 | - </tr> | ||
| 88 | - </thead> | 84 | + <thead class="thead thead-light"> |
| 85 | + <tr> | ||
| 86 | + <th>#</th> | ||
| 87 | + <th>Ok</th> | ||
| 88 | + <th>Número</th> | ||
| 89 | + <th>Nome</th> | ||
| 90 | + <th>Estado</th> | ||
| 91 | + <th>Nota</th> | ||
| 92 | + </tr> | ||
| 93 | + </thead> | ||
| 89 | </table> | 94 | </table> |
| 90 | 95 | ||
| 91 | </div> <!-- container --> | 96 | </div> <!-- container --> |
perguntations/templates/grade.html
| @@ -43,11 +43,11 @@ | @@ -43,11 +43,11 @@ | ||
| 43 | {% if t['state'] == 'FINISHED' %} | 43 | {% if t['state'] == 'FINISHED' %} |
| 44 | <h1>Resultado: | 44 | <h1>Resultado: |
| 45 | <strong>{{ f'{round(t["grade"], 1)}' }}</strong> | 45 | <strong>{{ f'{round(t["grade"], 1)}' }}</strong> |
| 46 | - valores na escala de {{t['scale_min']}} a {{t['scale_max']}}. | 46 | + valores na escala de {{t['scale'][0]}} a {{t['scale'][1]}}. |
| 47 | </h1> | 47 | </h1> |
| 48 | - <p>O seu teste foi registado.<br> | ||
| 49 | - Pode fechar o browser e desligar o computador.</p> | ||
| 50 | - {% if t['grade'] - t['scale_min'] >= 0.75*(t['scale_max'] - t['scale_min']) %} | 48 | + <p>O seu teste foi entregue e está registado.</p> |
| 49 | + <p><a href="/" class="btn btn-primary btn-lg active" role="button">Clique aqui para sair do teste</a></p> | ||
| 50 | + {% if t['grade'] - t['scale'][0] >= 0.75*(t['scale'][1] - t['scale'][0]) %} | ||
| 51 | <i class="fas fa-thumbs-up fa-5x text-success" aria-hidden="true"></i> | 51 | <i class="fas fa-thumbs-up fa-5x text-success" aria-hidden="true"></i> |
| 52 | {% end %} | 52 | {% end %} |
| 53 | {% elif t['state'] == 'QUIT' %} | 53 | {% elif t['state'] == 'QUIT' %} |
| @@ -78,19 +78,19 @@ | @@ -78,19 +78,19 @@ | ||
| 78 | <td> <!-- progress column --> | 78 | <td> <!-- progress column --> |
| 79 | <div class="progress" style="height: 20px;"> | 79 | <div class="progress" style="height: 20px;"> |
| 80 | <div class="progress-bar | 80 | <div class="progress-bar |
| 81 | - {% if g[1] - t['scale_min'] < 0.5*(t['scale_max'] - t['scale_min']) %} | 81 | + {% if g[1] - t['scale'][0] < 0.5*(t['scale'][1] - t['scale'][0]) %} |
| 82 | bg-danger | 82 | bg-danger |
| 83 | - {% elif g[1] - t['scale_min'] < 0.75*(t['scale_max'] - t['scale_min']) %} | 83 | + {% elif g[1] - t['scale'][0] < 0.75*(t['scale'][1] - t['scale'][0]) %} |
| 84 | bg-warning | 84 | bg-warning |
| 85 | {% else %} | 85 | {% else %} |
| 86 | bg-success | 86 | bg-success |
| 87 | {% end %} | 87 | {% end %} |
| 88 | " | 88 | " |
| 89 | role="progressbar" | 89 | role="progressbar" |
| 90 | - aria-valuenow="{{ round(100*(g[1] - t['scale_min'])/(t['scale_max'] - t['scale_min'])) }}" | 90 | + aria-valuenow="{{ 100*(g[1] - t['scale'][0])/(t['scale'][1] - t['scale'][0]) }}" |
| 91 | aria-valuemin="0" | 91 | aria-valuemin="0" |
| 92 | aria-valuemax="100" | 92 | aria-valuemax="100" |
| 93 | - style="min-width: 2em; width: {{ round(100*(g[1]-t['scale_min'])/(t['scale_max']-t['scale_min'])) }}%;" | 93 | + style="min-width: 2em; width: {{ 100*(g[1]-t['scale'][0])/(t['scale'][1]-t['scale'][0]) }}%;" |
| 94 | > | 94 | > |
| 95 | 95 | ||
| 96 | {{ str(round(g[1], 1)) }} | 96 | {{ str(round(g[1], 1)) }} |
perguntations/test.py
| @@ -42,10 +42,8 @@ class TestFactory(dict): | @@ -42,10 +42,8 @@ class TestFactory(dict): | ||
| 42 | # --- set test defaults and then use given configuration | 42 | # --- set test defaults and then use given configuration |
| 43 | super().__init__({ # defaults | 43 | super().__init__({ # defaults |
| 44 | 'title': '', | 44 | 'title': '', |
| 45 | - 'show_points': False, | ||
| 46 | - 'scale_points': True, | ||
| 47 | - 'scale_max': 20.0, | ||
| 48 | - 'scale_min': 0.0, | 45 | + 'show_points': True, |
| 46 | + 'scale': None, # or [0, 20] | ||
| 49 | 'duration': 0, # 0=infinite | 47 | 'duration': 0, # 0=infinite |
| 50 | 'autosubmit': False, | 48 | 'autosubmit': False, |
| 51 | 'debug': False, | 49 | 'debug': False, |
| @@ -197,11 +195,12 @@ class TestFactory(dict): | @@ -197,11 +195,12 @@ class TestFactory(dict): | ||
| 197 | 195 | ||
| 198 | def check_grade_scaling(self): | 196 | def check_grade_scaling(self): |
| 199 | '''Just informs the scale limits''' | 197 | '''Just informs the scale limits''' |
| 200 | - if self['scale_points']: | ||
| 201 | - smin, smax = self["scale_min"], self["scale_max"] | ||
| 202 | - logger.info('Grades will be scaled to [%g, %g]', smin, smax) | ||
| 203 | - else: | ||
| 204 | - logger.info('Grades are not being scaled.') | 198 | + if 'scale_points' in self: |
| 199 | + msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, ' | ||
| 200 | + 'scale_max were replaced by "scale: [min, max]".') | ||
| 201 | + logger.warning(msg) | ||
| 202 | + self['scale'] = [self['scale_min'], self['scale_max']] | ||
| 203 | + | ||
| 205 | 204 | ||
| 206 | # ------------------------------------------------------------------------ | 205 | # ------------------------------------------------------------------------ |
| 207 | def sanity_checks(self): | 206 | def sanity_checks(self): |
| @@ -254,25 +253,32 @@ class TestFactory(dict): | @@ -254,25 +253,32 @@ class TestFactory(dict): | ||
| 254 | 253 | ||
| 255 | test.append(question) | 254 | test.append(question) |
| 256 | 255 | ||
| 257 | - # normalize question points to scale | ||
| 258 | - if self['scale_points']: | ||
| 259 | - total_points = sum(q['points'] for q in test) | ||
| 260 | - if total_points == 0: | ||
| 261 | - logger.warning('Can\'t scale, total points in the test is 0!') | ||
| 262 | - else: | ||
| 263 | - scale = (self['scale_max'] - self['scale_min']) / total_points | 256 | + # setup scale |
| 257 | + total_points = sum(q['points'] for q in test) | ||
| 258 | + | ||
| 259 | + if total_points > 0: | ||
| 260 | + # normalize question points to scale | ||
| 261 | + if self['scale'] is not None: | ||
| 262 | + scale_min, scale_max = self['scale'] | ||
| 264 | for question in test: | 263 | for question in test: |
| 265 | - question['points'] *= scale | 264 | + question['points'] *= (scale_max - scale_min) / total_points |
| 265 | + else: | ||
| 266 | + self['scale'] = [0, total_points] | ||
| 267 | + else: | ||
| 268 | + logger.warning('Total points is **ZERO**.') | ||
| 269 | + if self['scale'] is None: | ||
| 270 | + self['scale'] = [0, 20] | ||
| 266 | 271 | ||
| 267 | if nerr > 0: | 272 | if nerr > 0: |
| 268 | logger.error('%s errors found!', nerr) | 273 | logger.error('%s errors found!', nerr) |
| 269 | 274 | ||
| 275 | + # these will be copied to the test instance | ||
| 270 | inherit = {'ref', 'title', 'database', 'answers_dir', | 276 | inherit = {'ref', 'title', 'database', 'answers_dir', |
| 271 | 'questions_dir', 'files', | 277 | 'questions_dir', 'files', |
| 272 | 'duration', 'autosubmit', | 278 | 'duration', 'autosubmit', |
| 273 | - 'scale_min', 'scale_max', 'show_points', | 279 | + 'scale', 'show_points', |
| 274 | 'show_ref', 'debug', } | 280 | 'show_ref', 'debug', } |
| 275 | - # NOT INCLUDED: scale_points, testfile, allow_all, review | 281 | + # NOT INCLUDED: testfile, allow_all, review |
| 276 | 282 | ||
| 277 | return Test({ | 283 | return Test({ |
| 278 | **{'student': student, 'questions': test}, | 284 | **{'student': student, 'questions': test}, |
| @@ -318,6 +324,7 @@ class Test(dict): | @@ -318,6 +324,7 @@ class Test(dict): | ||
| 318 | '''Corrects all the answers of the test and computes the final grade''' | 324 | '''Corrects all the answers of the test and computes the final grade''' |
| 319 | self['finish_time'] = datetime.now() | 325 | self['finish_time'] = datetime.now() |
| 320 | self['state'] = 'FINISHED' | 326 | self['state'] = 'FINISHED' |
| 327 | + | ||
| 321 | grade = 0.0 | 328 | grade = 0.0 |
| 322 | for question in self['questions']: | 329 | for question in self['questions']: |
| 323 | await question.correct_async() | 330 | await question.correct_async() |
| @@ -325,8 +332,8 @@ class Test(dict): | @@ -325,8 +332,8 @@ class Test(dict): | ||
| 325 | logger.debug('Correcting %30s: %3g%%', | 332 | logger.debug('Correcting %30s: %3g%%', |
| 326 | question["ref"], question["grade"]*100) | 333 | question["ref"], question["grade"]*100) |
| 327 | 334 | ||
| 328 | - # truncate to avoid negative grade and adjust scale | ||
| 329 | - self['grade'] = max(0.0, grade) + self['scale_min'] | 335 | + # truncate to avoid negative final grade and adjust scale |
| 336 | + self['grade'] = max(0.0, grade) + self['scale'][0] | ||
| 330 | return self['grade'] | 337 | return self['grade'] |
| 331 | 338 | ||
| 332 | # ------------------------------------------------------------------------ | 339 | # ------------------------------------------------------------------------ |