Commit 1b9491c2682cde0625d2dd4305c340375f6d5ea8
1 parent
ffb53a93
Exists in
master
and in
1 other branch
- use schema to validade test.
- removed option --show_ref, now depends on debug. - the option --correct is not implemented yet.
Showing
14 changed files
with
218 additions
and
226 deletions
Show diff stats
demo/demo.yaml
| @@ -14,9 +14,6 @@ database: students.db | @@ -14,9 +14,6 @@ database: students.db | ||
| 14 | # Directory where the submitted and corrected test are stored for later review. | 14 | # Directory where the submitted and corrected test are stored for later review. |
| 15 | answers_dir: ans | 15 | answers_dir: ans |
| 16 | 16 | ||
| 17 | -# Server used to compile & execute code | ||
| 18 | -jobe_server: 192.168.1.85 | ||
| 19 | - | ||
| 20 | # --- optional settings: ----------------------------------------------------- | 17 | # --- optional settings: ----------------------------------------------------- |
| 21 | 18 | ||
| 22 | # Title of this test, e.g. course name, year or test number | 19 | # Title of this test, e.g. course name, year or test number |
| @@ -35,49 +32,52 @@ autosubmit: false | @@ -35,49 +32,52 @@ autosubmit: false | ||
| 35 | # shown to the student. If false, the test is saved but not corrected. | 32 | # shown to the student. If false, the test is saved but not corrected. |
| 36 | # No grade is shown to the student. | 33 | # No grade is shown to the student. |
| 37 | # (default: true) | 34 | # (default: true) |
| 38 | -autocorrect: true | 35 | +autocorrect: false |
| 39 | 36 | ||
| 40 | # Show points for each question (min and max). | 37 | # Show points for each question (min and max). |
| 41 | # (default: true) | 38 | # (default: true) |
| 42 | show_points: true | 39 | show_points: true |
| 43 | 40 | ||
| 44 | -# scale final grade to an interval, e.g. [0, 20], keeping the relative weight | ||
| 45 | -# of the points declared in the questions below. | 41 | +# Scale the points of the questions so that the final grade is in the given |
| 42 | +# interval. | ||
| 46 | # (default: no scaling, just use question points) | 43 | # (default: no scaling, just use question points) |
| 47 | -scale: [0, 5] | 44 | +scale: [0, 20] |
| 48 | 45 | ||
| 49 | 46 | ||
| 50 | # ---------------------------------------------------------------------------- | 47 | # ---------------------------------------------------------------------------- |
| 51 | -# Base path applied to the questions files and all the scripts | ||
| 52 | -# including question generators and correctors. | ||
| 53 | -# Either absolute path or relative to current directory can be used. | ||
| 54 | -questions_dir: . | ||
| 55 | - | ||
| 56 | -# (optional) List of files containing questions in yaml format. | ||
| 57 | -# Selected questions will be obtained from these files. | ||
| 58 | -# If undefined, all yaml files in questions_dir are loaded (not recommended). | 48 | +# Files to import. Each file contains a list of questions in yaml format. |
| 59 | files: | 49 | files: |
| 60 | - questions/questions-tutorial.yaml | 50 | - questions/questions-tutorial.yaml |
| 61 | 51 | ||
| 62 | # This is the list of questions that will make up the test. | 52 | # This is the list of questions that will make up the test. |
| 63 | # The order is preserved. | 53 | # The order is preserved. |
| 64 | -# There are several ways to define each question (explained below). | 54 | +# Each question is a dictionary with a question `ref` or a list of `ref`. |
| 55 | +# If a list is given, one question will be choosen randomly to each student. | ||
| 56 | +# The `points` for each question is optional and is 1.0 by default for normal | ||
| 57 | +# questions. Informative type of "questions" will have 0.0 points. | ||
| 58 | +# Points are automatically scaled if `scale` key is defined. | ||
| 65 | questions: | 59 | questions: |
| 66 | - ref: tut-test | 60 | - ref: tut-test |
| 67 | - - tut-questions | 61 | + - ref: tut-questions |
| 62 | + | ||
| 63 | + # these will have 1.0 points | ||
| 64 | + - ref: tut-radio | ||
| 65 | + - ref: tut-checkbox | ||
| 66 | + - ref: tut-text | ||
| 67 | + - ref: tut-text-regex | ||
| 68 | + - ref: tut-numeric-interval | ||
| 68 | 69 | ||
| 69 | - - tut-radio | ||
| 70 | - - tut-checkbox | ||
| 71 | - - tut-text | ||
| 72 | - - tut-text-regex | ||
| 73 | - - tut-numeric-interval | 70 | + # this question will have 2.0 points |
| 74 | - ref: tut-textarea | 71 | - ref: tut-textarea |
| 75 | points: 2.0 | 72 | points: 2.0 |
| 76 | 73 | ||
| 77 | - - tut-information | ||
| 78 | - - tut-success | ||
| 79 | - - tut-warning | ||
| 80 | - - [tut-alert1, tut-alert2] | ||
| 81 | - - tut-generator | ||
| 82 | - - tut-yamllint | ||
| 83 | - # - tut-code | 74 | + # these will have 0.0 points: |
| 75 | + - ref: tut-information | ||
| 76 | + - ref: tut-success | ||
| 77 | + - ref: tut-warning | ||
| 78 | + | ||
| 79 | + # choose one from the list: | ||
| 80 | + - ref: [tut-alert1, tut-alert2] | ||
| 81 | + | ||
| 82 | + - ref: tut-generator | ||
| 83 | + - ref: tut-yamllint |
demo/questions/questions-tutorial.yaml
| @@ -20,24 +20,20 @@ | @@ -20,24 +20,20 @@ | ||
| 20 | database: students.db # base de dados previamente criada com initdb | 20 | database: students.db # base de dados previamente criada com initdb |
| 21 | answers_dir: ans # directório onde ficam os testes dos alunos | 21 | answers_dir: ans # directório onde ficam os testes dos alunos |
| 22 | 22 | ||
| 23 | - # opcional | 23 | + # opcionais |
| 24 | duration: 60 # duração da prova em minutos (default: inf) | 24 | duration: 60 # duração da prova em minutos (default: inf) |
| 25 | autosubmit: true # submissão automática (default: false) | 25 | autosubmit: true # submissão automática (default: false) |
| 26 | show_points: true # mostra cotação das perguntas (default: true) | 26 | show_points: true # mostra cotação das perguntas (default: true) |
| 27 | - scale: [0, 20] # limites inferior e superior da escala (default: [0,20]) | ||
| 28 | - scale_points: true # normaliza cotações para a escala definida | ||
| 29 | - jobe_server: moodle-jobe.uevora.pt # server used to compile & execute code | ||
| 30 | - debug: false # mostra informação de debug no browser | 27 | + scale: [0, 20] # normaliza cotações para o intervalo indicado. |
| 28 | + # não normaliza por defeito (default: None) | ||
| 31 | 29 | ||
| 32 | # -------------------------------------------------------------------------- | 30 | # -------------------------------------------------------------------------- |
| 33 | - questions_dir: ~/topics # raíz da árvore de directórios das perguntas | ||
| 34 | - | ||
| 35 | # Ficheiros de perguntas a importar (relativamente a `questions_dir`) | 31 | # Ficheiros de perguntas a importar (relativamente a `questions_dir`) |
| 36 | files: | 32 | files: |
| 37 | - tabelas.yaml | 33 | - tabelas.yaml |
| 38 | - - topic_A/questions.yaml | ||
| 39 | - - topic_B/part_1/questions.yaml | ||
| 40 | - - topic_B/part_2/questions.yaml | 34 | + - topic1/questions.yaml |
| 35 | + - topic2/part1/questions.yaml | ||
| 36 | + - topic2/part2/questions.yaml | ||
| 41 | 37 | ||
| 42 | # -------------------------------------------------------------------------- | 38 | # -------------------------------------------------------------------------- |
| 43 | # Especificação das perguntas do teste e respectivas cotações. | 39 | # Especificação das perguntas do teste e respectivas cotações. |
| @@ -50,13 +46,10 @@ | @@ -50,13 +46,10 @@ | ||
| 50 | - ref: pergunta2 | 46 | - ref: pergunta2 |
| 51 | points: 2.0 | 47 | points: 2.0 |
| 52 | 48 | ||
| 53 | - # a cotação é 1.0 por defeito | 49 | + # por defeinto, a cotação da pergunta é 1.0 valor |
| 54 | - ref: pergunta3 | 50 | - ref: pergunta3 |
| 55 | 51 | ||
| 56 | - # uma string (não dict), é interpretada como referência | ||
| 57 | - - tabela-auxiliar | ||
| 58 | - | ||
| 59 | - # escolhe aleatoriamente uma das variantes | 52 | + # escolhe aleatoriamente uma das variantes da pergunta |
| 60 | - ref: [pergunta3a, pergunta3b] | 53 | - ref: [pergunta3a, pergunta3b] |
| 61 | points: 0.5 | 54 | points: 0.5 |
| 62 | 55 | ||
| @@ -96,8 +89,7 @@ | @@ -96,8 +89,7 @@ | ||
| 96 | text: | | 89 | text: | |
| 97 | Quando o texto da pergunta tem várias linhas, dá jeito usar o símbolo | 90 | Quando o texto da pergunta tem várias linhas, dá jeito usar o símbolo |
| 98 | `|` de pipe, para indicar que tudo o que estiver indentado faz parte do | 91 | `|` de pipe, para indicar que tudo o que estiver indentado faz parte do |
| 99 | - texto. | ||
| 100 | - É o caso desta pergunta. | 92 | + texto. É o caso desta pergunta. |
| 101 | 93 | ||
| 102 | O texto das perguntas é escrito em `markdown` e suporta fórmulas em | 94 | O texto das perguntas é escrito em `markdown` e suporta fórmulas em |
| 103 | LaTeX. | 95 | LaTeX. |
| @@ -105,8 +97,8 @@ | @@ -105,8 +97,8 @@ | ||
| 105 | #--------------------------------------------------------------------------- | 97 | #--------------------------------------------------------------------------- |
| 106 | ``` | 98 | ``` |
| 107 | 99 | ||
| 108 | - As chaves são usadas para construir o teste e não se podem repetir, mesmo em | ||
| 109 | - ficheiros diferentes. | 100 | + As chaves são usadas para construir o teste e não se podem repetir, mesmo |
| 101 | + em ficheiros diferentes. | ||
| 110 | De seguida mostram-se exemplos dos vários tipos de perguntas. | 102 | De seguida mostram-se exemplos dos vários tipos de perguntas. |
| 111 | 103 | ||
| 112 | # ---------------------------------------------------------------------------- | 104 | # ---------------------------------------------------------------------------- |
mypy.ini
perguntations/__init__.py
| 1 | -# Copyright (C) 2021 Miguel Barão | 1 | +# Copyright (C) 2022 Miguel Barão |
| 2 | # | 2 | # |
| 3 | # THE MIT License | 3 | # THE MIT License |
| 4 | # | 4 | # |
| @@ -32,10 +32,10 @@ proof of submission and for review. | @@ -32,10 +32,10 @@ proof of submission and for review. | ||
| 32 | ''' | 32 | ''' |
| 33 | 33 | ||
| 34 | APP_NAME = 'perguntations' | 34 | APP_NAME = 'perguntations' |
| 35 | -APP_VERSION = '2021.09.dev1' | 35 | +APP_VERSION = '2022.01.dev1' |
| 36 | APP_DESCRIPTION = __doc__ | 36 | APP_DESCRIPTION = __doc__ |
| 37 | 37 | ||
| 38 | __author__ = 'Miguel Barão' | 38 | __author__ = 'Miguel Barão' |
| 39 | -__copyright__ = 'Copyright 2021, Miguel Barão' | 39 | +__copyright__ = 'Copyright 2022, Miguel Barão' |
| 40 | __license__ = 'MIT license' | 40 | __license__ = 'MIT license' |
| 41 | __version__ = APP_VERSION | 41 | __version__ = APP_VERSION |
perguntations/app.py
| @@ -55,6 +55,7 @@ class App(): | @@ -55,6 +55,7 @@ class App(): | ||
| 55 | 55 | ||
| 56 | # ------------------------------------------------------------------------ | 56 | # ------------------------------------------------------------------------ |
| 57 | def __init__(self, config): | 57 | def __init__(self, config): |
| 58 | + self.debug = config['debug'] | ||
| 58 | self._make_test_factory(config['testfile']) | 59 | self._make_test_factory(config['testfile']) |
| 59 | self._db_setup() # setup engine and load all students | 60 | self._db_setup() # setup engine and load all students |
| 60 | 61 | ||
| @@ -126,10 +127,9 @@ class App(): | @@ -126,10 +127,9 @@ class App(): | ||
| 126 | logger.warning('"%s" does not exist', uid) | 127 | logger.warning('"%s" does not exist', uid) |
| 127 | return 'nonexistent' | 128 | return 'nonexistent' |
| 128 | 129 | ||
| 129 | - | ||
| 130 | if uid != '0' and self._students[uid]['state'] != 'allowed': | 130 | if uid != '0' and self._students[uid]['state'] != 'allowed': |
| 131 | logger.warning('"%s" login not allowed', uid) | 131 | logger.warning('"%s" login not allowed', uid) |
| 132 | - return 'not allowed' | 132 | + return 'not_allowed' |
| 133 | 133 | ||
| 134 | if hashed == '': # set password on first login | 134 | if hashed == '': # set password on first login |
| 135 | await self.set_password(uid, password) | 135 | await self.set_password(uid, password) |
perguntations/main.py
| @@ -20,7 +20,7 @@ from perguntations.tools import load_yaml | @@ -20,7 +20,7 @@ from perguntations.tools import load_yaml | ||
| 20 | from perguntations import APP_NAME, APP_VERSION | 20 | from perguntations import APP_NAME, APP_VERSION |
| 21 | 21 | ||
| 22 | # ---------------------------------------------------------------------------- | 22 | # ---------------------------------------------------------------------------- |
| 23 | -def parse_cmdline_arguments(): | 23 | +def parse_cmdline_arguments() -> argparse.Namespace: |
| 24 | ''' | 24 | ''' |
| 25 | Get command line arguments | 25 | Get command line arguments |
| 26 | ''' | 26 | ''' |
| @@ -40,9 +40,6 @@ def parse_cmdline_arguments(): | @@ -40,9 +40,6 @@ def parse_cmdline_arguments(): | ||
| 40 | parser.add_argument('--debug', | 40 | parser.add_argument('--debug', |
| 41 | action='store_true', | 41 | action='store_true', |
| 42 | help='Enable debug messages') | 42 | help='Enable debug messages') |
| 43 | - parser.add_argument('--show-ref', | ||
| 44 | - action='store_true', | ||
| 45 | - help='Show question references') | ||
| 46 | parser.add_argument('--review', | 43 | parser.add_argument('--review', |
| 47 | action='store_true', | 44 | action='store_true', |
| 48 | help='Review mode: doesn\'t generate test') | 45 | help='Review mode: doesn\'t generate test') |
| @@ -59,7 +56,6 @@ def parse_cmdline_arguments(): | @@ -59,7 +56,6 @@ def parse_cmdline_arguments(): | ||
| 59 | help='Show version information and exit') | 56 | help='Show version information and exit') |
| 60 | return parser.parse_args() | 57 | return parser.parse_args() |
| 61 | 58 | ||
| 62 | - | ||
| 63 | # ---------------------------------------------------------------------------- | 59 | # ---------------------------------------------------------------------------- |
| 64 | def get_logger_config(debug=False) -> dict: | 60 | def get_logger_config(debug=False) -> dict: |
| 65 | ''' | 61 | ''' |
| @@ -120,10 +116,9 @@ def main(): | @@ -120,10 +116,9 @@ def main(): | ||
| 120 | # --- start application -------------------------------------------------- | 116 | # --- start application -------------------------------------------------- |
| 121 | config = { | 117 | config = { |
| 122 | 'testfile': args.testfile, | 118 | 'testfile': args.testfile, |
| 123 | - 'debug': args.debug, | ||
| 124 | 'allow_all': args.allow_all, | 119 | 'allow_all': args.allow_all, |
| 125 | 'allow_list': args.allow_list, | 120 | 'allow_list': args.allow_list, |
| 126 | - 'show_ref': args.show_ref, | 121 | + 'debug': args.debug, |
| 127 | 'review': args.review, | 122 | 'review': args.review, |
| 128 | 'correct': args.correct, | 123 | 'correct': args.correct, |
| 129 | } | 124 | } |
perguntations/serve.py
| @@ -93,6 +93,11 @@ class BaseHandler(tornado.web.RequestHandler): | @@ -93,6 +93,11 @@ class BaseHandler(tornado.web.RequestHandler): | ||
| 93 | '''simplifies access to the application a little bit''' | 93 | '''simplifies access to the application a little bit''' |
| 94 | return self.application.testapp | 94 | return self.application.testapp |
| 95 | 95 | ||
| 96 | + # @property | ||
| 97 | + # def debug(self) -> bool: | ||
| 98 | + # '''check if is running in debug mode''' | ||
| 99 | + # return self.application.testapp.debug | ||
| 100 | + | ||
| 96 | def get_current_user(self): | 101 | def get_current_user(self): |
| 97 | ''' | 102 | ''' |
| 98 | Since HTTP is stateless, a cookie is used to identify the user. | 103 | Since HTTP is stateless, a cookie is used to identify the user. |
| @@ -112,7 +117,7 @@ class LoginHandler(BaseHandler): | @@ -112,7 +117,7 @@ class LoginHandler(BaseHandler): | ||
| 112 | _prefix = re.compile(r'[a-z]') | 117 | _prefix = re.compile(r'[a-z]') |
| 113 | _error_msg = { | 118 | _error_msg = { |
| 114 | 'wrong_password': 'Senha errada', | 119 | 'wrong_password': 'Senha errada', |
| 115 | - 'not allowed': 'Não está autorizado a fazer o teste', | 120 | + 'not_allowed': 'Não está autorizado a fazer o teste', |
| 116 | 'nonexistent': 'Número de aluno inválido' | 121 | 'nonexistent': 'Número de aluno inválido' |
| 117 | } | 122 | } |
| 118 | 123 | ||
| @@ -195,7 +200,7 @@ class RootHandler(BaseHandler): | @@ -195,7 +200,7 @@ class RootHandler(BaseHandler): | ||
| 195 | test = self.testapp.get_test(uid) | 200 | test = self.testapp.get_test(uid) |
| 196 | name = self.testapp.get_name(uid) | 201 | name = self.testapp.get_name(uid) |
| 197 | self.render('test.html', t=test, uid=uid, name=name, md=md_to_html, | 202 | self.render('test.html', t=test, uid=uid, name=name, md=md_to_html, |
| 198 | - templ=self._templates) | 203 | + templ=self._templates, debug=self.testapp.debug) |
| 199 | 204 | ||
| 200 | # --- POST | 205 | # --- POST |
| 201 | @tornado.web.authenticated | 206 | @tornado.web.authenticated |
| @@ -448,8 +453,8 @@ class ReviewHandler(BaseHandler): | @@ -448,8 +453,8 @@ class ReviewHandler(BaseHandler): | ||
| 448 | 453 | ||
| 449 | uid = test['student'] | 454 | uid = test['student'] |
| 450 | name = self.testapp.get_name(uid) | 455 | name = self.testapp.get_name(uid) |
| 451 | - self.render('review.html', t=test, uid=uid, name=name, | ||
| 452 | - md=md_to_html, templ=self._templates) | 456 | + self.render('review.html', t=test, uid=uid, name=name, md=md_to_html, |
| 457 | + templ=self._templates, debug=self.testapp.debug) | ||
| 453 | 458 | ||
| 454 | 459 | ||
| 455 | # ---------------------------------------------------------------------------- | 460 | # ---------------------------------------------------------------------------- |
perguntations/templates/question-information.html
| @@ -17,9 +17,9 @@ | @@ -17,9 +17,9 @@ | ||
| 17 | {{ md(q['text']) }} | 17 | {{ md(q['text']) }} |
| 18 | </div> | 18 | </div> |
| 19 | 19 | ||
| 20 | - {% if show_ref %} | 20 | + {% if debug %} |
| 21 | <hr> | 21 | <hr> |
| 22 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | 22 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
| 23 | ref: <code>{{ q['ref'] }}</code> | 23 | ref: <code>{{ q['ref'] }}</code> |
| 24 | {% end %} | 24 | {% end %} |
| 25 | -</div> | ||
| 26 | \ No newline at end of file | 25 | \ No newline at end of file |
| 26 | +</div> |
perguntations/templates/question.html
| @@ -29,11 +29,11 @@ | @@ -29,11 +29,11 @@ | ||
| 29 | </p> | 29 | </p> |
| 30 | </div> | 30 | </div> |
| 31 | 31 | ||
| 32 | - {% if show_ref %} | 32 | + {% if debug %} |
| 33 | <div class="card-footer"> | 33 | <div class="card-footer"> |
| 34 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | 34 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
| 35 | ref: <code>{{ q['ref'] }}</code> | 35 | ref: <code>{{ q['ref'] }}</code> |
| 36 | </div> | 36 | </div> |
| 37 | {% end %} | 37 | {% end %} |
| 38 | </div> | 38 | </div> |
| 39 | -{% end %} | ||
| 40 | \ No newline at end of file | 39 | \ No newline at end of file |
| 40 | +{% end %} |
perguntations/templates/review-question-information.html
| @@ -16,9 +16,9 @@ | @@ -16,9 +16,9 @@ | ||
| 16 | <div id="text"> | 16 | <div id="text"> |
| 17 | {{ md(q['text']) }} | 17 | {{ md(q['text']) }} |
| 18 | </div> | 18 | </div> |
| 19 | - {% if t['show_ref'] %} | 19 | + {% if debug %} |
| 20 | <hr> | 20 | <hr> |
| 21 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | 21 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
| 22 | ref: <code>{{ q['ref'] }}</code> | 22 | ref: <code>{{ q['ref'] }}</code> |
| 23 | {% end %} | 23 | {% end %} |
| 24 | -</div> | ||
| 25 | \ No newline at end of file | 24 | \ No newline at end of file |
| 25 | +</div> |
perguntations/templates/review-question.html
| @@ -65,7 +65,7 @@ | @@ -65,7 +65,7 @@ | ||
| 65 | {% end %} | 65 | {% end %} |
| 66 | {% end %} | 66 | {% end %} |
| 67 | 67 | ||
| 68 | - {% if t['show_ref'] %} | 68 | + {% if debug %} |
| 69 | <hr> | 69 | <hr> |
| 70 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | 70 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
| 71 | ref: <code>{{ q['ref'] }}</code> | 71 | ref: <code>{{ q['ref'] }}</code> |
| @@ -109,7 +109,7 @@ | @@ -109,7 +109,7 @@ | ||
| 109 | {% end %} | 109 | {% end %} |
| 110 | </p> | 110 | </p> |
| 111 | 111 | ||
| 112 | - {% if t['show_ref'] %} | 112 | + {% if debug %} |
| 113 | <hr> | 113 | <hr> |
| 114 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | 114 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
| 115 | ref: <code>{{ q['ref'] }}</code> | 115 | ref: <code>{{ q['ref'] }}</code> |
| @@ -118,4 +118,4 @@ | @@ -118,4 +118,4 @@ | ||
| 118 | </div> <!-- card-footer --> | 118 | </div> <!-- card-footer --> |
| 119 | </div> <!-- card --> | 119 | </div> <!-- card --> |
| 120 | {% end %} <!-- if answer not None --> | 120 | {% end %} <!-- if answer not None --> |
| 121 | -{% end %} <!-- block --> | ||
| 122 | \ No newline at end of file | 121 | \ No newline at end of file |
| 122 | +{% end %} <!-- block --> |
perguntations/templates/review.html
| @@ -113,7 +113,7 @@ | @@ -113,7 +113,7 @@ | ||
| 113 | </div> <!-- jumbotron --> | 113 | </div> <!-- jumbotron --> |
| 114 | 114 | ||
| 115 | {% for i, q in enumerate(t['questions']) %} | 115 | {% for i, q in enumerate(t['questions']) %} |
| 116 | - {% module Template(templ[q['type']], i=i, q=q, md=md(q['ref']), t=t) %} | 116 | + {% module Template(templ[q['type']], i=i, q=q, md=md(q['ref']), t=t, debug=debug) %} |
| 117 | {% end %} | 117 | {% end %} |
| 118 | 118 | ||
| 119 | </div> <!-- container --> | 119 | </div> <!-- container --> |
perguntations/templates/test.html
| @@ -114,7 +114,7 @@ | @@ -114,7 +114,7 @@ | ||
| 114 | {% module xsrf_form_html() %} | 114 | {% module xsrf_form_html() %} |
| 115 | 115 | ||
| 116 | {% for i, q in enumerate(t['questions']) %} | 116 | {% for i, q in enumerate(t['questions']) %} |
| 117 | - {% module Template(templ[q['type']], i=i, q=q, md=md(q['ref']), show_ref=t['show_ref']) %} | 117 | + {% module Template(templ[q['type']], i=i, q=q, md=md(q['ref']), debug=debug) %} |
| 118 | {% end %} | 118 | {% end %} |
| 119 | 119 | ||
| 120 | <div class="form-row"> | 120 | <div class="form-row"> |
perguntations/testfactory.py
| @@ -6,8 +6,9 @@ TestFactory - generates tests for students | @@ -6,8 +6,9 @@ TestFactory - generates tests for students | ||
| 6 | from os import path | 6 | from os import path |
| 7 | import random | 7 | import random |
| 8 | import logging | 8 | import logging |
| 9 | -import re | ||
| 10 | -from typing import TypedDict | 9 | + |
| 10 | +# other libraries | ||
| 11 | +import schema | ||
| 11 | 12 | ||
| 12 | # this project | 13 | # this project |
| 13 | from perguntations.questions import QFactory, QuestionException, QDict | 14 | from perguntations.questions import QFactory, QuestionException, QDict |
| @@ -17,10 +18,49 @@ from perguntations.tools import load_yaml | @@ -17,10 +18,49 @@ from perguntations.tools import load_yaml | ||
| 17 | # Logger configuration | 18 | # Logger configuration |
| 18 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
| 19 | 20 | ||
| 20 | -ConfigDict = TypedDict('ConfigDict', { | ||
| 21 | - 'title': str | ||
| 22 | - # TODO add other fields | ||
| 23 | - }) | 21 | +# --- test validation -------------------------------------------------------- |
| 22 | +def check_answers_directory(ans: str) -> bool: | ||
| 23 | + '''Checks is answers_dir exists and is writable''' | ||
| 24 | + testfile = path.join(path.expanduser(ans), 'REMOVE-ME') | ||
| 25 | + try: | ||
| 26 | + with open(testfile, 'w', encoding='utf-8') as file: | ||
| 27 | + file.write('You can safely remove this file.') | ||
| 28 | + except OSError: | ||
| 29 | + return False | ||
| 30 | + return True | ||
| 31 | + | ||
| 32 | +def check_import_files(files: list) -> bool: | ||
| 33 | + '''Checks if the question files exist''' | ||
| 34 | + if not files: | ||
| 35 | + return False | ||
| 36 | + for file in files: | ||
| 37 | + if not path.isfile(file): | ||
| 38 | + return False | ||
| 39 | + return True | ||
| 40 | + | ||
| 41 | +def normalize_question_list(questions: list) -> None: | ||
| 42 | + '''convert question ref from string to list of string''' | ||
| 43 | + for question in questions: | ||
| 44 | + if isinstance(question['ref'], str): | ||
| 45 | + question['ref'] = [question['ref']] | ||
| 46 | + | ||
| 47 | +test_schema = schema.Schema({ | ||
| 48 | + 'ref': schema.Regex('^[a-zA-Z0-9_-]+$'), | ||
| 49 | + 'database': schema.And(str, path.isfile), | ||
| 50 | + 'answers_dir': schema.And(str, check_answers_directory), | ||
| 51 | + 'title': str, | ||
| 52 | + schema.Optional('duration'): int, | ||
| 53 | + schema.Optional('autosubmit'): bool, | ||
| 54 | + schema.Optional('autocorrect'): bool, | ||
| 55 | + schema.Optional('show_points'): bool, | ||
| 56 | + schema.Optional('scale'): schema.And([schema.Use(float)], | ||
| 57 | + lambda s: len(s) == 2), | ||
| 58 | + 'files': schema.And([str], check_import_files), | ||
| 59 | + 'questions': [{ | ||
| 60 | + 'ref': schema.Or(str, [str]), | ||
| 61 | + schema.Optional('points'): float | ||
| 62 | + }] | ||
| 63 | + }, ignore_extra_keys=True) | ||
| 24 | 64 | ||
| 25 | # ============================================================================ | 65 | # ============================================================================ |
| 26 | class TestFactoryException(Exception): | 66 | class TestFactoryException(Exception): |
| @@ -36,35 +76,31 @@ class TestFactory(dict): | @@ -36,35 +76,31 @@ class TestFactory(dict): | ||
| 36 | ''' | 76 | ''' |
| 37 | 77 | ||
| 38 | # ------------------------------------------------------------------------ | 78 | # ------------------------------------------------------------------------ |
| 39 | - def __init__(self, conf: ConfigDict) -> None: | 79 | + def __init__(self, conf) -> None: |
| 40 | ''' | 80 | ''' |
| 41 | Loads configuration from yaml file, then overrides some configurations | 81 | Loads configuration from yaml file, then overrides some configurations |
| 42 | using the conf argument. | 82 | using the conf argument. |
| 43 | Base questions are added to a pool of questions factories. | 83 | Base questions are added to a pool of questions factories. |
| 44 | ''' | 84 | ''' |
| 45 | 85 | ||
| 86 | + test_schema.validate(conf) | ||
| 87 | + | ||
| 46 | # --- set test defaults and then use given configuration | 88 | # --- set test defaults and then use given configuration |
| 47 | super().__init__({ # defaults | 89 | super().__init__({ # defaults |
| 48 | - 'title': '', | ||
| 49 | 'show_points': True, | 90 | 'show_points': True, |
| 50 | 'scale': None, | 91 | 'scale': None, |
| 51 | 'duration': 0, # 0=infinite | 92 | 'duration': 0, # 0=infinite |
| 52 | 'autosubmit': False, | 93 | 'autosubmit': False, |
| 53 | 'autocorrect': True, | 94 | 'autocorrect': True, |
| 54 | - # 'debug': False, # FIXME not property of a test... | ||
| 55 | - 'show_ref': False, | ||
| 56 | }) | 95 | }) |
| 57 | self.update(conf) | 96 | self.update(conf) |
| 97 | + normalize_question_list(self['questions']) | ||
| 58 | 98 | ||
| 59 | # --- for review, we are done. no factories needed | 99 | # --- for review, we are done. no factories needed |
| 60 | # if self['review']: FIXME | 100 | # if self['review']: FIXME |
| 61 | # logger.info('Review mode. No questions loaded. No factories.') | 101 | # logger.info('Review mode. No questions loaded. No factories.') |
| 62 | # return | 102 | # return |
| 63 | 103 | ||
| 64 | - # --- perform sanity checks and normalize the test questions | ||
| 65 | - self.sanity_checks() | ||
| 66 | - logger.info('Sanity checks PASSED.') | ||
| 67 | - | ||
| 68 | # --- find refs of all questions used in the test | 104 | # --- find refs of all questions used in the test |
| 69 | qrefs = {r for qq in self['questions'] for r in qq['ref']} | 105 | qrefs = {r for qq in self['questions'] for r in qq['ref']} |
| 70 | logger.info('Declared %d questions (each test uses %d).', | 106 | logger.info('Declared %d questions (each test uses %d).', |
| @@ -74,7 +110,7 @@ class TestFactory(dict): | @@ -74,7 +110,7 @@ class TestFactory(dict): | ||
| 74 | self['question_factory'] = {} | 110 | self['question_factory'] = {} |
| 75 | 111 | ||
| 76 | for file in self["files"]: | 112 | for file in self["files"]: |
| 77 | - fullpath = path.normpath(path.join(self["questions_dir"], file)) | 113 | + fullpath = path.normpath(file) |
| 78 | 114 | ||
| 79 | logger.info('Loading "%s"...', fullpath) | 115 | logger.info('Loading "%s"...', fullpath) |
| 80 | questions = load_yaml(fullpath) # , default=[]) | 116 | questions = load_yaml(fullpath) # , default=[]) |
| @@ -85,7 +121,7 @@ class TestFactory(dict): | @@ -85,7 +121,7 @@ class TestFactory(dict): | ||
| 85 | msg = f'Question {i} in {file} is not a dictionary' | 121 | msg = f'Question {i} in {file} is not a dictionary' |
| 86 | raise TestFactoryException(msg) | 122 | raise TestFactoryException(msg) |
| 87 | 123 | ||
| 88 | - # check if ref is missing, then set to '/path/file.yaml:3' | 124 | + # check if ref is missing, then set to '//file.yaml:3' |
| 89 | if 'ref' not in question: | 125 | if 'ref' not in question: |
| 90 | question['ref'] = f'{file}:{i:04}' | 126 | question['ref'] = f'{file}:{i:04}' |
| 91 | logger.warning('Missing ref set to "%s"', question["ref"]) | 127 | logger.warning('Missing ref set to "%s"', question["ref"]) |
| @@ -115,103 +151,104 @@ class TestFactory(dict): | @@ -115,103 +151,104 @@ class TestFactory(dict): | ||
| 115 | 151 | ||
| 116 | 152 | ||
| 117 | # ------------------------------------------------------------------------ | 153 | # ------------------------------------------------------------------------ |
| 118 | - def check_test_ref(self) -> None: | ||
| 119 | - '''Test must have a `ref`''' | ||
| 120 | - if 'ref' not in self: | ||
| 121 | - raise TestFactoryException('Missing "ref" in configuration!') | ||
| 122 | - if not re.match(r'^[a-zA-Z0-9_-]+$', self['ref']): | ||
| 123 | - raise TestFactoryException('Test "ref" can only contain the ' | ||
| 124 | - 'characters a-zA-Z0-9_-') | ||
| 125 | - | ||
| 126 | - def check_missing_database(self) -> None: | ||
| 127 | - '''Test must have a database''' | ||
| 128 | - if 'database' not in self: | ||
| 129 | - raise TestFactoryException('Missing "database" in configuration') | ||
| 130 | - if not path.isfile(path.expanduser(self['database'])): | ||
| 131 | - msg = f'Database "{self["database"]}" not found!' | ||
| 132 | - raise TestFactoryException(msg) | ||
| 133 | - | ||
| 134 | - def check_missing_answers_directory(self) -> None: | ||
| 135 | - '''Test must have a answers directory''' | ||
| 136 | - if 'answers_dir' not in self: | ||
| 137 | - msg = 'Missing "answers_dir" in configuration' | ||
| 138 | - raise TestFactoryException(msg) | ||
| 139 | - | ||
| 140 | - def check_answers_directory_writable(self) -> None: | ||
| 141 | - '''Answers directory must be writable''' | ||
| 142 | - testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') | ||
| 143 | - try: | ||
| 144 | - with open(testfile, 'w', encoding='utf-8') as file: | ||
| 145 | - file.write('You can safely remove this file.') | ||
| 146 | - except OSError as exc: | ||
| 147 | - msg = f'Cannot write answers to directory "{self["answers_dir"]}"' | ||
| 148 | - raise TestFactoryException(msg) from exc | ||
| 149 | - | ||
| 150 | - def check_questions_directory(self) -> None: | ||
| 151 | - '''Check if questions directory is missing or not accessible.''' | ||
| 152 | - if 'questions_dir' not in self: | ||
| 153 | - logger.warning('Missing "questions_dir". Using "%s"', | ||
| 154 | - path.abspath(path.curdir)) | ||
| 155 | - self['questions_dir'] = path.curdir | ||
| 156 | - elif not path.isdir(path.expanduser(self['questions_dir'])): | ||
| 157 | - raise TestFactoryException(f'Can\'t find questions directory ' | ||
| 158 | - f'"{self["questions_dir"]}"') | ||
| 159 | - | ||
| 160 | - def check_import_files(self) -> None: | ||
| 161 | - '''Check if there are files to import (with questions)''' | ||
| 162 | - if 'files' not in self: | ||
| 163 | - msg = ('Missing "files" in configuration with the list of ' | ||
| 164 | - 'question files to import!') | ||
| 165 | - raise TestFactoryException(msg) | ||
| 166 | - | ||
| 167 | - if isinstance(self['files'], str): | ||
| 168 | - self['files'] = [self['files']] | ||
| 169 | - | ||
| 170 | - def check_question_list(self) -> None: | ||
| 171 | - '''normalize question list''' | ||
| 172 | - if 'questions' not in self: | ||
| 173 | - raise TestFactoryException('Missing "questions" in configuration') | ||
| 174 | - | ||
| 175 | - for i, question in enumerate(self['questions']): | ||
| 176 | - # normalize question to a dict and ref to a list of references | ||
| 177 | - if isinstance(question, str): # e.g., - some_ref | ||
| 178 | - question = {'ref': [question]} # becomes - ref: [some_ref] | ||
| 179 | - elif isinstance(question, dict) and isinstance(question['ref'], str): | ||
| 180 | - question['ref'] = [question['ref']] | ||
| 181 | - elif isinstance(question, list): | ||
| 182 | - question = {'ref': [str(a) for a in question]} | ||
| 183 | - | ||
| 184 | - self['questions'][i] = question | ||
| 185 | - | ||
| 186 | - def check_missing_title(self) -> None: | ||
| 187 | - '''Warns if title is missing''' | ||
| 188 | - if not self['title']: | ||
| 189 | - logger.warning('Title is undefined!') | ||
| 190 | - | ||
| 191 | - def check_grade_scaling(self) -> None: | ||
| 192 | - '''Just informs the scale limits''' | ||
| 193 | - if 'scale_points' in self: | ||
| 194 | - msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, ' | ||
| 195 | - 'scale_max were replaced by "scale: [min, max]".') | ||
| 196 | - logger.warning(msg) | ||
| 197 | - self['scale'] = [self['scale_min'], self['scale_max']] | 154 | + # def check_test_ref(self) -> None: |
| 155 | + # '''Test must have a `ref`''' | ||
| 156 | + # if 'ref' not in self: | ||
| 157 | + # raise TestFactoryException('Missing "ref" in configuration!') | ||
| 158 | + # if not re.match(r'^[a-zA-Z0-9_-]+$', self['ref']): | ||
| 159 | + # raise TestFactoryException('Test "ref" can only contain the ' | ||
| 160 | + # 'characters a-zA-Z0-9_-') | ||
| 161 | + | ||
| 162 | + # def check_missing_database(self) -> None: | ||
| 163 | + # '''Test must have a database''' | ||
| 164 | + # if 'database' not in self: | ||
| 165 | + # raise TestFactoryException('Missing "database" in configuration') | ||
| 166 | + # if not path.isfile(path.expanduser(self['database'])): | ||
| 167 | + # msg = f'Database "{self["database"]}" not found!' | ||
| 168 | + # raise TestFactoryException(msg) | ||
| 169 | + | ||
| 170 | + # def check_missing_answers_directory(self) -> None: | ||
| 171 | + # '''Test must have a answers directory''' | ||
| 172 | + # if 'answers_dir' not in self: | ||
| 173 | + # msg = 'Missing "answers_dir" in configuration' | ||
| 174 | + # raise TestFactoryException(msg) | ||
| 175 | + | ||
| 176 | + # def check_answers_directory_writable(self) -> None: | ||
| 177 | + # '''Answers directory must be writable''' | ||
| 178 | + # testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') | ||
| 179 | + # try: | ||
| 180 | + # with open(testfile, 'w', encoding='utf-8') as file: | ||
| 181 | + # file.write('You can safely remove this file.') | ||
| 182 | + # except OSError as exc: | ||
| 183 | + # msg = f'Cannot write answers to directory "{self["answers_dir"]}"' | ||
| 184 | + # raise TestFactoryException(msg) from exc | ||
| 185 | + | ||
| 186 | + # def check_questions_directory(self) -> None: | ||
| 187 | + # '''Check if questions directory is missing or not accessible.''' | ||
| 188 | + # if 'questions_dir' not in self: | ||
| 189 | + # logger.warning('Missing "questions_dir". Using "%s"', | ||
| 190 | + # path.abspath(path.curdir)) | ||
| 191 | + # self['questions_dir'] = path.curdir | ||
| 192 | + # elif not path.isdir(path.expanduser(self['questions_dir'])): | ||
| 193 | + # raise TestFactoryException(f'Can\'t find questions directory ' | ||
| 194 | + # f'"{self["questions_dir"]}"') | ||
| 195 | + | ||
| 196 | + # def check_import_files(self) -> None: | ||
| 197 | + # '''Check if there are files to import (with questions)''' | ||
| 198 | + # if 'files' not in self: | ||
| 199 | + # msg = ('Missing "files" in configuration with the list of ' | ||
| 200 | + # 'question files to import!') | ||
| 201 | + # raise TestFactoryException(msg) | ||
| 202 | + | ||
| 203 | + # if isinstance(self['files'], str): | ||
| 204 | + # self['files'] = [self['files']] | ||
| 205 | + | ||
| 206 | + # def check_question_list(self) -> None: | ||
| 207 | + # '''normalize question list''' | ||
| 208 | + # if 'questions' not in self: | ||
| 209 | + # raise TestFactoryException('Missing "questions" in configuration') | ||
| 210 | + | ||
| 211 | + # for i, question in enumerate(self['questions']): | ||
| 212 | + # # normalize question to a dict and ref to a list of references | ||
| 213 | + # if isinstance(question, str): # e.g., - some_ref | ||
| 214 | + # logger.warning(f'Question "{question}" should be a dictionary') | ||
| 215 | + # question = {'ref': [question]} # becomes - ref: [some_ref] | ||
| 216 | + # elif isinstance(question, dict) and isinstance(question['ref'], str): | ||
| 217 | + # question['ref'] = [question['ref']] | ||
| 218 | + # elif isinstance(question, list): | ||
| 219 | + # question = {'ref': [str(a) for a in question]} | ||
| 220 | + | ||
| 221 | + # self['questions'][i] = question | ||
| 222 | + | ||
| 223 | + # def check_missing_title(self) -> None: | ||
| 224 | + # '''Warns if title is missing''' | ||
| 225 | + # if not self['title']: | ||
| 226 | + # logger.warning('Title is undefined!') | ||
| 227 | + | ||
| 228 | + # def check_grade_scaling(self) -> None: | ||
| 229 | + # '''Just informs the scale limits''' | ||
| 230 | + # if 'scale_points' in self: | ||
| 231 | + # msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, ' | ||
| 232 | + # 'scale_max were replaced by "scale: [min, max]".') | ||
| 233 | + # logger.warning(msg) | ||
| 234 | + # self['scale'] = [self['scale_min'], self['scale_max']] | ||
| 198 | 235 | ||
| 199 | 236 | ||
| 200 | # ------------------------------------------------------------------------ | 237 | # ------------------------------------------------------------------------ |
| 201 | - def sanity_checks(self) -> None: | ||
| 202 | - ''' | ||
| 203 | - Checks for valid keys and sets default values. | ||
| 204 | - Also checks if some files and directories exist | ||
| 205 | - ''' | ||
| 206 | - self.check_test_ref() | ||
| 207 | - self.check_missing_database() | ||
| 208 | - self.check_missing_answers_directory() | ||
| 209 | - self.check_answers_directory_writable() | ||
| 210 | - self.check_questions_directory() | ||
| 211 | - self.check_import_files() | ||
| 212 | - self.check_question_list() | ||
| 213 | - self.check_missing_title() | ||
| 214 | - self.check_grade_scaling() | 238 | + # def sanity_checks(self) -> None: |
| 239 | + # ''' | ||
| 240 | + # Checks for valid keys and sets default values. | ||
| 241 | + # Also checks if some files and directories exist | ||
| 242 | + # ''' | ||
| 243 | + # self.check_test_ref() | ||
| 244 | + # self.check_missing_database() | ||
| 245 | + # self.check_missing_answers_directory() | ||
| 246 | + # self.check_answers_directory_writable() | ||
| 247 | + # self.check_questions_directory() | ||
| 248 | + # self.check_import_files() | ||
| 249 | + # self.check_question_list() | ||
| 250 | + # self.check_missing_title() | ||
| 251 | + # self.check_grade_scaling() | ||
| 215 | 252 | ||
| 216 | # ------------------------------------------------------------------------ | 253 | # ------------------------------------------------------------------------ |
| 217 | def check_questions(self) -> None: | 254 | def check_questions(self) -> None: |
| @@ -230,42 +267,6 @@ class TestFactory(dict): | @@ -230,42 +267,6 @@ class TestFactory(dict): | ||
| 230 | 267 | ||
| 231 | if question['type'] == 'textarea': | 268 | if question['type'] == 'textarea': |
| 232 | _runtests_textarea(qref, question) | 269 | _runtests_textarea(qref, question) |
| 233 | - # if 'tests_right' in question: | ||
| 234 | - # for tnum, right_answer in enumerate(question['tests_right']): | ||
| 235 | - # try: | ||
| 236 | - # question.set_answer(right_answer) | ||
| 237 | - # question.correct() | ||
| 238 | - # except Exception as exc: | ||
| 239 | - # msg = f'Failed to correct "{qref}"' | ||
| 240 | - # raise TestFactoryException(msg) from exc | ||
| 241 | - | ||
| 242 | - # if question['grade'] == 1.0: | ||
| 243 | - # logger.info(' test %i Ok', tnum) | ||
| 244 | - # else: | ||
| 245 | - # logger.error(' TEST %i IS WRONG!!!', tnum) | ||
| 246 | - # elif 'tests_wrong' in question: | ||
| 247 | - # for tnum, wrong_answer in enumerate(question['tests_wrong']): | ||
| 248 | - # try: | ||
| 249 | - # question.set_answer(wrong_answer) | ||
| 250 | - # question.correct() | ||
| 251 | - # except Exception as exc: | ||
| 252 | - # msg = f'Failed to correct "{qref}"' | ||
| 253 | - # raise TestFactoryException(msg) from exc | ||
| 254 | - | ||
| 255 | - # if question['grade'] < 1.0: | ||
| 256 | - # logger.info(' test %i Ok', tnum) | ||
| 257 | - # else: | ||
| 258 | - # logger.error(' TEST %i IS WRONG!!!', tnum) | ||
| 259 | - # else: | ||
| 260 | - # try: | ||
| 261 | - # question.set_answer('') | ||
| 262 | - # question.correct() | ||
| 263 | - # except Exception as exc: | ||
| 264 | - # msg = f'Failed to correct "{qref}"' | ||
| 265 | - # raise TestFactoryException(msg) from exc | ||
| 266 | - # else: | ||
| 267 | - # logger.info(' correct Ok but no tests to run') | ||
| 268 | - | ||
| 269 | # ------------------------------------------------------------------------ | 270 | # ------------------------------------------------------------------------ |
| 270 | async def generate(self): | 271 | async def generate(self): |
| 271 | ''' | 272 | ''' |
| @@ -323,11 +324,8 @@ class TestFactory(dict): | @@ -323,11 +324,8 @@ class TestFactory(dict): | ||
| 323 | logger.error('%s errors found!', nerr) | 324 | logger.error('%s errors found!', nerr) |
| 324 | 325 | ||
| 325 | # copy these from the test configuratoin to each test instance | 326 | # copy these from the test configuratoin to each test instance |
| 326 | - inherit = {'ref', 'title', 'database', 'answers_dir', | ||
| 327 | - 'questions_dir', 'files', | ||
| 328 | - 'duration', 'autosubmit', 'autocorrect', | ||
| 329 | - 'scale', 'show_points', 'show_ref'} | ||
| 330 | - # NOT INCLUDED: testfile, allow_all, review, debug | 327 | + inherit = ['ref', 'title', 'database', 'answers_dir', 'files', 'scale', |
| 328 | + 'duration', 'autosubmit', 'autocorrect', 'show_points'] | ||
| 331 | 329 | ||
| 332 | return Test({'questions': questions, **{k:self[k] for k in inherit}}) | 330 | return Test({'questions': questions, **{k:self[k] for k in inherit}}) |
| 333 | 331 |