Merge pull request #3212 from normanjaeckel/Notify

Added notify system.
This commit is contained in:
Emanuel Schütze 2017-04-27 15:07:15 +02:00 committed by GitHub
commit 9387f83f8a
6 changed files with 309 additions and 35 deletions

View File

@ -23,6 +23,8 @@ Motions:
- Added support for export motions in a zip archive [#3189]. - Added support for export motions in a zip archive [#3189].
- Bugfix: changing motion line length did not invalidate cache [#3202] - Bugfix: changing motion line length did not invalidate cache [#3202]
- Bugfix: Added more distance in motion PDF for DEL-tags in new lines [#3211]. - 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: Users:
- User without permission to see users can now see agenda item speakers, - User without permission to see users can now see agenda item speakers,
@ -38,6 +40,7 @@ Core:
application [#3172]. application [#3172].
- Adding support for choosing image files as logos [#3184, #3207]. - Adding support for choosing image files as logos [#3184, #3207].
- Fixing error when clearing empty chat [#3199]. - Fixing error when clearing empty chat [#3199].
- Added notify system [#3212].
General: General:
- Several bugfixes and minor improvements. - Several bugfixes and minor improvements.

View File

@ -1196,7 +1196,7 @@ img {
border-color: #d6e9c6; border-color: #d6e9c6;
} }
#messaging .warning { #messaging .warning {
color: #8a6d3b; color: #725523;
background-color: #fcf8e3; background-color: #fcf8e3;
border-color: #faebcc; border-color: #faebcc;
} }

View File

@ -89,9 +89,15 @@ angular.module('OpenSlidesApp.core', [
} }
}; };
socket.onmessage = function (event) { socket.onmessage = function (event) {
var dataList = [];
try {
dataList = JSON.parse(event.data);
_.forEach(Autoupdate.messageReceivers, function (receiver) { _.forEach(Autoupdate.messageReceivers, function (receiver) {
receiver(event.data); receiver(dataList);
}); });
} catch(err) {
console.error(err);
}
// Check if the promise is not resolved yet. // Check if the promise is not resolved yet.
if (Autoupdate.firstMessageDeferred.promise.$$state.status === 0) { if (Autoupdate.firstMessageDeferred.promise.$$state.status === 0) {
Autoupdate.firstMessageDeferred.resolve(); Autoupdate.firstMessageDeferred.resolve();
@ -99,6 +105,11 @@ angular.module('OpenSlidesApp.core', [
ErrorMessage.clearConnectionError(); ErrorMessage.clearConnectionError();
}; };
}; };
Autoupdate.send = function (message) {
if (socket) {
socket.send(JSON.stringify(message));
}
};
Autoupdate.closeConnection = function () { Autoupdate.closeConnection = function () {
if (socket) { if (socket) {
socket.close(); socket.close();
@ -262,25 +273,19 @@ angular.module('OpenSlidesApp.core', [
'autoupdate', 'autoupdate',
'dsEject', 'dsEject',
function (DS, autoupdate, dsEject) { function (DS, autoupdate, dsEject) {
autoupdate.onMessage(function(json) { // Handler for normal autoupdate messages.
// TODO: when MODEL.find() is called after this autoupdate.onMessage(function(dataList) {
// a new request is fired. This could be a bug in DS
var dataList = [];
try {
dataList = JSON.parse(json);
} catch(err) {
console.error(json);
}
var dataListByCollection = _.groupBy(dataList, 'collection'); var dataListByCollection = _.groupBy(dataList, 'collection');
_.forEach(dataListByCollection, function(list, key) { _.forEach(dataListByCollection, function (list, key) {
var changedElements = []; var changedElements = [];
var deletedElements = []; var deletedElements = [];
var collectionString = key; var collectionString = key;
_.forEach(list, function(data) { _.forEach(list, function (data) {
// Uncomment this line for debugging to log all autoupdates: // Uncomment this line for debugging to log all autoupdates:
// console.log("Received object: " + data.collection + ", " + data.id); // console.log("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 // remove (=eject) object from local DS store
var instance = DS.get(data.collection, data.id); var instance = DS.get(data.collection, data.id);
if (instance) { if (instance) {
@ -295,6 +300,7 @@ angular.module('OpenSlidesApp.core', [
console.error('Error: Undefined action for received object' + console.error('Error: Undefined action for received object' +
'(' + data.collection + ', ' + data.id + ')'); '(' + data.collection + ', ' + data.id + ')');
} }
}
}); });
// add (=inject) all given objects into local DS store // add (=inject) all given objects into local DS store
if (changedElements.length > 0) { if (changedElements.length > 0) {
@ -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. // Save the server time to the rootscope.
.run([ .run([
'$http', '$http',
@ -1200,7 +1308,8 @@ angular.module('OpenSlidesApp.core', [
'Projector', 'Projector',
'ProjectionDefault', 'ProjectionDefault',
'Tag', '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) {}
]); ]);
}()); }());

View File

@ -287,7 +287,8 @@ angular.module('OpenSlidesApp.motions.site', [
'Workflow', 'Workflow',
'Agenda', 'Agenda',
'AgendaTree', '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 { return {
// ngDialog for motion form // ngDialog for motion form
getDialog: function (motion) { getDialog: function (motion) {
@ -1682,6 +1683,8 @@ angular.module('OpenSlidesApp.motions.site', [
.controller('MotionUpdateCtrl', [ .controller('MotionUpdateCtrl', [
'$scope', '$scope',
'$state', '$state',
'operator',
'gettextCatalog',
'Motion', 'Motion',
'Category', 'Category',
'Config', 'Config',
@ -1692,10 +1695,12 @@ angular.module('OpenSlidesApp.motions.site', [
'Workflow', 'Workflow',
'Agenda', 'Agenda',
'AgendaUpdate', 'AgendaUpdate',
'Notify',
'Messaging',
'motionId', 'motionId',
'ErrorMessage', 'ErrorMessage',
function($scope, $state, Motion, Category, Config, Mediafile, MotionForm, Tag, function($scope, $state, operator, gettextCatalog, Motion, Category, Config, Mediafile, MotionForm,
User, Workflow, Agenda, AgendaUpdate, motionId, ErrorMessage) { Tag, User, Workflow, Agenda, AgendaUpdate, Notify, Messaging, motionId, ErrorMessage) {
Category.bindAll({}, $scope, 'categories'); Category.bindAll({}, $scope, 'categories');
Mediafile.bindAll({}, $scope, 'mediafiles'); Mediafile.bindAll({}, $scope, 'mediafiles');
Tag.bindAll({}, $scope, 'tags'); 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) { $scope.save = function (motion, gotoDetailView) {
// inject the changed motion (copy) object back into DS store // inject the changed motion (copy) object back into DS store
Motion.inject(motion); Motion.inject(motion);

View File

@ -6,6 +6,7 @@ from openslides.utils.autoupdate import (
ws_add_site, ws_add_site,
ws_disconnect_projector, ws_disconnect_projector,
ws_disconnect_site, ws_disconnect_site,
ws_receive_site,
) )
projector_routing = [ projector_routing = [
@ -16,6 +17,7 @@ projector_routing = [
site_routing = [ site_routing = [
route("websocket.connect", ws_add_site), route("websocket.connect", ws_add_site),
route("websocket.disconnect", ws_disconnect_site), route("websocket.disconnect", ws_disconnect_site),
route("websocket.receive", ws_receive_site),
] ]
channel_routing = [ channel_routing = [

View File

@ -1,7 +1,7 @@
import json import json
import time import time
import warnings import warnings
from collections import Iterable from collections import Iterable, defaultdict
from channels import Channel, Group from channels import Channel, Group
from channels.asgi import get_channel_layer 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) 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 @channel_session_user_from_http
def ws_add_projector(message, projector_id): def ws_add_projector(message, projector_id):
""" """