diff --git a/config.py.example b/config.py.example index a9dfd82df9beedb642444a3d7f948aca7b16f954..186b638439da3da382799862eb27ab9d449e4038 100644 --- a/config.py.example +++ b/config.py.example @@ -29,3 +29,4 @@ LDAP_GROUPS = ['users'] #ICAL_URL = 'https://user:password@mail.fsmpi.rwth-aachen.de/SOGo/....ics' ERROR_PAGE = 'static/500.html' RWTH_IP_RANGES = ['134.130.0.0/16', '137.226.0.0/16', '134.61.0.0/16', '192.35.229.0/24', '2a00:8a60::/32'] +FSMPI_IP_RANGES = ['137.226.35.192/29', '137.226.75.0/27', '137.226.127.32/27', '137.226.231.192/26', '134.130.102.0/26' ] diff --git a/db.py b/db.py index 53523bde8f7512ed8b0761f1eb32dc202c06819e..866d3d1d77e280931912085d680c9f39954a6c39 100644 --- a/db.py +++ b/db.py @@ -13,7 +13,7 @@ if config['DB_ENGINE'] == 'sqlite': hours, minutes, seconds = map(int, timepart_full[0].split(b":")) val = datetime(year, month, day, hours, minutes, seconds, 0) except ValueError: - val = None + val = datetime.fromtimestamp(0) return val sqlite3.register_converter('datetime', convert_timestamp) diff --git a/edit.py b/edit.py index cfd7d1e7debcc85dbd8ef8f29bf259e7def2296d..556b2ccdd56e93f60e0f87dc1a825a6531d82a1f 100644 --- a/edit.py +++ b/edit.py @@ -14,19 +14,19 @@ editable_tables = { 'idcolumn': 'id', 'editable_fields': { 'visible': {'type': 'boolean'}, - 'listed': {'type': 'boolean'}, + 'listed': {'type': 'boolean', 'description': 'Soll die Veranstaltung auf der Hauptseite gelistet werden?'}, 'title': {'type': 'shortstring'}, - 'short': {'type': 'shortstring'}, + 'short': {'type': 'shortstring', 'description': 'Abkürzung für die Veranstaltung, z.B. für den Drehplan'}, 'handle': {'type': 'shortstring'}, 'organizer': {'type': 'shortstring'}, 'subject': {'type': 'shortstring'}, 'semester': {'type': 'shortstring'}, - 'downloadable': {'type': 'boolean'}, + 'downloadable': {'type': 'boolean', 'description': 'Hiermit kann der Download-Button disabled werden'}, 'internal': {'type': 'text'}, 'responsible': {'type': 'shortstring'}, 'deleted': {'type': 'boolean'}, 'description': {'type': 'text'}, - 'external': {'type': 'boolean'}}, + 'external': {'type': 'boolean', 'description': 'Soll die Veranstaltung nicht im Drehplan angezeigt werden?'}}, 'creationtime_fields': ['created_by', 'time_created', 'time_updated'] }, 'lectures': { 'table': 'lectures_data', @@ -42,7 +42,7 @@ editable_tables = { 'duration': {'type': 'duration'}, 'jumplist': {'type': ''}, 'deleted': {'type': 'boolean'}, - 'live': {'type': 'boolean'}, + 'live': {'type': 'boolean', 'description': 'Ist ein Livestream geplant?'}, 'norecording': {'type': 'boolean'}}, 'creationtime_fields': ['course_id', 'time_created', 'time_updated'] }, 'videos': { @@ -110,6 +110,13 @@ def parseeditpath(path): assert column in editable_tables[table]['editable_fields'] type = editable_tables[table]['editable_fields'][column]['type'] return {'table': table, 'id': id, 'column': column, 'type': type, 'tableinfo': editable_tables[table]} +@app.template_filter(name='getfielddescription') +def getfielddescription(path): + p = parseeditpath(path) + desc = p['tableinfo']['editable_fields'][p['column']].get('description', '') + if desc != '': + desc = '<br>'+desc + return desc @app.route('/internal/edit', methods=['GET', 'POST']) @mod_required diff --git a/icalexport.py b/icalexport.py new file mode 100644 index 0000000000000000000000000000000000000000..8ab175842f0ccc80c3464213a34ff817aeb88027 --- /dev/null +++ b/icalexport.py @@ -0,0 +1,57 @@ +from server import * +import icalendar +from werkzeug.datastructures import Headers +from datetime import timedelta, datetime + +def export_lectures(lectures, name): + cal = icalendar.Calendar() + cal.add('prodid', '-//Video AG//rwth.video//') + cal.add('version', '1.0') + for l in lectures: + event = icalendar.Event() + event.add('summary', l['course']['short']+': '+l['title']) + event.add('description', '\n\n'.join([s for s in [ + l['comment'], + l['internal'], + 'Zuständig: '+l['course']['responsible'] if l['course']['responsible'] else '' + ] if s])) + event.add('uid', '%i@rwth.video'%l['id']) + event.add('dtstamp', datetime.utcnow()) + event.add('categories', l['course']['short']) + event.add('dtstart', l['time']) + event.add('location', l['place']) + event.add('dtend', l['time'] + timedelta(minutes=l['duration'])) + cal.add_component(event) + h = Headers() + h.add_header("Content-Disposition", "inline", filename=name) + return Response(cal.to_ical(), mimetype="text/calendar", headers=h) + +def calperm(func): + @wraps(func) + def decorator(*args, **kwargs): + permission = ismod() + if 'X-Real-IP' in request.headers: + ip = ip_address(request.headers['X-Real-IP']) + for net in config['FSMPI_IP_RANGES']: + if ip in ip_network(net): + permission = True + if permission: + return func(*args, **kwargs) + else: + flash('Diese Funktion ist nur aus dem FSMPI-Netz(für SOGO-Import) oder eingeloggt verfügbar!') + return redirect(url_for('index')) + return decorator + +@app.route('/internal/ical/all') +@calperm +def ical_all(): + return export_lectures(query('''SELECT lectures.*, "course" AS sep, courses.* + FROM lectures JOIN courses ON courses.id = lectures.course_id + WHERE NOT norecording AND NOT external ORDER BY time DESC LIMIT 1000'''),'videoag_all.ics') + +@app.route('/internal/ical/course/<course>') +@calperm +def ical_course(course): + return export_lectures(query('''SELECT lectures.*, "course" AS sep, courses.* + FROM lectures JOIN courses ON courses.id = lectures.course_id + WHERE courses.handle = ? AND NOT norecording AND NOT external ORDER BY time DESC''', course),'videoag_course_'+course+'.ics') diff --git a/server.py b/server.py index 7503ead60ecf03487bb4109c9c0f9efcce56b0fe..1631071f941a81499b1444d25fe8ccda7d9f8d9f 100644 --- a/server.py +++ b/server.py @@ -404,7 +404,7 @@ def course(id=None, handle=None): if perm['lecture_id'] == lecture['id']: lecture['perm'].append(perm) videos = query(''' - SELECT videos.*, (videos.downloadable AND courses.downloadable) as downloadable, formats.description AS format_description, formats.player_prio, formats.prio + SELECT videos.*, (videos.downloadable AND courses.downloadable) as downloadable, "formats" AS sep, formats.* FROM videos JOIN lectures ON (videos.lecture_id = lectures.id) JOIN formats ON (videos.video_format = formats.id) @@ -412,7 +412,7 @@ def course(id=None, handle=None): WHERE lectures.course_id= ? AND (? OR videos.visible) ORDER BY lectures.time, formats.prio DESC ''', course['id'], ismod()) - livestreams = query('''SELECT streams.handle AS livehandle, streams.lecture_id, formats.description AS format_description, formats.player_prio, formats.prio + livestreams = query('''SELECT streams.handle AS livehandle, streams.lecture_id, "formats" AS sep, formats.* FROM streams JOIN lectures ON lectures.id = streams.lecture_id JOIN formats ON formats.keywords = "hls" @@ -434,14 +434,14 @@ def faq(): def lecture(id, course=None, courseid=None): lecture = query('SELECT * FROM lectures WHERE id = ? AND (? OR visible)', id, ismod())[0] videos = query(''' - SELECT videos.*, (videos.downloadable AND courses.downloadable) as downloadable, formats.description AS format_description, formats.player_prio, formats.prio, formats.mimetype + SELECT videos.*, (videos.downloadable AND courses.downloadable) as downloadable, "formats" AS sep, formats.* FROM videos JOIN formats ON (videos.video_format = formats.id) JOIN courses ON (courses.id = ?) WHERE videos.lecture_id = ? AND (? OR videos.visible) ORDER BY formats.prio DESC ''', lecture['course_id'], lecture['id'], ismod()) - livestreams = query('''SELECT streams.handle AS livehandle, streams.lecture_id, formats.description AS format_description, formats.player_prio, formats.prio, formats.mimetype + livestreams = query('''SELECT streams.handle AS livehandle, streams.lecture_id, "formats" AS sep, formats.* FROM streams JOIN lectures ON lectures.id = streams.lecture_id JOIN formats ON formats.keywords = "hls" @@ -687,3 +687,4 @@ if 'JOBS_API_KEY' in config: import jobs import timetable import chapters +import icalexport diff --git a/static/moderator.js b/static/moderator.js index ed9cb376f85ca7512b3d98e60bbdfcec5afc1bd6..dec55f9f690ebb1d4d7858d2a8f222e1cab98342 100644 --- a/static/moderator.js +++ b/static/moderator.js @@ -191,8 +191,11 @@ var moderator = { html += '<option value="none">Kein Zugriff</option>'; html += '</select>'; html += '<input class="col-xs-12 passwordinput authuser" type="text" placeholder="Benutzername">'; - html += '<input class="col-xs-12 passwordinput authpassword" type="text" placeholder="Passwort">'; + html += '<input class="col-xs-10 passwordinput authpassword" type="text" placeholder="Passwort">' + html += '<button class="col-xs-2 passwordinput authpgen" type="button" onclick="$(\'.authpassword\',this.parentNode).val(moderator.permissioneditor.randompw());"><span class="fa fa-plus" aria-hidden="true"></span></button>' + html += '<input class="col-xs-12 authl2p" type="text" placeholder="Lernraum" style="display: none;">'; + html += '<button class="col-xs-6" onclick="moderator.permissioneditor.addbtnclick(this)">Add</button>'; //html += '<button class="col-xs-4" onclick="moderator.permissionedior.updatebtnclick(this)">Update</button>'; html += '<button class="col-xs-6" onclick="moderator.permissioneditor.delbtnclick(this)">Delete</button>'; @@ -248,58 +251,76 @@ var moderator = { $(".authl2p",element.parentElement).hide(); break; } + }, + randompw: function () {; + var array = new Uint8Array(10); + window.crypto.getRandomValues(array); + var result = ''; + for (var i = 0; i< array.length; i++) { + result += String.fromCharCode(48+ (array[i]/255.0)*74); + } + return result; } }, + plots: { + init: function() { + $(window).on("resize", moderator.plots.resize); + $(".plotlyresize").on("click", moderator.plots.resize); + moderator.plots.createplots(".plot-view") + }, + resize: function() { + $(".plot-view").each(function () {Plotly.Plots.resize(this)}); + }, + createplots: function (selector) { + var l = $(selector); + for (var i = 0; i < l.length; i ++) { + if (!l[i].id) + l[i].id = "plot-"+i; + $(l[i]).html('<div class="plot-loader"></div>'); + $.ajax({ + divobj: l[i], + method: "GET", + url: l[i].dataset.url, + dataType: "json", + error: function (jqXHR, textStatus, errorThrow) { + $(this.divobj).html('<div class="plot-error">'+errorThrow+'</div>'); + }, + success: function (traces) { + var layout = {margin: {l: 30, r: 30, t: 10, b: 70, pad: 0}}; + for (var i = 0; i < traces.length; i ++) { + traces[i].type = this.divobj.dataset.type; + } + if (this.divobj.dataset.type == "pie") + layout.showlegend = false; + traces.sort(function (a, b) { + asum = 0; + bsum = 0; + for (var i = 0; i < a.y.length; i++) + asum += a.y[i] + for (var i = 0; i < b.y.length; i++) + bsum += b.y[i] + return bsum-asum; + }); + for (var i = 0; i < traces.length; i++) { + if (i > 15) { + traces[i].visible = "legendonly"; + } + } + $(this.divobj).html(""); + Plotly.newPlot(this.divobj.id, traces, layout, { "modeBarButtonsToRemove": ['sendDataToCloud','hoverCompareCartesian'], "displaylogo": false}); + } + }); + }; + }, + }, init: function () { moderator.api.init(); moderator.editor.init(); moderator.permissioneditor.init(); + moderator.plots.init(); } }; $( document ).ready( function () { moderator.init(); } ); - -$( document ).ready( function () { - var l = $(".plot-view"); - for (var i = 0; i < l.length; i ++) { - if (!l[i].id) - l[i].id = "plot-"+i; - $(l[i]).html('<div class="plot-loader"></div>'); - $.ajax({ - divobj: l[i], - method: "GET", - url: l[i].dataset.url, - dataType: "json", - error: function (jqXHR, textStatus, errorThrow) { - $(this.divobj).html('<div class="plot-error">'+errorThrow+'</div>'); - }, - success: function (traces) { - var layout = {margin: {l: 30, r: 30, t: 10, b: 70, pad: 0}}; - for (var i = 0; i < traces.length; i ++) { - traces[i].type = this.divobj.dataset.type; - } - if (this.divobj.dataset.type == "pie") - layout.showlegend = false; - traces.sort(function (a, b) { - asum = 0; - bsum = 0; - for (var i = 0; i < a.y.length; i++) - asum += a.y[i] - for (var i = 0; i < b.y.length; i++) - bsum += b.y[i] - return bsum-asum; - }); - for (var i = 0; i < traces.length; i++) - if (i > 20) - traces[i].visible = "legendonly"; - $(this.divobj).html(""); - Plotly.newPlot(this.divobj.id, traces, layout, { "modeBarButtonsToRemove": ['sendDataToCloud','hoverCompareCartesian'], "displaylogo": false}); - } - }); - }; -}); -$(window).on("resize", function () { - $(".plot-view").each(function () {Plotly.Plots.resize(this)}); -}); diff --git a/static/style.css b/static/style.css index 31e977c77aad7904a4ce30f76b702eb268314c0b..870481be8d90ca6103f983ccc4c05f8197dc46c1 100644 --- a/static/style.css +++ b/static/style.css @@ -10,6 +10,7 @@ font-size: 3em; text-align: center; line-height: 130px; + text-shadow: 0 0 2px black; } #timetable.table-bordered td:first-child { diff --git a/static/videojs/videojs-resolution-switcher.js b/static/videojs/videojs-resolution-switcher.js index eae1c38b579bd397135fdd092f85147b0b26bfdb..fc3647685c5bdcfd5c7c4e093b74774bc3ed887a 100644 --- a/static/videojs/videojs-resolution-switcher.js +++ b/static/videojs/videojs-resolution-switcher.js @@ -1,367 +1,406 @@ -/*! videojs-resolution-switcher - 2015-7-26 +/*! videojs-resolution-switcher - 2017-05-20 * Copyright (c) 2016 Kasper Moskwiak - * Modified by Pierre Kraft and Derk-Jan Hartman + * Modified by Pierre Kraft, Derk-Jan Hartman and Andreas Valder * Licensed under the Apache-2.0 license. */ (function() { - /* jshint eqnull: true*/ - /* global require */ - 'use strict'; - var videojs = null; - if(typeof window.videojs === 'undefined' && typeof require === 'function') { - videojs = require('video.js'); - } else { - videojs = window.videojs; - } - - (function(window, videojs) { - var videoJsResolutionSwitcher, - defaults = { - ui: true - }; - - /* - * Resolution menu item - */ - var MenuItem = videojs.getComponent('MenuItem'); - var ResolutionMenuItem = videojs.extend(MenuItem, { - constructor: function(player, options){ - options.selectable = true; - // Sets this.player_, this.options_ and initializes the component - MenuItem.call(this, player, options); - this.src = options.src; - - player.on('resolutionchange', videojs.bind(this, this.update)); - } - } ); - ResolutionMenuItem.prototype.handleClick = function(event){ - MenuItem.prototype.handleClick.call(this,event); - this.player_.currentResolution(this.options_.label); - }; - ResolutionMenuItem.prototype.update = function(){ - var selection = this.player_.currentResolution(); - this.selected(this.options_.label === selection.label); - }; - MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem); - - /* - * Resolution menu button - */ - var MenuButton = videojs.getComponent('MenuButton'); - var ResolutionMenuButton = videojs.extend(MenuButton, { - constructor: function(player, options){ - this.label = document.createElement('span'); - options.label = 'Quality'; - // Sets this.player_, this.options_ and initializes the component - MenuButton.call(this, player, options); - this.el().setAttribute('aria-label','Quality'); - this.controlText('Quality'); - - if(options.dynamicLabel){ - videojs.addClass(this.label, 'vjs-resolution-button-label'); - this.el().appendChild(this.label); - }else{ - var staticLabel = document.createElement('span'); - videojs.addClass(staticLabel, 'vjs-menu-icon'); - this.el().appendChild(staticLabel); - } - player.on('updateSources', videojs.bind( this, this.update ) ); - } - } ); - ResolutionMenuButton.prototype.createItems = function(){ - var menuItems = []; - var labels = (this.sources && this.sources.label) || {}; - - // FIXME order is not guaranteed here. - for (var key in labels) { - if (labels.hasOwnProperty(key)) { - menuItems.push(new ResolutionMenuItem( - this.player_, - { - label: key, - src: labels[key], - selected: key === (this.currentSelection ? this.currentSelection.label : false) - }) - ); - } - } - return menuItems; - }; - ResolutionMenuButton.prototype.update = function(){ - this.sources = this.player_.getGroupedSrc(); - this.currentSelection = this.player_.currentResolution(); - this.label.innerHTML = this.currentSelection ? this.currentSelection.label : ''; - return MenuButton.prototype.update.call(this); - }; - ResolutionMenuButton.prototype.buildCSSClass = function(){ - return MenuButton.prototype.buildCSSClass.call( this ) + ' vjs-resolution-button'; - }; - MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton); - - /** - * Initialize the plugin. - * @param {object} [options] configuration for the plugin - */ - videoJsResolutionSwitcher = function(options) { - var settings = videojs.mergeOptions(defaults, options), - player = this, - groupedSrc = {}, - currentSources = {}, - currentResolutionState = {}; - - /** - * Updates player sources or returns current source URL - * @param {Array} [src] array of sources [{src: '', type: '', label: '', res: ''}] - * @returns {Object|String|Array} videojs player object if used as setter or current source URL, object, or array of sources - */ - player.updateSrc = function(src){ - //Return current src if src is not given - if(!src){ return player.src(); } - - // Only add those sources which we can (maybe) play - src = src.filter( function(source) { - try { - return ( player.canPlayType( source.type ) !== '' ); - } catch (e) { - // If a Tech doesn't yet have canPlayType just add it - return true; - } - }); - //Sort sources - this.currentSources = src.sort(compareResolutions); - this.groupedSrc = bucketSources(this.currentSources); - // Pick one by default - var chosen = chooseSrc(this.groupedSrc, this.currentSources); - this.currentResolutionState = { - label: chosen.label, - sources: chosen.sources - }; - - player.trigger('updateSources'); - player.setSourcesSanitized(chosen.sources, chosen.label); - player.trigger('resolutionchange'); - return player; - }; - - /** - * Returns current resolution or sets one when label is specified - * @param {String} [label] label name - * @param {Function} [customSourcePicker] custom function to choose source. Takes 2 arguments: sources, label. Must return player object. - * @returns {Object} current resolution object {label: '', sources: []} if used as getter or player object if used as setter - */ - player.currentResolution = function(label, customSourcePicker){ - if(label == null) { return this.currentResolutionState; } - - // Lookup sources for label - if(!this.groupedSrc || !this.groupedSrc.label || !this.groupedSrc.label[label]){ - return; - } - var sources = this.groupedSrc.label[label]; - // Remember player state - var currentTime = player.currentTime(); - var isPaused = player.paused(); - - // Hide bigPlayButton - if(!isPaused && this.player_.options_.bigPlayButton){ - this.player_.bigPlayButton.hide(); - } - - // Change player source and wait for loadeddata event, then play video - // loadedmetadata doesn't work right now for flash. - // Probably because of https://github.com/videojs/video-js-swf/issues/124 - // If player preload is 'none' and then loadeddata not fired. So, we need timeupdate event for seek handle (timeupdate doesn't work properly with flash) - var handleSeekEvent = 'loadeddata'; - if(this.player_.techName_ !== 'Youtube' && this.player_.preload() === 'none' && this.player_.techName_ !== 'Flash') { - handleSeekEvent = 'timeupdate'; - } - player - .setSourcesSanitized(sources, label, customSourcePicker || settings.customSourcePicker) - .one(handleSeekEvent, function() { - player.currentTime(currentTime); - player.handleTechSeeked_(); - if(!isPaused){ - // Start playing and hide loadingSpinner (flash issue ?) - player.play().handleTechSeeked_(); - } - player.trigger('resolutionchange'); - }); - return player; - }; - - /** - * Returns grouped sources by label, resolution and type - * @returns {Object} grouped sources: { label: { key: [] }, res: { key: [] }, type: { key: [] } } - */ - player.getGroupedSrc = function(){ - return this.groupedSrc; - }; - - player.setSourcesSanitized = function(sources, label, customSourcePicker) { - this.currentResolutionState = { - label: label, - sources: sources - }; - if(typeof customSourcePicker === 'function'){ - return customSourcePicker(player, sources, label); - } - player.src(sources.map(function(src) { - return {src: src.src, type: src.type, res: src.res}; - })); - return player; - }; - - /** - * Method used for sorting list of sources - * @param {Object} a - source object with res property - * @param {Object} b - source object with res property - * @returns {Number} result of comparation - */ - function compareResolutions(a, b){ - if(!a.res || !b.res){ return 0; } - return (+b.res)-(+a.res); - } - - /** - * Group sources by label, resolution and type - * @param {Array} src Array of sources - * @returns {Object} grouped sources: { label: { key: [] }, res: { key: [] }, type: { key: [] } } - */ - function bucketSources(src){ - var resolutions = { - label: {}, - res: {}, - type: {} - }; - src.map(function(source) { - initResolutionKey(resolutions, 'label', source); - initResolutionKey(resolutions, 'res', source); - initResolutionKey(resolutions, 'type', source); - - appendSourceToKey(resolutions, 'label', source); - appendSourceToKey(resolutions, 'res', source); - appendSourceToKey(resolutions, 'type', source); - }); - return resolutions; - } - - function initResolutionKey(resolutions, key, source) { - if(resolutions[key][source[key]] == null) { - resolutions[key][source[key]] = []; - } - } - - function appendSourceToKey(resolutions, key, source) { - resolutions[key][source[key]].push(source); - } - - /** - * Choose src if option.default is specified - * @param {Object} groupedSrc {res: { key: [] }} - * @param {Array} src Array of sources sorted by resolution used to find high and low res - * @returns {Object} {res: string, sources: []} - */ - function chooseSrc(groupedSrc, src){ - var selectedRes = settings['default']; // use array access as default is a reserved keyword - var selectedLabel = ''; - if (selectedRes === 'high') { - selectedRes = src[0].res; - selectedLabel = src[0].label; - } else if (selectedRes === 'low' || selectedRes == null || !groupedSrc.res[selectedRes]) { - // Select low-res if default is low or not set - selectedRes = src[src.length - 1].res; - selectedLabel = src[src.length -1].label; - } else if (groupedSrc.res[selectedRes]) { - selectedLabel = groupedSrc.res[selectedRes][0].label; - } - - return {res: selectedRes, label: selectedLabel, sources: groupedSrc.res[selectedRes]}; - } - - function initResolutionForYt(player){ - // Map youtube qualities names - var _yts = { - highres: {res: 1080, label: '1080', yt: 'highres'}, - hd1080: {res: 1080, label: '1080', yt: 'hd1080'}, - hd720: {res: 720, label: '720', yt: 'hd720'}, - large: {res: 480, label: '480', yt: 'large'}, - medium: {res: 360, label: '360', yt: 'medium'}, - small: {res: 240, label: '240', yt: 'small'}, - tiny: {res: 144, label: '144', yt: 'tiny'}, - auto: {res: 0, label: 'auto', yt: 'auto'} - }; - // Overwrite default sourcePicker function - var _customSourcePicker = function(_player, _sources, _label){ - // Note that setPlayebackQuality is a suggestion. YT does not always obey it. - player.tech_.ytPlayer.setPlaybackQuality(_sources[0]._yt); - player.trigger('updateSources'); - return player; - }; - settings.customSourcePicker = _customSourcePicker; - - // Init resolution - player.tech_.ytPlayer.setPlaybackQuality('auto'); - - // This is triggered when the resolution actually changes - player.tech_.ytPlayer.addEventListener('onPlaybackQualityChange', function(event){ - for(var res in _yts) { - if(res.yt === event.data) { - player.currentResolution(res.label, _customSourcePicker); - return; - } - } - }); - - // We must wait for play event - player.one('play', function(){ - var qualities = player.tech_.ytPlayer.getAvailableQualityLevels(); - var _sources = []; - - qualities.map(function(q){ - _sources.push({ - src: player.src().src, - type: player.src().type, - label: _yts[q].label, - res: _yts[q].res, - _yt: _yts[q].yt - }); - }); - - player.groupedSrc = bucketSources(_sources); - var chosen = {label: 'auto', res: 0, sources: player.groupedSrc.label.auto}; - - this.currentResolutionState = { - label: chosen.label, - sources: chosen.sources - }; - - player.trigger('updateSources'); - player.setSourcesSanitized(chosen.sources, chosen.label, _customSourcePicker); - }); - } - - player.ready(function(){ - if( settings.ui ) { - var menuButton = new ResolutionMenuButton(player, settings); - player.controlBar.resolutionSwitcher = player.controlBar.el_.insertBefore(menuButton.el_, player.controlBar.getChild('fullscreenToggle').el_); - player.controlBar.resolutionSwitcher.dispose = function(){ - this.parentNode.removeChild(this); - }; - } - if(player.options_.sources.length > 1){ - // tech: Html5 and Flash - // Create resolution switcher for videos form <source> tag inside <video> - player.updateSrc(player.options_.sources); - } - - if(player.techName_ === 'Youtube'){ - // tech: YouTube - initResolutionForYt(player); - } - }); - - }; - - // register the plugin - videojs.plugin('videoJsResolutionSwitcher', videoJsResolutionSwitcher); - })(window, videojs); + /* jshint eqnull: true*/ + /* global require */ + 'use strict'; + var videojs = null; + if(typeof window.videojs === 'undefined' && typeof require === 'function') { + videojs = require('video.js'); + } else { + videojs = window.videojs; + } + + (function(window, videojs) { + var videoJsResolutionSwitcher, + defaults = { + ui: true + }; + + /* + * Resolution menu item + */ + var MenuItem = videojs.getComponent('MenuItem'); + var ResolutionMenuItem = videojs.extend(MenuItem, { + constructor: function(player, options){ + options.selectable = true; + // Sets this.player_, this.options_ and initializes the component + MenuItem.call(this, player, options); + this.src = options.src; + + player.on('resolutionchange', videojs.bind(this, this.update)); + } + } ); + ResolutionMenuItem.prototype.handleClick = function(event){ + MenuItem.prototype.handleClick.call(this,event); + this.player_.currentResolution(this.options_.label); + }; + ResolutionMenuItem.prototype.update = function(){ + var selection = this.player_.currentResolution(); + this.selected(this.options_.label === selection.label); + }; + MenuItem.registerComponent('ResolutionMenuItem', ResolutionMenuItem); + + /* + * Resolution menu button + */ + var MenuButton = videojs.getComponent('MenuButton'); + var ResolutionMenuButton = videojs.extend(MenuButton, { + constructor: function(player, options){ + this.label = document.createElement('span'); + options.label = 'Quality'; + // Sets this.player_, this.options_ and initializes the component + MenuButton.call(this, player, options); + this.el().setAttribute('aria-label','Quality'); + this.controlText('Quality'); + + if(options.dynamicLabel){ + videojs.addClass(this.label, 'vjs-resolution-button-label'); + this.el().appendChild(this.label); + }else{ + var staticLabel = document.createElement('span'); + videojs.addClass(staticLabel, 'vjs-menu-icon'); + this.el().appendChild(staticLabel); + } + player.on('updateSources', videojs.bind( this, this.update ) ); + } + } ); + ResolutionMenuButton.prototype.createItems = function(){ + var menuItems = []; + + // one large hack to sort the labels + var labels = (this.sources && this.sources.label) || []; + var sortable = []; + for (var l in labels) { + sortable.push({'key': l, 'value': labels[l]}); + } + sortable.sort(function(a,b) { + var calcPixel = function(item) { + var exp = item.res.replace('x','*'); + if (exp != item.res) { + return eval(exp); + } else { + return 0; + } + }; + if (a.value[0].res && b.value[0].res) { + return calcPixel(a.value[0])-calcPixel(b.value[0]); + } + return 0; + }); + sortable.reverse(); + var labels = {} + for (var i=0; i<sortable.length; i++) { + if (! labels[sortable[i].key]) { + labels[sortable[i].key] = []; + } + for (var l=0; l<sortable[i].value.length; l++) { + labels[sortable[i].key].push(sortable[i].value[l]); + } + } + labels = labels || {}; + //hack ends here + + for (var key in labels) { + if (labels.hasOwnProperty(key)) { + menuItems.push(new ResolutionMenuItem( + this.player_, + { + label: key, + src: labels[key], + selected: key === (this.currentSelection ? this.currentSelection.label : false) + }) + ); + } + } + return menuItems; + }; + ResolutionMenuButton.prototype.update = function(){ + this.sources = this.player_.getGroupedSrc(); + this.currentSelection = this.player_.currentResolution(); + this.label.innerHTML = this.currentSelection ? this.currentSelection.label : ''; + return MenuButton.prototype.update.call(this); + }; + ResolutionMenuButton.prototype.buildCSSClass = function(){ + return MenuButton.prototype.buildCSSClass.call( this ) + ' vjs-resolution-button'; + }; + MenuButton.registerComponent('ResolutionMenuButton', ResolutionMenuButton); + + /** + * Initialize the plugin. + * @param {object} [options] configuration for the plugin + */ + videoJsResolutionSwitcher = function(options) { + var settings = videojs.mergeOptions(defaults, options), + player = this, + groupedSrc = {}, + currentSources = {}, + currentResolutionState = {}; + + /** + * Updates player sources or returns current source URL + * @param {Array} [src] array of sources [{src: '', type: '', label: '', res: ''}] + * @returns {Object|String|Array} videojs player object if used as setter or current source URL, object, or array of sources + */ + player.updateSrc = function(src){ + //Return current src if src is not given + if(!src){ return player.src(); } + for (var i=0; i<src.length;i++) { + src[i].res = src[i]['data-res']; + src[i].label = src[i]['data-label']; + src[i].prio = -src[i]['data-player_prio']; + } + + // Only add those sources which we can (maybe) play + src = src.filter( function(source) { + try { + return ( player.canPlayType( source.type ) !== '' ); + } catch (e) { + // If a Tech doesn't yet have canPlayType just add it + return true; + } + }); + //Sort sources + this.currentSources = src.sort(compareResolutions); + this.groupedSrc = bucketSources(this.currentSources); + // Pick one by default + var chosen = chooseSrc(this.groupedSrc, this.currentSources); + this.currentResolutionState = { + label: chosen.label, + sources: chosen.sources + }; + + player.trigger('updateSources'); + player.setSourcesSanitized(chosen.sources, chosen.label); + player.trigger('resolutionchange'); + return player; + }; + + /** + * Returns current resolution or sets one when label is specified + * @param {String} [label] label name + * @param {Function} [customSourcePicker] custom function to choose source. Takes 2 arguments: sources, label. Must return player object. + * @returns {Object} current resolution object {label: '', sources: []} if used as getter or player object if used as setter + */ + player.currentResolution = function(label, customSourcePicker){ + if(label == null) { return this.currentResolutionState; } + + // Lookup sources for label + if(!this.groupedSrc || !this.groupedSrc.label || !this.groupedSrc.label[label]){ + return; + } + var sources = this.groupedSrc.label[label]; + // Remember player state + var currentTime = player.currentTime(); + var isPaused = player.paused(); + + // Hide bigPlayButton + if(!isPaused && this.player_.options_.bigPlayButton){ + this.player_.bigPlayButton.hide(); + } + + // Change player source and wait for loadeddata event, then play video + // loadedmetadata doesn't work right now for flash. + // Probably because of https://github.com/videojs/video-js-swf/issues/124 + // If player preload is 'none' and then loadeddata not fired. So, we need timeupdate event for seek handle (timeupdate doesn't work properly with flash) + var handleSeekEvent = 'loadeddata'; + if(this.player_.techName_ !== 'Youtube' && this.player_.preload() === 'none' && this.player_.techName_ !== 'Flash') { + handleSeekEvent = 'timeupdate'; + } + player + .setSourcesSanitized(sources, label, customSourcePicker || settings.customSourcePicker) + .one(handleSeekEvent, function() { + player.currentTime(currentTime); + player.handleTechSeeked_(); + if(!isPaused){ + // Start playing and hide loadingSpinner (flash issue ?) + player.play().handleTechSeeked_(); + } + player.trigger('resolutionchange'); + }); + return player; + }; + + /** + * Returns grouped sources by label, resolution and type + * @returns {Object} grouped sources: { label: { key: [] }, res: { key: [] }, type: { key: [] } } + */ + player.getGroupedSrc = function(){ + return this.groupedSrc; + }; + + player.setSourcesSanitized = function(sources, label, customSourcePicker) { + this.currentResolutionState = { + label: label, + sources: sources + }; + if(typeof customSourcePicker === 'function'){ + return customSourcePicker(player, sources, label); + } + player.src(sources.map(function(src) { + return {src: src.src, type: src.type, res: src.res}; + })); + return player; + }; + + /** + * Method used for sorting list of sources + * @param {Object} a - source object with res property + * @param {Object} b - source object with res property + * @returns {Number} result of comparation + */ + function compareResolutions(a, b){ + if(a.prio && b.prio){ + return (+b.prio)-(+a.prio) + } + if(!a.res || !b.res){ return 0; } + return (+b.res)-(+a.res); + } + + /** + * Group sources by label, resolution and type + * @param {Array} src Array of sources + * @returns {Object} grouped sources: { label: { key: [] }, res: { key: [] }, type: { key: [] } } + */ + function bucketSources(src){ + var resolutions = { + label: {}, + res: {}, + type: {} + }; + src.map(function(source) { + initResolutionKey(resolutions, 'label', source); + initResolutionKey(resolutions, 'res', source); + initResolutionKey(resolutions, 'type', source); + + appendSourceToKey(resolutions, 'label', source); + appendSourceToKey(resolutions, 'res', source); + appendSourceToKey(resolutions, 'type', source); + }); + return resolutions; + } + + function initResolutionKey(resolutions, key, source) { + if(resolutions[key][source[key]] == null) { + resolutions[key][source[key]] = []; + } + } + + function appendSourceToKey(resolutions, key, source) { + resolutions[key][source[key]].push(source); + } + + /** + * Choose src if option.default is specified + * @param {Object} groupedSrc {res: { key: [] }} + * @param {Array} src Array of sources sorted by resolution used to find high and low res + * @returns {Object} {res: string, sources: []} + */ + function chooseSrc(groupedSrc, src){ + var selectedRes = settings['default']; // use array access as default is a reserved keyword + var selectedLabel = ''; + if (selectedRes === 'high') { + selectedRes = src[0].res; + selectedLabel = src[0].label; + } else if (selectedRes === 'low' || selectedRes == null || !groupedSrc.res[selectedRes]) { + // Select low-res if default is low or not set + selectedRes = src[src.length - 1].res; + selectedLabel = src[src.length -1].label; + } else if (groupedSrc.res[selectedRes]) { + selectedLabel = groupedSrc.res[selectedRes][0].label; + } + + return {res: selectedRes, label: selectedLabel, sources: groupedSrc.res[selectedRes]}; + } + + function initResolutionForYt(player){ + // Map youtube qualities names + var _yts = { + highres: {res: 1080, label: '1080', yt: 'highres'}, + hd1080: {res: 1080, label: '1080', yt: 'hd1080'}, + hd720: {res: 720, label: '720', yt: 'hd720'}, + large: {res: 480, label: '480', yt: 'large'}, + medium: {res: 360, label: '360', yt: 'medium'}, + small: {res: 240, label: '240', yt: 'small'}, + tiny: {res: 144, label: '144', yt: 'tiny'}, + auto: {res: 0, label: 'auto', yt: 'auto'} + }; + // Overwrite default sourcePicker function + var _customSourcePicker = function(_player, _sources, _label){ + // Note that setPlayebackQuality is a suggestion. YT does not always obey it. + player.tech_.ytPlayer.setPlaybackQuality(_sources[0]._yt); + player.trigger('updateSources'); + return player; + }; + settings.customSourcePicker = _customSourcePicker; + + // Init resolution + player.tech_.ytPlayer.setPlaybackQuality('auto'); + + // This is triggered when the resolution actually changes + player.tech_.ytPlayer.addEventListener('onPlaybackQualityChange', function(event){ + for(var res in _yts) { + if(res.yt === event.data) { + player.currentResolution(res.label, _customSourcePicker); + return; + } + } + }); + + // We must wait for play event + player.one('play', function(){ + var qualities = player.tech_.ytPlayer.getAvailableQualityLevels(); + var _sources = []; + + qualities.map(function(q){ + _sources.push({ + src: player.src().src, + type: player.src().type, + label: _yts[q].label, + res: _yts[q].res, + _yt: _yts[q].yt + }); + }); + + player.groupedSrc = bucketSources(_sources); + var chosen = {label: 'auto', res: 0, sources: player.groupedSrc.label.auto}; + + this.currentResolutionState = { + label: chosen.label, + sources: chosen.sources + }; + + player.trigger('updateSources'); + player.setSourcesSanitized(chosen.sources, chosen.label, _customSourcePicker); + }); + } + + player.ready(function(){ + if( settings.ui ) { + var menuButton = new ResolutionMenuButton(player, settings); + player.controlBar.resolutionSwitcher = player.controlBar.el_.insertBefore(menuButton.el_, player.controlBar.getChild('fullscreenToggle').el_); + player.controlBar.resolutionSwitcher.dispose = function(){ + this.parentNode.removeChild(this); + }; + } + if(player.options_.sources.length > 1){ + // tech: Html5 and Flash + // Create resolution switcher for videos form <source> tag inside <video> + player.updateSrc(player.options_.sources); + } + + if(player.techName_ === 'Youtube'){ + // tech: YouTube + initResolutionForYt(player); + } + }); + + }; + + // register the plugin + videojs.plugin('videoJsResolutionSwitcher', videoJsResolutionSwitcher); + })(window, videojs); })(); diff --git a/stats.py b/stats.py index 8ef31660c07b1e13c41454b9ee5a716a23fbfa1c..d57e0fc8a09086d6ef7e00a3f05b2dda1f7f6b12 100644 --- a/stats.py +++ b/stats.py @@ -2,12 +2,23 @@ from server import * import json from jobs import date_json_handler from hashlib import md5 +from datetime import datetime @app.route('/internal/stats') +@app.route('/internal/stats/<semester>') @register_navbar('Statistiken', icon='stats') @mod_required def stats(): - return render_template('stats.html') + semester = query('SELECT DISTINCT semester from courses WHERE semester != ""'); + for s in semester: + year = int(s['semester'][0:4]) + if s['semester'].endswith('ss'): + s['from'] = datetime(year,4,1) + s['to'] = datetime(year,10,1) + if s['semester'].endswith('ws'): + s['from'] = datetime(year,10,1) + s['to'] = datetime(year+1,4,1) + return render_template('stats.html',semester=semester,filter=request.args.get('filter')) statsqueries = {} statsqueries['formats_views'] = "SELECT formats.description AS labels, count(DISTINCT log.id) AS `values` FROM log JOIN videos ON (videos.id = log.video) JOIN formats ON (formats.id = videos.video_format) GROUP BY formats.id" @@ -18,6 +29,7 @@ statsqueries['organizer_courses'] = "SELECT courses.organizer AS labels, count(c statsqueries['categories_lectures'] = "SELECT courses.subject AS labels, count(lectures.id) AS `values` FROM lectures JOIN courses ON (courses.id = lectures.course_id) WHERE lectures.visible GROUP BY courses.subject ORDER BY `values` DESC LIMIT 100" statsqueries['lecture_views'] = "SELECT lectures.time AS x, count(DISTINCT log.id) AS y FROM log JOIN videos ON (videos.id = log.video) JOIN lectures ON (lectures.id = videos.lecture_id) WHERE (lectures.course_id = ?) GROUP BY lectures.id ORDER BY lectures.time" statsqueries['live_views'] = "SELECT hlslog.segment AS x, COUNT(DISTINCT hlslog.id) AS y FROM hlslog WHERE hlslog.lecture = ? GROUP BY hlslog.segment ORDER BY hlslog.segment" +statsqueries['lecture_totalviews'] = "SELECT 42" def plotly_date_handler(obj): return obj.strftime("%Y-%m-%d %H:%M:%S") @@ -45,10 +57,52 @@ def stats_viewsperday(req, param=""): query_expr = 'SELECT date, trace, value AS y FROM logcache WHERE req = "%s" AND param = ? UNION SELECT * FROM (%s) AS cachetmp' date_subexpr = 'SELECT CASE WHEN MAX(date) IS NULL THEN "2000-00-00" ELSE MAX(date) END AS t FROM `logcache` WHERE req = "%s" AND param = ?' queries = { - 'lecture': 'SELECT log.date AS date, formats.description AS trace, COUNT(DISTINCT log.id) AS y FROM log JOIN videos ON videos.id = log.video JOIN formats ON formats.id = videos.video_format WHERE log.date > %T AND videos.lecture_id = ? GROUP BY log.date, videos.video_format UNION SELECT log.date AS date, "total" AS trace, COUNT(DISTINCT log.id) AS y FROM log JOIN videos ON videos.id = log.video WHERE log.date > %T AND videos.lecture_id = ? GROUP BY log.date', - 'course': 'SELECT log.date AS date, formats.description AS trace, COUNT(DISTINCT log.id) AS y FROM log JOIN videos ON videos.id = log.video JOIN lectures ON lectures.id = videos.lecture_id JOIN formats ON formats.id = videos.video_format WHERE log.date > %T AND lectures.course_id = ? GROUP BY log.date, videos.video_format UNION SELECT log.date AS date, "total" AS trace, COUNT(DISTINCT log.id) AS y FROM log JOIN videos ON videos.id = log.video JOIN lectures ON lectures.id = videos.lecture_id WHERE log.date > %T AND lectures.course_id = ? GROUP BY log.date', - 'global': 'SELECT log.date AS date, formats.description AS trace, COUNT(DISTINCT log.id) AS y FROM log JOIN videos ON videos.id = log.video JOIN formats ON formats.id = videos.video_format WHERE log.date > %T GROUP BY log.date, videos.video_format UNION SELECT log.date AS date, "total" AS trace, COUNT(DISTINCT log.id) AS y FROM log WHERE log.date > %T GROUP BY log.date', - 'courses': 'SELECT log.date AS date, courses.handle AS trace, COUNT(DISTINCT log.id) AS y FROM log JOIN videos ON videos.id = log.video JOIN lectures ON lectures.id = videos.lecture_id JOIN courses ON courses.id = lectures.course_id WHERE log.date > %T GROUP BY log.date, courses.id' + 'lecture': # views per day per lecture (split per format) + '''SELECT log.date AS date, formats.description AS trace, COUNT(DISTINCT log.id) AS y + FROM log + JOIN videos ON videos.id = log.video + JOIN formats ON formats.id = videos.video_format + WHERE log.date > %T AND videos.lecture_id = ? + GROUP BY log.date, videos.video_format + UNION + SELECT log.date AS date, "total" AS trace, COUNT(DISTINCT log.id) AS y + FROM log JOIN videos ON videos.id = log.video + WHERE log.date > %T AND videos.lecture_id = ? GROUP BY log.date''', + + 'course': # views per day per format for a single course + '''SELECT log.date AS date, formats.description AS trace, COUNT(DISTINCT log.id) AS y + FROM log JOIN videos ON videos.id = log.video + JOIN lectures ON lectures.id = videos.lecture_id + JOIN formats ON formats.id = videos.video_format + WHERE log.date > %T AND lectures.course_id = ? + GROUP BY log.date, videos.video_format + UNION + SELECT log.date AS date, "total" AS trace, COUNT(DISTINCT log.id) AS y + FROM log + JOIN videos ON videos.id = log.video + JOIN lectures ON lectures.id = videos.lecture_id + WHERE log.date > %T AND lectures.course_id = ? + GROUP BY log.date''', + + 'global': # views per format per day (split per format) + '''SELECT log.date AS date, formats.description AS trace, COUNT(DISTINCT log.id) AS y + FROM log + JOIN videos ON videos.id = log.video + JOIN formats ON formats.id = videos.video_format + WHERE log.date > %T GROUP BY log.date, videos.video_format + UNION + SELECT log.date AS date, "total" AS trace, COUNT(DISTINCT log.id) AS y + FROM log + WHERE log.date > %T + GROUP BY log.date''', + + 'courses': # views per course per day + '''SELECT log.date AS date, courses.handle AS trace, COUNT(DISTINCT log.id) AS y + FROM log JOIN videos ON videos.id = log.video + JOIN lectures ON lectures.id = videos.lecture_id + JOIN courses ON courses.id = lectures.course_id + WHERE log.date > %T + GROUP BY log.date, courses.id''' } expr = queries[req].replace('%T', '"'+query(date_subexpr%('viewsperday.'+req), param)[0]['t']+'"') params = [param]*expr.count('?') @@ -71,6 +125,12 @@ def stats_viewsperday(req, param=""): data[row['date']][row['trace']] = row['y'] end = date.today() res = [{'name': trace, 'x': [], 'y': []} for trace in traces] + + filter = request.args.get('filter') + if filter: + filter = filter.split('-') + start = date.fromtimestamp(int(filter[0])) + end = date.fromtimestamp(int(filter[1])) while start and start <= end: for trace in res: trace['x'].append(start) diff --git a/templates/base.html b/templates/base.html index be2177fbb0691cc6c4bbdca3977c6791585fe614..b41321a683d245a991eb284cb37bfa23991f172d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -42,8 +42,7 @@ <nav class="hidden-print navbar navbar-default navbar-static-top" {% if config.DEBUG %} style="background-color: red" {% endif %} > <div class="container-fluid"> <div class="navbar-header"> - <button type="button" class="navbar-toggle" data-toggle="collapse" - data-target=".navbar-collapse"> + <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse"> <span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span> @@ -52,10 +51,26 @@ <a class="navbar-brand" href="/" style="padding: 3px;"> <img alt="Brand" src="{{url_for('static', filename='logo.png')}}" style="height: 44px; width: 44px" > </a> + <ul class="nav nav-pills" style="margin-top: 5px; padding-left: 40px;"> + {% for endpoint, caption, iconlib, gly, visible in navbar if visible %} + <li{% if endpoint == request.endpoint %} class="active"{% endif %}> + <a href="{{ url_for(endpoint) }}" style="padding: 10px 6px;"> + {% if gly != '' %} + {% if iconlib == 'bootstrap' %} + <span aria-hidden="true" class="glyphicon glyphicon-{{ gly }}"></span> + {% elif iconlib == 'fa' %} + <span aria-hidden="true" class="fa fa-{{ gly }}"></span> + {% endif %} + {{ caption }} + {% endif %} + </a> + </li> + {% endfor %} + </ul> </div> <div class="collapse navbar-collapse"> <ul class="nav nav-pills" style="margin-top: 5px;"> - {% for endpoint, caption, iconlib, gly, visible in navbar if visible or ismod() %} + {% for endpoint, caption, iconlib, gly, visible in navbar if (not visible) and ismod() %} <li{% if endpoint == request.endpoint %} class="active"{% endif %}> <a href="{{ url_for(endpoint) }}"> {% if gly != '' %} @@ -70,7 +85,7 @@ </li> {% endfor %} - <li class="col-xs-12 col-sm-4 pull-right"> + <li class="col-xs-9 col-sm-4 pull-right"> <form action="{{ url_for('search') }}" role="search"> <div class="input-group" style="margin-top: 3px"> <input class="form-control" type="text" name="q" placeholder="Search" value="{{ searchtext }}"> @@ -187,7 +202,11 @@ {% endif %} <script> $( function () { - $('[data-toggle="tooltip"]').tooltip({ 'trigger': 'hover' }); + $('[data-toggle="tooltip"]').tooltip( + { + trigger: 'hover', + html: true + }); }); </script> </body> diff --git a/templates/course.html b/templates/course.html index 6bbb9e915a58af67c96861574ed0937f2ddd3f3a..0c85dd20d5618ac18f72d057f6bcc0d4509d53a6 100644 --- a/templates/course.html +++ b/templates/course.html @@ -35,7 +35,7 @@ </tbody> </table> </div> - {% if ismod() %} + {% if ismod() %} <div class="col-xs-12" style="margin-top: 20px"> <table class="table-condensed table-top-aligned"> <tbody> @@ -51,14 +51,45 @@ </tbody> </table> </div> - <div class="col-xs-6 plot-view" data-url="{{url_for('stats_viewsperday', req="course", param=course.id)}}"></div> - <div class="col-xs-6 plot-view" data-type="bar" data-url="{{url_for('stats_generic', req="lecture_views", param=course.id)}}"></div> - {% endif %} + {% endif %} </div> </div> + +{% if ismod() %} +<div class="panel panel-default"> + <div class="panel-heading"> + <a data-toggle="collapse" href="#statspanel" class="plotlyresize"><h1 class="panel-title">Statistiken</h1></a> + </div> + <div class="row panel-body collapse out panel-collapse" id="statspanel"> + <div class="col-md-6 col-xs-12"> + <p class="text-center">Zuschauer pro Tag</p> + <div class="plot-view" data-url="{{url_for('stats_viewsperday', req="course", param=course.id)}}"></div> + </div> + <div class="col-md-6 col-xs-12"> + <p class="text-center">Zuschauer pro Termin</p> + <div class="plot-view" data-type="bar" data-url="{{url_for('stats_generic', req="lecture_views", param=course.id)}}"></div> + </div> + </div> +</div> +{% endif %} + <div class="panel panel-default"> <div class="panel-heading"> - <h1 class="panel-title">Videos{% if ismod() %} <a class="btn btn-default" style="margin-right: 5px;" href="{{ url_for('create', table='lectures', time=datetime.now(), title='Noch kein Titel', visible='0', course_id=course.id, ref=request.url) }}">Neuer Termin</a><a class="btn btn-default" style="margin-right: 5px;" href="{{url_for('list_import_sources', id=course['id'])}}">Campus Import</a>{% endif %} <a class="fa fa-rss-square pull-right" aria-hidden="true" href="{{url_for('feed', handle=course.handle)}}" style="text-decoration: none"></a> </h1> + <h1 class="panel-title">Videos + {% if ismod() %} + <a class="btn btn-default" style="margin-right: 5px;" href="{{ url_for('create', table='lectures', time=datetime.now(), title='Noch kein Titel', visible='0', course_id=course.id, ref=url_for('course', id=course.id)) }}">Neuer Termin</a> + <a class="btn btn-default" style="margin-right: 5px;" href="{{url_for('list_import_sources', id=course['id'])}}">Campus Import</a> + {% endif %} + <ul class="list-inline pull-right"> + <li> + <a class="fa fa-rss-square" aria-hidden="true" href="{{url_for('feed', handle=course.handle)}}" style="text-decoration: none"></a> + </li> + {% if ismod() %} + <li> + <a class="fa fa-calendar" aria-hidden="true" href="{{url_for('ical_course', course=course.handle)}}" style="text-decoration: none"></a> + </li> + {% endif %} + </h1> </div> <ul class="list-group lectureslist"> {% for l in lectures %} @@ -66,4 +97,38 @@ {% endfor %} </ul> </div> + +<script> +$.ajax({ + method: "GET", + url: "{{url_for('stats_generic', req="lecture_views", param=course.id)}}", + dataType: "json", + error: function() { + var counter = $(".viewcounter"); + for (var i=0; i<counter.length; i++) { + $(counter[i]).text("0"); + } + }, + success: function (traces) { + var dates={}; + var t = traces[0]; + if (!t.x) { + return; + } + for (var i=0; i<t.x.length; i++) { + dates[t.x[i]] = t.y[i]; + } + var counter = $(".viewcounter"); + for (var i=0; i<counter.length; i++) { + $(counter[i]).text(dates[$(counter[i]).data("lecturedate")]); + } + var counter = $(".viewcounter"); + for (var i=0; i<counter.length; i++) { + if ($(counter[i]).text() == "loading...") { + $(counter[i]).text("0"); + } + } + } +}); +</script> {% endblock %} diff --git a/templates/courses.html b/templates/courses.html index 70c0dd10b2fbb70d9b03065bb322e40fb6bd4572..c1c461593f2efbfa286b2865efa0232556c80ccd 100644 --- a/templates/courses.html +++ b/templates/courses.html @@ -8,6 +8,9 @@ <a class="fa fa-rss-square btn btn-default" aria-hidden="true" href="{{url_for('courses_feed')}}" style="text-decoration: none"></a> </li> {% if ismod() %} + <li> + <a class="fa fa-calendar btn btn-default" aria-hidden="true" href="{{url_for('ical_all')}}" style="text-decoration: none"></a> + </li> <li> {% set newhandle = 'new'+(randint(0,1000)|string) %} <a class="btn btn-default" href="{{ url_for('create', table='courses', handle=newhandle, title='Neue Veranstaltung', responsible=session.user.givenName, ref=url_for('course', handle=newhandle)) }}">Neue Veranstaltung</a> @@ -56,7 +59,8 @@ <div class="panel-heading"> <a class="accordion-toggle" data-toggle="collapse" data-parent="#accordion-{{ g.grouper|tagid }}" href="#{{g.grouper|tagid}}" style="color: #222;"> {% if groupedby == 'semester' %} - <h1 class="panel-title">{{g.grouper|semester(long=True)}} ({{g.list|length}} Veranstaltungen)</h1> + <h1 class="panel-title">{{g.grouper|semester(long=True)}} ({{g.list|length}} Veranstaltungen) + </h1> {% else %} <h1 class="panel-title">{{g.grouper}}</h1> {% endif %} diff --git a/templates/lecture.html b/templates/lecture.html index c7d38a3bdf7b8d210fdfb7da9a84a16d36c30199..d703dc02470fd0881e10b78a788a8305837b4983 100644 --- a/templates/lecture.html +++ b/templates/lecture.html @@ -25,7 +25,7 @@ <div class="panel-body"> <div class="row" style="padding: 0px;"> <div class="col-xs-12" style="padding-bottom: 5px;"> - <a href="{{url_for('course', handle=course.handle)}}#lecture-{{lecture.id}}" class="btn btn-default" >Zur Veranstaltungsseite</a> + <a href="{{url_for('course', handle=course.handle)}}#lecture-{{lecture.id}}" class="btn btn-default" ><span class="fa fa-chevron-circle-left" aria-hidden="true"></span> Zur Veranstaltungsseite</a> <ul class="list-inline pull-right"> <li>{{ video_embed_btn(lecture.id, course=course.handle) }}</li> <li class="dropdown">{{ video_download_btn(videos) }}</li> @@ -39,7 +39,6 @@ <div class="col-xs-12" style="padding-top: 20px"> <p>{{ moderator_editor(['lectures',lecture.id,'comment'], lecture.comment) }}</p> </div> - {% if (chapters|length > 0) or ismod() %} <div class="col-xs-12 table-responsive" style="padding-top: 10px;"> <p>Kapitel: <button class="btn btn-default" id="hintnewchapter">{% if ismod() %}Neues Kapitel{% else %}Kapitelmarker vorschlagen{% endif %}</button> @@ -71,14 +70,29 @@ {% endfor %} </table> </div> - {% endif %} - {% if ismod() %} - <div class="col-xs-12 plot-view" data-url="{{url_for('stats_viewsperday', req="lecture", param=lecture.id)}}"></div> - <div class="col-xs-12 plot-view" data-url="{{url_for('stats_generic', req="live_views", param=lecture.id)}}"></div> - {% endif %} </div> </div> </div> + +{% if ismod() %} +<div class="panel panel-default"> + <div class="panel-heading"> + <a data-toggle="collapse" href="#statspanel" class="plotlyresize"><h1 class="panel-title">Statistiken</h1></a> + </div> + <div class="row panel-body collapse out panel-collapse" id="statspanel"> + <div class="col-md-6 col-xs-12"> + <p class="text-center">Zuschauer pro Tag</p> + <div class="plot-view" data-url="{{url_for('stats_viewsperday', req="lecture", param=lecture.id)}}"></div> + </div> + <div class="col-md-6 col-xs-12"> + <p class="text-center">Zuschauer im Livestream</p> + <div class="plot-view" data-url="{{url_for('stats_generic', req="live_views", param=lecture.id)}}"></div> + </div> + </div> +</div> +{% endif %} + + <script> function hintchapterclick (src) { $.ajax({ diff --git a/templates/macros.html b/templates/macros.html index cc40485bcd93ec93b64cac819e04037b71cc04fa..0c87bac504b0d0ac2b792a8206679fec194c28aa 100644 --- a/templates/macros.html +++ b/templates/macros.html @@ -50,8 +50,9 @@ {% macro player(lecture, videos, msgs) %} <video id="videoplayer" style="width: 100%" class="video-js vjs-default-skin vjs-big-play-centered" width="640" height="320" controls data-wasnotplayed="1" data-setup='{ "language":"de", "plugins" : {"hotkeys": {"seekStep": 15, "enableVolumeScroll": false, "alwaysCaptureHotkeys": true}, "videoJsResolutionSwitcher": { "ui": true, "default": "720p", "dynamicLabel": false } }, "customControlsOnMobile": true, "playbackRates": [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.25, 2.5, 2.75, 3, 3.25, 3.5, 3.75, 4] }'> - {% for v in videos|sort(attribute='player_prio', reverse=True) %} - <source type="{{ v.mimetype }}" src="{{ config.VIDEOPREFIX }}/{{ v.path }}" label="{{ v.format_description }}"/> + {% for v in videos|sort(attribute='formats.player_prio', reverse=True) %} + <source type="{{ v.formats.mimetype }}" src="{{ config.VIDEOPREFIX }}/{{ v.path }}" data-label="{{ v.formats.description }}" data-res="{{v.formats.resolution}}" data-aspect="{{v.formats.aspect}}" data-player_prio="{{v.formats.player_prio}}"/> + {{ v|safe }} {% endfor %} <track srclang="de" kind="chapters" src="{{ url_for('chapters',lectureid=lecture.id) }}" /> </video> @@ -162,8 +163,8 @@ $(function() { {% if not ismod() %} <span class="btn btn-default dropdown-toggle{% if not videos|selectattr('downloadable')|list and not ismod() %} disabled{% endif %}" type="button" data-toggle="dropdown">Download <span class="caret"></span></span> <ul class="dropdown-menu"> - {% for v in videos|sort(attribute='prio', reverse=True) if (v.downloadable or ismod() ) %} - <li><a href="{{ config.VIDEOPREFIX }}/{{v.path}}">{{v.format_description}} ({{v.file_size|filesizeformat(true)}})</a></li> + {% for v in videos|sort(attribute='formats.prio', reverse=True) if (v.downloadable or ismod() ) %} + <li><a href="{{ config.VIDEOPREFIX }}/{{v.path}}">{{v.formats.description}} ({{v.file_size|filesizeformat(true)}})</a></li> {% endfor %} </ul> {% endif %} @@ -171,8 +172,8 @@ $(function() { <noscript> {% endif %} <ul class="pull-right list-unstyled" style="margin-left:10px;"> -{% for v in videos|sort(attribute='prio', reverse=True) if (v.downloadable or ismod() ) %} - <li>{{moderator_delete(['videos',v.id,'deleted'])}} {{ moderator_checkbox(['videos',v.id,'visible'], v.visible) }} <a href="{{ config.VIDEOPREFIX }}/{{v.path}}">{{v.format_description}} ({{v.file_size|filesizeformat(true)}})</a></li> +{% for v in videos|sort(attribute='formats.prio', reverse=True) if (v.downloadable or ismod() ) %} + <li>{{moderator_delete(['videos',v.id,'deleted'])}} {{ moderator_checkbox(['videos',v.id,'visible'], v.visible) }} <a href="{{ config.VIDEOPREFIX }}/{{v.path}}">{{v.formats.description}} ({{v.file_size|filesizeformat(true)}})</a></li> {% endfor %} </ul> {% if not ismod() %} @@ -226,16 +227,25 @@ $('#embedcodebtn').popover( <li>Hörsaal: {{ moderator_editor(['lectures',lecture.id,'place'], lecture.place) }} </li> {% endif %} </ul> - <ul class="list-inline col-sm-4 col-xs-12"> - <li class="dropdown"> - {{ video_download_btn(videos) }} - </li> - <li class="pull-right"> - {{ moderator_permissioneditor('lecture', lecture.id, lecture.perm, global_permissions) }} + <ul class="col-sm-4 col-xs-12 list-unstyled"> + <li> + <ul class="list-inline"> + <li class="dropdown"> + {{ video_download_btn(videos) }} + </li> + <li class="pull-right"> + {{ moderator_permissioneditor('lecture', lecture.id, lecture.perm, global_permissions) }} + </li> + <li class="pull-right"> + {{ moderator_delete(['lectures',lecture.id,'deleted']) }} + </li> + </ul> </li> + {% if ismod() %} <li class="pull-right"> - {{ moderator_delete(['lectures',lecture.id,'deleted']) }} + <p>Abrufe: <span data-lectureid="{{ lecture.id }}" data-lecturedate="{{ lecture.time }}" class="viewcounter">loading...</span></p> </li> + {% endif %} </ul> {% else %} <div class="col-sm-2 col-xs-12"> @@ -257,7 +267,7 @@ $('#embedcodebtn').popover( {% macro moderator_editor (path,value,reload=false) %} {% if ismod() %} <span class="moderator_editor" data-path="{{path|join('.')}}" data-reload="{{ reload|int }}" > - <a class="moderator_editor_sign btn btn-default" title="{{path|join('.')}}" data-toggle="tooltip" tabindex="0" style="padding: 3px; margin-right: 5px;"> + <a class="moderator_editor_sign btn btn-default" title="{{path|join('.')}}{{ path|join('.')|getfielddescription }}" data-toggle="tooltip" tabindex="0" style="padding: 3px; margin-right: 5px;"> <span class="glyphicon glyphicon-pencil"></span> </a> <span class="moderator_editor_value">{{ value|fixnl|safe }}</span> @@ -269,13 +279,13 @@ $('#embedcodebtn').popover( {% macro moderator_checkbox (path,value) %} {% if ismod() %} - <input title="{{path|join('.')}}" data-toggle="tooltip" type="checkbox" data-path="{{path|join('.')}}" {% if value %} checked {% endif %} onchange="moderator.editor.changeboxclick(this)"/> + <input title="{{path|join('.')}}{{ path|join('.')|getfielddescription }}" data-toggle="tooltip" type="checkbox" data-path="{{path|join('.')}}" {% if value %} checked {% endif %} onchange="moderator.editor.changeboxclick(this)"/> {% endif %} {% endmacro %} {% macro moderator_delete (path) %} {% if ismod() %} - <button class="btn btn-default" style="background-color: red;" data-path="{{path|join('.')}}" onclick="moderator.editor.deletebtnclick(this)"> + <button class="btn btn-default" style="background-color: red;" data-path="{{path|join('.')}}" onclick="moderator.editor.deletebtnclick(this)" > <span class="glyphicon glyphicon-trash"></span> </button> {% endif %} diff --git a/templates/stats.html b/templates/stats.html index c7cb96aab7b11b643767610fe6534465afd8ae93..953d5e6656e839aefa48c341ebc2b207e360b779 100644 --- a/templates/stats.html +++ b/templates/stats.html @@ -3,21 +3,59 @@ <div class="panel-group"> <div class="panel panel-default"> <div class="panel-heading"> - <h1 class="panel-title">Statistiken</h1> + <h1 class="panel-title">Gesamt</h1> </div> <div class="panel-body"> <div class="row col-xs-12"> - <div class="col-xs-12 col-md-6 plot-view" data-url="{{url_for('stats_generic', req="course_count")}}"></div> - <div class="col-xs-12 col-md-6 plot-view" data-url="{{url_for('stats_generic', req="lectures_count")}}"></div> - <div class="col-xs-12 col-md-6 plot-view" data-type="pie" data-url="{{url_for('stats_generic', req="categories_courses")}}"></div> - <div class="col-xs-12 col-md-6 plot-view" data-type="pie" data-url="{{url_for('stats_generic', req="categories_lectures")}}"></div> - <!--<div class="col-xs-12 col-md-6 plot-view" data-type="pie" data-url="{{url_for('stats_generic', req="organizer_courses")}}"></div>--> - <!--<div class="col-xs-12 col-md-6 plot-view" data-type="pie" data-url="{{url_for('stats_generic', req="formats_views")}}"></div>--> - <div class="col-xs-12 plot-view" data-url="{{url_for('stats_viewsperday', req="global")}}"></div> - <div class="col-xs-12 plot-view" data-url="{{url_for('stats_viewsperday', req="courses")}}"></div> + <div class="col-xs-12 col-md-6"> + <p class="text-center">Veranstaltungen pro Semester</p> + <div class="plot-view" data-url="{{url_for('stats_generic', req="course_count")}}"></div> + </div> + <div class="col-xs-12 col-md-6"> + <p class="text-center">Vorlesungen pro Semester</p> + <div class="plot-view" data-url="{{url_for('stats_generic', req="lectures_count")}}"></div> + </div> + <div class="col-xs-12 col-md-6"> + <p class="text-center">Veranstaltungen pro Kategorie</p> + <div class="plot-view" data-type="pie" data-url="{{url_for('stats_generic', req="categories_courses")}}"></div> + </div> + <div class="col-xs-12 col-md-6"> + <p class="text-center">Vorlesungen pro Kategorie</p> + <div class="plot-view" data-type="pie" data-url="{{url_for('stats_generic', req="categories_lectures")}}"></div> + </div> + <!--<div class="col-xs-12 col-md-12 plot-view" style="height: 1200px;" data-type="pie" data-url="{{url_for('stats_generic', req="organizer_courses")}}"></div>!--> + </div> + </div> + </div> + <div class="panel panel-default"> + <div class="panel-heading"> + <span class="panel-title"><a name="semesterstats"></a>Semester <select id="semesterselect" name="semester"><option value="">alle</option></select></span> + </div> + <div class="panel-body" > + <div class=col-xs-12"> + <p class="text-center">Zuschauer pro Veranstaltung</p> + <div class="plot-view" data-url="{{url_for('stats_viewsperday', req="courses", filter=filter)}}"></div> + </div> + <div class=col-xs-12"> + <p class="text-center">Zuschauer pro Format</p> + <div class="plot-view" data-url="{{url_for('stats_viewsperday', req="global", filter=filter)}}"></div> </div> </div> </div> </div> +<script> +$( document ).ready(function () { + {% for s in semester if s.semester != '' %} + $("#semesterselect").append('<option value="{{ s.from.timestamp()|int }}-{{ s.to.timestamp()|int }}">{{ s.semester }}</option>'); + {% endfor %} + {% if filter %} + $("#semesterselect").val("{{ filter }}") + {% else %} + $("#semesterselect").val("") + {% endif %} + $("#semesterselect").on("change", function () { + window.location.href="{{ url_for('stats') }}?filter="+$("#semesterselect").val()+"#semesterstats"; + }); +}); </script> {% endblock %} diff --git a/templates/timetable.html b/templates/timetable.html index f0dfa4d9f11142df8bb12fd797581fa659c9ca4d..319e322d1ca3b7d5ffe25773c80429e85ce379f6 100644 --- a/templates/timetable.html +++ b/templates/timetable.html @@ -3,7 +3,9 @@ <div class="panel-group" id="accordion"> <div class="panel panel-default"> <div class="hidden-print panel-heading"> - <h1 class="panel-title">Drehplan</h1> + <h1 class="panel-title">Drehplan + <a class="pull-right fa fa-calendar" aria-hidden="true" href="{{url_for('ical_all')}}" style="text-decoration: none"></a> + </h1> </div> <div class="row hidden-print"> <div style="margin-top: 10px;" class="col-xs-12">