diff --git a/openslides/core/config.py b/openslides/core/config.py index f3683ad83..fc0869e9d 100644 --- a/openslides/core/config.py +++ b/openslides/core/config.py @@ -15,7 +15,7 @@ INPUT_TYPE_MAPPING = { 'integer': int, 'boolean': bool, 'choice': str, - 'comments': list, + 'comments': dict, 'colorpicker': str, 'datetimepicker': int, 'majorityMethod': str, @@ -101,17 +101,29 @@ class ConfigHandler: raise ConfigError(e.messages[0]) if config_variable.input_type == 'comments': - if not isinstance(value, list): - raise ConfigError(_('motions_comments has to be a list.')) - for comment in value: - if not isinstance(comment, dict): - raise ConfigError(_('Each element in motions_comments has to be a dict.')) - if comment.get('name') is None or comment.get('public') is None: - raise ConfigError(_('A name and a public property have to be given.')) - if not isinstance(comment['name'], str): - raise ConfigError(_('name has to be string.')) - if not isinstance(comment['public'], bool): - raise ConfigError(_('public property has to be bool.')) + if not isinstance(value, dict): + raise ConfigError(_('motions_comments has to be a dict.')) + valuecopy = dict() + for id, commentsfield in value.items(): + try: + id = int(id) + except ValueError: + raise ConfigError(_('Each id has to be an int.')) + + if id < 1: + raise ConfigError(_('Each id has to be greater then 0.')) + # Deleted commentsfields are saved as None to block the used ids + if commentsfield is not None: + if not isinstance(commentsfield, dict): + raise ConfigError(_('Each commentsfield in motions_comments has to be a dict.')) + if commentsfield.get('name') is None or commentsfield.get('public') is None: + raise ConfigError(_('A name and a public property have to be given.')) + if not isinstance(commentsfield['name'], str): + raise ConfigError(_('name has to be string.')) + if not isinstance(commentsfield['public'], bool): + raise ConfigError(_('public property has to be bool.')) + valuecopy[id] = commentsfield + value = valuecopy if config_variable.input_type == 'logo': if not isinstance(value, dict): diff --git a/openslides/core/static/js/core/site.js b/openslides/core/static/js/core/site.js index e4cd2c079..1c053357d 100644 --- a/openslides/core/static/js/core/site.js +++ b/openslides/core/static/js/core/site.js @@ -743,15 +743,6 @@ angular.module('OpenSlidesApp.core.site', [ } ]) -.filter('excludeSpecialComments', function () { - return function (comments) { - return _.filter(comments, function (comment) { - var specialComment = comment.forState || comment.forRecommendation; - return !specialComment; - }); - }; -}) - // angular formly config options .run([ 'formlyConfig', @@ -1124,14 +1115,20 @@ angular.module('OpenSlidesApp.core.site', [ // For comments input $scope.addComment = function (configOption, parent) { - parent.value.push({ + var maxId = _.max(_.keys(parent.value)); + if (maxId === undefined) { + maxId = 1; + } else { + maxId = parseInt(maxId) + 1; + } + parent.value[maxId] = { name: gettextCatalog.getString('New'), public: false, - }); + }; $scope.save(configOption, parent.value); }; - $scope.removeComment = function (configOption, parent, index) { - parent.value.splice(index, 1); + $scope.removeComment = function (configOption, parent, id) { + parent.value[id] = null; $scope.save(configOption, parent.value); }; diff --git a/openslides/core/static/templates/config-form-field.html b/openslides/core/static/templates/config-form-field.html index d192c55fa..8ee60eb7d 100644 --- a/openslides/core/static/templates/config-form-field.html +++ b/openslides/core/static/templates/config-form-field.html @@ -20,8 +20,11 @@
-
- + diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index 0308a616f..ea34327b7 100644 --- a/openslides/motions/config_variables.py +++ b/openslides/motions/config_variables.py @@ -174,7 +174,7 @@ def get_config_variables(): yield ConfigVariable( name='motions_comments', - default_value=[], + default_value={}, input_type='comments', label='Comment fields for motions', weight=353, diff --git a/openslides/motions/migrations/0003_motion_comments.py b/openslides/motions/migrations/0003_motion_comments.py new file mode 100644 index 000000000..2622eb8be --- /dev/null +++ b/openslides/motions/migrations/0003_motion_comments.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations + + +def change_motions_comments(apps, schema_editor): + """ + Index the comments fields in the config. Changing from an array to a dict with the + ids as keys. CHange all motions from an array for comments to a dict with the comments + field id as key to link motion comments and comments fields. + """ + # We get the model from the versioned app registry; + # if we directly import it, it will be the wrong version. + ConfigStore = apps.get_model('core', 'ConfigStore') + Motion = apps.get_model('motions', 'Motion') + + try: + config_comments_fields = ConfigStore.objects.get(key='motions_comments').value + except ConfigStore.DoesNotExist: + config_comments_fields = [] # The old default: An empty list. + + comments_fields = {} + for index, field in enumerate(config_comments_fields): + comments_fields[index+1] = field + + max_index = len(config_comments_fields)-1 + + try: + db_value = ConfigStore.objects.get(key='motions_comments') + except ConfigStore.DoesNotExist: + db_value = ConfigStore(key='motions_comments') + db_value.value = comments_fields + # We cannot provide skip_autoupdate=True here, becuase this object is a fake object. It does *not* + # inherit from the RESTModelMixin, so the save() methos from base_model.py (django's default) + # gets called. This is because we are in the core app and try to save a core model. See + # comments in PR #3376. + db_value.save() + + for motion in Motion.objects.all(): + comments = {} + for index, comment in enumerate(motion.comments or []): + if index > max_index: + break + comments[index+1] = comment + motion.comments = comments + motion.save(skip_autoupdate=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('motions', '0002_misc_features'), + ] + + operations = [ + migrations.RunPython( + change_motions_comments + ), + ] diff --git a/openslides/motions/models.py b/openslides/motions/models.py index cddd9ca79..28fec2ead 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -167,7 +167,7 @@ class Motion(RESTModelMixin, models.Model): comments = JSONField(null=True) """ - Configurable fields for comments. Contains a list of strings. + Configurable fields for comments. """ # In theory there could be one then more agenda_item. But we support only diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index 77da86107..1df147583 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -105,11 +105,15 @@ class MotionCommentsJSONSerializerField(Field): """ Checks that data is a list of strings. """ - if type(data) is not list: - raise ValidationError({'detail': 'Data must be an array.'}) - for element in data: - if type(element) is not str: - raise ValidationError({'detail': 'Data must be an array of strings.'}) + if type(data) is not dict: + raise ValidationError({'detail': 'Data must be a dict.'}) + for id, comment in data.items(): + try: + id = int(id) + except ValueError: + raise ValidationError({'detail': 'Id must be an int.'}) + if type(comment) is not str: + raise ValidationError({'detail': 'Comment must be a string.'}) return data @@ -317,9 +321,9 @@ class MotionSerializer(ModelSerializer): data['text'] = validate_html(data['text']) if 'reason' in data: data['reason'] = validate_html(data['reason']) - validated_comments = [] - for comment in data.get('comments', []): - validated_comments.append(validate_html(comment)) + validated_comments = dict() + for id, comment in data.get('comments', {}).items(): + validated_comments[id] = validate_html(comment) data['comments'] = validated_comments return data diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js index c372b1f75..7f67303bc 100644 --- a/openslides/motions/static/js/motions/base.js +++ b/openslides/motions/static/js/motions/base.js @@ -678,18 +678,32 @@ angular.module('OpenSlidesApp.motions', [ // Service for generic comment fields .factory('MotionComment', [ + '$filter', 'Config', 'operator', 'Editor', - function (Config, operator, Editor) { + function ($filter, Config, operator, Editor) { return { - getFormFields: function () { + isSpecialCommentField: function (field) { + if (field) { + return field.forState || field.forRecommendation; + } else { + return false; + } + }, + getCommentsFields: function () { var fields = Config.get('motions_comments').value; - return _.map( - _.filter(fields, function(element) { return !element.forState && !element.forRecommendation; }), - function (field) { + return $filter('excludeDeletedAndForbiddenCommentsFields')(fields); + }, + getNoSpecialCommentsFields: function () { + var fields = this.getCommentsFields(); + return $filter('excludeSpecialCommentsFields')(fields); + }, + getFormFields: function () { + var fields = this.getNoSpecialCommentsFields(); + return _.map(fields, function (field, id) { return { - key: 'comment ' + field.name, + key: 'comment_' + id, type: 'editor', templateOptions: { label: field.name, @@ -704,36 +718,60 @@ angular.module('OpenSlidesApp.motions', [ }, populateFields: function (motion) { // Populate content of motion.comments to the single comment - // fields like motion['comment MyComment'], motion['comment MyOtherComment'], ... - var fields = Config.get('motions_comments').value; - if (!motion.comments) { - motion.comments = []; - } - for (var i = 0; i < fields.length; i++) { - motion['comment ' + fields[i].name] = motion.comments[i]; + var fields = this.getCommentsFields(); + if (motion.comments) { + _.forEach(fields, function (field, id) { + motion['comment_' + id] = motion.comments[id]; + }); } }, populateFieldsReverse: function (motion) { // Reverse equivalent to populateFields. - var fields = Config.get('motions_comments').value; - motion.comments = []; - for (var i = 0; i < fields.length; i++) { - motion.comments.push(motion['comment ' + fields[i].name] || ''); - } + var fields = this.getCommentsFields(); + motion.comments = {}; + _.forEach(fields, function (field, id) { + motion.comments[id] = motion['comment_' + id] || ''; + }); }, - getFieldNameForFlag: function (flag) { - var fields = Config.get('motions_comments').value; - var fieldName = ''; - var index = _.findIndex(fields, [flag, true]); - if (index > -1) { - fieldName = fields[index].name; - } - return fieldName; + getFieldIdForFlag: function (flag) { + var fields = this.getCommentsFields(); + return _.findKey(fields, [flag, true]); }, }; } ]) +.filter('excludeSpecialCommentsFields', [ + 'MotionComment', + function (MotionComment) { + return function (commentsFields) { + var withoutSpecialCommentsFields = {}; + _.forEach(commentsFields, function (field, id) { + if (!MotionComment.isSpecialCommentField(field)) { + withoutSpecialCommentsFields[id] = field; + } + }); + return withoutSpecialCommentsFields; + }; + } +]) + +.filter('excludeDeletedAndForbiddenCommentsFields', [ + 'MotionComment', + 'operator', + function (MotionComment, operator) { + return function (commentsFields) { + var withoutDeletedAndForbiddenCommentsFields = {}; + _.forEach(commentsFields, function (field, id) { + if (field && (field.public || operator.hasPerms('motions.can_see_and_manage_comments'))) { + withoutDeletedAndForbiddenCommentsFields[id] = field; + } + }); + return withoutDeletedAndForbiddenCommentsFields; + }; + } +]) + .factory('Category', [ 'DS', function(DS) { diff --git a/openslides/motions/static/js/motions/docx.js b/openslides/motions/static/js/motions/docx.js index cbf7086f1..584fc848e 100644 --- a/openslides/motions/static/js/motions/docx.js +++ b/openslides/motions/static/js/motions/docx.js @@ -14,7 +14,9 @@ angular.module('OpenSlidesApp.motions.docx', ['OpenSlidesApp.core.docx']) 'FileSaver', 'lineNumberingService', 'Html2DocxConverter', - function ($http, $q, operator, Config, Category, gettextCatalog, FileSaver, lineNumberingService, Html2DocxConverter) { + 'MotionComment', + function ($http, $q, operator, Config, Category, gettextCatalog, FileSaver, lineNumberingService, + Html2DocxConverter, MotionComment) { var PAGEBREAK = ''; @@ -153,19 +155,15 @@ angular.module('OpenSlidesApp.motions.docx', ['OpenSlidesApp.core.docx']) }; var getMotionComments = function (motion) { - var fields = Config.get('motions_comments').value; - var canSeeComment = function (index) { - var specialComment = fields[index].forState || fields[index].forRecommendation; - return (fields[index].public || operator.hasPerms('motions.can_manage')) && !specialComment; - }; + var fields = MotionComment.getNoSpecialCommentsFields(); var comments = []; - for (var i = 0; i < fields.length; i++) { - if (motion.comments[i] && canSeeComment(i)) { - var title = gettextCatalog.getString('Comment') + ' ' + fields[i].name; - if (!fields[i].public) { + _.forEach(fields, function (field, id) { + if (motion.comments[id]) { + var title = gettextCatalog.getString('Comment') + ' ' + field.name; + if (!field.public) { title += ' (' + gettextCatalog.getString('internal') + ')'; } - var comment = motion.comments[i]; + var comment = motion.comments[id]; if (comment.indexOf('

') !== 0) { comment = '

' + comment + '

'; } @@ -174,7 +172,7 @@ angular.module('OpenSlidesApp.motions.docx', ['OpenSlidesApp.core.docx']) comment: comment, }); } - } + }); return comments; }; diff --git a/openslides/motions/static/js/motions/motion-services.js b/openslides/motions/static/js/motions/motion-services.js index 96acd82f4..173a188a6 100644 --- a/openslides/motions/static/js/motions/motion-services.js +++ b/openslides/motions/static/js/motions/motion-services.js @@ -134,14 +134,14 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions', editors: [] }; var options = Editor.getOptions('inline', 'YOffset'); - _.forEach($scope.commentsFieldsNoSpecialComments, function (field) { + _.forEach($scope.noSpecialCommentsFields, function (field, id) { var inlineEditing = MotionInlineEditing.createInstance($scope, motion, - 'view-original-comment-inline-editor-' + field.name, false, options, + 'view-original-comment-inline-editor-' + id, false, options, function (obj) { - return motion['comment ' + field.name]; + return motion['comment_' + id]; }, function (obj) { - motion['comment ' + field.name] = obj.editor.getData(); + motion['comment_' + id] = obj.editor.getData(); } ); commentsInlineEditing.editors.push(inlineEditing); diff --git a/openslides/motions/static/js/motions/pdf.js b/openslides/motions/static/js/motions/pdf.js index 16de80897..e72c67e8a 100644 --- a/openslides/motions/static/js/motions/pdf.js +++ b/openslides/motions/static/js/motions/pdf.js @@ -15,7 +15,9 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf']) 'Category', 'Config', 'Motion', - function($q, operator, gettextCatalog, PDFLayout, PdfMakeConverter, ImageConverter, HTMLValidizer, Category, Config, Motion) { + 'MotionComment', + function($q, operator, gettextCatalog, PDFLayout, PdfMakeConverter, ImageConverter, HTMLValidizer, + Category, Config, Motion, MotionComment) { /** * Provides the content as JS objects for Motions in pdfMake context * @constructor @@ -27,10 +29,19 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf']) var converter; // Query all image sources from motion text and reason - // TODO: Do we need images for comments here too?? var getImageSources = function () { var text = motion.getTextByMode(changeRecommendationMode, null); - var content = HTMLValidizer.validize(text) + HTMLValidizer.validize(motion.getReason()); + var reason = motion.getReason(); + var comments = ''; + if (includeComments) { + var fields = MotionComment.getNoSpecialCommentsFields(); + _.forEach(fields, function (field, id) { + if (motion.comments[id]) { + comments += HTMLValidizer.validize(motion.comments[id]); + } + }); + } + var content = HTMLValidizer.validize(text) + HTMLValidizer.validize(motion.getReason()) + comments; var map = Function.prototype.call.bind([].map); return map($(content).find('img'), function(element) { return element.getAttribute('src'); @@ -298,16 +309,12 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf']) // motion comments handling var motionComments = function () { if (includeComments) { - var fields = Config.get('motions_comments').value; - var canSeeComment = function (index) { - var specialComment = fields[index].forState || fields[index].forRecommendation; - return (fields[index].public || operator.hasPerms('motions.can_manage')) && !specialComment; - }; + var fields = MotionComment.getNoSpecialCommentsFields(); var comments = []; - for (var i = 0; i < fields.length; i++) { - if (motion.comments[i] && canSeeComment(i)) { - var title = gettextCatalog.getString('Comment') + ' ' + fields[i].name; - if (!fields[i].public) { + _.forEach(fields, function (field, id) { + if (motion.comments[id]) { + var title = gettextCatalog.getString('Comment') + ' ' + field.name; + if (!field.public) { title += ' (' + gettextCatalog.getString('internal') + ')'; } comments.push({ @@ -315,9 +322,9 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf']) style: 'heading3', marginTop: 25, }); - comments.push(converter.convertHTML(motion.comments[i])); + comments.push(converter.convertHTML(motion.comments[id])); } - } + }); return comments; } }; @@ -859,12 +866,13 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf']) 'PdfCreate', 'PDFLayout', 'PersonalNoteManager', + 'MotionComment', 'Messaging', 'FileSaver', function ($http, $q, operator, Config, gettextCatalog, MotionChangeRecommendation, HTMLValidizer, PdfMakeConverter, MotionContentProvider, MotionCatalogContentProvider, PdfMakeDocumentProvider, PollContentProvider, PdfMakeBallotPaperProvider, MotionPartialContentProvider, PdfCreate, - PDFLayout, PersonalNoteManager, Messaging, FileSaver) { + PDFLayout, PersonalNoteManager, MotionComment, Messaging, FileSaver) { return { getDocumentProvider: function (motions, params, singleMotion) { params = _.clone(params || {}); // Clone this to avoid sideeffects. @@ -1008,24 +1016,20 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf']) }); }, exportComments: function (motion, filename) { - var fields = Config.get('motions_comments').value; - var canSeeComment = function (index) { - var specialComment = fields[index].forState || fields[index].forRecommendation; - return (fields[index].public || operator.hasPerms('motions.can_manage')) && !specialComment; - }; + var fields = MotionComment.getNoSpecialCommentsFields(); var content = []; - for (var i = 0; i < fields.length; i++) { - if (motion.comments[i] && canSeeComment(i)) { - var title = gettextCatalog.getString('Comment') + ' ' + fields[i].name; - if (!fields[i].public) { + _.forEach(fields, function (field, id) { + if (motion.comments[id]) { + var title = gettextCatalog.getString('Comment') + ' ' + field.name; + if (!field.public) { title += ' (' + gettextCatalog.getString('internal') + ')'; } content.push({ heading: title, - text: motion.comments[i], + text: motion.comments[id], }); } - } + }); MotionPartialContentProvider.createInstance(motion, content).then(function (contentProvider) { PdfMakeDocumentProvider.createInstance(contentProvider).then(function (documentProvider) { PdfCreate.download(documentProvider.getDocument(), filename); diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index 3070f019a..a7babaad1 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -910,11 +910,10 @@ angular.module('OpenSlidesApp.motions.site', [ $scope.alert = {}; // Motion comments - $scope.commentsFields = Config.get('motions_comments').value; - $scope.commentsFieldsNoSpecialComments = _.filter($scope.commentsFields, function (field) { - var specialComment = field.forState || field.forRecommendation; - return !specialComment; - }); + $scope.noSpecialCommentsFields = MotionComment.getNoSpecialCommentsFields(); + $scope.showCommentsFilter = function () { + return _.keys($scope.noSpecialCommentsFields).length > 0; + }; // collect all states and all recommendations of all workflows $scope.states = []; @@ -1289,13 +1288,10 @@ angular.module('OpenSlidesApp.motions.site', [ }); } }; - $scope.commentsFields = Config.get('motions_comments').value; - $scope.commentsFieldsNoSpecialComments = _.filter($scope.commentsFields, function (field) { - var specialComment = field.forState || field.forRecommendation; - return !specialComment; - }); - $scope.commentFieldForState = MotionComment.getFieldNameForFlag('forState'); - $scope.commentFieldForRecommendation = MotionComment.getFieldNameForFlag('forRecommendation'); + $scope.commentsFields = MotionComment.getCommentsFields(); + $scope.noSpecialCommentsFields = MotionComment.getNoSpecialCommentsFields(); + $scope.commentFieldForStateId = MotionComment.getFieldIdForFlag('forState'); + $scope.commentFieldForRecommendationId = MotionComment.getFieldIdForFlag('forRecommendation'); $scope.version = motion.active_version; $scope.isCollapsed = true; $scope.lineNumberMode = Config.get('motions_default_line_numbering').value; @@ -1402,14 +1398,14 @@ angular.module('OpenSlidesApp.motions.site', [ // save additional state field $scope.saveAdditionalStateField = function (stateExtension) { if (stateExtension) { - motion["comment " + $scope.commentFieldForState] = stateExtension; + motion['comment_' + $scope.commentFieldForStateId] = stateExtension; $scope.save(motion); } }; // save additional recommendation field $scope.saveAdditionalRecommendationField = function (recommendationExtension) { if (recommendationExtension) { - motion["comment " + $scope.commentFieldForRecommendation] = recommendationExtension; + motion['comment_' + $scope.commentFieldForRecommendationId] = recommendationExtension; $scope.save(motion); } }; @@ -1471,18 +1467,9 @@ angular.module('OpenSlidesApp.motions.site', [ $scope.showVersion({id: motion.active_version}); }); }; - // check if user is allowed to see at least one comment field - $scope.isAllowedToSeeCommentField = function () { - var isAllowed = false; - if ($scope.commentsFields.length > 0) { - isAllowed = operator.hasPerms('motions.can_see_and_manage_comments') || _.find( - $scope.commentsFields, - function(field) { - return field.public && !field.forState && !field.forRecommendation; - } - ); - } - return Boolean(isAllowed); + // check if there is at least one comment field + $scope.commentFieldsAvailable = function () { + return _.keys($scope.noSpecialCommentsFields).length > 0; }; // personal note // For pinning the personal note container we need to adjust the width with JS. We diff --git a/openslides/motions/static/templates/motions/motion-detail.html b/openslides/motions/static/templates/motions/motion-detail.html index 4419aae11..ccb39a83f 100644 --- a/openslides/motions/static/templates/motions/motion-detail.html +++ b/openslides/motions/static/templates/motions/motion-detail.html @@ -161,10 +161,10 @@
- + + placeholder="{{ commentsFields[commentFieldForStateId] }}">
- + + placeholder="{{ commentsFields[commentFieldForRecommendationId] }}">