Commit b20bb0a86ae5f8edc4ae6e046104c70020b4c03c
1 parent
feacfc1b
Exists in
master
and in
1 other branch
- added a MANUAL.md
- added commandline flags --debug --show_hints --show_points --save_answers and --practice_mode
Showing
7 changed files
with
361 additions
and
18 deletions
Show diff stats
BUGS.md
... | ... | @@ -2,12 +2,17 @@ |
2 | 2 | |
3 | 3 | # BUGS |
4 | 4 | |
5 | +- textarea monospace, disable tab behavior. | |
6 | +- information points é definido onde? test.y ou questions.py? | |
7 | +- hash das passwords obtidas da concatenacao do numero de aluno com password (evita que passwords repetidas sejam detectadas). | |
8 | +- mostrar erro quando nao consegue importar questions files | |
5 | 9 | - numeros das perguntas não fazem sentido quando há caixas de informação |
6 | 10 | - testar regex na definicao das perguntas. como se faz rawstring em yaml? singlequote? problemas de backslash??? sim... necessário fazer \\ em varios casos, mas não é claro! e.g. \n é convertido em espaço mas \w é convertido em \\ e w. |
7 | 11 | |
8 | 12 | |
9 | 13 | # TODO |
10 | 14 | |
15 | +- command line options --debug --show_points --show_hints --practice_mode | |
11 | 16 | - testar envio de parametros para stdin para perguntas tipo generator |
12 | 17 | - permitir enviar varios testes, aluno escolhe qual o teste que quer fazer. |
13 | 18 | - alterar o script json2md.py em conformidade |
... | ... | @@ -19,7 +24,8 @@ |
19 | 24 | |
20 | 25 | # FIXED |
21 | 26 | |
22 | -- criar pergunta gerada por script externo. | |
27 | +- manual de utilizacao. | |
28 | +- criar pergunta gerada por script externo. | |
23 | 29 | - debug mode |
24 | 30 | - in the train_mode, there is no way to logout. Add logout option in the menu. |
25 | 31 | - simplificar a gravacao do teste em json. | ... | ... |
... | ... | @@ -0,0 +1,255 @@ |
1 | +# Perguntas | |
2 | + | |
3 | +## Quick How to | |
4 | + | |
5 | +### Create students database | |
6 | + | |
7 | +We need a sqlite3 database to store students, passwords, test results, and questions results, etc. | |
8 | + | |
9 | +The database can be initialized from a list of students in CSV format using the script | |
10 | + | |
11 | + $ ./initdb_from_csv.py list_of_students.csv | |
12 | + | |
13 | +This script will create a new sqlite3 database with the correct tables and insert the students with empty passwords. | |
14 | + | |
15 | +There is also a special user with number 0. This is the administrator user. | |
16 | + | |
17 | +### Create new questions | |
18 | + | |
19 | +Questions are defined in `yaml` files and can reside anywhere in the filesystem. | |
20 | +Each file contains a list of questions, where each question is a dictionary. Example | |
21 | + | |
22 | + - | |
23 | + ref: question-1 | |
24 | + type: radio | |
25 | + text: Select the correct option | |
26 | + options: | |
27 | + - correct | |
28 | + - wrong | |
29 | + | |
30 | + - | |
31 | + ref: question-2 | |
32 | + type: checkbox | |
33 | + text: Which ones are correct? | |
34 | + options: | |
35 | + - correct | |
36 | + - correct | |
37 | + - wrong | |
38 | + correct: [1, 1, -1] | |
39 | + hint: There are two correct answers! | |
40 | + | |
41 | +There are several kinds of questions: | |
42 | + | |
43 | +- __information__: nothing to answer | |
44 | +- __radio__: only one option is correct | |
45 | +- __checkbox__: several options are correct | |
46 | +- __text__: compares text with a list of accepted answers | |
47 | +- __text_regex__: matches text agains regular expression | |
48 | +- __textarea__: send text to an external script for validation | |
49 | +- __generator__: the question is generated from an external script, the actual question generated can be any of the above types. | |
50 | + | |
51 | +Detailed information on each question type is described later on. | |
52 | + | |
53 | + | |
54 | +### Creating a new test | |
55 | + | |
56 | +A test is a file in `yaml` format that can reside anywhere on the filesystem. It has the following structure: | |
57 | + | |
58 | + ref: this-is-a-key | |
59 | + title: Titulo do teste | |
60 | + database: db/mystudents.db | |
61 | + | |
62 | + # Will save the entire test of each student in JSON format. | |
63 | + # If tests are to be saved, we must specify the directory. | |
64 | + # The directory is created if it doesn't exist already. | |
65 | + # The name of the JSON files will include the student number, test | |
66 | + # reference key, date and time. | |
67 | + save_answers: True | |
68 | + answers_dir: ans/asc1_test4 | |
69 | + | |
70 | + # Some questions can contain hints, embedded videos, etc | |
71 | + show_hints: True | |
72 | + | |
73 | + # Each question has some number of points. Show them normalized to 0-20. | |
74 | + show_points: True | |
75 | + | |
76 | + # In train mode, the correction of the test is shown and the test can | |
77 | + # be repeated | |
78 | + practice_mode: True | |
79 | + | |
80 | + # Show the data structures obtained from the test and the questions | |
81 | + debug: True | |
82 | + | |
83 | + # ------------------------------------------------------------------------- | |
84 | + # This are the questions database to be imported. | |
85 | + files: | |
86 | + - questions/file1.yaml | |
87 | + - questions/file2.yaml | |
88 | + - questions/file3.yaml | |
89 | + # ------------------------------------------------------------------------- | |
90 | + # This is the actual test configuration. Selection of questions and points | |
91 | + # It'a defined as a list of questions. Each question can be a single | |
92 | + # question key or a list of keys from which one is chosen at random. | |
93 | + # Each question has a default value of 1.0 point, but it can be overridden. | |
94 | + # The points defined here do not need to be normalized (it's automatic). | |
95 | + questions: | |
96 | + - ref: | |
97 | + - first-question-1 # randomly choose one from these 3 questions | |
98 | + - first-question-2 | |
99 | + - first-question-3 | |
100 | + points: 0.5 | |
101 | + | |
102 | + - ref: second-question # just one question, 1.0 point (unnormalized) | |
103 | + | |
104 | + - third-question # "ref:" not needed in simple cases | |
105 | + | |
106 | + - wrong-question # ref: missing because we also have | |
107 | + points: 2 # points: | |
108 | + | |
109 | +Some of the options have default values if they are omitted. The defaults are the following: | |
110 | + | |
111 | + ref: filename.yaml | |
112 | + title: '' | |
113 | + save_answers: False | |
114 | + show_hints: False | |
115 | + show_points: False | |
116 | + practice_mode: False | |
117 | + debug: False | |
118 | + points: 1.0 | |
119 | + | |
120 | +The defaults and the values defined in the yaml file can also be overriden on command line options. Example | |
121 | + | |
122 | + $ ./serve.py --debug --show_points --show_hints --practice_mode mytest.yaml | |
123 | + | |
124 | +### Running an existing test | |
125 | + | |
126 | +A test is a file in `yaml` format. Just run `serve.py` with the test to run as argument: | |
127 | + | |
128 | + $ ./serve.py tests_dir/mytest.yaml | |
129 | + | |
130 | +To terminate the test just do `^C` on the keyboard. | |
131 | + | |
132 | +## Questions | |
133 | + | |
134 | +Every question should have a `ref` and a `type`. The other keys depend on the type of question. | |
135 | + | |
136 | +### Information | |
137 | + | |
138 | +Not a real question. Just text to be shown without expecting an answer. | |
139 | + | |
140 | + - | |
141 | + ref: some-key | |
142 | + type: information | |
143 | + text: Tomorrow will rain. | |
144 | + | |
145 | +Correcting an information will always be considered correct, but the grade will be zero because it has 0.0 points by default. | |
146 | + | |
147 | +### Radio | |
148 | + | |
149 | +Only one option is correct. | |
150 | + | |
151 | + - | |
152 | + ref: some-key | |
153 | + type: radio | |
154 | + text: The horse is white. # optional (default: '') | |
155 | + options: | |
156 | + - The horse is white | |
157 | + - The horse is not black | |
158 | + - The horse is black | |
159 | + correct: 0 # optional (default: 0). Index is 0-based. | |
160 | + shuffle: True # optional (default: True) | |
161 | + discount: True # optional (default: True) | |
162 | + | |
163 | +The `correct` value can also be defined as a list of degrees of correctness between 0 (wrong) and 1 (correct), e.g. if answering "the horse is not black" should be considered half-right, then we should use `correct: [1, 0.5, 0]`. | |
164 | + | |
165 | +Wrong answers discount by default. If there are half-right answers, the discount values are calculated automatically. `discount: False` disables the discount calculation and the values are the ones defined in `correct`. | |
166 | + | |
167 | +### Checkbox | |
168 | + | |
169 | +There can be several options correct. Each option is like answering an independent question. | |
170 | + | |
171 | + - | |
172 | + ref: some-key | |
173 | + type: checkbox | |
174 | + text: The horse is white. # optional (default: '') | |
175 | + options: | |
176 | + - The horse is white | |
177 | + - The horse is not black | |
178 | + - The horse is black | |
179 | + correct: [1,1,-1] # optional (default: [0,0,0]). | |
180 | + shuffle: True # optional (default: True) | |
181 | + discount: True # optional (default: True) | |
182 | + | |
183 | +Wrong answers discount by default. The discount values are calculated automatically and are simply the symmetric of the correct value. | |
184 | +E.g. consider `correct: [1, 0.5, -1]`, then | |
185 | +- if the first option is marked the value is 1, otherwise if it's unmarked the value is -1. | |
186 | +- if the second option is marked the value is 0.5, otherwise if it's unmarked the value is -0.5. | |
187 | +- if the third option is marked the value is -1, otherwise if it's unmarked the value is 1. (the student shouldn't have marked this one) | |
188 | + | |
189 | +`discount: False` disables the discount and the values are the ones defined in `correct` if the answer is right, or 0.0 if wrong. | |
190 | + | |
191 | +### Text | |
192 | + | |
193 | +The answer is a line of text. | |
194 | +The server will check if the answer exactly matches the correct one. | |
195 | + | |
196 | + - | |
197 | + ref: some-key | |
198 | + type: text | |
199 | + text: What's your favorite color? # optional (default: '') | |
200 | + correct: white | |
201 | + | |
202 | +alternatively, we can give a list of acceptable answers | |
203 | + | |
204 | + correct: ['white', 'blue', 'red'] | |
205 | + | |
206 | +### Regular expression | |
207 | + | |
208 | +The answer is a line of text. | |
209 | +The server will check if the answer matches a regular expression. | |
210 | + | |
211 | + - | |
212 | + ref: some-key | |
213 | + type: text_regex | |
214 | + text: What's your favorite color? # optional (default: '') | |
215 | + correct: '[Ww]hite' | |
216 | + | |
217 | +Careful: yaml does not support raw text. Some characters have to be escaped. | |
218 | + | |
219 | +### Text area | |
220 | + | |
221 | +The answer is given in a textarea. The text (usually code) is sent to an external program running on a separate process for validation. | |
222 | +The external program should accept input from stdin, and print to stdout a single number in the interval 0.0 to 1.0 indicating the level of correctness. | |
223 | +The server will try to convert the printed message to a float, a failure will give 0.0. | |
224 | + | |
225 | + - | |
226 | + ref: some-key | |
227 | + type: textarea | |
228 | + text: write an expression to add x and y. # optional (default: '') | |
229 | + correct: path/to/myscript | |
230 | + | |
231 | +### Generator | |
232 | + | |
233 | +A generator question will run an external program that is expected to print a question in yaml format to stdout. After running the generator, the question can be any of the other types (but not another generator!). | |
234 | + | |
235 | + - | |
236 | + ref: some-key | |
237 | + type: generator | |
238 | + script: path/to/generator_script | |
239 | + | |
240 | +## Writing good looking questions | |
241 | + | |
242 | +The text of the questions (and options in radio and checkbox type questios) is parsed as markdown and code is prettyfied using Pygments. Equations can be inserted like in LaTeX and are rendered using MathJax. | |
243 | + | |
244 | +A good way to define multiple lines of text in the questions is to use the bar |. Yaml will use all the text that is indented to the right of that column. Example | |
245 | + | |
246 | + text: | | |
247 | + Text is parsed as __markdown__. We can include equations $\sqrt{\pi}$ like in LaTeX | |
248 | + and pretty code in several languages | |
249 | + | |
250 | + ```.C | |
251 | + int main(){ | |
252 | + return 0; | |
253 | + } | |
254 | + ``` | |
255 | + | ... | ... |
... | ... | @@ -0,0 +1,73 @@ |
1 | +#!/usr/bin/env python3.4 | |
2 | +# -*- coding: utf-8 -*- | |
3 | + | |
4 | +import sqlite3 | |
5 | +import csv | |
6 | +from optparse import OptionParser | |
7 | +from hashlib import sha256 | |
8 | +import os.path | |
9 | +import sys | |
10 | + | |
11 | +# --------- Parse command line options ----------- | |
12 | +parser = OptionParser('usage: %prog [options] inputfile.csv') | |
13 | + | |
14 | +parser.add_option('--db', dest='db_filename', default='students.db', | |
15 | + help='database filename to create [default: %default]') | |
16 | + | |
17 | +parser.add_option('--pw', dest='password', default='', | |
18 | + help='initial password [default: %default]') | |
19 | + | |
20 | +(options, args) = parser.parse_args() | |
21 | + | |
22 | +if len(args) != 1: | |
23 | + print('You must specify a CSV file to import.\nUse option -h for help.') | |
24 | + sys.exit() | |
25 | + | |
26 | +# terminate if db_filename exist | |
27 | +if os.path.exists(options.db_filename): | |
28 | + print('Database already exists. Please use a different name.') | |
29 | + sys.exit() | |
30 | + | |
31 | +# -------- Create database ------------ | |
32 | +conn = sqlite3.connect(options.db_filename) | |
33 | +c = conn.cursor() | |
34 | + | |
35 | +sql_cmd = '''PRAGMA foreign_keys = ON; | |
36 | + CREATE TABLE students ( | |
37 | + number TEXT PRIMARY KEY, | |
38 | + name TEXT, | |
39 | + password TEXT | |
40 | + ); | |
41 | + CREATE TABLE tests ( | |
42 | + test_id TEXT NOT NULL, | |
43 | + student_id TEXT NOT NULL, | |
44 | + grade REAL, | |
45 | + start_time TEXT, | |
46 | + finish_time TEXT, | |
47 | + FOREIGN KEY(student_id) REFERENCES students(number) | |
48 | + ); | |
49 | + CREATE TABLE questions ( | |
50 | + test_id TEXT NOT NULL, | |
51 | + question_id TEXT NOT NULL, | |
52 | + student_id TEXT NOT NULL, | |
53 | + grade REAL, | |
54 | + time TEXT, | |
55 | + FOREIGN KEY(student_id) REFERENCES students(number) | |
56 | + );''' | |
57 | +c.executescript(sql_cmd) | |
58 | + | |
59 | +# -------- Parse CSV and insert into database -------- | |
60 | + | |
61 | +password = options.password # initial common password for all students | |
62 | +if password != '': | |
63 | + password = sha256(password.encode('utf-8')).hexdigest() | |
64 | + | |
65 | +with open(args[0], encoding='iso-8859-1') as csvfile: # SIIUE format | |
66 | + csvreader = csv.reader(csvfile, delimiter=';', quotechar='"') | |
67 | + next(csvreader) # ignore header | |
68 | + | |
69 | + c.executemany('INSERT INTO students VALUES (?,?,?)', | |
70 | + [(row[0], row[1], password) for row in csvreader]) | |
71 | + c.execute('INSERT INTO students VALUES ("0", "Professor", "")') | |
72 | + conn.commit() # commit DB changes | |
73 | + c.close() # close DB cursor | ... | ... |
questions.py
... | ... | @@ -386,11 +386,11 @@ class QuestionTextArea(Question): |
386 | 386 | |
387 | 387 | # =========================================================================== |
388 | 388 | class QuestionInformation(Question): |
389 | - '''An instance of QuestionCheckbox will always have the keys: | |
389 | + '''An instance of QuestionInformation will always have the keys: | |
390 | 390 | type (str) |
391 | 391 | text (str) |
392 | - correct (str with regex) | |
393 | 392 | answer (None or an actual answer) |
393 | + points (0.0) | |
394 | 394 | ''' |
395 | 395 | #------------------------------------------------------------------------ |
396 | 396 | def __init__(self, q): |
... | ... | @@ -398,7 +398,7 @@ class QuestionInformation(Question): |
398 | 398 | super().__init__(q) |
399 | 399 | self['text'] = self.get('text', '') |
400 | 400 | self['answer'] = None |
401 | - self['points'] = 0.0 | |
401 | + self['points'] = 0.0 # FIXME shouldnt be defined on the test??? | |
402 | 402 | |
403 | 403 | #------------------------------------------------------------------------ |
404 | 404 | # can return negative values for wrong answers | ... | ... |
serve.py
... | ... | @@ -56,7 +56,7 @@ class Root(object): |
56 | 56 | for num in reset_pw: |
57 | 57 | cherrypy.log.error('Password updated for student %s.' % str(num), 'APPLICATION') |
58 | 58 | |
59 | - students = self.database.students() | |
59 | + students = self.database.get_students() | |
60 | 60 | template = self.templates.get_template('students.html') |
61 | 61 | return template.render(students=students, loggedin=self.loggedin) |
62 | 62 | |
... | ... | @@ -113,7 +113,7 @@ class Root(object): |
113 | 113 | t.update_answers(ans) |
114 | 114 | t.correct() |
115 | 115 | |
116 | - if t['train_mode']: | |
116 | + if t['practice_mode']: | |
117 | 117 | raise cherrypy.HTTPRedirect('/test') |
118 | 118 | |
119 | 119 | else: |
... | ... | @@ -137,14 +137,24 @@ class Root(object): |
137 | 137 | def parse_arguments(): |
138 | 138 | 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.') |
139 | 139 | argparser.add_argument('--server', default='conf/server.conf', type=str, help='server configuration file') |
140 | - argparser.add_argument('testfile', type=str, help='test in YAML format.') | |
140 | + argparser.add_argument('--debug', action='store_true', | |
141 | + help='Show datastructures when rendering questions') | |
142 | + argparser.add_argument('--show_points', action='store_true', | |
143 | + help='Show normalized points for each question') | |
144 | + argparser.add_argument('--show_hints', action='store_true', | |
145 | + help='Show hints in questions, if available') | |
146 | + argparser.add_argument('--save_answers', action='store_true', | |
147 | + help='Saves answers in JSON format') | |
148 | + argparser.add_argument('--practice_mode', action='store_true', | |
149 | + help='Show correction results and allow repetitive resubmission of the test') | |
150 | + argparser.add_argument('testfile', type=str, nargs='+', help='test in YAML format.') | |
141 | 151 | return argparser.parse_args() |
142 | 152 | |
143 | 153 | # ============================================================================ |
144 | 154 | if __name__ == '__main__': |
145 | 155 | # --- parse command line arguments and build base test |
146 | 156 | arg = parse_arguments() |
147 | - testconf = test.read_configuration(arg.testfile) | |
157 | + 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_mode=arg.practice_mode) | |
148 | 158 | |
149 | 159 | print('=' * 79) |
150 | 160 | print('- Title: %s' % testconf['title']) | ... | ... |
templates/test.html
... | ... | @@ -119,7 +119,7 @@ |
119 | 119 | </pre> |
120 | 120 | % endif |
121 | 121 | |
122 | - % if t['train_mode'] and 'grade' in t: | |
122 | + % if t['practice_mode'] and 'grade' in t: | |
123 | 123 | <div class="jumbotron drop-shadow"> |
124 | 124 | <h1>Resultado</h1> |
125 | 125 | <p>Teve <strong>${'{:.1f}'.format(t['grade'])}</strong> valores no teste.</p> |
... | ... | @@ -214,7 +214,7 @@ |
214 | 214 | % endif # modal |
215 | 215 | % endif # show_hints |
216 | 216 | |
217 | - % if t['train_mode'] and 'grade' in q: | |
217 | + % if t['practice_mode'] and 'grade' in q: | |
218 | 218 | % if q['grade'] > 0.99: |
219 | 219 | <div class="alert alert-success" role="alert"> |
220 | 220 | <span class="glyphicon glyphicon-ok" aria-hidden="true"></span> | ... | ... |
test.py
... | ... | @@ -9,7 +9,7 @@ import questions |
9 | 9 | import database |
10 | 10 | |
11 | 11 | # ============================================================================ |
12 | -def read_configuration(filename): | |
12 | +def read_configuration(filename, debug=False, show_points=False, show_hints=False, practice_mode=False, save_answers=False): | |
13 | 13 | # FIXME validar se ficheiros e directorios existem??? |
14 | 14 | if not os.path.isfile(filename): |
15 | 15 | print('Cannot find file "%s"' % filename) |
... | ... | @@ -21,15 +21,14 @@ def read_configuration(filename): |
21 | 21 | # defaults: |
22 | 22 | test['ref'] = str(test.get('ref', filename)) |
23 | 23 | test['title'] = str(test.get('title', '')) |
24 | - test['show_hints'] = bool(test.get('show_hints', False)) | |
25 | - test['show_points'] = bool(test.get('show_points', False)) | |
26 | - test['train_mode'] = bool(test.get('train_mode', False)) | |
27 | - test['debug'] = bool(test.get('debug', False)) | |
28 | - test['save_answers'] = bool(test.get('save_answers', True)) | |
24 | + test['show_hints'] = bool(test.get('show_hints', show_hints)) | |
25 | + test['show_points'] = bool(test.get('show_points', show_points)) | |
26 | + test['practice_mode'] = bool(test.get('practice_mode', practice_mode)) | |
27 | + test['debug'] = bool(test.get('debug', debug)) | |
28 | + test['save_answers'] = bool(test.get('save_answers', save_answers)) | |
29 | 29 | if test['save_answers']: |
30 | 30 | if 'answers_dir' not in test: |
31 | - print(' * Missing "answers_dir" in the test configuration.') | |
32 | - sys.exit(1) | |
31 | + raise exception('Missing "answers_dir" in the test configuration.') | |
33 | 32 | if not os.path.isdir(test['answers_dir']): |
34 | 33 | print(' * Directory "%s" does not exist. Creating...' % test['answers_dir']) |
35 | 34 | os.mkdir(test['answers_dir']) | ... | ... |