serve.py
11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from os import path
import sys
import argparse
import logging.config
import html
import json
import yaml
try:
import cherrypy
from cherrypy.lib import auth_digest
from mako.lookup import TemplateLookup
except ImportError:
print('Some python packages are missing. See README.md for instructions.')
sys.exit(1)
# ============================================================================
# Authentication
# ============================================================================
def check_auth(*args, **kwargs):
"""A tool that looks in config for 'auth.require'. If found and it
is not None, a login is required and the entry is evaluated as a list of
conditions that the user must fulfill"""
conditions = cherrypy.request.config.get('auth.require', None)
if conditions is not None:
username = cherrypy.session.get(SESSION_KEY)
if username:
# user logged in
cherrypy.request.login = username
for condition in conditions:
# A condition is just a callable that returns true or false
if not condition():
raise cherrypy.HTTPRedirect("/")
else:
# user not currently logged in
raise cherrypy.HTTPRedirect("/login")
cherrypy.tools.auth = cherrypy.Tool('before_handler', check_auth)
# A decorator that appends conditions to the auth.require config variable.
def require(*conditions):
def decorate(f):
if not hasattr(f, '_cp_config'):
f._cp_config = dict()
f._cp_config.setdefault('auth.require', []).extend(conditions)
return f
return decorate
def name_is(reqd_username):
return lambda: reqd_username == cherrypy.request.login
# ============================================================================
# Improve cherrypy security
# http://docs.cherrypy.org/en/latest/advanced.html#securing-your-server
# ============================================================================
def secureheaders():
headers = cherrypy.response.headers
headers['X-Frame-Options'] = 'DENY'
headers['X-XSS-Protection'] = '1; mode=block'
headers['Content-Security-Policy'] = "default-src='self'"
if (cherrypy.server.ssl_certificate != None and cherrypy.server.ssl_private_key != None):
headers['Strict-Transport-Security'] = 'max-age=31536000' # one year
# ============================================================================
# Admin webservice
# ============================================================================
class AdminWebService(object):
exposed = True
_cp_config = {
'auth.require': [name_is('0')]
}
def __init__(self, app):
self.app = app
@cherrypy.tools.accept(media='application/json') # FIXME
def GET(self):
data = {
'students': list(self.app.get_students_state().items()),
'test': self.app.testfactory
}
return json.dumps(data, default=str)
def POST(self, **args):
# print('POST', args) # FIXME
if args['cmd'] == 'allow':
if args['value'] == 'true':
return self.app.allow_student(args['name'])
else:
return self.app.deny_student(args['name'])
elif args['cmd'] == 'reset':
return self.app.reset_password(args['name'])
# ============================================================================
# Webserver root
# ============================================================================
class Root(object):
def __init__(self, app):
self.app = app
t = TemplateLookup(directories=[TEMPLATES_DIR], input_encoding='utf-8')
self.template = {
'login': t.get_template('/login.html'),
'test': t.get_template('/test.html'),
'grade': t.get_template('/grade.html'),
'admin': t.get_template('/admin.html'),
}
# --- DEFAULT ------------------------------------------------------------
@cherrypy.expose
@require()
def default(self, *args, **kwargs):
uid = cherrypy.session.get(SESSION_KEY)
if uid == '0':
raise cherrypy.HTTPRedirect('/admin')
else:
raise cherrypy.HTTPRedirect('/test')
# --- LOGIN --------------------------------------------------------------
@cherrypy.expose
def login(self, uid=None, pw=None):
if uid is None or pw is None: # first try
return self.template['login'].render()
if self.app.login(uid, pw): # ok
cherrypy.session[SESSION_KEY] = cherrypy.request.login = uid
self.app.set_user_agent(uid, cherrypy.request.headers.get('User-Agent', ''))
self.app.set_user_ip(uid, cherrypy.request.remote.ip)
raise cherrypy.HTTPRedirect('/')
else: # denied
return self.template['login'].render()
# --- LOGOUT -------------------------------------------------------------
@cherrypy.expose
@require()
def logout(self):
uid = cherrypy.session.get(SESSION_KEY)
cherrypy.lib.sessions.expire() # session coockie expires client side
cherrypy.session[SESSION_KEY] = cherrypy.request.login = None
cherrypy.log.error('Student {0} logged out.'.format(uid), 'APPLICATION')
self.app.logout(uid)
raise cherrypy.HTTPRedirect('/')
# --- TEST ---------------------------------------------------------------
# Get student number and assigned questions from current session.
# If it's the first time, create instance of the test and register the
# time.
@cherrypy.expose
@require()
def test(self):
uid = cherrypy.session.get(SESSION_KEY)
test = self.app.get_test(uid)
if test is None:
test = self.app.generate_test(uid) # try to generate a new test
return self.template['test'].render(t=test)
# --- CORRECT ------------------------------------------------------------
@cherrypy.expose
@require()
def correct(self, **kwargs):
# receives dictionary with answers
# kwargs = {'answered-xpto': 'on', 'xpto': '13.45', ...}
# Format:
# checkbox - all off -> no key, 1 on -> string '0', >1 on -> ['0', '1']
# radio - all off -> no key, 1 on -> string '0'
# text - always returns string. no answer '', otherwise 'dskdjs'
uid = cherrypy.session.get(SESSION_KEY)
student_name = self.app.get_student_name(uid)
title = self.app.get_test(uid)['title']
qq = self.app.get_test_qtypes(uid) # {'q1_ref': 'checkbox', ...}
# each question that is marked to be classified must have an answer.
# `ans` contains the answers to be corrected. The missing ones were
# disabled by the student
ans = {}
for qref, qtype in qq.items():
if 'answered-' + qref in kwargs:
# HTML HACK: checkboxes in html return None instead of an empty list if none is selected. Also, if only one is selected returns string instead of list of strings.
default_ans = [] if qtype == 'checkbox' else None
a = kwargs.get(qref, default_ans)
if qtype == 'checkbox' and isinstance(a, str):
a = [a]
ans[qref] = a
grade = self.app.correct_test(uid, ans)
self.app.logout(uid)
# --- Expire session
cherrypy.lib.sessions.expire() # session coockie expires client side
cherrypy.session[SESSION_KEY] = cherrypy.request.login = None
# --- Show result to student
return self.template['grade'].render(
title=title,
student_id=uid + ' - ' + student_name,
grade=grade,
allgrades=self.app.get_student_grades_from_all_tests(uid)
)
# --- ADMIN --------------------------------------------------------------
@cherrypy.expose
@require(name_is('0'))
def admin(self, **reset_pw):
return self.template['admin'].render()
# ============================================================================
def parse_arguments():
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.')
serverconf_file = path.normpath(path.join(SERVER_PATH, 'config', 'server.conf'))
argparser.add_argument('--conf', default=serverconf_file, type=str, help='server configuration file')
argparser.add_argument('--debug', action='store_true',
help='Show datastructures when rendering questions')
argparser.add_argument('testfile', type=str, nargs='+', help='test/exam in YAML format.') # FIXME only one exam supported at the moment
return argparser.parse_args()
# ============================================================================
if __name__ == '__main__':
SERVER_PATH = path.dirname(path.realpath(__file__))
TEMPLATES_DIR = path.join(SERVER_PATH, 'templates')
LOGGER_CONF = path.join(SERVER_PATH, 'config/logger.yaml')
SESSION_KEY = 'userid'
# --- parse command line arguments and build base test
arg = parse_arguments()
if arg.debug:
LOGGER_CONF = path.join(SERVER_PATH, 'config/logger-debug.yaml')
# --- Setup logging
with open(LOGGER_CONF,'r') as f:
logging.config.dictConfig(yaml.load(f))
# --- start application
from app import App
# FIXME do not send args that were not defined in the commandline
# this means options should be like --show-ref=true|false
# and have no default value
filename = path.abspath(path.expanduser(arg.testfile[0]))
try:
app = App(filename, vars(arg))
except:
sys.exit(1)
# --- create webserver
webapp = Root(app)
webapp.adminwebservice = AdminWebService(app)
# --- site wide configuration (valid for all apps)
cherrypy.tools.secureheaders = cherrypy.Tool('before_finalize', secureheaders, priority=60)
cherrypy.config.update(arg.conf) # configuration file in /config
conf = {
'/': {
'tools.sessions.on': True,
'tools.sessions.timeout': 240, # sessions last 4 hours
'tools.sessions.storage_type': 'ram', # or 'file'
'tools.sessions.storage_path': 'sessions', # if storage_type='file'
# tools.sessions.secure = True
# tools.sessions.httponly = True
# Turn on authentication (required for check_auth to work)
'tools.auth.on': True,
'tools.secureheaders.on': True,
'tools.staticdir.root': SERVER_PATH,
'tools.staticdir.dir': 'static', # where to get js,css,jpg,...
'tools.staticdir.on': True,
},
'/adminwebservice': {
'request.dispatch': cherrypy.dispatch.MethodDispatcher(),
'tools.response_headers.on': True,
'tools.response_headers.headers': [('Content-Type', 'text/plain')],
},
'/static': {
'tools.auth.on': False, # everything in /static is public
'tools.staticdir.on': True,
'tools.staticdir.dir': 'static', # where to get js,css,jpg,...
},
}
cherrypy.engine.unsubscribe('graceful', cherrypy.log.reopen_files) # FIXME what's this?
# --- Start server
cherrypy.tree.mount(webapp, script_name='/', config=conf)
if hasattr(cherrypy.engine, "signal_handler"):
cherrypy.engine.signal_handler.subscribe()
if hasattr(cherrypy.engine, "console_control_handler"):
cherrypy.engine.console_control_handler.subscribe()
cherrypy.engine.start()
cherrypy.engine.block()
# ...App running...
app.exit()