server.py 17.3 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
def sched_func(delay, priority=0, firstdelay=None, args=[], kargs={}):
	if firstdelay == None:
		firstdelay = random.randint(1, 120)
30
31
32
33
34
	def wrapper(func):
		def sched_wrapper():
			with app.test_request_context():
				func(*args, *kargs)
			scheduler.enter(delay, priority, sched_wrapper)
35
		scheduler.enter(firstdelay, priority, sched_wrapper)
36
37
38
39
		return func
	return wrapper

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

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

Julian Rother's avatar
Julian Rother committed
52
from db import query, modify, searchquery, ldapauth, ldapget
Julian Rother's avatar
Julian Rother committed
53

54
mod_endpoints = []
Julian Rother's avatar
Julian Rother committed
55

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

231
232
233
234
235
236
237

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

245
246
247
def check_mod(user, groups):
	return user and 'users' in groups

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

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

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

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

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

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

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

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

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

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

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