Commit 1b9491c2682cde0625d2dd4305c340375f6d5ea8

Authored by Miguel Barão
1 parent ffb53a93
Exists in master and in 1 other branch dev

- use schema to validade test.

- removed option --show_ref, now depends on debug.
- the option --correct is not implemented yet.
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 # ----------------------------------------------------------------------------
1 [mypy] 1 [mypy]
2 python_version = 3.9 2 python_version = 3.9
  3 +ignore_missing_imports = True
  4 +
3 5
4 ; [mypy-setuptools.*] 6 ; [mypy-setuptools.*]
5 ; ignore_missing_imports = True 7 ; ignore_missing_imports = True
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