server.py 17.1 KB
Newer Older
1
from flask import Flask, g, request, url_for, redirect, session, render_template, flash, Response
2
from werkzeug.routing import Rule
3
from functools import wraps
Julian Rother's avatar
Julian Rother committed
4
from datetime import date, timedelta, datetime, time, MINYEAR
5
import threading
6
import os
7
import sys
Julian Rother's avatar
Julian Rother committed
8
import hashlib
9
import random
10
import sched
11

12
app = Flask(__name__)
13

Andreas Valder's avatar
Andreas Valder committed
14
15
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
Julian Rother's avatar
Julian Rother committed
16
app.add_template_global(random.randint, name='randint')
17
18
app.add_template_global(datetime, name='datetime')
app.add_template_global(timedelta, name='timedelta')
Andreas Valder's avatar
Andreas Valder committed
19

20
21
22
23
scheduler = sched.scheduler()
def run_scheduler():
	while True:
		scheduler.run()
24

25
26
27
28
29
30
31
32
33
34
35
def sched_func(delay, priority=0, args=[], kargs={}):
	def wrapper(func):
		def sched_wrapper():
			with app.test_request_context():
				func(*args, *kargs)
			scheduler.enter(delay, priority, sched_wrapper)
		scheduler.enter(delay, priority, sched_wrapper)
		return func
	return wrapper

threading.Thread(target=run_scheduler, daemon=True).start()
36

37
config = app.config
38
config.from_pyfile('config.py.example', silent=True)
39
40
41
if sys.argv[0].endswith('run.py'): 
	config['SQLITE_INIT_DATA'] = True
	config['DEBUG'] = True
42
config.from_pyfile('config.py', silent=True)
Andreas Valder's avatar
Andreas Valder committed
43
44
if config['DEBUG']:
	app.jinja_env.auto_reload = True
45
46
if not config.get('SECRET_KEY', None):
	config['SECRET_KEY'] = os.urandom(24)
Julian Rother's avatar
Julian Rother committed
47

Julian Rother's avatar
Julian Rother committed
48
from db import query, modify, searchquery, ldapauth, ldapget
Julian Rother's avatar
Julian Rother committed
49

50
mod_endpoints = []
Julian Rother's avatar
Julian Rother committed
51

Julian Rother's avatar
Cleanup    
Julian Rother committed
52
@app.template_global()
53
54
55
56
def ismod(*args):
	return ('user' in session)

def mod_required(func):
57
	mod_endpoints.append(func.__name__)
58
59
	@wraps(func)
	def decorator(*args, **kwargs):
60
		if not ismod():
61
62
63
64
65
66
			flash('Diese Funktion ist nur für Moderatoren verfügbar!')
			return redirect(url_for('login', ref=request.url))
		else:
			return func(*args, **kwargs)
	return decorator

67
app.jinja_env.globals['navbar'] = []
68
69
70
71
72
# iconlib can be 'bootstrap'
# ( see: http://getbootstrap.com/components/#glyphicons )
# or 'fa'
# ( see: http://fontawesome.io/icons/ )
def register_navbar(name, iconlib='bootstrap', icon=None):
73
	def wrapper(func):
74
		endpoint = func.__name__
75
		app.jinja_env.globals['navbar'].append((endpoint, name, iconlib, icon, not endpoint in mod_endpoints))
76
77
78
		return func
	return wrapper

Julian Rother's avatar
Cleanup    
Julian Rother committed
79
80
81
82
def render_endpoint(endpoint, flashtext=None, **kargs):
	if flashtext:
		flash(flashtext)
	# request.endpoint is used for navbar highlighting
83
	request.url_rule = Rule(request.path, endpoint=endpoint)
Julian Rother's avatar
Cleanup    
Julian Rother committed
84
85
	return app.view_functions[endpoint](**kargs)

86
87
88
89
90
91
92
def handle_errors(endpoint, text, code, *errors, **epargs):
	def wrapper(func):
		@wraps(func)
		def decorator(*args, **kwargs):
			try:
				return func(*args, **kwargs)
			except errors:
Julian Rother's avatar
Julian Rother committed
93
94
95
96
				if endpoint:
					return render_endpoint(endpoint, text, **epargs), code
				else:
					return text, code
97
98
99
		return decorator
	return wrapper

Julian Rother's avatar
Cleanup    
Julian Rother committed
100
101
@app.errorhandler(404)
def handle_not_found(e):
102
	return render_endpoint('index', 'Diese Seite existiert nicht!'), 404
Julian Rother's avatar
Cleanup    
Julian Rother committed
103

Julian Rother's avatar
Julian Rother committed
104
@app.template_filter(name='semester')
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def human_semester(s, long=False):
	if not s or s == 'zeitlos' or len(s) != 6:
		return 'Zeitlos'
	year = s[0:4]
	semester = s[4:6].upper()
	if not year.isdigit() or semester not in ['SS', 'WS']:
		print('Invalid semester string "%s"'%s)
		return '??'
	if not long:
		return semester+year[2:]
	elif semester == 'SS':
		return 'Sommersemester %s'%year
	else:
		return 'Wintersemester %s/%s'%(year, str(int(year)+1)[2:])
Julian Rother's avatar
Julian Rother committed
119
120
121

@app.template_filter(name='date')
def human_date(d):
Andreas Valder's avatar
Andreas Valder committed
122
	return d.strftime('%d.%m.%Y')
Julian Rother's avatar
Julian Rother committed
123

Andreas Valder's avatar
Andreas Valder committed
124
@app.template_filter(name='time')
125
def human_time(d):
Andreas Valder's avatar
Andreas Valder committed
126
127
	return d.strftime('%H:%M')

Julian Rother's avatar
Julian Rother committed
128
129
130
131
@app.template_filter()
def rfc3339(d):
	return d.strftime('%Y-%m-%dT%H:%M:%S+02:00')

132
133
@app.template_global()
def get_announcements(minlevel=0):
134
135
	offset = timedelta()
	if ismod():
136
		offset = timedelta(hours=24)
137
	return query('SELECT * FROM announcements WHERE NOT deleted AND (time_expire ISNULL OR time_expire > ?) AND (? OR (visible AND time_publish < ?)) AND level >= ? ORDER BY level DESC', datetime.now()-offset, ismod(), datetime.now(), minlevel)
138

139
140
141
142
143
@app.template_filter()
def fixnl(s):
	# To be remove, as soon as db schema is cleaned-up
	return str(s).replace('\n', '<br>')

144
@app.route('/')
145
@register_navbar('Home', icon='home')
146
def index():
147
148
	start = date.today() - timedelta(days=1)
	end = start + timedelta(days=7)
149
150
	upcomming = query('''
		SELECT lectures.*, "course" AS sep, courses.*
Andreas Valder's avatar
Andreas Valder committed
151
152
		FROM lectures
		JOIN courses ON (lectures.course_id = courses.id)
153
154
		WHERE (time > ?) AND (time < ?) and lectures.visible and courses.visible and courses.listed
		ORDER BY time ASC LIMIT 30''',start,end)
Andreas Valder's avatar
Andreas Valder committed
155
156
157
	for i in upcomming:
		i['date'] = i['time'].date()
	latestvideos=query('''
158
		SELECT lectures.*, "course" AS sep, courses.*
Andreas Valder's avatar
Andreas Valder committed
159
160
161
162
163
		FROM lectures
		LEFT JOIN videos ON (videos.lecture_id = lectures.id)
		LEFT JOIN courses on (courses.id = lectures.course_id)
		WHERE (? OR (courses.visible AND courses.listed AND lectures.visible AND videos.visible))
		GROUP BY videos.lecture_id
164
		ORDER BY MAX(videos.time_updated) DESC
Andreas Valder's avatar
Andreas Valder committed
165
		LIMIT 6	''',ismod())
166
167
	featured = query('SELECT * FROM featured WHERE NOT deleted AND (? OR visible)', ismod())
	return render_template('index.html', latestvideos=latestvideos, upcomming=upcomming, featured=featured)
168

169
@app.route('/course')
170
@register_navbar('Videos', icon='film')
171
def courses():
172
	courses = query('SELECT * FROM courses WHERE (? OR (visible AND listed)) ORDER BY title', ismod())
173
174
175
	for course in courses:
		if course['semester'] == '':
			course['semester'] = 'zeitlos'
Andreas Valder's avatar
Andreas Valder committed
176
	groupedby = request.args.get('groupedby')
Julian Rother's avatar
Cleanup    
Julian Rother committed
177
	if groupedby not in ['title', 'semester', 'organizer']:
Andreas Valder's avatar
Andreas Valder committed
178
		groupedby = 'semester'
179
	return render_template('courses.html', courses=courses, groupedby=groupedby)
Andreas Valder's avatar
Andreas Valder committed
180

181
182
@app.route('/course/<handle>')
@app.route('/course/<int:id>')
183
@handle_errors('courses', 'Diese Veranstaltung existiert nicht!', 404, IndexError)
184
185
def course(id=None, handle=None):
	if id:
186
		course = query('SELECT * FROM courses WHERE id = ? AND (? OR visible)', id, ismod())[0]
187
	else:
188
		course = query('SELECT * FROM courses WHERE handle = ? AND (? OR visible)', handle, ismod())[0]
189
190
191
	course['auth'] = query('SELECT * FROM auth WHERE course_id = ? ORDER BY auth_type', course['id'])
	auths = query('SELECT auth.* FROM auth JOIN lectures ON (auth.lecture_id = lectures.id) WHERE lectures.course_id = ? ORDER BY auth.auth_type', course['id'])
	lectures = query('SELECT * FROM lectures WHERE course_id = ? AND (? OR visible) ORDER BY time, duration DESC', course['id'], ismod())
192
193
194
195
196
	for lecture in lectures:
		lecture['auth'] = []
		for auth in auths:
			if auth['lecture_id'] == lecture['id']:
				lecture['auth'].append(auth)
Andreas Valder's avatar
Andreas Valder committed
197
	videos = query('''
198
			SELECT videos.*, (videos.downloadable AND courses.downloadable) as downloadable, formats.description AS format_description, formats.player_prio, formats.prio
Andreas Valder's avatar
Andreas Valder committed
199
200
201
202
203
204
			FROM videos
			JOIN lectures ON (videos.lecture_id = lectures.id)
			JOIN formats ON (videos.video_format = formats.id)
			JOIN courses ON (lectures.course_id = courses.id)
			WHERE lectures.course_id= ? AND (? OR videos.visible)
			ORDER BY lectures.time, formats.prio DESC
205
206
			''', course['id'], ismod())
	return render_template('course.html', course=course, lectures=lectures, videos=videos)
Andreas Valder's avatar
Andreas Valder committed
207

Andreas Valder's avatar
Andreas Valder committed
208
@app.route('/faq')
209
@register_navbar('FAQ', icon='question-sign')
Andreas Valder's avatar
Andreas Valder committed
210
def faq():
211
	return render_template('faq.html')
Andreas Valder's avatar
Andreas Valder committed
212

213
@app.route('/play/<int:id>')
Andreas Valder's avatar
Andreas Valder committed
214
@app.route('/embed/<int:id>', endpoint='embed')
215
@handle_errors('course', 'Diese Vorlesung existiert nicht!', 404, IndexError)
216
def lecture(id):
217
	lectures = query('SELECT * FROM lectures WHERE id = ? AND (? OR visible)', id, ismod())
218
	videos = query('SELECT videos.*, formats.description AS format_description, formats.prio, formats.player_prio FROM videos JOIN formats ON (videos.video_format = formats.id) WHERE lecture_id = ? AND (? OR visible)', id, ismod())
219
220
221
222
	if not videos:
		flash('Zu dieser Vorlesung wurden noch keine Videos veröffentlicht!')
	courses = query('SELECT * FROM courses WHERE id = ? AND (? OR (visible AND listed))', lectures[0]['course_id'], ismod())
	if not courses:
223
		return render_endpoint('course', 'Diese Veranstaltung existiert nicht!'), 404
224
	chapters = query('SELECT * FROM chapters WHERE lecture_id = ? AND NOT deleted AND (? OR visible) ORDER BY time ASC', id, ismod())
Andreas Valder's avatar
Andreas Valder committed
225
226
	return render_template('embed.html' if request.endpoint == 'embed' else 'lecture.html', course=courses[0], lecture=lectures[0], videos=videos, chapters=chapters)

227
228
229
230
231
232
233

@app.route('/search')
def search():
	if 'q' not in request.args:
		return redirect(url_for('index'))
	q = request.args['q']
	courses = searchquery(q, '*', ['title', 'short', 'organizer', 'subject', 'description'],
234
			'courses', 'WHERE (? OR (visible AND listed)) GROUP BY id ORDER BY _score DESC, semester DESC LIMIT 20', ismod())
235
	lectures = searchquery(q, 'lectures.*, courses.visible AS coursevisible, courses.listed, "course" AS sep, courses.*',
236
237
			['lectures.title', 'lectures.comment', 'lectures.speaker', 'courses.short'],
			'lectures LEFT JOIN courses on (courses.id = lectures.course_id)',
238
			'WHERE (? OR (coursevisible AND listed AND visible)) GROUP BY id ORDER BY _score DESC, time DESC LIMIT 30', ismod())
239
	return render_template('search.html', searchtext=request.args['q'], courses=courses, lectures=lectures)
Andreas Valder's avatar
Andreas Valder committed
240

241
242
243
def check_mod(user, groups):
	return user and 'users' in groups

244
@app.route('/login', methods=['GET', 'POST'])
Julian Rother's avatar
Julian Rother committed
245
def login():
246
247
	if request.method == 'GET':
		return render_template('login.html')
Julian Rother's avatar
Julian Rother committed
248
	user, groups = ldapauth(request.form.get('user'), request.form.get('password'))
249
	if not check_mod(user, groups):
250
		flash('Login fehlgeschlagen!')
251
252
253
254
		return render_template('login.html')
	session['user'] = ldapget(user)
	dbuser = query('SELECT * FROM users WHERE name = ?', user)
	if not dbuser:
Julian Rother's avatar
Julian Rother committed
255
		modify('INSERT INTO users (name, realname, fsacc, level, calendar_key, rfc6238) VALUES (?, ?, ?, 1, "", "")', user, session['user']['givenName'], user)
256
257
		dbuser = query('SELECT * FROM users WHERE name = ?', user)
	session['user']['dbid'] = dbuser[0]['id']
Julian Rother's avatar
Julian Rother committed
258
	return redirect(request.values.get('ref', url_for('index')))
Julian Rother's avatar
Julian Rother committed
259

Julian Rother's avatar
Julian Rother committed
260
@app.route('/logout', methods=['GET', 'POST'])
Julian Rother's avatar
Julian Rother committed
261
262
def logout():
	session.pop('user')
Julian Rother's avatar
Julian Rother committed
263
	return redirect(request.values.get('ref', url_for('index')))
Julian Rother's avatar
Julian Rother committed
264

265
266
267
tabs = {
	'courses': ('courses_data', 'id', ['visible', 'listed', 'title', 'short',
			'handle', 'organizer', 'subject', 'semester', 'downloadable',
268
269
			'internal', 'responsible','deleted'],
			['created_by', 'time_created', 'time_updated']),
270
	'lectures': ('lectures_data', 'id', ['visible', 'title', 'comment',
271
272
273
274
275
276
277
278
279
280
			'internal', 'speaker', 'place', 'time', 'duration', 'jumplist','deleted'],
			['course_id', 'time_created', 'time_updated']),
	'videos': ('videos_data', 'id', ['visible','deleted'],
			['created_by', 'time_created', 'time_updated']),
	'chapters': ('chapters', 'id', ['time', 'text', 'visible', 'deleted'],
			['created_by', 'time_created', 'time_updated']),
	'announcements': ('announcements', 'id', ['text', 'level', 'visible',
			'deleted', 'time_publish', 'time_expire'],
			['created_by', 'time_created', 'time_updated']),
	'featured': ('featured', 'id', ['title', 'text', 'internal', 'visible', 'deleted'],
281
282
			['created_by', 'time_created', 'time_updated']),
	'auth': ('auth', 'auth_id', ['auth_type', 'auth_user', 'auth_passwd'],
283
			['course_id', 'lecture_id', 'video_id'])
284
285
}

286
@app.route('/edit', methods=['GET', 'POST'])
287
@mod_required
288
def edit(prefix='', ignore=[]):
289
	# All editable tables are expected to have a 'time_updated' field
290
	ignore.append('ref')
291
292
293
	ignore.append('prefix')
	if not prefix and 'prefix' in request.args:
		prefix = request.args['prefix']
Julian Rother's avatar
Julian Rother committed
294
	modify('BEGIN')
295
	changes = request.values.items()
296
	if request.is_json:
Julian Rother's avatar
Julian Rother committed
297
298
		changes = request.get_json().items()
	for key, val in changes:
299
300
301
		if key in ignore:
			continue
		key = prefix+key
302
		table, id, column = key.split('.', 2)
Julian Rother's avatar
Julian Rother committed
303
304
		assert table in tabs
		assert column in tabs[table][2]
305
306
		modify('INSERT INTO changelog (`table`,id_value, id_key, field, value_new, value_old, `when`, who, executed) VALUES (?,?,?,?,?,(SELECT %s FROM %s WHERE %s = ?),?,?,1)'%(column, tabs[table][0], tabs[table][1]),
				table, id, tabs[table][1], column, val, id, datetime.now(), session['user']['dbid'])
Julian Rother's avatar
Julian Rother committed
307
308
		modify('UPDATE %s SET %s = ?, time_updated = ? WHERE %s = ?'%(tabs[table][0], column, tabs[table][1]), val, datetime.now(), id)
	modify('COMMIT')
309
310
	if 'ref' in request.values:
		return redirect(request.values['ref'])
311
	return "OK", 200
Julian Rother's avatar
Julian Rother committed
312

313
@app.route('/new/<table>', methods=['GET', 'POST'])
314
@mod_required
315
316
def create(table):
	assert table in tabs
317
318
319
320
321
322
323
	defaults = {'created_by': session['user']['dbid'], 'time_created': datetime.now(), 'time_updated': datetime.now()}
	columns = []
	values = []
	for column, val in defaults.items():
		if column in tabs[table][3]:
			columns.append(column)
			values.append(val)
324
325
326
327
328
329
	args = request.values
	if request.is_json:
		args = request.get_json()
	for column, val in args.items():
		if column == 'ref':
			continue
330
331
		assert column in tabs[table][2]+tabs[table][3]
		assert column not in defaults
332
333
334
335
		columns.append(column)
		values.append(val)
	id = modify('INSERT INTO %s (%s) VALUES (%s)'%(tabs[table][0],
				','.join(columns), ','.join(['?']*len(values))), *values)
336
337
338
339
	if 'ref' in request.values:
		return redirect(request.values['ref'])
	return str(id), 200

340
341
342
343
344
@app.route('/auth')
def auth(): # For use with nginx auth_request
	if 'X-Original-Uri' not in request.headers:
		return 'Internal Server Error', 500
	url = request.headers['X-Original-Uri'].lstrip(config['VIDEOPREFIX'])
345
	ip = request.headers.get('X-Real-IP', '')
346
	if url.endswith('jpg'):
347
		return "OK", 200
348
	videos = query('''SELECT videos.path, videos.id, lectures.id AS lecture_id, courses.id AS course_id, auth.*
349
350
351
      FROM videos
      JOIN lectures ON (videos.lecture_id = lectures.id)
      JOIN courses ON (lectures.course_id = courses.id)
352
			LEFT JOIN auth ON (videos.id = auth.video_id OR lectures.id = auth.lecture_id OR courses.id = auth.course_id)
353
354
      WHERE videos.path = ?
      AND (? OR (courses.visible AND lectures.visible AND videos.visible))
355
			ORDER BY auth.video_id DESC, auth.lecture_id DESC, auth.course_id DESC''',
356
357
			url, ismod())
	if not videos:
358
		return "Not allowed", 403
359
360
361
362
	allowed = False
	types = []
	auth = request.authorization
	for video in videos:
363
364
		if videos[0] and ((videos[0]['video_id'] and not video['video_id']) \
				or (videos[0]['lecture_id'] and not video['lecture_id'])):
365
366
367
368
369
370
371
372
373
			break
		types.append(video['auth_type'])
		if video['auth_type'] == 'public':
			allowed = True
			break
		elif video['auth_type'] == 'password':
			if auth and video['auth_user'] == auth.username and video['auth_passwd'] == auth.password:
				allowed = True
				break
374
375
	if not types[0] or allowed or ismod() or \
			(auth and check_mod(*ldapauth(auth.username, auth.password))):
376
		return 'OK', 200
Julian Rother's avatar
Julian Rother committed
377
		modify('INSERT INTO log VALUES (?, "", ?, "video", ?, ?)', ip, datetime.now(), videos[0]['id'], url)
378
379
380
	elif 'password' in types:
		return Response("Login required", 401, {'WWW-Authenticate': 'Basic realm="Login Required"'})
	return "Not allowed", 403
Andreas Valder's avatar
Andreas Valder committed
381

Andreas Valder's avatar
Andreas Valder committed
382
@app.route('/stats')
Andreas Valder's avatar
Andreas Valder committed
383
@register_navbar('Statistiken', icon='stats')
Andreas Valder's avatar
Andreas Valder committed
384
385
386
@mod_required
def stats():
	return render_template('stats.html')
Andreas Valder's avatar
Andreas Valder committed
387

Andreas Valder's avatar
Andreas Valder committed
388
@app.route('/changelog')
Andreas Valder's avatar
Andreas Valder committed
389
@register_navbar('Changelog', icon='book')
Andreas Valder's avatar
Andreas Valder committed
390
@mod_required
391
def changelog():
392
393
394
	changelog = query('SELECT * FROM changelog LEFT JOIN users ON (changelog.who = users.id) ORDER BY `when` DESC LIMIT 50')
	for entry in changelog:
		entry['path'] = '.'.join([entry['table'], entry['id_value'], entry['field']])
395
	return render_template('changelog.html', changelog=changelog)
Andreas Valder's avatar
Andreas Valder committed
396

Julian Rother's avatar
Julian Rother committed
397
398
399
400
@app.route('/files/<filename>')
def files(filename):
	return redirect(config['VIDEOPREFIX']+'/'+filename)

401
402
403
404
405
@app.route('/newchapter/<int:lectureid>', methods=['POST', 'GET'])
def suggest_chapter(lectureid):
	time = request.values['time']
	text = request.values['text']
	assert(time and text)
406
407
408
409
410
411
412
	try:
		x = datetime.strptime(time,'%H:%M:%S')
		time= timedelta(hours=x.hour,minutes=x.minute,seconds=x.second).total_seconds()
		time = int(time)
	except ValueError:
		flash('Falsches Zeitformat, "%H:%M:%S" wird erwartet. Z.B. "01:39:42" für eine Kapitel bei Stunde 1, Minute 39, Sekunde 42')
		
413
414
415
	submitter = None
	if not ismod():
		submitter = request.environ['REMOTE_ADDR']
Julian Rother's avatar
Julian Rother committed
416
	id = modify('INSERT INTO chapters (lecture_id, time, text, time_created, time_updated, created_by, submitted_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
417
418
419
420
421
				lectureid, time, text, datetime.now(), datetime.now(), session.get('user', {'dbid':None})['dbid'], submitter)
	if 'ref' in request.values:
		return redirect(request.values['ref'])
	return 'OK',  200

422
423
424
425
426
427
428
429
430
431
@app.route('/chapters/<int:lectureid>')
def chapters(lectureid):
	chapters = query("SELECT * FROM chapters WHERE lecture_id = ? and visible ORDER BY time DESC", lectureid)
	last = None
	for c in chapters:
		c['start'] = c['time']
		c['end'] = last['start'] if last else 9999
		last = c
	return Response(render_template('chapters.srt',chapters=chapters), 200, {'Content-Type':'text/vtt'})

Andreas Valder's avatar
Andreas Valder committed
432
@app.route('/sitemap.xml')
Andreas Valder's avatar
Andreas Valder committed
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
def sitemap():
	pages=[]
	# static pages
	for rule in app.url_map.iter_rules():
		if 'GET' in rule.methods and len(rule.arguments)==0:
			if rule.endpoint not in mod_endpoints:
				pages.append([rule.rule])
	for i in query('select * from courses where visible and listed'):
		pages.append([url_for('course',handle=i['handle'])])
		for j in query('select * from lectures where (course_id = ? and visible)',i['id']):
			pages.append([url_for('lecture',id=j['id'])])


	return Response(render_template('sitemap.xml', pages=pages), 200, {'Content-Type': 'application/atom+xml'} )

Julian Rother's avatar
Julian Rother committed
448
import feeds
449
import importer
Andreas Valder's avatar
Andreas Valder committed
450
import schedule
Andreas Valder's avatar
Andreas Valder committed
451
import sorter