Commit fa02522c2e869d2b441571279fa5e2be47b2483f

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

- corrections in the MANUAL.md

- corrections in the /config/server.conf
- fixed paths so that server can be run from other directories.
- fixed paths so that questions, tests and logs do not have to be in the server directory.
- check existence of some files, directories and database
- removed offensive comments
BUGS.md
... ... @@ -2,10 +2,10 @@
2 2  
3 3 # BUGS
4 4  
5   -!!! - questions type script, necessário dar um caminho exacto relativamete ao directorio do server em vez da pergunta. deveria ser possivel mover as perguntas de directorio sem rebentar os caminhos.
6   -
  5 +- check if script to generate questions exist before instantiation.
  6 +- paths manipulation in strings is unix only ('/something'). use os.path to create paths.
  7 +- fix ans directory. relative to what?? current dir?
7 8 - parece que é preciso criar à mão a pasta para as respostas (ans/...) depois apercebo-me que os caminhos no teste dizem respeito à directoria donde o teste é corrido... as respostas deveriam guardadas no directório dado.
8   -- se database for mal configurada, é criada uma base de dados vazia e rebenta na autenticacao.
9 9 - 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.
10 10  
11 11 # TODO
... ... @@ -25,6 +25,10 @@
25 25  
26 26 # FIXED
27 27  
  28 +- se database for mal configurada, é criada uma base de dados vazia e rebenta na autenticacao.
  29 +- questions type script, necessário dar um caminho exacto relativamete ao directorio do server em vez da pergunta. deveria ser possivel mover as perguntas de directorio sem rebentar os caminhos.
  30 +- check that files exist in questions generator e correct textarea. add path in test.yaml
  31 +- scripts generator and correct should consider the questions path.
28 32 - testar envio de parametros para stdin para perguntas tipo generator.
29 33 - mathjax e jquery no login
30 34 - mostrar erro quando nao consegue importar questions files
... ...
MANUAL.md
... ... @@ -11,10 +11,27 @@ Install python 3.4 and the following packages from pip:
11 11  
12 12 Before using the program you need to
13 13  
14   -1. Create the students database
15   -1. Create questions
16   -1. Create a test
17   -1. Configure the server (the default should be enough)
  14 +1. Edit `config/server.conf` in the server directory and define
  15 + - Logging
  16 +
  17 + `log.error_file= '/Users/USERNAME/Library/Logs/Perguntations/errors.log'`
  18 +
  19 + `log.access_file= '/Users/USERNAME/Library/Logs/Perguntations/access.log'`
  20 +
  21 + You must create the directories if they do not exist already.
  22 +
  23 + Setting these locations to empty strings `''` disables logging.
  24 +
  25 + - Sessions
  26 + If `tools.sessions.storage_type='file'` sessions are saved on the file system in the location given in `tools.sessions.storage_path`. Restarting the server will maintain the sessions active.
  27 +
  28 + If `storage_type='ram'` (default) no files are stored but restaring the server will reset sessions.
  29 +
  30 + You should give enough time in the `tools.sessions.timeout` to complete an exam. The default is 240 minutes (4 hours).
  31 +
  32 +1. Create the students database (see below)
  33 +1. Create questions (see below)
  34 +1. Create a test (see below)
18 35  
19 36 ### Create students database
20 37  
... ... @@ -103,13 +120,17 @@ debug: False
103 120 # Show the file and ref field of each question
104 121 show_ref: True
105 122  
106   -# -------------------------------------------------------------------------
107   -# This are the questions database to be imported.
  123 +# ----------------------------------------------------------------------------
  124 +# Location of the questions files (absolute path or relative to current dir)
  125 +path: questions
  126 +
  127 +# This are the questions files to be imported.
108 128 files:
109   - - questions/file1.yaml
110   - - questions/file2.yaml
111   - - questions/file3.yaml
112   -# -------------------------------------------------------------------------
  129 + - file1.yaml
  130 + - file2.yaml
  131 + - file3.yaml
  132 +
  133 +# ----------------------------------------------------------------------------
113 134 # This is the actual test configuration. Selection of questions and points
114 135 # It'a defined as a list of questions. Each question can be a single
115 136 # question key or a list of keys from which one is chosen at random.
... ... @@ -129,7 +150,7 @@ questions:
129 150 This following one is wrong:
130 151  
131 152 - wrong-question # missing "ref:" key
132   - points: 2
  153 + points: 2
133 154 ```
134 155  
135 156 Some of the options have default values if they are omitted. The defaults are the following:
... ... @@ -265,9 +286,10 @@ The server will try to convert the printed message to a float, a failure will gi
265 286 ref: some-key
266 287 type: textarea
267 288 text: write an expression to add x and y. # optional (default: '')
268   - correct: path/to/myscript
  289 + correct: myscript
269 290 ```
270 291  
  292 +The script location is the same as the questions file.
271 293 An example of a script in python that validades an answer is
272 294  
273 295 ```python
... ...
conf/server.conf
... ... @@ -1,37 +0,0 @@
1   -# -*- coding: utf-8 -*-
2   -
3   -[global]
4   -;environment= 'production'
5   -
6   -; number of threads running
7   -server.thread_pool= 10
8   -
9   -; Host address and port
10   -; set socket_port = 443 if SSL is enabled below, 8080 otherwise
11   -; if port is 443 then the server should be run as root. SSL also works on 8080.
12   -server.socket_host = '0.0.0.0'
13   -server.socket_port = 8080
14   -
15   -; Uncomment to enable SSL (see README.txt on how to generate certificates)
16   -; server.ssl_module can be 'builtin' or 'pyopenssl'.
17   -; server.ssl_module = 'builtin'
18   -; server.ssl_certificate = 'certs/webserver.crt'
19   -; server.ssl_private_key = 'certs/webserver.key'
20   -; not required for snakeoil:
21   -; server.ssl_certificate_chain = 'ca_certs.crt'
22   -
23   -log.screen = False
24   -log.error_file = 'logs/errors.log'
25   -log.access_file = 'logs/access.log'
26   -
27   -tools.sessions.on = True
28   -tools.sessions.timeout = 240
29   -tools.sessions.storage_type = 'ram'
30   -tools.sessions.storage_path = 'sessions'
31   -tools.auth.on = True
32   -
33   -[/]
34   -tools.staticdir.root = os.path.normpath(os.path.abspath(os.path.curdir))
35   -tools.staticdir.dir = 'static'
36   -tools.staticdir.on = True
37   -; tools.staticdir.debug = True
demo/questions.yaml
... ... @@ -44,13 +44,13 @@
44 44 ref: question-colors
45 45 type: textarea
46 46 text: Write names of the three basic colors.
47   - correct: demo/correct-question.py
  47 + correct: correct-question.py
48 48 hint: They start by RGB and order does not matter.
49 49 # ---------------------------------------------------------------------------
50 50 -
51 51 ref: question-whatever
52 52 type: generator
53   - script: demo/generate-question.py
  53 + script: generate-question.py
54 54 arg: "11,120"
55 55 # the script should print a question in yaml format like the ones above.
56 56 # Print only the dictionary, not the list (hiffen).
... ...
demo/test.yaml
... ... @@ -4,7 +4,7 @@ title: Teste de Demonstração
4 4  
5 5 # database contains students+passwords, final results of the tests, and questions done
6 6 # database: path/to/students.db
7   -database: db/students.db
  7 +database: inscritos.db
8 8  
9 9 # this will generate a file for each test done. The file is like a replacement
10 10 # for a test done in paper.
... ... @@ -16,19 +16,20 @@ show_hints: True
16 16 practice: True
17 17 # debug: False
18 18  
19   -# When in practice mode and an answer is wrong (i.e. less that 0.5 correct),
20   -# an insult is chosen from the list (optional)
21   -offensive:
22   - - Ó meu grande asno, então não sabes esta?
23   - - Pois, pois... não estudes não...
24   - - E eu sou o Elvis Presley...
25   - - Pois, e bróculos também.
  19 +#-----------------------------------------------------------------------------
  20 +# This is the base path applied to the questions files and all the scripts
  21 +# including generators and correctors.
  22 +# Either absolute path or relative to current directory.
  23 +path: demo
  24 +# (Note: answers are saved in a different path defined in answers_dir)
26 25  
27 26 #-----------------------------------------------------------------------------
28 27 # List of files containing questions in yaml format.
29 28 # Selected questions will be obtained from these files.
  29 +# (search in absolute path or current working directory)
30 30 files:
31   - - demo/questions.yaml
  31 + - questions.yaml
  32 +
32 33 #-----------------------------------------------------------------------------
33 34 # This is the list of questions. If a "ref:" has a list of keys, then
34 35 # one question is selected from the list.
... ...
logs/.gitignore
... ... @@ -1,3 +0,0 @@
1   -# ignore everything except .gitignore
2   -*
3   -!.gitignore
4 0 \ No newline at end of file
myauth.py
... ... @@ -11,10 +11,14 @@ from hashlib import sha256
11 11 from mako.lookup import TemplateLookup
12 12 import urllib
13 13 import html
  14 +from os import path
  15 +
  16 +# path where this file is located
  17 +server_path = path.dirname(path.realpath(__file__))
14 18  
15 19 SESSION_KEY = 'userid'
16 20  
17   -templates = TemplateLookup(directories=['templates'], input_encoding='utf-8')
  21 +templates = TemplateLookup(directories=[server_path+'/templates'], input_encoding='utf-8')
18 22  
19 23  
20 24 def credentials_ok(uid, password, db):
... ... @@ -152,7 +156,7 @@ class AuthController(object):
152 156 cherrypy.session[SESSION_KEY] = cherrypy.request.login = uid
153 157 cherrypy.session['name'] = name
154 158 raise cherrypy.HTTPRedirect(from_page)
155   - logintemplate = templates.get_template('login.html')
  159 + logintemplate = templates.get_template('/login.html')
156 160 return logintemplate.render(from_page=from_page)
157 161  
158 162 @cherrypy.expose
... ...
questions.py
... ... @@ -4,6 +4,7 @@ import random
4 4 import re
5 5 import subprocess
6 6 import sys
  7 +import os.path
7 8  
8 9 # Example usage:
9 10 #
... ... @@ -18,19 +19,19 @@ import sys
18 19 # test[0]['answer'] = 42 # insert answer
19 20 # grade = test[0].correct() # correct answer
20 21  
21   -
22 22 # ===========================================================================
23 23 class QuestionsPool(dict):
24 24 '''This class contains base questions read from files, but which are
25 25 not ready yet. They have to be instantiated separatly for each student.'''
26 26  
27 27 #------------------------------------------------------------------------
28   - def add_from_file(self, filename):
  28 + def add_from_file(self, filename, path='.'):
  29 + path_file = os.path.normpath(os.path.join(path, filename))
29 30 try:
30   - with open(filename, 'r') as f:
  31 + with open(path_file, 'r') as f:
31 32 questions = yaml.load(f)
32 33 except(FileNotFoundError):
33   - print(' * Questions file "{0}" not found. Aborting...'.format(filename))
  34 + print(' * Questions file "{0}" not found. Aborting...'.format(path_file))
34 35 sys.exit(1)
35 36 except(yaml.parser.ParserError):
36 37 print(' * Error in questions file "{0}". Aborting...'.format(filename))
... ... @@ -47,6 +48,7 @@ class QuestionsPool(dict):
47 48  
48 49 # filename and index (number in the file, 0 based)
49 50 q['filename'] = filename
  51 + q['path'] = path
50 52 q['index'] = i
51 53  
52 54 # ref (if missing, add 'filename.yaml:3')
... ... @@ -59,9 +61,9 @@ class QuestionsPool(dict):
59 61 self[q['ref']] = q
60 62  
61 63 #------------------------------------------------------------------------
62   - def add_from_files(self, file_list):
63   - for filename in file_list:
64   - self.add_from_file(filename)
  64 + def add_from_files(self, files, path='.'):
  65 + for filename in files:
  66 + self.add_from_file(filename, path)
65 67  
66 68  
67 69 #============================================================================
... ... @@ -116,10 +118,12 @@ def question_generator(q):
116 118  
117 119 q['arg'] = q.get('arg', '') # send this string to stdin
118 120  
  121 + script = os.path.abspath(os.path.normpath(os.path.join(q['path'], q['script'])))
119 122 try:
120   - p = subprocess.Popen([q['script']], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT)
  123 + p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT)
121 124 except FileNotFoundError:
122   - print(' * Script "{0}" defined in question "{1}" of file "{2}" could not be found'.format(q['script'], q['ref'], q['filename']))
  125 + print(' * Script "{0}" of question "{1}" in file "{2}" could not be found'.format(script, q['ref'], q['filename']))
  126 + sys.exit(1)
123 127  
124 128 try:
125 129 qyaml = p.communicate(input=q['arg'].encode('utf-8'), timeout=5)[0].decode('utf-8')
... ... @@ -386,8 +390,9 @@ class QuestionTextArea(Question):
386 390  
387 391 # The correction program expects data from stdin and prints the result to stdout.
388 392 # The result should be a string that can be parsed to a float.
  393 + script = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct'])))
389 394 try:
390   - p = subprocess.Popen([self['correct']], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT)
  395 + p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT)
391 396 except FileNotFoundError as e:
392 397 print(' * Script "{0}" defined in question "{1}" of file "{2}" could not be found'.format(self['correct'], self['ref'], self['filename']))
393 398 raise e
... ...
serve.py
... ... @@ -7,12 +7,19 @@
7 7 import cherrypy
8 8 from mako.lookup import TemplateLookup
9 9 import argparse
  10 +from os import path
  11 +
  12 +# path where this file is located
  13 +server_path = path.dirname(path.realpath(__file__))
10 14  
11 15 # my code
12 16 from myauth import AuthController, require
13 17 import test
14 18 import database
15 19  
  20 +TEMPLATES_DIR = server_path + '/templates'
  21 +
  22 +
16 23 # ============================================================================
17 24 # Classes that respond to HTTP
18 25 # ============================================================================
... ... @@ -22,7 +29,7 @@ class Root(object):
22 29 self.testconf = testconf # base test dict (not instance)
23 30 self.database = database.Database(testconf['database'])
24 31 self.auth = AuthController(database=testconf['database'])
25   - self.templates = TemplateLookup(directories=['templates'], input_encoding='utf-8')
  32 + self.templates = TemplateLookup(directories=[TEMPLATES_DIR], input_encoding='utf-8')
26 33 self.tags = {'online': set(), 'finished': set()} # FIXME should be in application, not server
27 34  
28 35 # --- DEFAULT ------------------------------------------------------------
... ... @@ -32,7 +39,7 @@ class Root(object):
32 39 def default(self, *args):
33 40 raise cherrypy.HTTPRedirect('/test')
34 41  
35   - # --- LOGOUT ------------------------------------------------------------
  42 + # --- LOGOUT -------------------------------------------------------------
36 43 @cherrypy.expose
37 44 @require()
38 45 def logout(self):
... ... @@ -57,7 +64,7 @@ class Root(object):
57 64 cherrypy.log.error('Password updated for student %s.' % str(num), 'APPLICATION')
58 65  
59 66 students = self.database.get_students()
60   - template = self.templates.get_template('students.html')
  67 + template = self.templates.get_template('/students.html')
61 68 return template.render(students=students, tags=self.tags)
62 69  
63 70 # --- RESULTS ------------------------------------------------------------
... ... @@ -65,7 +72,7 @@ class Root(object):
65 72 def results(self):
66 73 if self.testconf.get('practice', False):
67 74 r = self.database.test_grades(self.testconf['ref'])
68   - template = self.templates.get_template('results.html')
  75 + template = self.templates.get_template('/results.html')
69 76 return template.render(t=self.testconf, results=r)
70 77 else:
71 78 raise cherrypy.HTTPRedirect('/')
... ... @@ -88,7 +95,7 @@ class Root(object):
88 95 self.tags['online'].add(uid) # track logged in students
89 96  
90 97 # Generate question
91   - template = self.templates.get_template('test.html')
  98 + template = self.templates.get_template('/test.html')
92 99 return template.render(t=t, questions=t['questions'])
93 100  
94 101 # --- CORRECT ------------------------------------------------------------
... ... @@ -146,7 +153,8 @@ class Root(object):
146 153 # ============================================================================
147 154 def parse_arguments():
148 155 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.')
149   - argparser.add_argument('--server', default='conf/server.conf', type=str, help='server configuration file')
  156 + serverconf_file = path.normpath(path.join(server_path, 'config', 'server.conf'))
  157 + argparser.add_argument('--server', default=serverconf_file, type=str, help='server configuration file')
150 158 argparser.add_argument('--debug', action='store_true',
151 159 help='Show datastructures when rendering questions')
152 160 argparser.add_argument('--show_ref', action='store_true',
... ... @@ -159,7 +167,7 @@ def parse_arguments():
159 167 help='Saves answers in JSON format')
160 168 argparser.add_argument('--practice', action='store_true',
161 169 help='Show correction results and allow repetitive resubmission of the test')
162   - argparser.add_argument('testfile', type=str, nargs='+', help='test in YAML format.')
  170 + argparser.add_argument('testfile', type=str, nargs='+', help='test/exam in YAML format.') # FIXME only one exam supported at the moment
163 171 return argparser.parse_args()
164 172  
165 173 # ============================================================================
... ... @@ -180,7 +188,8 @@ if __name__ == '__main__':
180 188 root = Root(testconf)
181 189  
182 190 # --- Mount and run server.
  191 + cherrypy.config.update({'tools.staticdir.root': server_path})
183 192 cherrypy.quickstart(root, '/', config=arg.server)
184   - cherrypy.log.error('Terminated OK ------------------------', 'APPLICATION')
  193 + cherrypy.log('Terminated OK ------------------------', 'APPLICATION')
185 194 print('\n- Server terminated OK')
186 195 print('=' * 79)
... ...
templates/grade.html
... ... @@ -68,8 +68,8 @@
68 68 <div class="container">
69 69 <div class="jumbotron drop-shadow">
70 70 <h1>Resultado</h1>
71   - <p>Teve <strong>${'{:.1f}'.format(t['grade'])}</strong> valores no teste, numa escala de 0 a 20.</p>
72   - <p>Pode desligar.</p>
  71 + <p>Teve <strong>${'{:.1f}'.format(t['grade'])}</strong> valores na escala de 0 a 20.</p>
  72 + <p>Pode desligar o computador.</p>
73 73 <!-- <p><a class="btn btn-primary btn-lg" href="#" role="button">Learn more</a></p> -->
74 74 </div>
75 75  
... ... @@ -107,7 +107,7 @@
107 107 </tr>
108 108 % endfor
109 109 </tbody>
110   - <tfoot>
  110 +<!-- <tfoot>
111 111 <tr>
112 112 <th>Média (não ponderada)</th>
113 113 <th colspan="2">
... ... @@ -127,7 +127,7 @@
127 127 </div>
128 128 </th>
129 129 </tr>
130   - </tfoot>
  130 + </tfoot> -->
131 131 </table>
132 132 </div> <!-- panel -->
133 133 </div> <!-- container -->
... ...
templates/login.html
... ... @@ -62,32 +62,25 @@
62 62 <div class="col-xs-4">
63 63 <img src="/logo_horizontal.png" class="img-responsive" />
64 64 </div>
65   - <div class="col-xs-8">
66   - <h4 class="text-right">
67   - <small>
68   - $\sqrt{2\pi}$
69   - </small>
70   - </h4>
71   - </div>
72   - </div>
73   -
74   - <form method="post" action="/auth/login" class="form-signin">
75 65  
76   - <div class="row">
77   - <input type="hidden" name="from_page" value="${from_page}">
78   -
79   - <input type="text" name="uid" class="form-control" placeholder="Número" required autofocus>
  66 + <div class="col-xs-4">
  67 + </div>
80 68  
81   - <input type="password" name="pw" class="form-control" placeholder="Password" required>
82   - </div>
83   - <div class="row">
84   - <button class="btn btn-primary" type="submit">
85   - <span class="glyphicon glyphicon-log-in"></span>&nbsp; Entrar
86   - </button>
  69 + <div class="col-xs-4">
  70 + <h4>Identificação:</h4>
  71 + <form method="post" action="/auth/login" class="form-signin">
  72 + <input type="hidden" name="from_page" value="${from_page}">
  73 + <div class="form-group">
  74 + <input type="text" name="uid" class="form-control" placeholder="Número" required autofocus>
  75 + <input type="password" name="pw" class="form-control" placeholder="Password" required>
  76 + </div>
  77 + <button class="btn btn-primary" type="submit">
  78 + <span class="glyphicon glyphicon-log-in"></span>&nbsp; Entrar
  79 + </button>
  80 + </form>
  81 + </div>
87 82 </div>
88   - </form>
89 83 </div>
90   -
91 84 </div> <!-- /container -->
92 85 </body>
93 86 </html>
... ...
templates/test.html
... ... @@ -128,9 +128,8 @@
128 128 % if t['practice'] and 'grade' in t:
129 129 <div class="jumbotron drop-shadow">
130 130 <h1>Resultado</h1>
131   - <p>Teve <strong>${'{:.1f}'.format(t['grade'])}</strong> valores no teste.</p>
  131 + <p>Teve <strong>${'{:.1f}'.format(t['grade'])}</strong> valores.</p>
132 132 <p>Se quiser, pode corrigir e submeter o teste novamente.<br>Para terminar escolha a opção 'Sair' no menu.</p>
133   -
134 133 </div>
135 134 % endif
136 135  
... ... @@ -241,9 +240,6 @@
241 240 </div>
242 241 % else:
243 242 <div class="alert alert-danger" role="alert">
244   - <p>
245   - ${random.choice(t['offensive']) if 'offensive' in t else ''}
246   - </p>
247 243 <span class="glyphicon glyphicon-remove" aria-hidden="true"></span>
248 244 ${round(q['grade'] * q['points'] / total_points * 20.0, 1)} pontos
249 245 </div>
... ...
test.py
... ... @@ -32,6 +32,11 @@ def read_configuration(filename, debug=False, show_points=False, show_hints=Fals
32 32 test['practice'] = bool(test.get('practice', practice))
33 33 test['debug'] = bool(test.get('debug', debug))
34 34 test['show_ref'] = bool(test.get('show_ref', show_ref))
  35 + test['path'] = str(test.get('path', '.')) # questions dir FIXME use os.path
  36 + # FIXME check that the directory exists
  37 + if not os.path.exists(test['path']):
  38 + print(' * Questions path "{0}" does not exist. Fix it in "{1}".'.format(test['path'], filename))
  39 + sys.exit(1)
35 40 test['save_answers'] = bool(test.get('save_answers', save_answers))
36 41 if test['save_answers']:
37 42 if 'answers_dir' not in test:
... ... @@ -43,13 +48,18 @@ def read_configuration(filename, debug=False, show_points=False, show_hints=Fals
43 48 if 'database' not in test:
44 49 print(' * Missing database in the test configuration.')
45 50 sys.exit(1)
  51 + if not os.path.exists(test['database']):
  52 + print(' * Database "{0}" not found.'.format(test['database']))
  53 + sys.exit(1)
  54 +
46 55  
47 56 if isinstance(test['files'], str):
48 57 test['files'] = [test['files']]
49 58  
50 59 # replace ref,points by actual questions from pool
51 60 pool = questions.QuestionsPool()
52   - pool.add_from_files(test['files'])
  61 + pool.add_from_files(files=
  62 + test['files'], path=test['path'])
53 63  
54 64 for i, q in enumerate(test['questions']):
55 65 # each question is a list of alternative versions, even if the list
... ...