server.py 17.2 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
import time
12

13
app = Flask(__name__)
14

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

21
22
scheduler = sched.scheduler()
def run_scheduler():
23
	time.sleep(1) # UWSGI does weird things on startup
24
25
	while True:
		scheduler.run()
26

27
28
29
30
31
32
33
34
35
36
37
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()
38

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

Julian Rother's avatar
Julian Rother committed
50
from db import query, modify, searchquery, ldapauth, ldapget
Julian Rother's avatar
Julian Rother committed
51

52
mod_endpoints = []
Julian Rother's avatar
Julian Rother committed
53

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

def mod_required(func):
59
	mod_endpoints.append(func.__name__)
60
61
	@wraps(func)
	def decorator(*args, **kwargs):
62
		if not ismod():
63
64
65
66
67
68
			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

69
app.jinja_env.globals['navbar'] = []
70
71
72
73
74
# 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):
75
	def wrapper(func):
76
		endpoint = func.__name__
77
		app.jinja_env.globals['navbar'].append((endpoint, name, iconlib, icon, not endpoint in mod_endpoints))
78
79
80
		return func
	return wrapper

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

88
89
90
91
92
93
94
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
95
96
97
98
				if endpoint:
					return render_endpoint(endpoint, text, **epargs), code
				else:
					return text, code
99
100
101
		return decorator
	return wrapper

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

Julian Rother's avatar
Julian Rother committed
106
@app.template_filter(name='semester')
107
108
109
110
111
112
113
114
115
116
117
118
119
120
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
121
122
123

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

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

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

134
135
@app.template_global()
def get_announcements(minlevel=0):
136
137
	offset = timedelta()
	if ismod():
138
		offset = timedelta(hours=24)
139
	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)
140

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

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

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

183
184
@app.route('/course/<handle>')
@app.route('/course/<int:id>')
185
@handle_errors('courses', 'Diese Veranstaltung existiert nicht!', 404, IndexError)
186
187
def course(id=None, handle=None):
	if id:
188
		course = query('SELECT * FROM courses WHERE id = ? AND (? OR visible)', id, ismod())[0]
189
	else:
190
		course = query('SELECT * FROM courses WHERE handle = ? AND (? OR visible)', handle, ismod())[0]
191
192
193
	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())
194
195
196
197
198
	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
199
	videos = query('''
200
			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
201
202
203
204
205
206
			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
207
208
			''', course['id'], ismod())
	return render_template('course.html', course=course, lectures=lectures, videos=videos)
Andreas Valder's avatar
Andreas Valder committed
209

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

215
@app.route('/play/<int:id>')
Andreas Valder's avatar
Andreas Valder committed
216
@app.route('/embed/<int:id>', endpoint='embed')
217
@handle_errors('course', 'Diese Vorlesung existiert nicht!', 404, IndexError)
218
def lecture(id):
219
	lectures = query('SELECT * FROM lectures WHERE id = ? AND (? OR visible)', id, ismod())
220
	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())
221
222
223
224
	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:
225
		return render_endpoint('course', 'Diese Veranstaltung existiert nicht!'), 404
226
	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
227
228
	return render_template('embed.html' if request.endpoint == 'embed' else 'lecture.html', course=courses[0], lecture=lectures[0], videos=videos, chapters=chapters)

229
230
231
232
233
234
235

@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'],
236
			'courses', 'WHERE (? OR (visible AND listed)) GROUP BY id ORDER BY _score DESC, semester DESC LIMIT 20', ismod())
237
	lectures = searchquery(q, 'lectures.*, courses.visible AS coursevisible, courses.listed, "course" AS sep, courses.*',
238
239
			['lectures.title', 'lectures.comment', 'lectures.speaker', 'courses.short'],
			'lectures LEFT JOIN courses on (courses.id = lectures.course_id)',
240
			'WHERE (? OR (coursevisible AND listed AND visible)) GROUP BY id ORDER BY _score DESC, time DESC LIMIT 30', ismod())
241
	return render_template('search.html', searchtext=request.args['q'], courses=courses, lectures=lectures)
Andreas Valder's avatar
Andreas Valder committed
242

243
244
245
def check_mod(user, groups):
	return user and 'users' in groups

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

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

267
268
269
tabs = {
	'courses': ('courses_data', 'id', ['visible', 'listed', 'title', 'short',
			'handle', 'organizer', 'subject', 'semester', 'downloadable',
270
271
			'internal', 'responsible','deleted'],
			['created_by', 'time_created', 'time_updated']),
272
	'lectures': ('lectures_data', 'id', ['visible', 'title', 'comment',
273
274
275
276
277
278
279
280
281
282
			'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'],
283
284
			['created_by', 'time_created', 'time_updated']),
	'auth': ('auth', 'auth_id', ['auth_type', 'auth_user', 'auth_passwd'],
285
			['course_id', 'lecture_id', 'video_id'])
286
287
}

288
@app.route('/edit', methods=['GET', 'POST'])
289
@mod_required
290
def edit(prefix='', ignore=[]):
291
	# All editable tables are expected to have a 'time_updated' field
292
	ignore.append('ref')
293
294
295
	ignore.append('prefix')
	if not prefix and 'prefix' in request.args:
		prefix = request.args['prefix']
Julian Rother's avatar
Julian Rother committed
296
	modify('BEGIN')
297
	changes = request.values.items()
298
	if request.is_json:
Julian Rother's avatar
Julian Rother committed
299
300
		changes = request.get_json().items()
	for key, val in changes:
301
302
303
		if key in ignore:
			continue
		key = prefix+key
304
		table, id, column = key.split('.', 2)
Julian Rother's avatar
Julian Rother committed
305
306
		assert table in tabs
		assert column in tabs[table][2]
307
308
		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
309
310
		modify('UPDATE %s SET %s = ?, time_updated = ? WHERE %s = ?'%(tabs[table][0], column, tabs[table][1]), val, datetime.now(), id)
	modify('COMMIT')
311
312
	if 'ref' in request.values:
		return redirect(request.values['ref'])
313
	return "OK", 200
Julian Rother's avatar
Julian Rother committed
314

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

342
343
344
345
346
@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'])
347
	ip = request.headers.get('X-Real-IP', '')
348
	if url.endswith('jpg'):
349
		return "OK", 200
350
	videos = query('''SELECT videos.path, videos.id, lectures.id AS lecture_id, courses.id AS course_id, auth.*
351
352
353
      FROM videos
      JOIN lectures ON (videos.lecture_id = lectures.id)
      JOIN courses ON (lectures.course_id = courses.id)
354
			LEFT JOIN auth ON (videos.id = auth.video_id OR lectures.id = auth.lecture_id OR courses.id = auth.course_id)
355
356
      WHERE videos.path = ?
      AND (? OR (courses.visible AND lectures.visible AND videos.visible))
357
			ORDER BY auth.video_id DESC, auth.lecture_id DESC, auth.course_id DESC''',
358
359
			url, ismod())
	if not videos:
360
		return "Not allowed", 403
361
362
363
364
	allowed = False
	types = []
	auth = request.authorization
	for video in videos:
365
366
		if videos[0] and ((videos[0]['video_id'] and not video['video_id']) \
				or (videos[0]['lecture_id'] and not video['lecture_id'])):
367
368
369
370
371
372
373
374
375
			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
376
377
	if not types[0] or allowed or ismod() or \
			(auth and check_mod(*ldapauth(auth.username, auth.password))):
378
		return 'OK', 200
Julian Rother's avatar
Julian Rother committed
379
		modify('INSERT INTO log VALUES (?, "", ?, "video", ?, ?)', ip, datetime.now(), videos[0]['id'], url)
380
381
382
	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
383

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

Andreas Valder's avatar
Andreas Valder committed
390
@app.route('/changelog')
Andreas Valder's avatar
Andreas Valder committed
391
@register_navbar('Changelog', icon='book')
Andreas Valder's avatar
Andreas Valder committed
392
@mod_required
393
def changelog():
394
395
396
	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']])
397
	return render_template('changelog.html', changelog=changelog)
Andreas Valder's avatar
Andreas Valder committed
398

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

403
404
405
406
407
@app.route('/newchapter/<int:lectureid>', methods=['POST', 'GET'])
def suggest_chapter(lectureid):
	time = request.values['time']
	text = request.values['text']
	assert(time and text)
408
409
410
411
412
413
414
	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')
		
415
416
417
	submitter = None
	if not ismod():
		submitter = request.environ['REMOTE_ADDR']
Julian Rother's avatar
Julian Rother committed
418
	id = modify('INSERT INTO chapters (lecture_id, time, text, time_created, time_updated, created_by, submitted_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
419
420
421
422
423
				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

424
425
426
427
428
429
430
431
432
433
@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
434
@app.route('/sitemap.xml')
Andreas Valder's avatar
Andreas Valder committed
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
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
450
import feeds
451
import importer
Andreas Valder's avatar
Andreas Valder committed
452
import schedule
Andreas Valder's avatar
Andreas Valder committed
453
import sorter
454
455
if 'ICAL_URL' in config:
	import meetings