diff --git a/CHANGELOG b/CHANGELOG index b17f7fc19..c1287b8d7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -25,6 +25,7 @@ Users: - Used authentication frontend via AngularJS. Other: - New OpenSlides logo. +- Added multiple countdown support. - Changed supported Python version to >= 3.3. - Used Django 1.7 as lowest requirement. - Added Django's application configuration. Refactored loading of signals diff --git a/openslides/core/migrations/0007_clear_default_countdown.py b/openslides/core/migrations/0007_clear_default_countdown.py new file mode 100644 index 000000000..61e0f901a --- /dev/null +++ b/openslides/core/migrations/0007_clear_default_countdown.py @@ -0,0 +1,36 @@ +import uuid + +from django.db import migrations + + +def clear_all_and_make_it_new_2(apps, schema_editor): + """ + Clear all projector elements and them write new. + """ + # We get the model from the versioned app registry; + # if we directly import it, it will be the wrong version. + Projector = apps.get_model('core', 'Projector') + projector = Projector.objects.get() + projector.config = {} + projector.config[uuid.uuid4().hex] = { + 'name': 'core/clock', + 'stable': True} + projector.config[uuid.uuid4().hex] = { + 'name': 'core/customslide', + 'id': 1} # TODO: Use ID from model here. Do not guess. + projector.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0006_auto_20150914_2232'), + ] + + operations = [ + migrations.RunPython( + code=clear_all_and_make_it_new_2, + reverse_code=None, + atomic=True, + ), + ] diff --git a/openslides/core/models.py b/openslides/core/models.py index 9fb928eae..28089de44 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -82,6 +82,7 @@ class Projector(RESTModelMixin, models.Model): for key, value in self.config.items(): # Use a copy here not to change the origin value in the config field. result[key] = value.copy() + result[key]['uuid'] = key element = elements.get(value['name']) if element is None: result[key]['error'] = _('Projector element does not exist.') diff --git a/openslides/core/static/css/app.css b/openslides/core/static/css/app.css index 82918b547..b5f76af3b 100644 --- a/openslides/core/static/css/app.css +++ b/openslides/core/static/css/app.css @@ -23,6 +23,32 @@ body { border: 2px dashed #bed2db; box-sizing: border-box; } +.countdown.panel, .message.panel { + margin-bottom: 7px; +} +.countdown .panel-heading { + padding: 3px 15px; +} +.countdown .panel-body { + padding: 5px 15px; +} +.message .panel-body { + padding: 10px 15px; +} +.countdown_timer { + font-size: 2.2em; + font-weight: bold; +} +.countdown .editicon, .message .editicon { + padding-right: 10px; +} +.vcenter { + vertical-align: middle; +} +.notNull { + color: red; + font-weight: bold; +} /* TODO: used by ng-fab-forms */ .validation-success { @@ -184,7 +210,7 @@ a:hover { } /* List tables */ -th.sortable:hover, tr.pointer:hover { +th.sortable:hover, tr.pointer:hover, .pointer { cursor: pointer; } @@ -245,7 +271,7 @@ div.import > div > input[type="text"] { tr.offline td, li.offline { background-color: #EAEAEA !important; } -tr.activeline td, li.activeline { +tr.activeline td, li.activeline, .projected { background-color: #bed4de; } .nopadding { diff --git a/openslides/core/static/css/projector.css b/openslides/core/static/css/projector.css index 189ee17a6..31aebe511 100644 --- a/openslides/core/static/css/projector.css +++ b/openslides/core/static/css/projector.css @@ -130,7 +130,28 @@ hr { /*** Overlay ***/ -#overlay_transparent { +.countdown { + position: fixed; + margin: 0; + top: 0; + right: 0px; + padding: 19px 20px 5px 19px; + min-height: 72px; + font-size: 4em; + font-weight: bold; + text-align: center; + border-radius: 0.2em 0 0 0.2em; + z-index: 200; +} +.countdown .description { + font-weight: normal; + font-size: 0.2em; + margin-top: 20px; +} +.countdown.negative { + color: #CC0000; +} +.message_background { background-color: #777777; opacity: 0.6; position: absolute; @@ -138,23 +159,9 @@ hr { left: 0; width: 100%; height: 100%; + z-index: 200; } -#overlay_countdown_inner { - position: fixed; - right: 40px; - height: 30px; - width: 215px; - margin: 0; - top: 0; - font-size: 4em; - font-weight: bold; - text-align: center; - border-radius: 0 0 0.2em 0.2em; -} -#overlay_countdown_inner.negative { - color: #CC0000; -} -#overlay_message_inner { +.message { position: fixed; top: 35%; left: 10%; @@ -165,6 +172,7 @@ hr { font-size: 2.75em; padding: 0.2em 0; line-height: normal !important; + z-index: 201; } diff --git a/openslides/core/static/js/core/core.js b/openslides/core/static/js/core/core.js index 9964ac68f..60f01f7c4 100644 --- a/openslides/core/static/js/core/core.js +++ b/openslides/core/static/js/core/core.js @@ -77,9 +77,10 @@ angular.module('OpenSlidesApp.core', [ .factory('loadGlobalData', [ '$rootScope', + '$http', 'Config', 'Projector', - function ($rootScope, Config, Projector) { + function ($rootScope, $http, Config, Projector) { return function () { // Puts the config object into each scope. Config.findAll().then(function() { @@ -96,6 +97,11 @@ angular.module('OpenSlidesApp.core', [ // Loads all projector data Projector.findAll(); + + // Loads server time and calculates server offset + $http.get('/core/servertime/').then(function(data) { + $rootScope.serverOffset = Math.floor( Date.now() / 1000 - data.data ); + }); } } ]) @@ -176,6 +182,34 @@ angular.module('OpenSlidesApp.core', [ }); }]) +/* Converts number of seconds into string "hh:mm:ss" or "mm:ss" */ +.filter('osSecondsToTime', [ + function () { + return function (totalseconds) { + var time; + var total = Math.abs(totalseconds); + if (parseInt(totalseconds)) { + var hh = Math.floor(total / 3600); + var mm = Math.floor(total % 3600 / 60); + var ss = Math.floor(total % 60); + var zero = "0"; + // Add leading "0" for double digit values + hh = (zero+hh).slice(-2); + mm = (zero+mm).slice(-2); + ss = (zero+ss).slice(-2); + if (hh == "00") + time = mm + ':' + ss; + else + time = hh + ":" + mm + ":" + ss; + if (totalseconds < 0) + time = "-"+time; + } else { + time = "--:--"; + } + return time; + }; + } +]) // Make sure that the DS factories are loaded by making them a dependency .run(['Projector', 'Config', 'Tag', 'Customslide', function(Projector, Config, Tag, Customslide){}]); @@ -572,12 +606,16 @@ angular.module('OpenSlidesApp.core.site', [ ]) // Version Controller -.controller('VersionCtrl', function ($scope, $http) { - $http.get('/core/version/').success(function(data) { - $scope.core_version = data.openslides_version; - $scope.plugins = data.plugins; - }); -}) +.controller('VersionCtrl', [ + '$scope', + '$http', + function ($scope, $http) { + $http.get('/core/version/').success(function(data) { + $scope.core_version = data.openslides_version; + $scope.plugins = data.plugins; + }); + } +]) // Config Controller .controller('ConfigCtrl', function($scope, Config, configOption) { @@ -592,33 +630,231 @@ angular.module('OpenSlidesApp.core.site', [ }) // Customslide Controller -.controller('CustomslideListCtrl', function($scope, Customslide) { - Customslide.bindAll({}, $scope, 'customslides'); +.controller('CustomslideListCtrl', [ + '$scope', + '$http', + 'Customslide', + function($scope, $http, Customslide) { + Customslide.bindAll({}, $scope, 'customslides'); - // setup table sorting - $scope.sortColumn = 'title'; - $scope.reverse = false; - // function to sort by clicked column - $scope.toggleSort = function ( column ) { - if ( $scope.sortColumn === column ) { - $scope.reverse = !$scope.reverse; - } - $scope.sortColumn = column; - }; - - // save changed customslide - $scope.save = function (customslide) { - Customslide.save(customslide); - }; - $scope.delete = function (customslide) { - //TODO: add confirm message - Customslide.destroy(customslide.id).then( - function(success) { - //TODO: success message + // setup table sorting + $scope.sortColumn = 'title'; + $scope.reverse = false; + // function to sort by clicked column + $scope.toggleSort = function ( column ) { + if ( $scope.sortColumn === column ) { + $scope.reverse = !$scope.reverse; } - ); - }; -}) + $scope.sortColumn = column; + }; + + // save changed customslide + $scope.save = function (customslide) { + Customslide.save(customslide); + }; + $scope.delete = function (customslide) { + //TODO: add confirm message + Customslide.destroy(customslide.id).then( + function(success) { + //TODO: success message + } + ); + }; + } +]) + +// Projector Control Controller +.controller('ProjectorControlCtrl', [ + '$scope', + '$http', + '$interval', + '$state', + 'Config', + 'Projector', + function($scope, $http, $interval, $state, Config, Projector) { + // bind projector elements to the scope, update after projector changed + $scope.$watch(function () { + return Projector.lastModified(1); + }, function () { + // stop ALL interval timer + for (var i=0; i<$scope.countdowns.length; i++) { + if ( $scope.countdowns[i].interval ) { + $interval.cancel($scope.countdowns[i].interval); + } + } + // rebuild all variables after projector update + $scope.rebuildAllElements(); + }); + $scope.$on('$destroy', function() { + // Cancel all intervals if the controller is destroyed + for (var i=0; i<$scope.countdowns.length; i++) { + if ( $scope.countdowns[i].interval ) { + $interval.cancel($scope.countdowns[i].interval); + } + } + + }); + + // *** countdown functions *** + $scope.calculateCountdownTime = function (countdown) { + countdown.seconds = Math.floor( countdown.countdown_time - Date.now() / 1000 + $scope.serverOffset ); + } + $scope.rebuildAllElements = function () { + $scope.countdowns = []; + $scope.messages = []; + // iterate via all projector elements and catch all countdowns and messages + $.each(Projector.get(1).elements, function(key, value) { + if (value.name == 'core/countdown') { + $scope.countdowns.push(value); + if (value.status == "running") { + // calculate remaining seconds directly because interval starts with 1 second delay + $scope.calculateCountdownTime(value); + // start interval timer (every second) + value.interval = $interval( function() { $scope.calculateCountdownTime(value); }, 1000); + } else { + value.seconds = value.countdown_time; + } + } + if (value.name == 'core/message') { + $scope.messages.push(value); + } + }); + $scope.scrollLevel = Projector.get(1).scroll; + $scope.scaleLevel = Projector.get(1).scale; + } + + // get initial values for $scope.countdowns, $scope.messages, $scope.scrollLevel + // and $scope.scaleLevel (after page reload) + $scope.rebuildAllElements(); + + $scope.addCountdown = function () { + var defaultvalue = parseInt(Config.get('projector_default_countdown').value); + $http.post('/rest/core/projector/1/activate_elements/', [{ + name: 'core/countdown', + status: 'stop', + visible: false, + index: $scope.countdowns.length, + countdown_time: defaultvalue, + default: defaultvalue, + stable: true + }]); + }; + $scope.removeCountdown = function (countdown) { + var data = {}; + var delta = 0; + // rebuild index for all countdowns after the selected (deleted) countdown + for (var i=0; i<$scope.countdowns.length; i++) { + if ( $scope.countdowns[i].uuid == countdown.uuid ) { + delta = 1; + } else if (delta > 0) { + data[$scope.countdowns[i].uuid] = { "index": i - delta }; + } + } + $http.post('/rest/core/projector/1/deactivate_elements/', [countdown.uuid]); + if (Object.keys(data).length > 0) { + $http.post('/rest/core/projector/1/update_elements/', data); + } + }; + $scope.showCountdown = function (countdown) { + var data = {}; + data[countdown.uuid] = { "visible": !countdown.visible }; + $http.post('/rest/core/projector/1/update_elements/', data); + }; + $scope.editCountdown = function (countdown) { + var data = {}; + data[countdown.uuid] = { + "description": countdown.description, + "default": parseInt(countdown.default) + }; + if (countdown.status == "stop") { + data[countdown.uuid].countdown_time = parseInt(countdown.default); + } + $http.post('/rest/core/projector/1/update_elements/', data); + }; + $scope.startCountdown = function (countdown) { + var data = {}; + // calculate end point of countdown (in seconds!) + var endTimestamp = Date.now() / 1000 - $scope.serverOffset + countdown.countdown_time; + data[countdown.uuid] = { + "status": "running", + "countdown_time": endTimestamp + }; + $http.post('/rest/core/projector/1/update_elements/', data); + }; + $scope.stopCountdown = function (countdown) { + var data = {}; + // calculate rest duration of countdown (in seconds!) + var newDuration = Math.floor( countdown.countdown_time - Date.now() / 1000 + $scope.serverOffset ); + data[countdown.uuid] = { + "status": "stop", + "countdown_time": newDuration + }; + $http.post('/rest/core/projector/1/update_elements/', data); + }; + $scope.resetCountdown = function (countdown) { + var data = {}; + data[countdown.uuid] = { + "status": "stop", + "countdown_time": countdown.default, + }; + $http.post('/rest/core/projector/1/update_elements/', data); + }; + + // *** message functions *** + $scope.addMessage = function () { + $http.post('/rest/core/projector/1/activate_elements/', [{ + name: 'core/message', + visible: false, + index: $scope.messages.length, + message: '', + stable: true + }]); + }; + $scope.removeMessage = function (message) { + $http.post('/rest/core/projector/1/deactivate_elements/', [message.uuid]); + }; + $scope.showMessage = function (message) { + var data = {}; + // if current message is activated, deactivate all other messages + if ( !message.visible ) { + for (var i=0; i<$scope.messages.length; i++) { + if ( $scope.messages[i].uuid == message.uuid ) { + data[$scope.messages[i].uuid] = { "visible": true }; + } else { + data[$scope.messages[i].uuid] = { "visible": false }; + } + } + } else { + data[message.uuid] = { "visible": false }; + } + $http.post('/rest/core/projector/1/update_elements/', data); + }; + $scope.editMessage = function (message) { + var data = {}; + data[message.uuid] = { + "message": message.message, + }; + $http.post('/rest/core/projector/1/update_elements/', data); + message.editMessageFlag = false; + }; + + // *** projector controls *** + $scope.scrollLevel = Projector.get(1).scroll; + $scope.scaleLevel = Projector.get(1).scale; + $scope.controlProjector = function (action, direction) { + $http.post('/rest/core/projector/1/control_view/', {"action": action, "direction": direction}); + }; + $scope.editCurrentSlide = function () { + $.each(Projector.get(1).elements, function(key, value) { + if (value.name != 'core/clock' && + value.name != 'core/countdown' && + value.name != 'core/message' ) { + $state.go(value.name.replace('/', '.')+'.detail.update', {id: value.id }); + } + }); + }; + } +]) .controller('CustomslideDetailCtrl', function($scope, Customslide, customslide) { Customslide.bindOne(customslide.id, $scope, 'customslide'); @@ -748,13 +984,14 @@ angular.module('OpenSlidesApp.core.projector', ['OpenSlidesApp.core']) slidesProvider.registerSlide('core/clock', { template: 'static/templates/core/slide_clock.html', }); -}) -.filter('osServertime',function() { - return function(serverOffset) { - var date = new Date(); - return date.setTime(date.getTime() - serverOffset); - }; + slidesProvider.registerSlide('core/countdown', { + template: 'static/templates/core/slide_countdown.html', + }); + + slidesProvider.registerSlide('core/message', { + template: 'static/templates/core/slide_message.html', + }); }) .controller('ProjectorCtrl', function($scope, Projector, slides) { @@ -770,22 +1007,70 @@ angular.module('OpenSlidesApp.core.projector', ['OpenSlidesApp.core']) console.error("Error for slide " + element.name + ": " + element.error) } }); + $scope.scroll = -10 * Projector.get(1).scroll; + $scope.scale = 100 + 20 * Projector.get(1).scale; }); }); }) -.controller('SlideCustomSlideCtrl', function($scope, Customslide) { - // Attention! Each object that is used here has to be dealt on server side. - // Add it to the coresponding get_requirements method of the ProjectorElement - // class. - var id = $scope.element.id; - Customslide.find(id); - Customslide.bindOne(id, $scope, 'customslide'); -}) +.controller('SlideCustomSlideCtrl', [ + '$scope', + 'Customslide', + function($scope, Customslide) { + // Attention! Each object that is used here has to be dealt on server side. + // Add it to the coresponding get_requirements method of the ProjectorElement + // class. + var id = $scope.element.id; + Customslide.find(id); + Customslide.bindOne(id, $scope, 'customslide'); + } +]) -.controller('SlideClockCtrl', function($scope) { - // Attention! Each object that is used here has to be dealt on server side. - // Add it to the coresponding get_requirements method of the ProjectorElement - // class. - $scope.serverOffset = Date.parse(new Date().toUTCString()) - $scope.element.context.server_time; -}); +.controller('SlideClockCtrl', [ + '$scope', + function($scope) { + // Attention! Each object that is used here has to be dealt on server side. + // Add it to the coresponding get_requirements method of the ProjectorElement + // class. + $scope.servertime = ( Date.now() / 1000 - $scope.serverOffset ) * 1000; + } +]) + +.controller('SlideCountdownCtrl', [ + '$scope', + '$interval', + function($scope, $interval) { + // Attention! Each object that is used here has to be dealt on server side. + // Add it to the coresponding get_requirements method of the ProjectorElement + // class. + $scope.seconds = Math.floor( $scope.element.countdown_time - Date.now() / 1000 + $scope.serverOffset ); + $scope.status = $scope.element.status; + $scope.visible = $scope.element.visible; + $scope.index = $scope.element.index; + $scope.description = $scope.element.description; + // start interval timer if countdown status is running + var interval; + if ($scope.status == "running") { + interval = $interval( function() { + $scope.seconds = Math.floor( $scope.element.countdown_time - Date.now() / 1000 + $scope.serverOffset ); + }, 1000); + } else { + $scope.seconds = $scope.element.countdown_time; + } + $scope.$on('$destroy', function() { + // Cancel the interval if the controller is destroyed + $interval.cancel(interval); + }); + } +]) + +.controller('SlideMessageCtrl', [ + '$scope', + function($scope) { + // Attention! Each object that is used here has to be dealt on server side. + // Add it to the coresponding get_requirements method of the ProjectorElement + // class. + $scope.message = $scope.element.message; + $scope.visible = $scope.element.visible; + } +]); diff --git a/openslides/core/static/templates/core/customslide-list.html b/openslides/core/static/templates/core/customslide-list.html index d871b5a0d..d013e7075 100644 --- a/openslides/core/static/templates/core/customslide-list.html +++ b/openslides/core/static/templates/core/customslide-list.html @@ -48,15 +48,8 @@
-
-

Projector live view

- -
- -
-
-
+
diff --git a/openslides/core/static/templates/core/projector-controls.html b/openslides/core/static/templates/core/projector-controls.html new file mode 100644 index 000000000..da6b3d09d --- /dev/null +++ b/openslides/core/static/templates/core/projector-controls.html @@ -0,0 +1,177 @@ + +

Projector

+ +
+ +
+
+
+ +
+ +

+ + + +   + + + + + + + {{ scaleLevel }} +   + + + + + + + + + + {{ scrollLevel }} +

+ + +
+
+
+ {{ countdown.description }} + Countdown {{ countdown.index +1 }} + + + + +
+
+ + + + +    + + + + + + + + + + + + + {{ countdown.seconds | osSecondsToTime }} + + +
+
+ + +
+
+ + +
+ + +
+
+
+ + + Add new countdown + +
+ + +
+

Messages

+
+
+ + + + +    + {{ message.message }} + + + +
+ + + + +
+
+
+ + + Add new message + +
+
diff --git a/openslides/core/static/templates/core/slide_clock.html b/openslides/core/static/templates/core/slide_clock.html index 3f4f205df..ff12cb007 100644 --- a/openslides/core/static/templates/core/slide_clock.html +++ b/openslides/core/static/templates/core/slide_clock.html @@ -1,4 +1,4 @@
- {{ serverOffset | osServertime | date:'HH:mm' }} + {{ servertime | date:'HH:mm' }}
diff --git a/openslides/core/static/templates/core/slide_countdown.html b/openslides/core/static/templates/core/slide_countdown.html new file mode 100644 index 000000000..17949e128 --- /dev/null +++ b/openslides/core/static/templates/core/slide_countdown.html @@ -0,0 +1,8 @@ +
+
+
+ {{ seconds | osSecondsToTime}} +
{{ description }}
+
+
+
diff --git a/openslides/core/static/templates/core/slide_customslide.html b/openslides/core/static/templates/core/slide_customslide.html index 4890dd0b7..4b6d20a8f 100644 --- a/openslides/core/static/templates/core/slide_customslide.html +++ b/openslides/core/static/templates/core/slide_customslide.html @@ -1,4 +1,4 @@ -
+

{{ customslide.title }}

{{ customslide.text }}
diff --git a/openslides/core/static/templates/core/slide_message.html b/openslides/core/static/templates/core/slide_message.html new file mode 100644 index 000000000..1d86416c7 --- /dev/null +++ b/openslides/core/static/templates/core/slide_message.html @@ -0,0 +1,4 @@ +
+
+
{{ message }}
+
diff --git a/openslides/core/static/templates/index.html b/openslides/core/static/templates/index.html index 46e23c9b0..e8d40ff11 100644 --- a/openslides/core/static/templates/index.html +++ b/openslides/core/static/templates/index.html @@ -54,6 +54,7 @@
+
diff --git a/openslides/core/views.py b/openslides/core/views.py index 85289b9f5..5fc10bdc8 100644 --- a/openslides/core/views.py +++ b/openslides/core/views.py @@ -247,7 +247,7 @@ class ProjectorViewSet(ReadOnlyModelViewSet): for key, value in request.data.items(): if key not in projector_config: raise ValidationError({'data': 'Invalid projector element. Wrong UUID.'}) - projector_config.update(request.data) + projector_config[key].update(request.data[key]) serializer = self.get_serializer(projector_instance, data={'config': projector_config}, partial=False) serializer.is_valid(raise_exception=True) diff --git a/tests/integration/agenda/test_viewsets.py b/tests/integration/agenda/test_viewsets.py index 445fa159f..bdf7e6c34 100644 --- a/tests/integration/agenda/test_viewsets.py +++ b/tests/integration/agenda/test_viewsets.py @@ -183,6 +183,17 @@ class Speak(TestCase): def test_begin_speech_with_countdown(self): config['agenda_couple_countdown_and_speakers'] = True + projector = Projector.objects.get(pk=1) + projector.config['03e87dea9c3f43c88b756c06a4c044fb'] = { + 'name': 'core/countdown', + 'status': 'stop', + 'visible': True, + 'default': 60, + 'countdown_time': 60, + 'stable': True, + 'index': 0 + } + projector.save() Speaker.objects.add(self.user, self.item) speaker = Speaker.objects.add(get_user_model().objects.get(username='admin'), self.item) self.client.put( @@ -199,6 +210,17 @@ class Speak(TestCase): def test_end_speech_with_countdown(self): config['agenda_couple_countdown_and_speakers'] = True + projector = Projector.objects.get(pk=1) + projector.config['03e87dea9c3f43c88b756c06a4c044fb'] = { + 'name': 'core/countdown', + 'status': 'stop', + 'visible': True, + 'default': 60, + 'countdown_time': 60, + 'stable': True, + 'index': 0 + } + projector.save() speaker = Speaker.objects.add(get_user_model().objects.get(username='admin'), self.item) speaker.begin_speech() self.client.delete(reverse('item-speak', args=[self.item.pk])) diff --git a/tests/integration/core/test_views.py b/tests/integration/core/test_views.py index 61e97868a..0791731fc 100644 --- a/tests/integration/core/test_views.py +++ b/tests/integration/core/test_views.py @@ -33,6 +33,7 @@ class ProjectorAPI(TestCase): 'elements': { 'aae4a07b26534cfb9af4232f361dce73': {'id': customslide.id, + 'uuid': 'aae4a07b26534cfb9af4232f361dce73', 'name': 'core/customslide', 'context': None}}, 'scale': 0, @@ -53,6 +54,7 @@ class ProjectorAPI(TestCase): 'elements': { 'fc6ef43b624043068c8e6e7a86c5a1b0': {'name': 'invalid_slide', + 'uuid': 'fc6ef43b624043068c8e6e7a86c5a1b0', 'error': 'Projector element does not exist.'}}, 'scale': 0, 'scroll': 0})