Merge pull request #2283 from normanjaeckel/MotionComments

Added generic fields for comments for motions.
This commit is contained in:
Norman Jäckel 2016-09-07 22:11:59 +02:00 committed by GitHub
commit 41cb8b37cd
16 changed files with 378 additions and 36 deletions

View File

@ -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.

View File

@ -171,6 +171,8 @@ OpenSlides uses the following projects or parts of them:
* `django-jsonfield <https://github.com/bradjasper/django-jsonfield/>`_,
License: MIT
* `jsonschema <https://github.com/Julian/jsonschema>`_, License: MIT
* `natsort <https://pypi.python.org/pypi/natsort>`_, License: MIT
* `PyPDF2 <http://mstamy2.github.io/PyPDF2/>`_, License: BSD
@ -216,6 +218,7 @@ OpenSlides uses the following projects or parts of them:
* `js-data <http://www.js-data.io>`_, License: MIT
* `js-data-angular <http://www.js-data.io/docs/js-data-angular>`_, License: MIT
* `js-data-http <http://www.js-data.io/docs/dshttpadapter>`_, License: MIT
* `jsen <https://github.com/bugventure/jsen>`_, License: MIT
* `lodash <https://lodash.com/>`_, License: MIT
* `ng-dialog <https://github.com/likeastore/ngDialog>`_, License: MIT
* `ng-file-upload <https://github.com/danialfarid/ng-file-upload>`_, License: MIT

View File

@ -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"
]
}
},

View File

@ -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'):

View File

@ -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):
"""

View File

@ -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(

View File

@ -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),
),
]

View File

@ -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', )

View File

@ -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])

View File

@ -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;

View File

@ -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) {

View File

@ -369,3 +369,12 @@
</div>
</div>
<div class="details">
<h3>Motion Comments</h3>
<div ng-repeat="field in commentsFields">
<p ng-if="field.public || operator.hasPerms('motions.can_see_and_manage_comments')">
{{ field.name }}: {{ motion.comments[$index] }}
</p>
</div>
</div>

View File

@ -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):

View File

@ -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'],)

View File

@ -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

View File

@ -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())