Commit da2178534091980d64fe4b11c9b60443fe7e407f
1 parent
f9a2254f
Exists in
master
and in
1 other branch
Very large rewrite of test and question modules.
Some of the improvements are: - cleaner code with TestFactory and QuestionFactory. - application logging to the console - correction script now can return yaml dictionary with grade and comments. Regressions: - command line arguments temporarily disabled. - cherrypy logging temporarily disabled
Showing
8 changed files
with
473 additions
and
403 deletions
Show diff stats
BUGS.md
1 | 1 | |
2 | 2 | # BUGS |
3 | 3 | |
4 | +- em practice, depois da submissao o teste corrigido perde as respostas anteriores. perguntas estao todas expostas. | |
4 | 5 | - cherrypy faz logs para consola... |
5 | 6 | - mensagens info nao aparecem no serve.py |
6 | 7 | - usar thread.Lock para aceder a variaveis de estado. |
... | ... | @@ -20,24 +21,29 @@ |
20 | 21 | |
21 | 22 | # TODO |
22 | 23 | |
24 | +- refazer questions.py para ter uma classe QuestionFectory? | |
25 | +- refazer serve.py para usar uma classe App() com lógica separada do cherrypy | |
26 | +- controlar acessos dos alunos: allowed/denied/online threadsafe na App() | |
27 | +- argumentos da linha de comando a funcionar. | |
28 | +- permitir adicionar imagens nas perguntas | |
29 | +- aviso na pagina principal para quem usa browser da treta | |
30 | +- permitir enviar varios testes, aluno escolhe qual o teste que quer fazer. | |
31 | +- criar perguntas de outros tipos, e.g. associação, ordenação, varios textinput | |
32 | +- browser e ip usados gravado no test. | |
33 | +- single page web no frontend | |
34 | +- SQLAlchemy em vez da classe database. | |
23 | 35 | - script de correcção pode enviar dicionario yaml com grade e comentarios. ex: |
24 | 36 | grade: 0.5 |
25 | 37 | comments: Falhou na função xpto. |
26 | 38 | os comentários são guardados no teste (ficheiro) ou enviados para o browser no modo practice. |
27 | 39 | - warning quando se executa novamente o mesmo teste na consola. ie se ja houver submissoes desse teste. |
28 | 40 | - na cotacao da pergunta indicar o intervalo, e.g. [-0.2, 1], [0, 0.5] |
29 | -- fazer uma calculadora javascript e por no menu. surge como modal | |
30 | -- SQLAlchemy em vez da classe database. | |
31 | 41 | - Criar botão para o docente fazer enable/disable do aluno explicitamente (exames presenciais). |
32 | -- permitir enviar varios testes, aluno escolhe qual o teste que quer fazer. | |
33 | 42 | - criar script json2md.py ou outra forma de gerar um teste ja realizado |
34 | 43 | - Menu para professor com link para /results e /students |
35 | -- implementar singlepage/multipage. Fazer uma class para single page que trate de andar gerir o avanco e correcao das perguntas | |
36 | -- permitir adicionar imagens nas perguntas | |
37 | -- criar perguntas de outros tipos, e.g. associação, ordenação, varios textinput | |
38 | 44 | - perguntas para professor corrigir mais tarde. |
39 | -- testar com microsoft surface. | |
40 | 45 | - share do score em /results (email) |
46 | +- fazer uma calculadora javascript e por no menu. surge como modal | |
41 | 47 | |
42 | 48 | # FIXED |
43 | 49 | ... | ... |
config/server.conf
... | ... | @@ -23,8 +23,10 @@ server.socket_port = 8080 |
23 | 23 | log.screen = False |
24 | 24 | |
25 | 25 | # add path to the log files here. empty strings disable logging |
26 | -log.error_file = 'logs/errors.log' | |
27 | -log.access_file = 'logs/access.log' | |
26 | +; log.error_file = 'logs/errors.log' | |
27 | +; log.access_file = 'logs/access.log' | |
28 | +log.error_file = '' | |
29 | +log.access_file = '' | |
28 | 30 | |
29 | 31 | # DO NOT DISABLE SESSIONS! |
30 | 32 | tools.sessions.on = True | ... | ... |
database.py
... | ... | @@ -55,12 +55,12 @@ class Database(object): |
55 | 55 | def save_test(self, t): |
56 | 56 | with sqlite3.connect(self.db) as c: |
57 | 57 | # store result of the test |
58 | - values = (t['ref'], t['number'], t['grade'], str(t['start_time']), str(t['finish_time'])) | |
58 | + values = (t['ref'], t['student']['number'], t['grade'], str(t['start_time']), str(t['finish_time'])) | |
59 | 59 | c.execute('INSERT INTO tests VALUES (?,?,?,?,?)', values) |
60 | 60 | |
61 | 61 | # store grade of every question in the test |
62 | 62 | try: |
63 | - ans = [(t['ref'], q['ref'], t['number'], q['grade'], str(t['finish_time'])) for q in t['questions']] | |
63 | + ans = [(t['ref'], q['ref'], t['student']['number'], q['grade'], str(t['finish_time'])) for q in t['questions']] | |
64 | 64 | except KeyError as e: |
65 | 65 | print(' * Questions {0} do not have grade defined.'.format(tuple(q['ref'] for q in t['questions'] if 'grade' not in q))) |
66 | 66 | raise e | ... | ... |
questions.py
1 | 1 | |
2 | +# We start with an empty QuestionFactory() that will be populated with | |
3 | +# question generators that we can load from YAML files. | |
4 | +# To generate an instance of a question we use the method generate(ref) where | |
5 | +# the argument is que reference of the question we wish to produce. | |
6 | +# | |
2 | 7 | # Example: |
3 | 8 | # |
4 | 9 | # # read everything from question files |
5 | -# pool = QuestionPool() | |
6 | -# pool.add_from_files(['file1.yaml', 'file1.yaml']) | |
10 | +# factory = QuestionFactory() | |
11 | +# factory.load_files(['file1.yaml', 'file1.yaml'], '/path/to') | |
7 | 12 | # |
8 | -# # generate a new test, creating instances for all questions | |
9 | -# test = [] | |
10 | -# for q in pool.values(): | |
11 | -# test.append(create_question(q)) | |
13 | +# question = factory.generate('some_ref') | |
12 | 14 | # |
13 | 15 | # # experiment answering one question and correct it |
14 | -# test[0]['answer'] = 42 # insert answer | |
15 | -# grade = test[0].correct() # correct answer | |
16 | - | |
17 | - | |
18 | - | |
19 | -# QuestionsPool - dictionary of questions not yet instantiated | |
20 | -# | |
21 | -# question_generator - runs external script to get a question dictionary | |
22 | -# create_question - returns question instance with the correct class | |
16 | +# question['answer'] = 42 # insert answer | |
17 | +# grade = question.correct() # correct answer | |
23 | 18 | |
24 | -# An instance of an actual question is a Question object: | |
19 | +# An instance of an actual question is an object that inherits from Question() | |
25 | 20 | # |
26 | 21 | # Question - base class inherited by other classes |
27 | 22 | # QuestionRadio - single choice from a list of options |
... | ... | @@ -34,25 +29,24 @@ |
34 | 29 | import random |
35 | 30 | import re |
36 | 31 | import subprocess |
37 | -import os.path | |
32 | +from os import path | |
38 | 33 | import logging |
39 | 34 | import sys |
40 | 35 | |
36 | +# setup logger for this module | |
37 | +logger = logging.getLogger(__name__) | |
38 | +logger.setLevel(logging.INFO) | |
41 | 39 | |
42 | - | |
43 | -qlogger = logging.getLogger('questions') | |
44 | -qlogger.setLevel(logging.INFO) | |
45 | - | |
46 | -fh = logging.FileHandler('question.log') | |
40 | +# fh = logging.FileHandler('question.log') | |
47 | 41 | ch = logging.StreamHandler() |
48 | 42 | ch.setLevel(logging.INFO) |
49 | 43 | |
50 | 44 | formatter = logging.Formatter('%(asctime)s | %(name)-10s | %(levelname)-8s | %(message)s') |
51 | -fh.setFormatter(formatter) | |
45 | +# fh.setFormatter(formatter) | |
52 | 46 | ch.setFormatter(formatter) |
53 | 47 | |
54 | -qlogger.addHandler(fh) | |
55 | -qlogger.addHandler(ch) | |
48 | +# logger.addHandler(fh) | |
49 | +logger.addHandler(ch) | |
56 | 50 | |
57 | 51 | try: |
58 | 52 | import yaml |
... | ... | @@ -61,137 +55,144 @@ except ImportError: |
61 | 55 | sys.exit(1) |
62 | 56 | |
63 | 57 | |
64 | -# if an error occurs in a question, the question is replaced by this message | |
65 | -qerror = { | |
66 | - 'filename': 'questions.py', | |
67 | - 'ref': '__error__', | |
68 | - 'type': 'warning', | |
69 | - 'text': 'An error occurred while generating this question.' | |
70 | - } | |
71 | - | |
72 | -# =========================================================================== | |
73 | -class QuestionsPool(dict): | |
74 | - '''This class contains base questions read from files, but which are | |
75 | - not ready yet. They have to be instantiated for each student.''' | |
76 | - | |
77 | - #------------------------------------------------------------------------ | |
78 | - def add(self, questions, filename, path): | |
79 | - # add some defaults if missing from sources | |
80 | - for i, q in enumerate(questions): | |
81 | - if not isinstance(q, dict): | |
82 | - qlogger.error('Question index {0} from file {1} is not a dictionary. Skipped...'.format(i, filename)) | |
83 | - continue | |
84 | - | |
85 | - if q['ref'] in self: | |
86 | - qlogger.error('Duplicate question "{0}" in files "{1}" and "{2}". Skipped...'.format(q['ref'], filename, self[q['ref']]['filename'])) | |
87 | - continue | |
88 | - | |
89 | - # index is the position in the questions file, 0 based | |
90 | - q.update({ | |
91 | - 'filename': filename, | |
92 | - 'path': path, | |
93 | - 'index': i | |
94 | - }) | |
95 | - q.setdefault('ref', filename + ':' + str(i)) # 'filename.yaml:3' | |
96 | - q.setdefault('type', 'information') | |
97 | - | |
98 | - # add question to the pool | |
99 | - self[q['ref']] = q | |
100 | - qlogger.debug('Added question "{0}" to the pool.'.format(q['ref'])) | |
101 | - | |
102 | - #------------------------------------------------------------------------ | |
103 | - def add_from_files(self, files, path='.'): | |
104 | - '''Given a list of YAML files, reads them all and tries to add | |
105 | - questions to the pool.''' | |
106 | - for filename in files: | |
107 | - try: | |
108 | - with open(os.path.normpath(os.path.join(path, filename)), 'r', encoding='utf-8') as f: | |
109 | - questions = yaml.load(f) | |
110 | - except(FileNotFoundError): | |
111 | - qlogger.error('Questions file "{0}" not found. Skipping this one.'.format(filename)) | |
112 | - continue | |
113 | - except(yaml.parser.ParserError): | |
114 | - qlogger.error('Error loading questions from YAML file "{0}". Skipping this one.'.format(filename)) | |
115 | - continue | |
116 | - self.add(questions, filename, path) | |
117 | - qlogger.info('Loaded {0} questions from "{1}".'.format(len(questions), filename)) | |
118 | - | |
119 | - | |
120 | -#============================================================================ | |
121 | -# Question Factory | |
122 | -# Given a dictionary returns a question instance. | |
123 | -def create_question(q): | |
124 | - '''To create a question, q must be a dictionary with at least the | |
125 | - following keys defined: | |
126 | - filename | |
127 | - ref | |
128 | - type | |
129 | - The remaing keys depend on the type of question. | |
130 | - ''' | |
131 | - | |
132 | - # Depending on the type of question, a different question class is | |
133 | - # instantiated. All these classes derive from the base class `Question`. | |
134 | - types = { | |
135 | - 'radio' : QuestionRadio, | |
136 | - 'checkbox' : QuestionCheckbox, | |
137 | - 'text' : QuestionText, | |
138 | - 'text_regex': QuestionTextRegex, | |
139 | - 'textarea' : QuestionTextArea, | |
140 | - 'information': QuestionInformation, | |
141 | - 'warning' : QuestionInformation, | |
142 | - } | |
143 | - | |
144 | - | |
145 | - # If `q` is of a question generator type, an external program will be run | |
146 | - # and expected to print a valid question in yaml format to stdout. This | |
147 | - # output is then converted to a dictionary and `q` becomes that dict. | |
148 | - if q['type'] == 'generator': | |
149 | - qlogger.debug('Generating question "{0}"...'.format(q['ref'])) | |
150 | - q.update(question_generator(q)) | |
151 | - # At this point the generator question was replaced by an actual question. | |
152 | - | |
153 | - # Get the correct question class for the declared question type | |
154 | - try: | |
155 | - questiontype = types[q['type']] | |
156 | - except KeyError: | |
157 | - qlogger.error('Unsupported question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) | |
158 | - questiontype, q = QuestionWarning, qerror | |
159 | - | |
160 | - # Create question instance and return | |
161 | - try: | |
162 | - qinstance = questiontype(q) | |
163 | - except: | |
164 | - qlogger.error('Could not create question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) | |
165 | - qinstance = QuestionInformation(qerror) | |
166 | - | |
167 | - return qinstance | |
168 | - | |
169 | - | |
170 | 58 | # --------------------------------------------------------------------------- |
171 | -def question_generator(q): | |
172 | - '''Run an external program that will generate a question in yaml format. | |
173 | - This function will return the yaml converted back to a dict.''' | |
174 | - | |
175 | - q.setdefault('arg', '') # will be sent to stdin | |
176 | - | |
177 | - script = os.path.abspath(os.path.normpath(os.path.join(q['path'], q['script']))) | |
59 | +# Runs a script and returns its stdout parsed as yaml, or None on error. | |
60 | +# --------------------------------------------------------------------------- | |
61 | +def run_script(script, stdin='', timeout=5): | |
178 | 62 | try: |
179 | - p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) | |
63 | + p = subprocess.run([script], | |
64 | + input=stdin, | |
65 | + stdout=subprocess.PIPE, | |
66 | + stderr=subprocess.STDOUT, | |
67 | + universal_newlines=True, | |
68 | + timeout=timeout, | |
69 | + ) | |
180 | 70 | except FileNotFoundError: |
181 | - qlogger.error('Script "{0}" of question "{2}:{1}" not found'.format(script, q['ref'], q['filename'])) | |
182 | - return qerror | |
71 | + logger.error('Script "{0}" not found.'.format(script)) | |
72 | + # return qerror | |
183 | 73 | except PermissionError: |
184 | - qlogger.error('Script "{0}" has wrong permissions. Is it executable?'.format(script, q['ref'], q['filename'])) | |
185 | - return qerror | |
186 | - | |
187 | - try: | |
188 | - qyaml = p.communicate(input=q['arg'].encode('utf-8'), timeout=5)[0].decode('utf-8') | |
74 | + logger.error('Script "{0}" has wrong permissions. Is it executable?'.format(script)) | |
189 | 75 | except subprocess.TimeoutExpired: |
190 | - p.kill() | |
191 | - qlogger.error('Timeout on script "{0}" of question "{2}:{1}"'.format(script, q['ref'], q['filename'])) | |
192 | - return qerror | |
76 | + logger.error('Timeout {0}s exceeded while running script "{1}"'.format(timeout, script)) | |
77 | + else: | |
78 | + if p.returncode != 0: | |
79 | + logger.warning('Script "{0}" returned error code {1}.'.format(script, p.returncode)) | |
80 | + else: | |
81 | + try: | |
82 | + output = yaml.load(p.stdout) | |
83 | + except: | |
84 | + logger.error('Error parsing yaml output of script "{0}"'.format(script)) | |
85 | + else: | |
86 | + return output | |
87 | + | |
88 | +# =========================================================================== | |
89 | +# This class contains a pool of questions generators from which particular | |
90 | +# Question() instances are generated using QuestionsFactory.generate(ref). | |
91 | +# =========================================================================== | |
92 | +class QuestionFactory(dict): | |
93 | + # ----------------------------------------------------------------------- | |
94 | + def __init__(self): | |
95 | + super().__init__() | |
96 | + | |
97 | + # ----------------------------------------------------------------------- | |
98 | + # Add single question defined provided a dictionary. | |
99 | + # After this, each question will have at least 'ref' and 'type' keys. | |
100 | + # ----------------------------------------------------------------------- | |
101 | + def add(self, question): | |
102 | + # if ref missing try ref='/path/file.yaml:3' | |
103 | + try: | |
104 | + question.setdefault('ref', question['filename'] + ':' + str(question['index'])) | |
105 | + except KeyError: | |
106 | + logger.error('Missing "ref". Cannot add question to the pool.') | |
107 | + return | |
108 | + | |
109 | + # check duplicate references | |
110 | + if question['ref'] in self: | |
111 | + logger.error('Duplicate reference "{0}". Replacing the original one!'.format(question['ref'])) | |
112 | + | |
113 | + question.setdefault('type', 'information') | |
114 | + | |
115 | + self[question['ref']] = question | |
116 | + logger.debug('Added question "{0}" to the pool.'.format(question['ref'])) | |
117 | + | |
118 | + # ----------------------------------------------------------------------- | |
119 | + # load single YAML questions file | |
120 | + # ----------------------------------------------------------------------- | |
121 | + def load_file(self, filename, questions_dir=''): | |
122 | + try: | |
123 | + with open(path.normpath(path.join(questions_dir, filename)), 'r', encoding='utf-8') as f: | |
124 | + questions = yaml.load(f) | |
125 | + except EnvironmentError: | |
126 | + logger.error('Couldn''t open "{0}". Skipped!'.format(file)) | |
127 | + questions = [] | |
128 | + except yaml.parser.ParserError: | |
129 | + logger.error('While loading questions from "{0}". Skipped!'.format(file)) | |
130 | + questions = [] | |
131 | + | |
132 | + n = 0 | |
133 | + for i, q in enumerate(questions): | |
134 | + if isinstance(q, dict): | |
135 | + q.update({ | |
136 | + 'filename': filename, | |
137 | + 'path': questions_dir, | |
138 | + 'index': i # position in the file, 0 based | |
139 | + }) | |
140 | + self.add(q) # add question | |
141 | + n += 1 # counter | |
142 | + else: | |
143 | + logger.error('Question index {0} from file {1} is not a dictionary. Skipped!'.format(i, filename)) | |
144 | + | |
145 | + logger.info('Loaded {0} questions from "{1}" to the pool.'.format(n, filename)) | |
193 | 146 | |
194 | - return yaml.load(qyaml) | |
147 | + # ----------------------------------------------------------------------- | |
148 | + # load multiple YAML question files | |
149 | + # ----------------------------------------------------------------------- | |
150 | + def load_files(self, files, questions_dir=''): | |
151 | + for filename in files: | |
152 | + self.load_file(filename, questions_dir) | |
153 | + | |
154 | + # ----------------------------------------------------------------------- | |
155 | + # Given a ref returns an instance of a descendent of Question(), | |
156 | + # i.e. a question object (radio, checkbox, ...). | |
157 | + # ----------------------------------------------------------------------- | |
158 | + def generate(self, ref): | |
159 | + | |
160 | + # Depending on the type of question, a different question class will be | |
161 | + # instantiated. All these classes derive from the base class `Question`. | |
162 | + types = { | |
163 | + 'radio' : QuestionRadio, | |
164 | + 'checkbox' : QuestionCheckbox, | |
165 | + 'text' : QuestionText, | |
166 | + 'text_regex': QuestionTextRegex, | |
167 | + 'textarea' : QuestionTextArea, | |
168 | + 'information': QuestionInformation, | |
169 | + 'warning' : QuestionInformation, | |
170 | + } | |
171 | + | |
172 | + # Shallow copy so that script generated questions will not replace | |
173 | + # the original generators | |
174 | + q = self[ref].copy() | |
175 | + | |
176 | + # If question is of generator type, an external program will be run | |
177 | + # which will print a valid question in yaml format to stdout. This | |
178 | + # output is then converted to a dictionary and `q` becomes that dict. | |
179 | + if q['type'] == 'generator': | |
180 | + logger.debug('Running script to generate question "{0}".'.format(q['ref'])) | |
181 | + q.setdefault('arg', '') # optional arguments will be sent to stdin | |
182 | + script = path.normpath(path.join(q['path'], q['script'])) | |
183 | + q.update(run_script(script=script, stdin=q['arg'])) | |
184 | + # The generator was replaced by a question but not yet instantiated | |
185 | + | |
186 | + # Finally we create an instance of Question() | |
187 | + try: | |
188 | + qinstance = types[q['type']](q) # instance with correct class | |
189 | + except KeyError: | |
190 | + logger.error('Unknown question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) | |
191 | + except: | |
192 | + logger.error('Failed to create question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) | |
193 | + else: | |
194 | + logger.debug('Generated question "{}".'.format(ref)) | |
195 | + return qinstance | |
195 | 196 | |
196 | 197 | |
197 | 198 | # =========================================================================== |
... | ... | @@ -207,7 +208,7 @@ class Question(dict): |
207 | 208 | def __init__(self, q): |
208 | 209 | super().__init__(q) |
209 | 210 | |
210 | - # these are mandatory for any question: | |
211 | + # add these if missing | |
211 | 212 | self.set_defaults({ |
212 | 213 | 'title': '', |
213 | 214 | 'answer': None, |
... | ... | @@ -215,6 +216,7 @@ class Question(dict): |
215 | 216 | |
216 | 217 | def correct(self): |
217 | 218 | self['grade'] = 0.0 |
219 | + self['comments'] = '' | |
218 | 220 | return 0.0 |
219 | 221 | |
220 | 222 | def set_defaults(self, d): |
... | ... | @@ -237,7 +239,6 @@ class QuestionRadio(Question): |
237 | 239 | |
238 | 240 | #------------------------------------------------------------------------ |
239 | 241 | def __init__(self, q): |
240 | - # create key/values as given in q | |
241 | 242 | super().__init__(q) |
242 | 243 | |
243 | 244 | # set defaults if missing |
... | ... | @@ -256,7 +257,7 @@ class QuestionRadio(Question): |
256 | 257 | self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] |
257 | 258 | |
258 | 259 | if len(self['correct']) != n: |
259 | - qlogger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) | |
260 | + logger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) | |
260 | 261 | |
261 | 262 | # generate random permutation, e.g. [2,1,4,0,3] |
262 | 263 | # and apply to `options` and `correct` |
... | ... | @@ -269,17 +270,17 @@ class QuestionRadio(Question): |
269 | 270 | #------------------------------------------------------------------------ |
270 | 271 | # can return negative values for wrong answers |
271 | 272 | def correct(self): |
272 | - if self['answer'] is None: | |
273 | - x = 0.0 # zero points if no answer given | |
274 | - else: | |
273 | + super().correct() | |
274 | + | |
275 | + if self['answer'] is not None: | |
275 | 276 | x = self['correct'][int(self['answer'])] |
276 | 277 | if self['discount']: |
277 | 278 | n = len(self['options']) # number of options |
278 | 279 | x_aver = sum(self['correct']) / n |
279 | 280 | x = (x - x_aver) / (1.0 - x_aver) |
281 | + self['grade'] = x | |
280 | 282 | |
281 | - self['grade'] = x | |
282 | - return x | |
283 | + return self['grade'] | |
283 | 284 | |
284 | 285 | |
285 | 286 | # =========================================================================== |
... | ... | @@ -296,7 +297,6 @@ class QuestionCheckbox(Question): |
296 | 297 | |
297 | 298 | #------------------------------------------------------------------------ |
298 | 299 | def __init__(self, q): |
299 | - # create key/values as given in q | |
300 | 300 | super().__init__(q) |
301 | 301 | |
302 | 302 | n = len(self['options']) |
... | ... | @@ -310,7 +310,7 @@ class QuestionCheckbox(Question): |
310 | 310 | }) |
311 | 311 | |
312 | 312 | if len(self['correct']) != n: |
313 | - qlogger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) | |
313 | + logger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) | |
314 | 314 | |
315 | 315 | # generate random permutation, e.g. [2,1,4,0,3] |
316 | 316 | # and apply to `options` and `correct` |
... | ... | @@ -323,11 +323,9 @@ class QuestionCheckbox(Question): |
323 | 323 | #------------------------------------------------------------------------ |
324 | 324 | # can return negative values for wrong answers |
325 | 325 | def correct(self): |
326 | - if self['answer'] is None: | |
327 | - # not answered | |
328 | - self['grade'] = 0.0 | |
329 | - else: | |
330 | - # answered | |
326 | + super().correct() | |
327 | + | |
328 | + if self['answer'] is not None: | |
331 | 329 | sum_abs = sum(abs(p) for p in self['correct']) |
332 | 330 | if sum_abs < 1e-6: # case correct [0,...,0] avoid div-by-zero |
333 | 331 | self['grade'] = 0.0 |
... | ... | @@ -358,7 +356,6 @@ class QuestionText(Question): |
358 | 356 | |
359 | 357 | #------------------------------------------------------------------------ |
360 | 358 | def __init__(self, q): |
361 | - # create key/values as given in q | |
362 | 359 | super().__init__(q) |
363 | 360 | |
364 | 361 | self.set_defaults({ |
... | ... | @@ -376,11 +373,9 @@ class QuestionText(Question): |
376 | 373 | #------------------------------------------------------------------------ |
377 | 374 | # can return negative values for wrong answers |
378 | 375 | def correct(self): |
379 | - if self['answer'] is None: | |
380 | - # not answered | |
381 | - self['grade'] = 0.0 | |
382 | - else: | |
383 | - # answered | |
376 | + super().correct() | |
377 | + | |
378 | + if self['answer'] is not None: | |
384 | 379 | self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0 |
385 | 380 | |
386 | 381 | return self['grade'] |
... | ... | @@ -397,7 +392,6 @@ class QuestionTextRegex(Question): |
397 | 392 | |
398 | 393 | #------------------------------------------------------------------------ |
399 | 394 | def __init__(self, q): |
400 | - # create key/values as given in q | |
401 | 395 | super().__init__(q) |
402 | 396 | |
403 | 397 | self.set_defaults({ |
... | ... | @@ -408,11 +402,8 @@ class QuestionTextRegex(Question): |
408 | 402 | #------------------------------------------------------------------------ |
409 | 403 | # can return negative values for wrong answers |
410 | 404 | def correct(self): |
411 | - if self['answer'] is None: | |
412 | - # not answered | |
413 | - self['grade'] = 0.0 | |
414 | - else: | |
415 | - # answered | |
405 | + super().correct() | |
406 | + if self['answer'] is not None: | |
416 | 407 | self['grade'] = 1.0 if re.match(self['correct'], self['answer']) else 0.0 |
417 | 408 | |
418 | 409 | return self['grade'] |
... | ... | @@ -430,7 +421,6 @@ class QuestionTextArea(Question): |
430 | 421 | |
431 | 422 | #------------------------------------------------------------------------ |
432 | 423 | def __init__(self, q): |
433 | - # create key/values as given in q | |
434 | 424 | super().__init__(q) |
435 | 425 | |
436 | 426 | self.set_defaults({ |
... | ... | @@ -439,42 +429,32 @@ class QuestionTextArea(Question): |
439 | 429 | 'timeout': 5, # seconds |
440 | 430 | }) |
441 | 431 | |
442 | - self['correct'] = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct']))) | |
432 | + self['correct'] = path.abspath(path.normpath(path.join(self['path'], self['correct']))) | |
443 | 433 | |
444 | 434 | #------------------------------------------------------------------------ |
445 | 435 | # can return negative values for wrong answers |
446 | 436 | def correct(self): |
447 | - if self['answer'] is None: | |
448 | - # not answered | |
449 | - self['grade'] = 0.0 | |
450 | - else: | |
451 | - # answered | |
452 | - try: | |
453 | - p = subprocess.run([self['correct']], | |
454 | - input=self['answer'], | |
455 | - stdout=subprocess.PIPE, | |
456 | - stderr=subprocess.STDOUT, | |
457 | - universal_newlines=True, | |
458 | - timeout=self['timeout'], | |
459 | - ) | |
460 | - except FileNotFoundError: | |
461 | - qlogger.error('Script "{0}" defined in question "{1}" of file "{2}" could not be found.'.format(self['correct'], self['ref'], self['filename'])) | |
462 | - self['grade'] = 0.0 | |
463 | - except PermissionError: | |
464 | - qlogger.error('Script "{0}" has wrong permissions. Is it executable?'.format(self['correct'])) | |
465 | - self['grade'] = 0.0 | |
466 | - except subprocess.TimeoutExpired: | |
467 | - qlogger.warning('Timeout {1}s exceeded while running "{0}"'.format(self['correct'], self['timeout'])) | |
468 | - self['grade'] = 0.0 # student gets a zero if timout occurs | |
469 | - else: | |
470 | - if p.returncode != 0: | |
471 | - qlogger.warning('Script "{0}" returned error code {1}.'.format(self['correct'], p.returncode)) | |
472 | - | |
437 | + super().correct() | |
438 | + | |
439 | + if self['answer'] is not None: | |
440 | + # correct answer | |
441 | + out = run_script( | |
442 | + script=self['correct'], | |
443 | + stdin=self['answer'], | |
444 | + timeout=self['timeout'] | |
445 | + ) | |
446 | + if type(out) in (int, float): | |
447 | + self['grade'] = float(out) | |
448 | + | |
449 | + elif isinstance(out, dict): | |
473 | 450 | try: |
474 | - self['grade'] = float(p.stdout) | |
451 | + self['grade'] = float(out['grade']) | |
475 | 452 | except ValueError: |
476 | - qlogger.error('Correction script of "{0}" returned nonfloat:\n{1}\n'.format(self['ref'], p.stdout)) | |
477 | - self['grade'] = 0.0 | |
453 | + logger.error('Correction script of "{0}" returned nonfloat.'.format(self['ref'])) | |
454 | + except KeyError: | |
455 | + logger.error('Correction script of "{0}" returned no "grade" key.'.format(self['ref'])) | |
456 | + else: | |
457 | + self['comments'] = out.get('comments', '') | |
478 | 458 | |
479 | 459 | return self['grade'] |
480 | 460 | |
... | ... | @@ -488,17 +468,14 @@ class QuestionInformation(Question): |
488 | 468 | ''' |
489 | 469 | #------------------------------------------------------------------------ |
490 | 470 | def __init__(self, q): |
491 | - # create key/values as given in q | |
492 | 471 | super().__init__(q) |
493 | - | |
494 | 472 | self.set_defaults({ |
495 | 473 | 'text': '', |
496 | 474 | }) |
497 | 475 | |
498 | - self['points'] = 0.0 # always override the default points of 1.0 | |
499 | - | |
500 | 476 | #------------------------------------------------------------------------ |
501 | 477 | # can return negative values for wrong answers |
502 | 478 | def correct(self): |
479 | + super().correct() | |
503 | 480 | self['grade'] = 1.0 # always "correct" but points should be zero! |
504 | 481 | return self['grade'] | ... | ... |
serve.py
... | ... | @@ -22,24 +22,12 @@ except ImportError: |
22 | 22 | print('The package "mako" is missing. See README.md for instructions.') |
23 | 23 | sys.exit(1) |
24 | 24 | |
25 | -# path where this file is located | |
26 | -SERVER_PATH = path.dirname(path.realpath(__file__)) | |
27 | -TEMPLATES_DIR = path.join(SERVER_PATH, 'templates') | |
28 | - | |
29 | 25 | # my code |
30 | 26 | from myauth import AuthController, require |
31 | 27 | import test |
32 | 28 | import database |
33 | 29 | |
34 | 30 | |
35 | -ch = logging.StreamHandler() | |
36 | -ch.setLevel(logging.INFO) | |
37 | -ch.setFormatter(logging.Formatter('%(asctime)s | %(name)-10s | %(levelname)-8s | %(message)s')) | |
38 | - | |
39 | -logger = logging.getLogger('serve') | |
40 | -logger.addHandler(ch) | |
41 | - | |
42 | - | |
43 | 31 | # ============================================================================ |
44 | 32 | # Classes that respond to HTTP |
45 | 33 | # ============================================================================ |
... | ... | @@ -123,14 +111,19 @@ class Root(object): |
123 | 111 | t = cherrypy.session.get('test', None) |
124 | 112 | if t is None: |
125 | 113 | # create instance and add the name and number of the student |
126 | - cherrypy.session['test'] = t = test.Test(self.testconf) | |
127 | - t['number'] = uid | |
128 | - t['name'] = name | |
114 | + t = self.testconf.generate(number=uid, name=name) | |
115 | + cherrypy.session['test'] = t | |
116 | + | |
117 | + # cherrypy.session['test'] = t = test.Test(self.testconf) | |
118 | + | |
119 | + # t['number'] = uid | |
120 | + # t['name'] = name | |
129 | 121 | self.tags['online'].add(uid) # track logged in students |
130 | 122 | |
123 | + t.reset_answers() | |
131 | 124 | # Generate question |
132 | 125 | template = self.templates.get_template('/test.html') |
133 | - return template.render(t=t, questions=t['questions']) | |
126 | + return template.render(t=t) | |
134 | 127 | |
135 | 128 | # --- CORRECT ------------------------------------------------------------ |
136 | 129 | @cherrypy.expose |
... | ... | @@ -161,26 +154,29 @@ class Root(object): |
161 | 154 | t.correct() |
162 | 155 | |
163 | 156 | if t['save_answers']: |
164 | - t.save_json(self.testconf['answers_dir']) | |
157 | + fname = ' -- '.join((t['student']['number'], t['ref'], str(t['finish_time']))) + '.json' | |
158 | + fpath = path.abspath(path.join(t['answers_dir'], fname)) | |
159 | + t.save_json(fpath) | |
160 | + | |
165 | 161 | self.database.save_test(t) |
166 | 162 | |
167 | 163 | if t['practice']: |
168 | 164 | # ---- Repeat the test ---- |
169 | 165 | cherrypy.log.error('Student %s terminated with grade = %.2f points.' % |
170 | - (t['number'], t['grade']), 'APPLICATION') | |
166 | + (t['student']['number'], t['grade']), 'APPLICATION') | |
171 | 167 | raise cherrypy.HTTPRedirect('/test') |
172 | 168 | |
173 | 169 | else: |
174 | 170 | # ---- Expire session ---- |
175 | - self.tags['online'].discard(t['number']) | |
176 | - self.tags['finished'].add(t['number']) | |
171 | + self.tags['online'].discard(t['student']['number']) | |
172 | + self.tags['finished'].add(t['student']['number']) | |
177 | 173 | cherrypy.lib.sessions.expire() # session coockie expires client side |
178 | 174 | cherrypy.session['userid'] = cherrypy.request.login = None |
179 | 175 | cherrypy.log.error('Student %s terminated with grade = %.2f points.' % |
180 | - (t['number'], t['grade']), 'APPLICATION') | |
176 | + (t['student']['number'], t['grade']), 'APPLICATION') | |
181 | 177 | |
182 | 178 | # ---- Show result to student ---- |
183 | - grades = self.database.student_grades(t['number']) | |
179 | + grades = self.database.student_grades(t['student']['number']) | |
184 | 180 | template = self.templates.get_template('grade.html') |
185 | 181 | return template.render(t=t, allgrades=grades) |
186 | 182 | |
... | ... | @@ -189,33 +185,47 @@ def parse_arguments(): |
189 | 185 | argparser = argparse.ArgumentParser(description='Server for online tests. Enrolled students and tests have to be previously configured. Please read the documentation included with this software before running the server.') |
190 | 186 | serverconf_file = path.normpath(path.join(SERVER_PATH, 'config', 'server.conf')) |
191 | 187 | argparser.add_argument('--server', default=serverconf_file, type=str, help='server configuration file') |
192 | - argparser.add_argument('--debug', action='store_true', | |
193 | - help='Show datastructures when rendering questions') | |
194 | - argparser.add_argument('--show_ref', action='store_true', | |
195 | - help='Show filename and ref field for each question') | |
196 | - argparser.add_argument('--show_points', action='store_true', | |
197 | - help='Show normalized points for each question') | |
198 | - argparser.add_argument('--show_hints', action='store_true', | |
199 | - help='Show hints in questions, if available') | |
200 | - argparser.add_argument('--save_answers', action='store_true', | |
201 | - help='Saves answers in JSON format') | |
202 | - argparser.add_argument('--practice', action='store_true', | |
203 | - help='Show correction results and allow repetitive resubmission of the test') | |
188 | + # argparser.add_argument('--debug', action='store_true', | |
189 | + # help='Show datastructures when rendering questions') | |
190 | + # argparser.add_argument('--show_ref', action='store_true', | |
191 | + # help='Show filename and ref field for each question') | |
192 | + # argparser.add_argument('--show_points', action='store_true', | |
193 | + # help='Show normalized points for each question') | |
194 | + # argparser.add_argument('--show_hints', action='store_true', | |
195 | + # help='Show hints in questions, if available') | |
196 | + # argparser.add_argument('--save_answers', action='store_true', | |
197 | + # help='Saves answers in JSON format') | |
198 | + # argparser.add_argument('--practice', action='store_true', | |
199 | + # help='Show correction results and allow repetitive resubmission of the test') | |
204 | 200 | argparser.add_argument('testfile', type=str, nargs='+', help='test/exam in YAML format.') # FIXME only one exam supported at the moment |
205 | 201 | return argparser.parse_args() |
206 | 202 | |
207 | 203 | # ============================================================================ |
208 | 204 | if __name__ == '__main__': |
209 | 205 | |
210 | - logger.error('---------- Running perguntations ----------') | |
206 | + ch = logging.StreamHandler() | |
207 | + ch.setLevel(logging.INFO) | |
208 | + ch.setFormatter(logging.Formatter('%(asctime)s | %(name)-10s | %(levelname)-8s | %(message)s')) | |
209 | + | |
210 | + logger = logging.getLogger(__name__) | |
211 | + logger.setLevel(logging.INFO) | |
212 | + logger.addHandler(ch) | |
213 | + | |
214 | + logger.info('============= Running perguntations =============') | |
215 | + | |
216 | + # --- path where this file is located | |
217 | + SERVER_PATH = path.dirname(path.realpath(__file__)) | |
218 | + TEMPLATES_DIR = path.join(SERVER_PATH, 'templates') | |
211 | 219 | |
212 | 220 | # --- parse command line arguments and build base test |
213 | 221 | arg = parse_arguments() |
214 | - testconf = test.read_configuration(arg.testfile[0], debug=arg.debug, show_points=arg.show_points, show_hints=arg.show_hints, save_answers=arg.save_answers, practice=arg.practice, show_ref=arg.show_ref) | |
222 | + logger.info('Reading test configuration.') | |
215 | 223 | |
216 | - # FIXME problems with UnicodeEncodeError | |
217 | - logger.error(' Title: %s' % testconf['title']) | |
218 | - logger.error(' Database: %s' % testconf['database']) # FIXME check if db is ok? | |
224 | + # FIXME do not send args that were not defined in the commandline | |
225 | + # this means options should be like --show-ref=true|false | |
226 | + # and have no default value | |
227 | + filename = path.abspath(path.expanduser(arg.testfile[0])) | |
228 | + testconf = test.TestFactory(filename, conf=vars(arg)) | |
219 | 229 | |
220 | 230 | # --- site wide configuration (valid for all apps) |
221 | 231 | cherrypy.config.update({'tools.staticdir.root': SERVER_PATH}) |
... | ... | @@ -223,7 +233,7 @@ if __name__ == '__main__': |
223 | 233 | # --- app specific configuration |
224 | 234 | app = cherrypy.tree.mount(Root(testconf), '/', arg.server) |
225 | 235 | |
226 | - logger.info('Starting server at {}:{}'.format( | |
236 | + logger.info('Webserver listening at {}:{}'.format( | |
227 | 237 | cherrypy.config['server.socket_host'], |
228 | 238 | cherrypy.config['server.socket_port'])) |
229 | 239 | ... | ... |
templates/grade.html
... | ... | @@ -54,7 +54,7 @@ |
54 | 54 | --> |
55 | 55 | <ul class="nav navbar-nav navbar-right"> |
56 | 56 | <li class="dropdown"> |
57 | - <a class="dropdown-toggle" data-toggle="dropdown" href="#">${t['number']} - ${t['name']} <span class="caret"></span></a> | |
57 | + <a class="dropdown-toggle" data-toggle="dropdown" href="#">${t['student']['number']} - ${t['student']['name']} <span class="caret"></span></a> | |
58 | 58 | <!-- <ul class="dropdown-menu"> |
59 | 59 | <li><a href="#">Toggle colors (day/night)</a></li> |
60 | 60 | <li><a href="#">Change password</a></li> |
... | ... | @@ -82,6 +82,7 @@ |
82 | 82 | <thead> |
83 | 83 | <tr> |
84 | 84 | <th>Data</th> |
85 | + <th>Hora</th> | |
85 | 86 | <th>Teste</th> |
86 | 87 | <th>Nota (0-20)</th> |
87 | 88 | </tr> |
... | ... | @@ -90,6 +91,7 @@ |
90 | 91 | % for g in allgrades: |
91 | 92 | <tr> |
92 | 93 | <td>${g[2][:10]}</td> <!-- data --> |
94 | + <td>${g[2][11:19]}</td> <!-- hora --> | |
93 | 95 | <td>${g[0]}</td> <!-- teste --> |
94 | 96 | <td> |
95 | 97 | <div class="progress"> | ... | ... |
templates/test.html
... | ... | @@ -77,7 +77,7 @@ |
77 | 77 | <li class="dropdown"> |
78 | 78 | <a class="dropdown-toggle" data-toggle="dropdown" href="#"> |
79 | 79 | <span class="glyphicon glyphicon-user" aria-hidden="true"></span> |
80 | - ${t['name']} (${t['number']}) <span class="caret"></span> | |
80 | + ${t['student']['name']} (${t['student']['number']}) <span class="caret"></span> | |
81 | 81 | </a> |
82 | 82 | <ul class="dropdown-menu"> |
83 | 83 | <li class="active"><a href="/test">Teste</a></li> |
... | ... | @@ -111,7 +111,7 @@ |
111 | 111 | 'markdown.extensions.sane_lists'])} |
112 | 112 | </%def> |
113 | 113 | <% |
114 | - total_points = sum(q['points'] for q in questions) | |
114 | + total_points = sum(q['points'] for q in t['questions']) | |
115 | 115 | %> |
116 | 116 | % if t['debug']: |
117 | 117 | <pre> |
... | ... | @@ -127,7 +127,7 @@ |
127 | 127 | </div> |
128 | 128 | % endif |
129 | 129 | |
130 | - % for i,q in enumerate(questions): | |
130 | + % for i,q in enumerate(t['questions']): | |
131 | 131 | <div class="ui-corner-all custom-corners"> |
132 | 132 | % if q['type'] == 'information': |
133 | 133 | <div class="alert alert-warning drop-shadow" role="alert"> |
... | ... | @@ -153,7 +153,7 @@ |
153 | 153 | <div class="panel panel-primary drop-shadow"> |
154 | 154 | <div class="panel-heading clearfix"> |
155 | 155 | <h4 class="panel-title pull-left"> |
156 | - ${i+1}. ${q['title']} | |
156 | + ${q['title']} | |
157 | 157 | </h4> |
158 | 158 | <div class="pull-right"> |
159 | 159 | Classificar |
... | ... | @@ -237,17 +237,20 @@ |
237 | 237 | % if q['grade'] > 0.99: |
238 | 238 | <div class="alert alert-success" role="alert"> |
239 | 239 | <span class="glyphicon glyphicon-ok" aria-hidden="true"></span> |
240 | - ${round(q['grade'] * q['points'] / total_points * 20.0, 1)} pontos | |
240 | + ${round(q['grade'] * q['points'] / total_points * 20.0, 1)} pontos<br> | |
241 | + ${q['comments']} | |
241 | 242 | </div> |
242 | 243 | % elif q['grade'] > 0.49: |
243 | 244 | <div class="alert alert-warning" role="alert"> |
244 | 245 | <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> |
245 | - ${round(q['grade'] * q['points'] / total_points * 20.0, 1)} pontos | |
246 | + ${round(q['grade'] * q['points'] / total_points * 20.0, 1)} pontos<br> | |
247 | + ${q['comments']} | |
246 | 248 | </div> |
247 | 249 | % else: |
248 | 250 | <div class="alert alert-danger" role="alert"> |
249 | 251 | <span class="glyphicon glyphicon-remove" aria-hidden="true"></span> |
250 | - ${round(q['grade'] * q['points'] / total_points * 20.0, 1)} pontos | |
252 | + ${round(q['grade'] * q['points'] / total_points * 20.0, 1)} pontos<br> | |
253 | + ${q['comments']} | |
251 | 254 | </div> |
252 | 255 | % endif |
253 | 256 | % endif | ... | ... |
test.py
1 | 1 | |
2 | -import os, sys, fnmatch | |
2 | +from os import path, listdir | |
3 | +import sys, fnmatch | |
3 | 4 | import random |
5 | +from datetime import datetime | |
4 | 6 | import sqlite3 |
5 | 7 | import logging |
6 | -from datetime import datetime | |
7 | 8 | |
9 | +# Logger configuration | |
10 | +logger = logging.getLogger(__name__) | |
11 | +logger.setLevel(logging.INFO) | |
8 | 12 | |
9 | 13 | ch = logging.StreamHandler() |
10 | 14 | ch.setLevel(logging.INFO) |
11 | 15 | ch.setFormatter(logging.Formatter('%(asctime)s | %(name)-10s | %(levelname)-8s | %(message)s')) |
12 | - | |
13 | -logger = logging.getLogger('test') | |
14 | 16 | logger.addHandler(ch) |
15 | 17 | |
16 | 18 | try: |
... | ... | @@ -30,171 +32,239 @@ import questions |
30 | 32 | import database |
31 | 33 | |
32 | 34 | # =========================================================================== |
33 | -def read_configuration(filename, debug=False, show_points=False, show_hints=False, practice=False, save_answers=False, show_ref=False): | |
34 | - # FIXME validar se ficheiros e directorios existem??? | |
35 | 35 | |
36 | + | |
37 | + | |
38 | +# FIXME replace sys.exit calls by exceptions | |
39 | + | |
40 | +# ----------------------------------------------------------------------- | |
41 | +# load dictionary from yaml file | |
42 | +# ----------------------------------------------------------------------- | |
43 | +def load_yaml(filename): | |
36 | 44 | try: |
37 | 45 | f = open(filename, 'r', encoding='utf-8') |
38 | 46 | except IOError: |
39 | - logger.critical('Cannot open YAML file "%s"' % filename) | |
40 | - sys.exit(1) | |
47 | + logger.critical('Cannot open YAML file "{}"'.format(filename)) | |
48 | + sys.exit(1) # FIXME | |
41 | 49 | else: |
42 | 50 | with f: |
43 | 51 | try: |
44 | - test = yaml.load(f) | |
45 | - except yaml.YAMLError as exc: | |
46 | - mark = exc.problem_mark | |
47 | - logger.critical('In YAML file "{0}" near line {1}, column {2}.'.format(filename,mark.line,mark.column+1)) | |
48 | - sys.exit(1) | |
49 | - # -- test yaml was loaded ok | |
50 | - | |
51 | - errors = 0 | |
52 | - | |
53 | - # defaults: | |
54 | - test['ref'] = str(test.get('ref', filename)) | |
55 | - test['title'] = str(test.get('title', '')) | |
56 | - test['show_hints'] = bool(test.get('show_hints', show_hints)) | |
57 | - test['show_points'] = bool(test.get('show_points', show_points)) | |
58 | - test['practice'] = bool(test.get('practice', practice)) | |
59 | - test['debug'] = bool(test.get('debug', debug)) | |
60 | - test['show_ref'] = bool(test.get('show_ref', show_ref)) | |
61 | - | |
62 | - # this is the base directory where questions are stored | |
63 | - test['questions_dir'] = os.path.normpath(os.path.expanduser(str(test.get('questions_dir', os.path.curdir)))) | |
64 | - if not os.path.exists(test['questions_dir']): | |
65 | - logger.error('Questions directory "{0}" does not exist. Fix the "questions_dir" key in the configuration file "{1}".'.format(test['questions_dir'], filename)) | |
66 | - errors += 1 | |
67 | - | |
68 | - # where to put the students answers (optional) | |
69 | - if 'answers_dir' not in test: | |
70 | - logger.warning('Missing "answers_dir" in "{0}". Tests will NOT be saved.'.format(filename)) | |
71 | - test['save_answers'] = False | |
72 | - else: | |
73 | - test['answers_dir'] = os.path.normpath(os.path.expanduser(str(test['answers_dir']))) | |
74 | - if not os.path.isdir(test['answers_dir']): | |
75 | - logger.error('Directory "{0}" does not exist.'.format(test['answers_dir'])) | |
76 | - errors += 1 | |
77 | - test['save_answers'] = True | |
78 | - | |
79 | - # database with login credentials and grades | |
80 | - if 'database' not in test: | |
81 | - logger.error('Missing "database" key in the test configuration "{0}".'.format(filename)) | |
82 | - errors += 1 | |
83 | - else: | |
84 | - test['database'] = os.path.normpath(os.path.expanduser(str(test['database']))) | |
85 | - if not os.path.exists(test['database']): | |
86 | - logger.error('Database "{0}" not found.'.format(test['database'])) | |
87 | - errors += 1 | |
88 | - | |
89 | - if errors > 0: | |
90 | - logger.critical('{0} error(s) found. Aborting!'.format(errors)) | |
91 | - sys.exit(1) | |
92 | - | |
93 | - # deal with questions files | |
94 | - if 'files' not in test: | |
95 | - # no files were defined = load all from questions_dir | |
96 | - test['files'] = fnmatch.filter(os.listdir(test['questions_dir']), '*.yaml') | |
97 | - logger.warning('All YAML files from directory were loaded. Might not be such a good idea...') | |
98 | - else: | |
99 | - # only one file | |
100 | - if isinstance(test['files'], str): | |
101 | - test['files'] = [test['files']] | |
102 | - | |
103 | - # replace ref,points by actual questions from pool | |
104 | - pool = questions.QuestionsPool() | |
105 | - pool.add_from_files(files=test['files'], path=test['questions_dir']) | |
106 | - | |
107 | - for i, q in enumerate(test['questions']): | |
108 | - # each question is a list of alternative versions, even if the list | |
109 | - # contains only one element | |
110 | - if isinstance(q, str): | |
111 | - # normalize question to a dict | |
112 | - # some_ref --> ref: some_ref | |
113 | - # points: 1.0 | |
52 | + d = yaml.load(f) | |
53 | + except yaml.YAMLError as e: | |
54 | + mark = e.problem_mark | |
55 | + logger.critical('In YAML file "{0}" near line {1}, column {2}.'.format(filename, mark.line, mark.column+1)) | |
56 | + sys.exit(1) # FIXME | |
57 | + return d | |
58 | + | |
59 | + | |
60 | +# =========================================================================== | |
61 | +class TestFactoryException(Exception): # FIXME unused | |
62 | + pass | |
63 | + | |
64 | +# =========================================================================== | |
65 | +# Each instance of TestFactory() is a test generator. | |
66 | +# For example, if we want to serve two different tests, then we need two | |
67 | +# instances of TestFactory(), one for each test. | |
68 | +# =========================================================================== | |
69 | +class TestFactory(dict): | |
70 | + # ----------------------------------------------------------------------- | |
71 | + # loads configuration from yaml file, then updates (overriding) | |
72 | + # some configurations using the conf argument. | |
73 | + # base questions are loaded from files into a pool. | |
74 | + # ----------------------------------------------------------------------- | |
75 | + def __init__(self, filename=None, conf={}): | |
76 | + if filename is not None: | |
77 | + super().__init__(load_yaml(filename)) # load config from file | |
78 | + # elif 'testfile' in conf: | |
79 | + # super().__init__(load_yaml(conf['testfile'])) # load config from file | |
80 | + else: | |
81 | + super().__init__({}) # else start empty | |
82 | + self['filename'] = filename if filename is not None else '' | |
83 | + | |
84 | + self.configure(conf) # defaults and sanity checks | |
85 | + self.normalize_questions() # to list of dictionaries | |
86 | + | |
87 | + # loads question_factory | |
88 | + self.question_factory = questions.QuestionFactory() | |
89 | + self.question_factory.load_files(files=self['files'], questions_dir=self['questions_dir']) | |
90 | + | |
91 | + logger.info('Test factory ready.') | |
92 | + | |
93 | + | |
94 | + # ----------------------------------------------------------------------- | |
95 | + # The argument conf is a dictionary containing the test configuration. | |
96 | + # It merges conf with the current configuration and performs some checks | |
97 | + # ----------------------------------------------------------------------- | |
98 | + def configure(self, conf={}): | |
99 | + self.update(conf) | |
100 | + | |
101 | + # check for important missing keys in the test configuration file | |
102 | + if 'database' not in self: | |
103 | + logger.critical('Missing "database"!') | |
104 | + sys.exit(1) # FIXME | |
105 | + | |
106 | + if 'ref' not in self: | |
107 | + logger.warning('Missing "ref". Will use current date/time.') | |
108 | + if 'answers_dir' not in self and self.get('save_answers', False): | |
109 | + logger.warning('Missing "answers_dir". Will use current directory!') | |
110 | + if 'save_answers' not in self: | |
111 | + logger.warning('Missing "save_answers". Answers will NOT be saved!') | |
112 | + if 'questions_dir' not in self: | |
113 | + logger.warning('Missing "questions_dir". Using {}'.format(path.abspath(path.curdir))) | |
114 | + if 'files' not in self: | |
115 | + logger.warning('Missing "files". Loading all YAML''s from "questions_dir". Not a good idea...') | |
116 | + | |
117 | + self.setdefault('ref', str(datetime.now())) | |
118 | + self.setdefault('title', '') | |
119 | + self.setdefault('show_hints', False) | |
120 | + self.setdefault('show_points', False) | |
121 | + self.setdefault('practice', False) | |
122 | + self.setdefault('debug', False) | |
123 | + self.setdefault('show_ref', False) | |
124 | + self.setdefault('questions_dir', path.curdir) | |
125 | + self.setdefault('save_answers', False) | |
126 | + self.setdefault('answers_dir', path.curdir) | |
127 | + self['database'] = path.abspath(path.expanduser(self['database'])) | |
128 | + self['questions_dir'] = path.abspath(path.expanduser(self['questions_dir'])) | |
129 | + self['answers_dir'] = path.abspath(path.expanduser(self['answers_dir'])) | |
130 | + | |
131 | + if not path.isfile(self['database']): | |
132 | + logger.critical('Cannot find database "{}"'.format(self['database'])) | |
133 | + sys.exit(1) | |
134 | + | |
135 | + if not path.isdir(self['questions_dir']): | |
136 | + logger.critical('Cannot find questions directory "{}"'.format(self['questions_dir'])) | |
137 | + sys.exit(1) | |
138 | + | |
139 | + # make sure we have a list of question files. | |
140 | + # no files were defined ==> load all YAML files from questions_dir | |
141 | + if 'files' not in self: | |
114 | 142 | try: |
115 | - test['questions'][i] = [pool[q]] # list with just one question | |
116 | - except KeyError: | |
117 | - logger.critical('Could not find question "{}".'.format(q)) | |
143 | + self['files'] = fnmatch.filter(listdir(self['questions_dir']), '*.yaml') | |
144 | + except EnvironmentError: | |
145 | + logger.critical('Could not get list of YAML question files.') | |
118 | 146 | sys.exit(1) |
119 | 147 | |
120 | - test['questions'][i][0]['points'] = 1.0 | |
121 | - # Note: at this moment we do not know the questions types. | |
122 | - # Some questions, like information, should have default points | |
123 | - # set to 0. That must be done later when the question is | |
124 | - # instantiated. | |
148 | + if isinstance(self['files'], str): | |
149 | + self['files'] = [self['files']] | |
125 | 150 | |
126 | - elif isinstance(q, dict): | |
127 | - if 'ref' not in q: | |
128 | - logger.critical('Found question missing the "ref" key in "{}"'.format(filename)) | |
129 | - sys.exit(1) | |
151 | + # FIXME if 'questions' not in self: load all of them | |
130 | 152 | |
131 | - if isinstance(q['ref'], str): | |
132 | - q['ref'] = [q['ref']] # ref is always a list | |
133 | - p = float(q.get('points', 1.0)) # default points is 1.0 | |
134 | 153 | |
135 | - # create list of alternatives, normalized | |
136 | - l = [] | |
137 | - for r in q['ref']: | |
138 | - try: | |
139 | - qq = pool[r] | |
140 | - except KeyError: | |
141 | - logger.warning('Question reference "{0}" of test "{1}" not found. Skipping...'.format(r, test['ref'])) | |
142 | - continue | |
143 | - qq['points'] = p | |
144 | - l.append(qq) | |
154 | + try: # FIXME write logs to answers_dir? | |
155 | + f = open(path.join(self['answers_dir'],'REMOVE-ME'), 'w') | |
156 | + except EnvironmentError: | |
157 | + logger.critical('Cannot write answers to "{0}".'.format(self['answers_dir'])) | |
158 | + sys.exit(1) | |
159 | + else: | |
160 | + with f: | |
161 | + f.write('You can safely remove this file.') | |
162 | + | |
163 | + # ----------------------------------------------------------------------- | |
164 | + # normalize questions to a list of dictionaries | |
165 | + # ----------------------------------------------------------------------- | |
166 | + def normalize_questions(self): | |
167 | + | |
168 | + for i, q in enumerate(self['questions']): | |
169 | + # normalize question to a dict and ref to a list of references | |
170 | + if isinstance(q, str): | |
171 | + q = {'ref': [q]} | |
172 | + elif isinstance(q, dict) and isinstance(q['ref'], str): | |
173 | + q['ref'] = [q['ref']] | |
174 | + | |
175 | + self['questions'][i] = q | |
176 | + | |
177 | + # ----------------------------------------------------------------------- | |
178 | + # Return instance of a test for a particular student | |
179 | + # ----------------------------------------------------------------------- | |
180 | + def generate(self, **student): | |
181 | + test = [] | |
182 | + n = 1 | |
183 | + for i, qq in enumerate(self['questions']): | |
184 | + # generate Question() selected randomly from list of references | |
185 | + q = self.question_factory.generate(random.choice(qq['ref'])) | |
186 | + | |
187 | + # some defaults | |
188 | + if q['type'] in ('information', 'warning'): | |
189 | + q['points'] = qq.get('points', 0.0) | |
190 | + else: | |
191 | + q['title'] = '{}. '.format(n) + q['title'] | |
192 | + q['points'] = qq.get('points', 1.0) | |
193 | + n += 1 | |
194 | + | |
195 | + test.append(q) | |
196 | + | |
197 | + return Test({ | |
198 | + 'ref': self['ref'], | |
199 | + 'title': self['title'], # title of the test | |
200 | + 'student': student, # student id | |
201 | + 'questions': test, # list of questions | |
202 | + 'save_answers': self['save_answers'], | |
203 | + 'answers_dir': self['answers_dir'], | |
204 | + | |
205 | + # FIXME which ones are required? | |
206 | + 'practice': self['practice'], | |
207 | + 'show_hints': self['show_hints'], | |
208 | + 'show_points': self['show_points'], | |
209 | + 'show_ref': self['show_ref'], | |
210 | + 'debug': self['debug'], | |
211 | + # 'answers_dir': self['answers_dir'], | |
212 | + 'database': self['database'], | |
213 | + 'questions_dir': self['questions_dir'], | |
214 | + 'files': self['files'], | |
215 | + }) | |
145 | 216 | |
146 | - # add question (i.e. list of alternatives) to the test | |
147 | - test['questions'][i] = l | |
217 | + # ----------------------------------------------------------------------- | |
218 | + def __repr__(self): | |
219 | + return '{\n' + '\n'.join(' {0:14s}: {1}'.format(k, v) for k,v in self.items()) + '\n}' | |
148 | 220 | |
149 | - return test | |
150 | 221 | |
151 | 222 | # =========================================================================== |
223 | +# Each instance of the Test() class is a concrete test to be answered by | |
224 | +# a single student. It must/will contain at least these keys: | |
225 | +# start_time, finish_time, questions, grade [0,20] | |
226 | +# Note: for the save_json() function other keys are required | |
227 | +# =========================================================================== | |
152 | 228 | class Test(dict): |
153 | 229 | # ----------------------------------------------------------------------- |
154 | 230 | def __init__(self, d): |
155 | 231 | super().__init__(d) |
156 | - | |
157 | - qlist = [] | |
158 | - for i, qq in enumerate(self['questions']): | |
159 | - try: | |
160 | - q = random.choice(qq) # select from alternative versions | |
161 | - except TypeError: | |
162 | - logger.error('in question {} (0-based index).'.format(i)) | |
163 | - continue | |
164 | - qlist.append(questions.create_question(q)) # create instance | |
165 | - self['questions'] = qlist | |
232 | + self.reset_answers() | |
166 | 233 | self['start_time'] = datetime.now() |
234 | + self['finish_time'] = None | |
235 | + logger.info('Start test for student {}.'.format(self['student']['number'])) | |
236 | + | |
237 | + # ----------------------------------------------------------------------- | |
238 | + def reset_answers(self): | |
239 | + for q in self['questions']: | |
240 | + q['answer'] = None | |
167 | 241 | |
168 | 242 | # ----------------------------------------------------------------------- |
169 | 243 | def update_answers(self, ans): |
170 | - '''given a dictionary ans={'ref':'some answer'} updates the answers | |
171 | - of the test. FIXME: check if answer is to be corrected or not | |
172 | - ''' | |
244 | + # Given a dictionary ans={'someref': 'some answer'} updates the | |
245 | + # answers of the test. Only affects questions referred. | |
173 | 246 | for q in self['questions']: |
174 | - q['answer'] = ans[q['ref']] if q['ref'] in ans else None | |
247 | + if q['ref'] in ans: | |
248 | + q['answer'] = ans[q['ref']] | |
175 | 249 | |
176 | 250 | # ----------------------------------------------------------------------- |
177 | 251 | def correct(self): |
178 | - '''Corrects all the answers and computes the final grade.''' | |
179 | - | |
252 | + # Corrects all the answers and computes the final grade | |
180 | 253 | self['finish_time'] = datetime.now() |
181 | 254 | |
255 | + grade = 0.0 | |
182 | 256 | total_points = 0.0 |
183 | - final_grade = 0.0 | |
184 | 257 | for q in self['questions']: |
185 | - final_grade += q.correct() * q['points'] | |
258 | + grade += q.correct() * q['points'] | |
186 | 259 | total_points += q['points'] |
187 | 260 | |
188 | - final_grade = 20.0 * max(final_grade / total_points, 0.0) | |
189 | - | |
190 | - self['grade'] = final_grade | |
191 | - return final_grade | |
261 | + self['grade'] = 20.0 * max(grade / total_points, 0.0) | |
262 | + logger.info('Finish test for student {0}. Grade={1}.'.format(self['student']['number'], self['grade'])) | |
263 | + return self['grade'] | |
192 | 264 | |
193 | 265 | # ----------------------------------------------------------------------- |
194 | - def save_json(self, path): | |
195 | - filename = ' -- '.join((str(self['number']), self['ref'], | |
196 | - str(self['finish_time']))) + '.json' | |
197 | - filepath = os.path.abspath(os.path.join(path, filename)) | |
266 | + def save_json(self, filepath): | |
198 | 267 | with open(filepath, 'w') as f: |
199 | 268 | json.dump(self, f, indent=2, default=str) |
200 | 269 | # HACK default=str is required for datetime objects |
270 | + logger.debug('JSON file saved "{}"'.format(filepath)) | ... | ... |