diff --git a/CHANGELOG b/CHANGELOG index 4fd085a01..a9c9a045b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -23,6 +23,8 @@ Motions: - Added support for export motions in a zip archive [#3189]. - Bugfix: changing motion line length did not invalidate cache [#3202] - Bugfix: Added more distance in motion PDF for DEL-tags in new lines [#3211]. +- Added warning message if an edit dialog was already opened by another + client [#3212]. Users: - User without permission to see users can now see agenda item speakers, @@ -38,6 +40,7 @@ Core: application [#3172]. - Adding support for choosing image files as logos [#3184, #3207]. - Fixing error when clearing empty chat [#3199]. +- Added notify system [#3212]. General: - Several bugfixes and minor improvements. diff --git a/openslides/core/static/css/app.css b/openslides/core/static/css/app.css index ca8755442..f6f6591a6 100644 --- a/openslides/core/static/css/app.css +++ b/openslides/core/static/css/app.css @@ -1196,7 +1196,7 @@ img { border-color: #d6e9c6; } #messaging .warning { - color: #8a6d3b; + color: #725523; background-color: #fcf8e3; border-color: #faebcc; } diff --git a/openslides/core/static/js/core/base.js b/openslides/core/static/js/core/base.js index 6307c6b02..fc60c3396 100644 --- a/openslides/core/static/js/core/base.js +++ b/openslides/core/static/js/core/base.js @@ -89,9 +89,15 @@ angular.module('OpenSlidesApp.core', [ } }; socket.onmessage = function (event) { - _.forEach(Autoupdate.messageReceivers, function (receiver) { - receiver(event.data); - }); + var dataList = []; + try { + dataList = JSON.parse(event.data); + _.forEach(Autoupdate.messageReceivers, function (receiver) { + receiver(dataList); + }); + } catch(err) { + console.error(err); + } // Check if the promise is not resolved yet. if (Autoupdate.firstMessageDeferred.promise.$$state.status === 0) { Autoupdate.firstMessageDeferred.resolve(); @@ -99,6 +105,11 @@ angular.module('OpenSlidesApp.core', [ ErrorMessage.clearConnectionError(); }; }; + Autoupdate.send = function (message) { + if (socket) { + socket.send(JSON.stringify(message)); + } + }; Autoupdate.closeConnection = function () { if (socket) { socket.close(); @@ -262,38 +273,33 @@ angular.module('OpenSlidesApp.core', [ 'autoupdate', 'dsEject', function (DS, autoupdate, dsEject) { - autoupdate.onMessage(function(json) { - // TODO: when MODEL.find() is called after this - // a new request is fired. This could be a bug in DS - var dataList = []; - try { - dataList = JSON.parse(json); - } catch(err) { - console.error(json); - } - + // Handler for normal autoupdate messages. + autoupdate.onMessage(function(dataList) { var dataListByCollection = _.groupBy(dataList, 'collection'); - _.forEach(dataListByCollection, function(list, key) { + _.forEach(dataListByCollection, function (list, key) { var changedElements = []; var deletedElements = []; var collectionString = key; - _.forEach(list, function(data) { + _.forEach(list, function (data) { // Uncomment this line for debugging to log all autoupdates: // console.log("Received object: " + data.collection + ", " + data.id); - // remove (=eject) object from local DS store - var instance = DS.get(data.collection, data.id); - if (instance) { - dsEject(data.collection, instance); - } - // check if object changed or deleted - if (data.action === 'changed') { - changedElements.push(data.data); - } else if (data.action === 'deleted') { - deletedElements.push(data.id); - } else { - console.error('Error: Undefined action for received object' + - '(' + data.collection + ', ' + data.id + ')'); + // Now handle autoupdate message but do not handle notify messages. + if (data.collection !== 'notify') { + // remove (=eject) object from local DS store + var instance = DS.get(data.collection, data.id); + if (instance) { + dsEject(data.collection, instance); + } + // check if object changed or deleted + if (data.action === 'changed') { + changedElements.push(data.data); + } else if (data.action === 'deleted') { + deletedElements.push(data.id); + } else { + console.error('Error: Undefined action for received object' + + '(' + data.collection + ', ' + data.id + ')'); + } } }); // add (=inject) all given objects into local DS store @@ -310,6 +316,108 @@ angular.module('OpenSlidesApp.core', [ } ]) +.factory('Notify', [ + 'autoupdate', + 'operator', + function (autoupdate, operator) { + var anonymousTrackId; + + // Handler for notify messages. + autoupdate.onMessage(function(dataList) { + var dataListByCollection = _.groupBy(dataList, 'collection'); + _.forEach(dataListByCollection.notify, function (notifyItem) { + // Check, if this current user (or anonymous instance) has send this notify. + if (notifyItem.senderUserId) { + if (operator.user) { // User send to user + notifyItem.sendBySelf = (notifyItem.senderUserId === operator.user.id); + } else { // User send to anonymous + notifyItem.sendBySelf = false; + } + } else { + if (operator.user) { // Anonymous send to user + notifyItem.sendBySelf = false; + } else { // Anonymous send to anonymous + notifyItem.sendBySelf = (notifyItem.anonymousTrackId === anonymousTrackId); + } + } + // notify registered receivers. + _.forEach(callbackReceivers[notifyItem.name], function (item) { + item.fn(notifyItem); + }); + }); + }); + + var callbackReceivers = {}; + /* Structure of callbackReceivers: + * event_name_one: [ {id:0, fn:fn}, {id:3, fn:fn} ], + * event_name_two: [ {id:2, fn:fn} ], + * */ + var idCounter = 0; + var eventNameRegex = new RegExp('^[a-zA-Z_-]+$'); + var externIdRegex = new RegExp('^[a-zA-Z_-]+\/[0-9]+$'); + return { + registerCallback: function (eventName, fn) { + if (!eventNameRegex.test(eventName)) { + throw 'eventName should only consist of [a-zA-z_-]'; + } else if (typeof fn === 'function') { + var id = idCounter++; + + if (!callbackReceivers[eventName]) { + callbackReceivers[eventName] = []; + } + callbackReceivers[eventName].push({ + id: id, + fn: fn, + }); + return eventName + '/' + id; + } else { + throw 'fn should be a function.'; + } + }, + deregisterCallback: function (externId) { + if (externIdRegex.test(externId)){ + var split = externId.split('/'); + var eventName = split[0]; + var id = parseInt(split[1]); + callbackReceivers[eventName] = _.filter(callbackReceivers[eventName], function (item) { + return item.id !== id; + }); + } else { + throw externId + ' is not a valid id'; + } + }, + // variable length of parameters, just pass ids. + deregisterCallbacks: function () { + _.forEach(arguments, this.deregisterCallback); + }, + notify: function(eventName, params, users, channels) { + if (eventNameRegex.test(eventName)) { + if (!params || typeof params !== 'object') { + params = {}; + } + + var notifyItem = { + collection: 'notify', + name: eventName, + params: params, + users: users, + replyChannels: channels, + }; + if (!operator.user) { + if (!anonymousTrackId) { + anonymousTrackId = Math.floor(Math.random()*1000000); + } + notifyItem.anonymousTrackId = anonymousTrackId; + } + autoupdate.send([notifyItem]); + } else { + throw 'eventName should only consist of [a-zA-z_-]'; + } + }, + }; + } +]) + // Save the server time to the rootscope. .run([ '$http', @@ -1200,7 +1308,8 @@ angular.module('OpenSlidesApp.core', [ 'Projector', 'ProjectionDefault', 'Tag', - function (ChatMessage, Config, Countdown, ProjectorMessage, Projector, ProjectionDefault, Tag) {} + 'Notify', // For setting up the autoupdate callback + function (ChatMessage, Config, Countdown, ProjectorMessage, Projector, ProjectionDefault, Tag, Notify) {} ]); }()); diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index 8f6baa1bd..b55b431d0 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -287,7 +287,8 @@ angular.module('OpenSlidesApp.motions.site', [ 'Workflow', 'Agenda', 'AgendaTree', - function (gettextCatalog, operator, Editor, MotionComment, Category, Config, Mediafile, MotionBlock, Tag, User, Workflow, Agenda, AgendaTree) { + function (gettextCatalog, operator, Editor, MotionComment, Category, Config, Mediafile, MotionBlock, + Tag, User, Workflow, Agenda, AgendaTree) { return { // ngDialog for motion form getDialog: function (motion) { @@ -1682,6 +1683,8 @@ angular.module('OpenSlidesApp.motions.site', [ .controller('MotionUpdateCtrl', [ '$scope', '$state', + 'operator', + 'gettextCatalog', 'Motion', 'Category', 'Config', @@ -1692,10 +1695,12 @@ angular.module('OpenSlidesApp.motions.site', [ 'Workflow', 'Agenda', 'AgendaUpdate', + 'Notify', + 'Messaging', 'motionId', 'ErrorMessage', - function($scope, $state, Motion, Category, Config, Mediafile, MotionForm, Tag, - User, Workflow, Agenda, AgendaUpdate, motionId, ErrorMessage) { + function($scope, $state, operator, gettextCatalog, Motion, Category, Config, Mediafile, MotionForm, + Tag, User, Workflow, Agenda, AgendaUpdate, Notify, Messaging, motionId, ErrorMessage) { Category.bindAll({}, $scope, 'categories'); Mediafile.bindAll({}, $scope, 'mediafiles'); Tag.bindAll({}, $scope, 'tags'); @@ -1750,7 +1755,97 @@ angular.module('OpenSlidesApp.motions.site', [ } } - // save motion + /* Notify for displaying a warning, if other users edit this motion too */ + var editorNames = []; + var messagingId = 'motionEditDialogOtherUsersWarning'; + var addActiveEditor = function (editorName) { + editorNames.push(editorName); + updateActiveEditors(); + }; + var removeActiveEditor = function (editorName) { + var firstIndex = _.indexOf(editorNames, editorName); + editorNames.splice(firstIndex, 1); + updateActiveEditors(); + }; + var updateActiveEditors = function () { + if (editorNames.length === 0) { + Messaging.deleteMessage(messagingId); + } else { + // This block is only for getting the message string together... + var editorsWithoutAnonymous = _.filter(editorNames, function (name) { + return name; + }); + var text = gettextCatalog.getString('Warning') + ': '; + if (editorsWithoutAnonymous.length === 0) { // Only anonymous + // Singular vs. plural + if (editorNames.length === 1) { + text += gettextCatalog.getString('One anonymous users is also editing this motion.'); + } else { + text += editorNames.length + ' ' + gettextCatalog.getString('anonymous users are also editing this motion.'); + } + } else { + // At least one named user. The max users to display is 5. Anonymous users doesn't get displayed + // by name, but the amount of users is shown. + text += _.slice(editorsWithoutAnonymous, 0, 5).join(', '); + if (editorsWithoutAnonymous.length > 5) { + // More than 5 users with names. + text += ', ... [+' + (editorNames.length - 5) + ']'; + } else if (editorsWithoutAnonymous.length !== editorNames.length) { + // Less than 5 users, so the difference is calculated different. + text += ', ... [+' + (editorNames.length - editorsWithoutAnonymous.length) + ']'; + } + // Singular vs. plural + if (editorNames.length === 1) { + text += ' ' + gettextCatalog.getString('is also editing this motion.'); + } else { + text += ' ' + gettextCatalog.getString('are also editing this motion.'); + } + } + Messaging.createOrEditMessage(messagingId, text, 'warning'); + } + }; + + var responseCallbackId = Notify.registerCallback('motion_dialog_open_response', function (notify) { + if (!notify.sendBySelf && notify.params.motionId === motionId) { + addActiveEditor(notify.params.name); + } + }); + var queryCallbackId = Notify.registerCallback('motion_dialog_open_query', function (notify) { + if (!notify.sendBySelf && notify.params.motionId === motionId) { + addActiveEditor(notify.params.name); + if (notify.senderUserId) { + Notify.notify('motion_dialog_open_response', { + name: operator.user ? operator.user.short_name : '', + motionId: motionId, + }, [notify.senderUserId]); + } else { + Notify.notify('motion_dialog_open_response', { + name: operator.user ? operator.user.short_name : '', + motionId: motionId, + }, null, [notify.senderReplyChannelName]); + } + } + }); + var closeCallbackId = Notify.registerCallback('motion_dialog_closed', function (notify) { + if (notify.params.motionId === motionId) { + removeActiveEditor(notify.params.name); + } + }); + + $scope.$on('$destroy', function () { + Notify.deregisterCallbacks(responseCallbackId, queryCallbackId, closeCallbackId); + Messaging.deleteMessage(messagingId); + Notify.notify('motion_dialog_closed', { + name: operator.user ? operator.user.short_name : '', + motionId: motionId + }); + }); + Notify.notify('motion_dialog_open_query', { + name: operator.user ? operator.user.short_name : '', + motionId: motionId, + }); + + // Save motion $scope.save = function (motion, gotoDetailView) { // inject the changed motion (copy) object back into DS store Motion.inject(motion); diff --git a/openslides/routing.py b/openslides/routing.py index 69fa74e45..d2f144f73 100644 --- a/openslides/routing.py +++ b/openslides/routing.py @@ -6,6 +6,7 @@ from openslides.utils.autoupdate import ( ws_add_site, ws_disconnect_projector, ws_disconnect_site, + ws_receive_site, ) projector_routing = [ @@ -16,6 +17,7 @@ projector_routing = [ site_routing = [ route("websocket.connect", ws_add_site), route("websocket.disconnect", ws_disconnect_site), + route("websocket.receive", ws_receive_site), ] channel_routing = [ diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index cd27f37d5..a585b2e69 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -1,7 +1,7 @@ import json import time import warnings -from collections import Iterable +from collections import Iterable, defaultdict from channels import Channel, Group from channels.asgi import get_channel_layer @@ -88,6 +88,71 @@ def ws_disconnect_site(message): websocket_user_cache.remove(message.user.id or 0, message.reply_channel.name) +@channel_session_user +def ws_receive_site(message): + """ + This function is called if a message from a client comes in. The message + should be a list. Every item is broadcasted to the given users (or all + users if no user list is given) if it is a notify element. + + The server adds the sender's user id (0 for anonymous) and reply + channel name so that a receiver client may reply to the sender or to all + sender's instances. + """ + try: + incomming = json.loads(message.content['text']) + except ValueError: + # Message content is invalid. Just do nothing. + pass + else: + if isinstance(incomming, list): + # Parse all items + receivers_users = defaultdict(list) + receivers_reply_channels = defaultdict(list) + items_for_all = [] + for item in incomming: + if item.get('collection') == 'notify': + use_receivers_dict = False + item['senderReplyChannelName'] = message.reply_channel.name + item['senderUserId'] = message.user.id or 0 + + # Force the params to be a dict + if not isinstance(item.get('params'), dict): + item['params'] = {} + + users = item.get('users') + if isinstance(users, list): + # Send this item only to all reply channels of some site users. + for user_id in users: + receivers_users[user_id].append(item) + use_receivers_dict = True + + reply_channels = item.get('replyChannels') + if isinstance(reply_channels, list): + # Send this item only to some reply channels. + for reply_channel_name in reply_channels: + receivers_reply_channels[reply_channel_name].append(item) + use_receivers_dict = True + + if not use_receivers_dict: + # Send this item to all reply channels. + items_for_all.append(item) + + # Send all items + for user_id, channel_names in websocket_user_cache.get_all().items(): + output = receivers_users[user_id] + if len(output) > 0: + for channel_name in channel_names: + send_or_wait(Channel(channel_name).send, {'text': json.dumps(output)}) + + for channel_name, output in receivers_reply_channels.items(): + if len(output) > 0: + send_or_wait(Channel(channel_name).send, {'text': json.dumps(output)}) + + if len(items_for_all) > 0: + send_or_wait(Group('site').send, {'text': json.dumps(items_for_all)}) + + @channel_session_user_from_http def ws_add_projector(message, projector_id): """