diff --git a/CHANGELOG b/CHANGELOG index ad568cbf3..abcf59ad5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -24,6 +24,7 @@ Motions: - Added origin field. - Added button to sort and number all motions in a category. - Introduced pdfMake for clientside generation of PDFs. +- Added configurable fields for comments. Users: - Added field is_committee and new default group Committees. diff --git a/README.rst b/README.rst index e718f235c..85bff1311 100644 --- a/README.rst +++ b/README.rst @@ -171,6 +171,8 @@ OpenSlides uses the following projects or parts of them: * `django-jsonfield `_, License: MIT +* `jsonschema `_, License: MIT + * `natsort `_, License: MIT * `PyPDF2 `_, License: BSD @@ -216,6 +218,7 @@ OpenSlides uses the following projects or parts of them: * `js-data `_, License: MIT * `js-data-angular `_, License: MIT * `js-data-http `_, License: MIT + * `jsen `_, License: MIT * `lodash `_, License: MIT * `ng-dialog `_, License: MIT * `ng-file-upload `_, License: MIT diff --git a/bower.json b/bower.json index dc521234c..32b9fab14 100644 --- a/bower.json +++ b/bower.json @@ -25,6 +25,7 @@ "jquery.cookie": "~1.4.1", "js-data": "~2.8.2", "js-data-angular": "~3.1.0", + "jsen": "~0.6.1", "lodash": "~3.10.0", "ng-dialog": "~0.5.6", "ng-file-upload": "~11.2.3", @@ -44,9 +45,9 @@ }, "tinymce-dist": { "main": [ - "tinymce.js", - "themes/modern/theme.js", - "plugins/*/plugin.js" + "tinymce.js", + "themes/modern/theme.js", + "plugins/*/plugin.js" ] } }, diff --git a/openslides/assignments/access_permissions.py b/openslides/assignments/access_permissions.py index db5fbe3ab..fc77dad06 100644 --- a/openslides/assignments/access_permissions.py +++ b/openslides/assignments/access_permissions.py @@ -26,7 +26,7 @@ class AssignmentAccessPermissions(BaseAccessPermissions): def get_restricted_data(self, full_data, user): """ Returns the restricted serialized data for the instance prepared - for the user. Removes unpublushed polls for non admins so that they + for the user. Removes unpublished polls for non admins so that they only get a result like the AssignmentShortSerializer would give them. """ if user.has_perm('assignments.can_manage'): diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py index f4546d5d3..35b45d0a3 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -1,3 +1,8 @@ +import json + +from jsonschema import ValidationError, validate + +from ..core.config import config from ..utils.access_permissions import BaseAccessPermissions @@ -19,6 +24,87 @@ class MotionAccessPermissions(BaseAccessPermissions): return MotionSerializer + def get_restricted_data(self, full_data, user): + """ + Returns the restricted serialized data for the instance prepared for + the user. Removes non public comment fields for some unauthorized + users. + """ + if user.has_perm('motions.can_see_and_manage_comments') or not full_data.get('comments'): + data = full_data + else: + data = full_data.copy() + for i, field in enumerate(self.get_comments_config_fields()): + if not field.get('public'): + try: + data['comments'][i] = None + except IndexError: + # No data in range. Just do nothing. + pass + return data + + def get_comments_config_fields(self): + """ + Take input from config field and parse it. It can be some + JSON or just a comma separated list of strings. + + The result is an array of objects. Each object contains + at least the name of the comment field See configSchema. + + Attention: This code does also exist on server side. + """ + configSchema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Motion Comments", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "public": { + "type": "boolean" + }, + "forRecommendation": { + "type": "boolean" + }, + "forState": { + "type": "boolean" + } + }, + "required": ["name"] + }, + "minItems": 1, + "uniqueItems": True + } + configValue = config['motions_comments'] + fields = None + isJSON = True + try: + fields = json.loads(configValue) + except ValueError: + isJSON = False + if isJSON: + # Config is JSON. Validate it. + try: + validate(fields, configSchema) + except ValidationError: + fields = [] + else: + # Config is a comma separated list of strings. Strip out + # empty parts. All valid strings lead to public comment + # fields. + fields = map( + lambda name: {'name': name, 'public': True}, + filter( + lambda name: name, + configValue.split(',') + ) + ) + return fields + class CategoryAccessPermissions(BaseAccessPermissions): """ diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index 469811f18..8be4fa638 100644 --- a/openslides/motions/config_variables.py +++ b/openslides/motions/config_variables.py @@ -151,6 +151,20 @@ def get_config_variables(): group='Motions', subgroup='Supporters') + # Comments + + yield ConfigVariable( + name='motions_comments', + default_value='Comment', + input_type='text', + label='Comment fields for motions', + help_text='Use comma separated list of field names for public ' + 'fields or use special JSON. Example: [{"name": "Hidden Comment", ' + '"public": false}, {"name": "Public Comment", "public": true}]', + weight=353, + group='Motions', + subgroup='Comments') + # Voting and ballot papers yield ConfigVariable( diff --git a/openslides/motions/migrations/0003_auto_20160819_0925.py b/openslides/motions/migrations/0003_auto_20160819_0925.py new file mode 100644 index 000000000..e182d66ac --- /dev/null +++ b/openslides/motions/migrations/0003_auto_20160819_0925.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.7 on 2016-08-19 09:25 +from __future__ import unicode_literals + +import jsonfield.fields + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('motions', '0002_motion_origin'), + ] + + operations = [ + migrations.AlterModelOptions( + name='motion', + options={ + 'default_permissions': (), + 'ordering': ('identifier',), + 'permissions': ( + ('can_see', 'Can see motions'), + ('can_create', 'Can create motions'), + ('can_support', 'Can support motions'), + ('can_see_and_manage_comments', 'Can see and manage comments'), + ('can_manage', 'Can manage motions')), + 'verbose_name': 'Motion'}, + ), + migrations.AddField( + model_name='motion', + name='comments', + field=jsonfield.fields.JSONField(null=True), + ), + ] diff --git a/openslides/motions/models.py b/openslides/motions/models.py index ac1de7607..f573fa7a9 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -119,12 +119,18 @@ class Motion(RESTModelMixin, models.Model): Users who support this motion. """ + comments = JSONField(null=True) + """ + Configurable fields for comments. Contains a list of strings. + """ + class Meta: default_permissions = () permissions = ( ('can_see', 'Can see motions'), ('can_create', 'Can create motions'), ('can_support', 'Can support motions'), + ('can_see_and_manage_comments', 'Can see and manage comments'), ('can_manage', 'Can manage motions'), ) ordering = ('identifier', ) diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index 714d56f89..1a24ae1aa 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -5,6 +5,7 @@ from openslides.poll.serializers import default_votes_validator from openslides.utils.rest_api import ( CharField, DictField, + Field, IntegerField, ModelSerializer, PrimaryKeyRelatedField, @@ -74,6 +75,28 @@ class WorkflowSerializer(ModelSerializer): fields = ('id', 'name', 'states', 'first_state',) +class MotionCommentsJSONSerializerField(Field): + """ + Serializer for motions's comments JSONField. + """ + def to_representation(self, obj): + """ + Returns the value of the field. + """ + return obj + + def to_internal_value(self, data): + """ + 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.'}) + return data + + class MotionLogSerializer(ModelSerializer): """ Serializer for motion.models.MotionLog objects. @@ -209,6 +232,7 @@ class MotionSerializer(ModelSerializer): Serializer for motion.models.Motion objects. """ active_version = PrimaryKeyRelatedField(read_only=True) + comments = MotionCommentsJSONSerializerField(required=False) log_messages = MotionLogSerializer(many=True, read_only=True) polls = MotionPollSerializer(many=True, read_only=True) reason = CharField(allow_blank=True, required=False, write_only=True) @@ -236,6 +260,7 @@ class MotionSerializer(ModelSerializer): 'origin', 'submitters', 'supporters', + 'comments', 'state', 'workflow_id', 'tags', @@ -257,6 +282,7 @@ class MotionSerializer(ModelSerializer): motion.identifier = validated_data.get('identifier') motion.category = validated_data.get('category') motion.origin = validated_data.get('origin', '') + motion.comments = validated_data.get('comments') motion.parent = validated_data.get('parent') motion.reset_state(validated_data.get('workflow_id')) motion.save() @@ -274,8 +300,8 @@ class MotionSerializer(ModelSerializer): """ Customized method to update a motion. """ - # Identifier, category and origin. - for key in ('identifier', 'category', 'origin'): + # Identifier, category, origin and comments. + for key in ('identifier', 'category', 'origin', 'comments'): if key in validated_data.keys(): setattr(motion, key, validated_data[key]) diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js index 43b7fe983..3511c376c 100644 --- a/openslides/motions/static/js/motions/base.js +++ b/openslides/motions/static/js/motions/base.js @@ -117,18 +117,23 @@ angular.module('OpenSlidesApp.motions', [ .factory('Motion', [ 'DS', 'MotionPoll', + 'MotionComment', 'jsDataModel', 'gettext', 'operator', 'Config', 'lineNumberingService', - function(DS, MotionPoll, jsDataModel, gettext, operator, Config, lineNumberingService) { + function(DS, MotionPoll, MotionComment, jsDataModel, gettext, operator, Config, lineNumberingService) { var name = 'motions/motion'; return DS.defineResource({ name: name, useClass: jsDataModel, verboseName: gettext('Motion'), verboseNamePlural: gettext('Motions'), + validate: function (resource, data, callback) { + MotionComment.populateFieldsReverse(data); + callback(null, data); + }, methods: { getResourceName: function () { return name; diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index 204f53631..21fb8853d 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -208,7 +208,6 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid }; }) .factory('PollContentProvider', function() { - /** * Generates a content provider for polls * @constructor @@ -216,7 +215,6 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid * @param {string} id - if of poll * @param {object} gettextCatalog - for translation */ - var createInstance = function(title, id, gettextCatalog){ //left and top margin for a single sheet @@ -527,11 +525,124 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid } ]) +// Service for generic comment fields +.factory('MotionComment', [ + 'Config', + function (Config) { + return { + getFields: function () { + // Take input from config field and parse it. It can be some + // JSON or just a comma separated list of strings. + // + // The result is an array of objects. Each object contains + // at least the name of the comment field See configSchema. + // + // Attention: This code does also exist on server side. + var configSchema = { + $schema: "http://json-schema.org/draft-04/schema#", + title: "Motion Comments", + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1 + }, + public: { + type: "boolean" + }, + forRecommendation: { + type: "boolean" + }, + forState: { + type: "boolean" + } + }, + required: ["name"] + }, + minItems: 1, + uniqueItems: true + }; + var configValue = Config.get('motions_comments').value; + var fields; + var isJSON = true; + try { + fields = JSON.parse(configValue); + } catch (err) { + isJSON = false; + } + if (isJSON) { + // Config is JSON. Validate it. + if (!jsen(configSchema)(fields)) { + fields = []; + } + } else { + // Config is a comma separated list of strings. Strip out + // empty parts. All valid strings lead to public comment + // fields. + fields = _.map( + _.filter( + configValue.split(','), + function (name) { + return name; + }), + function (name) { + return { + 'name': name, + 'public': true + }; + } + ); + } + return fields; + }, + getFormFields: function () { + var fields = this.getFields(); + return _.map( + fields, + function (field) { + // TODO: Hide non-public fields for unauthorized users. + return { + key: 'comment ' + field.name, + type: 'input', + templateOptions: { + label: field.name, + }, + hideExpression: '!model.more' + }; + } + ); + }, + populateFields: function (motion) { + // Populate content of motion.comments to the single comment + // fields like motion['comment MyComment'], motion['comment MyOtherComment'], ... + var fields = this.getFields(); + if (!motion.comments) { + motion.comments = []; + } + for (var i = 0; i < fields.length; i++) { + motion['comment ' + fields[i].name] = motion.comments[i]; + } + }, + populateFieldsReverse: function (motion) { + // Reverse equivalent to populateFields. + var fields = this.getFields(); + motion.comments = []; + for (var i = 0; i < fields.length; i++) { + motion.comments.push(motion['comment ' + fields[i].name] || ''); + } + } + }; + } +]) + // Service for generic motion form (create and update) .factory('MotionForm', [ 'gettextCatalog', 'operator', 'Editor', + 'MotionComment', 'Category', 'Config', 'Mediafile', @@ -540,7 +651,7 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid 'Workflow', 'Agenda', 'AgendaTree', - function (gettextCatalog, operator, Editor, Category, Config, Mediafile, Tag, User, Workflow, Agenda, AgendaTree) { + function (gettextCatalog, operator, Editor, MotionComment, Category, Config, Mediafile, Tag, User, Workflow, Agenda, AgendaTree) { return { // ngDialog for motion form getDialog: function (motion) { @@ -548,6 +659,7 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid if (motion) { resolve = { motion: function() { + MotionComment.populateFields(motion); return motion; }, agenda_item: function(Motion) { @@ -708,8 +820,9 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid placeholder: gettextCatalog.getString('Select or search a supporter ...') }, hideExpression: '!model.more' - }, - { + }] + .concat(MotionComment.getFormFields()) + .concat([{ key: 'workflow_id', type: 'select-single', templateOptions: { @@ -720,7 +833,7 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid placeholder: gettextCatalog.getString('Select or search a workflow ...') }, hideExpression: '!model.more', - }]; + }]); } }; } @@ -936,6 +1049,7 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid '$http', '$timeout', 'ngDialog', + 'MotionComment', 'MotionForm', 'Motion', 'Category', @@ -953,9 +1067,9 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid 'PdfMakeDocumentProvider', 'gettextCatalog', 'diffService', - function($scope, $http, $timeout, ngDialog, MotionForm, Motion, Category, Mediafile, Tag, User, Workflow, Editor, - Config, motion, SingleMotionContentProvider, MotionContentProvider, PollContentProvider, PdfMakeConverter, - PdfMakeDocumentProvider, gettextCatalog, diffService) { + function($scope, $http, $timeout, ngDialog, MotionComment, MotionForm, Motion, Category, Mediafile, Tag, + User, Workflow, Editor, Config, motion, SingleMotionContentProvider, MotionContentProvider, + PollContentProvider, PdfMakeConverter, PdfMakeDocumentProvider, gettextCatalog, diffService) { Motion.bindOne(motion.id, $scope, 'motion'); Category.bindAll({}, $scope, 'categories'); Mediafile.bindAll({}, $scope, 'mediafiles'); @@ -965,6 +1079,7 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid Motion.loadRelations(motion, 'agenda_item'); $scope.version = motion.active_version; $scope.isCollapsed = true; + $scope.commentsFields = MotionComment.getFields(); $scope.lineNumberMode = Config.get('motions_default_line_numbering').value; $scope.lineBrokenText = motion.getTextWithLineBreaks($scope.version); if (motion.parent_id) { diff --git a/openslides/motions/static/templates/motions/motion-detail.html b/openslides/motions/static/templates/motions/motion-detail.html index 133907b57..4ade8077b 100644 --- a/openslides/motions/static/templates/motions/motion-detail.html +++ b/openslides/motions/static/templates/motions/motion-detail.html @@ -369,3 +369,12 @@ + +
+

Motion Comments

+
+

+ {{ field.name }}: {{ motion.comments[$index] }} +

+
+
diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 703558528..0537b5297 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -1,4 +1,4 @@ -from django.db import transaction +from django.db import IntegrityError, transaction from django.http import Http404 from django.utils.text import slugify from django.utils.translation import ugettext as _ @@ -67,6 +67,27 @@ class MotionViewSet(ModelViewSet): result = False return result + def list(self, request, *args, **kwargs): + """ + Customized view endpoint to list all motions. + + Hides non public comment fields for some users. + """ + response = super().list(request, *args, **kwargs) + for i, motion in enumerate(response.data): + response.data[i] = self.get_access_permissions().get_restricted_data(motion, self.request.user) + return response + + def retrieve(self, request, *args, **kwargs): + """ + Customized view endpoint to retrieve a motion. + + Hides non public comment fields for some users. + """ + response = super().retrieve(request, *args, **kwargs) + response.data = self.get_access_permissions().get_restricted_data(response.data, self.request.user) + return response + def create(self, request, *args, **kwargs): """ Customized view endpoint to create a new motion. @@ -77,6 +98,12 @@ class MotionViewSet(ModelViewSet): # Non-staff users are not allowed to send submitter or supporter data. self.permission_denied(request) + # Check permission to send comment data. + if (not request.user.has_perm('motions.can_see_and_manage_comments') and + request.data.get('comments')): + # Some users are not allowed to send comments data. + self.permission_denied(request) + # Validate data and create motion. serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -114,6 +141,12 @@ class MotionViewSet(ModelViewSet): if key not in whitelist: # Non-staff users are allowed to send only some data. Ignore other data. del request.data[key] + if not request.user.has_perm('motions.can_see_and_manage_comments'): + try: + del request.data['comments'] + except KeyError: + # No comments here. Just do nothing. + pass # Validate data and update motion. serializer = self.get_serializer( @@ -327,24 +360,30 @@ class CategoryViewSet(ModelViewSet): motion_dict[motion.pk] = motion motions = [motion_dict[pk] for pk in motion_list] - with transaction.atomic(): - for motion in motions: - motion.identifier = None - motion.save() + try: + with transaction.atomic(): + for motion in motions: + motion.identifier = None + motion.save() - for motion in motions: - if motion.is_amendment(): - parent_identifier = motion.parent.identifier or '' - prefix = '%s %s ' % (parent_identifier, config['motions_amendments_prefix']) - number += 1 - identifier = '%s%d' % (prefix, number) - motion.identifier = identifier - motion.identifier_number = number - motion.save() - - message = _('All motions in category {category} numbered ' - 'successfully.').format(category=category) - return Response({'detail': message}) + for motion in motions: + if motion.is_amendment(): + parent_identifier = motion.parent.identifier or '' + prefix = '%s %s ' % (parent_identifier, config['motions_amendments_prefix']) + number += 1 + identifier = '%s%d' % (prefix, number) + motion.identifier = identifier + motion.identifier_number = number + motion.save() + except IntegrityError: + message = _('Error: At least one identifier of this category does ' + 'already exist in another category.') + response = Response({'detail': message}, status_code=400) + else: + message = _('All motions in category {category} numbered ' + 'successfully.').format(category=category) + response = Response({'detail': message}) + return response class WorkflowViewSet(ModelViewSet): diff --git a/openslides/users/signals.py b/openslides/users/signals.py index 95a008701..483bd4869 100644 --- a/openslides/users/signals.py +++ b/openslides/users/signals.py @@ -36,6 +36,7 @@ def create_builtin_groups_and_admin(**kwargs): 'motions.can_create', 'motions.can_manage', 'motions.can_see', + 'motions.can_see_and_manage_comments', 'motions.can_support', 'users.can_manage', 'users.can_see_extra_data', @@ -106,6 +107,7 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['motions.can_see'], permission_dict['motions.can_create'], permission_dict['motions.can_manage'], + permission_dict['motions.can_see_and_manage_comments'], permission_dict['users.can_see_name'], permission_dict['users.can_manage'], permission_dict['users.can_see_extra_data'],) diff --git a/requirements_production.txt b/requirements_production.txt index 2a0a5b96b..f81f60541 100644 --- a/requirements_production.txt +++ b/requirements_production.txt @@ -5,6 +5,7 @@ channels>=0.15,<1.0 djangorestframework>=3.4,<3.5 html5lib>=0.99,<1.0 jsonfield>=0.9.19,<1.1 +jsonschema>=2.5,<2.6 natsort>=3.2,<5.1 PyPDF2>=1.25.0,<1.27 reportlab>=3.0,<3.4 diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py index eeafc4b03..ce1d2055c 100644 --- a/tests/integration/motions/test_viewset.py +++ b/tests/integration/motions/test_viewset.py @@ -29,9 +29,8 @@ class CreateMotion(TestCase): reverse('motion-list'), {'title': 'test_title_OoCoo3MeiT9li5Iengu9', 'text': 'test_text_thuoz0iecheiheereiCi'}) - - motion = Motion.objects.get() self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() self.assertEqual(motion.title, 'test_title_OoCoo3MeiT9li5Iengu9') self.assertEqual(motion.identifier, '1') self.assertTrue(motion.submitters.exists())