Rework on motion comments (fixes #3350).
This commit is contained in:
parent
c9ad15621c
commit
288a706d01
@ -15,7 +15,7 @@ INPUT_TYPE_MAPPING = {
|
|||||||
'integer': int,
|
'integer': int,
|
||||||
'boolean': bool,
|
'boolean': bool,
|
||||||
'choice': str,
|
'choice': str,
|
||||||
'comments': list,
|
'comments': dict,
|
||||||
'colorpicker': str,
|
'colorpicker': str,
|
||||||
'datetimepicker': int,
|
'datetimepicker': int,
|
||||||
'majorityMethod': str,
|
'majorityMethod': str,
|
||||||
@ -101,17 +101,29 @@ class ConfigHandler:
|
|||||||
raise ConfigError(e.messages[0])
|
raise ConfigError(e.messages[0])
|
||||||
|
|
||||||
if config_variable.input_type == 'comments':
|
if config_variable.input_type == 'comments':
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, dict):
|
||||||
raise ConfigError(_('motions_comments has to be a list.'))
|
raise ConfigError(_('motions_comments has to be a dict.'))
|
||||||
for comment in value:
|
valuecopy = dict()
|
||||||
if not isinstance(comment, dict):
|
for id, commentsfield in value.items():
|
||||||
raise ConfigError(_('Each element in motions_comments has to be a dict.'))
|
try:
|
||||||
if comment.get('name') is None or comment.get('public') is None:
|
id = int(id)
|
||||||
raise ConfigError(_('A name and a public property have to be given.'))
|
except ValueError:
|
||||||
if not isinstance(comment['name'], str):
|
raise ConfigError(_('Each id has to be an int.'))
|
||||||
raise ConfigError(_('name has to be string.'))
|
|
||||||
if not isinstance(comment['public'], bool):
|
if id < 1:
|
||||||
raise ConfigError(_('public property has to be bool.'))
|
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 config_variable.input_type == 'logo':
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
|
@ -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
|
// angular formly config options
|
||||||
.run([
|
.run([
|
||||||
'formlyConfig',
|
'formlyConfig',
|
||||||
@ -1124,14 +1115,20 @@ angular.module('OpenSlidesApp.core.site', [
|
|||||||
|
|
||||||
// For comments input
|
// For comments input
|
||||||
$scope.addComment = function (configOption, parent) {
|
$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'),
|
name: gettextCatalog.getString('New'),
|
||||||
public: false,
|
public: false,
|
||||||
});
|
};
|
||||||
$scope.save(configOption, parent.value);
|
$scope.save(configOption, parent.value);
|
||||||
};
|
};
|
||||||
$scope.removeComment = function (configOption, parent, index) {
|
$scope.removeComment = function (configOption, parent, id) {
|
||||||
parent.value.splice(index, 1);
|
parent.value[id] = null;
|
||||||
$scope.save(configOption, parent.value);
|
$scope.save(configOption, parent.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -20,8 +20,11 @@
|
|||||||
|
|
||||||
<!-- comments -->
|
<!-- comments -->
|
||||||
<div class="input-comments" ng-if="type === 'comments'">
|
<div class="input-comments" ng-if="type === 'comments'">
|
||||||
<div ng-repeat="entry in $parent.value | excludeSpecialComments" class="input-group">
|
<div ng-repeat="(id, field) in ($parent.value
|
||||||
<input ng-model="entry.name"
|
| excludeSpecialCommentsFields
|
||||||
|
| excludeDeletedAndForbiddenCommentsFields)"
|
||||||
|
class="input-group">
|
||||||
|
<input ng-model="field.name"
|
||||||
ng-model-options="{debounce: 1000}"
|
ng-model-options="{debounce: 1000}"
|
||||||
ng-change="save(configOption, $parent.value)"
|
ng-change="save(configOption, $parent.value)"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
@ -29,12 +32,12 @@
|
|||||||
type="text">
|
type="text">
|
||||||
<span class="input-group-btn">
|
<span class="input-group-btn">
|
||||||
<button type=button" class="btn btn-default"
|
<button type=button" class="btn btn-default"
|
||||||
ng-click="entry.public = !entry.public; save(configOption, $parent.value);">
|
ng-click="field.public = !field.public; save(configOption, $parent.value);">
|
||||||
<i class="fa" ng-class="entry.public ? 'fa-unlock' : 'fa-lock'"></i>
|
<i class="fa" ng-class="field.public ? 'fa-unlock' : 'fa-lock'"></i>
|
||||||
{{ (entry.public ? 'Public' : 'Private') | translate }}
|
{{ (field.public ? 'Public' : 'Private') | translate }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-default"
|
<button type="button" class="btn btn-default"
|
||||||
ng-click="removeComment(configOption, $parent, $index)">
|
ng-click="removeComment(configOption, $parent, id)">
|
||||||
<i class="fa fa-minus"></i>
|
<i class="fa fa-minus"></i>
|
||||||
<translate>Remove</translate>
|
<translate>Remove</translate>
|
||||||
</button>
|
</button>
|
||||||
|
@ -174,7 +174,7 @@ def get_config_variables():
|
|||||||
|
|
||||||
yield ConfigVariable(
|
yield ConfigVariable(
|
||||||
name='motions_comments',
|
name='motions_comments',
|
||||||
default_value=[],
|
default_value={},
|
||||||
input_type='comments',
|
input_type='comments',
|
||||||
label='Comment fields for motions',
|
label='Comment fields for motions',
|
||||||
weight=353,
|
weight=353,
|
||||||
|
60
openslides/motions/migrations/0003_motion_comments.py
Normal file
60
openslides/motions/migrations/0003_motion_comments.py
Normal file
@ -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
|
||||||
|
),
|
||||||
|
]
|
@ -167,7 +167,7 @@ class Motion(RESTModelMixin, models.Model):
|
|||||||
|
|
||||||
comments = JSONField(null=True)
|
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
|
# In theory there could be one then more agenda_item. But we support only
|
||||||
|
@ -105,11 +105,15 @@ class MotionCommentsJSONSerializerField(Field):
|
|||||||
"""
|
"""
|
||||||
Checks that data is a list of strings.
|
Checks that data is a list of strings.
|
||||||
"""
|
"""
|
||||||
if type(data) is not list:
|
if type(data) is not dict:
|
||||||
raise ValidationError({'detail': 'Data must be an array.'})
|
raise ValidationError({'detail': 'Data must be a dict.'})
|
||||||
for element in data:
|
for id, comment in data.items():
|
||||||
if type(element) is not str:
|
try:
|
||||||
raise ValidationError({'detail': 'Data must be an array of strings.'})
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
@ -317,9 +321,9 @@ class MotionSerializer(ModelSerializer):
|
|||||||
data['text'] = validate_html(data['text'])
|
data['text'] = validate_html(data['text'])
|
||||||
if 'reason' in data:
|
if 'reason' in data:
|
||||||
data['reason'] = validate_html(data['reason'])
|
data['reason'] = validate_html(data['reason'])
|
||||||
validated_comments = []
|
validated_comments = dict()
|
||||||
for comment in data.get('comments', []):
|
for id, comment in data.get('comments', {}).items():
|
||||||
validated_comments.append(validate_html(comment))
|
validated_comments[id] = validate_html(comment)
|
||||||
data['comments'] = validated_comments
|
data['comments'] = validated_comments
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@ -678,18 +678,32 @@ angular.module('OpenSlidesApp.motions', [
|
|||||||
|
|
||||||
// Service for generic comment fields
|
// Service for generic comment fields
|
||||||
.factory('MotionComment', [
|
.factory('MotionComment', [
|
||||||
|
'$filter',
|
||||||
'Config',
|
'Config',
|
||||||
'operator',
|
'operator',
|
||||||
'Editor',
|
'Editor',
|
||||||
function (Config, operator, Editor) {
|
function ($filter, Config, operator, Editor) {
|
||||||
return {
|
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;
|
var fields = Config.get('motions_comments').value;
|
||||||
return _.map(
|
return $filter('excludeDeletedAndForbiddenCommentsFields')(fields);
|
||||||
_.filter(fields, function(element) { return !element.forState && !element.forRecommendation; }),
|
},
|
||||||
function (field) {
|
getNoSpecialCommentsFields: function () {
|
||||||
|
var fields = this.getCommentsFields();
|
||||||
|
return $filter('excludeSpecialCommentsFields')(fields);
|
||||||
|
},
|
||||||
|
getFormFields: function () {
|
||||||
|
var fields = this.getNoSpecialCommentsFields();
|
||||||
|
return _.map(fields, function (field, id) {
|
||||||
return {
|
return {
|
||||||
key: 'comment ' + field.name,
|
key: 'comment_' + id,
|
||||||
type: 'editor',
|
type: 'editor',
|
||||||
templateOptions: {
|
templateOptions: {
|
||||||
label: field.name,
|
label: field.name,
|
||||||
@ -704,36 +718,60 @@ angular.module('OpenSlidesApp.motions', [
|
|||||||
},
|
},
|
||||||
populateFields: function (motion) {
|
populateFields: function (motion) {
|
||||||
// Populate content of motion.comments to the single comment
|
// Populate content of motion.comments to the single comment
|
||||||
// fields like motion['comment MyComment'], motion['comment MyOtherComment'], ...
|
var fields = this.getCommentsFields();
|
||||||
var fields = Config.get('motions_comments').value;
|
if (motion.comments) {
|
||||||
if (!motion.comments) {
|
_.forEach(fields, function (field, id) {
|
||||||
motion.comments = [];
|
motion['comment_' + id] = motion.comments[id];
|
||||||
}
|
});
|
||||||
for (var i = 0; i < fields.length; i++) {
|
|
||||||
motion['comment ' + fields[i].name] = motion.comments[i];
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
populateFieldsReverse: function (motion) {
|
populateFieldsReverse: function (motion) {
|
||||||
// Reverse equivalent to populateFields.
|
// Reverse equivalent to populateFields.
|
||||||
var fields = Config.get('motions_comments').value;
|
var fields = this.getCommentsFields();
|
||||||
motion.comments = [];
|
motion.comments = {};
|
||||||
for (var i = 0; i < fields.length; i++) {
|
_.forEach(fields, function (field, id) {
|
||||||
motion.comments.push(motion['comment ' + fields[i].name] || '');
|
motion.comments[id] = motion['comment_' + id] || '';
|
||||||
}
|
});
|
||||||
},
|
},
|
||||||
getFieldNameForFlag: function (flag) {
|
getFieldIdForFlag: function (flag) {
|
||||||
var fields = Config.get('motions_comments').value;
|
var fields = this.getCommentsFields();
|
||||||
var fieldName = '';
|
return _.findKey(fields, [flag, true]);
|
||||||
var index = _.findIndex(fields, [flag, true]);
|
|
||||||
if (index > -1) {
|
|
||||||
fieldName = fields[index].name;
|
|
||||||
}
|
|
||||||
return fieldName;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
.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', [
|
.factory('Category', [
|
||||||
'DS',
|
'DS',
|
||||||
function(DS) {
|
function(DS) {
|
||||||
|
@ -14,7 +14,9 @@ angular.module('OpenSlidesApp.motions.docx', ['OpenSlidesApp.core.docx'])
|
|||||||
'FileSaver',
|
'FileSaver',
|
||||||
'lineNumberingService',
|
'lineNumberingService',
|
||||||
'Html2DocxConverter',
|
'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 = '<w:p><w:r><w:br w:type="page" /></w:r></w:p>';
|
var PAGEBREAK = '<w:p><w:r><w:br w:type="page" /></w:r></w:p>';
|
||||||
|
|
||||||
@ -153,19 +155,15 @@ angular.module('OpenSlidesApp.motions.docx', ['OpenSlidesApp.core.docx'])
|
|||||||
};
|
};
|
||||||
|
|
||||||
var getMotionComments = function (motion) {
|
var getMotionComments = function (motion) {
|
||||||
var fields = Config.get('motions_comments').value;
|
var fields = MotionComment.getNoSpecialCommentsFields();
|
||||||
var canSeeComment = function (index) {
|
|
||||||
var specialComment = fields[index].forState || fields[index].forRecommendation;
|
|
||||||
return (fields[index].public || operator.hasPerms('motions.can_manage')) && !specialComment;
|
|
||||||
};
|
|
||||||
var comments = [];
|
var comments = [];
|
||||||
for (var i = 0; i < fields.length; i++) {
|
_.forEach(fields, function (field, id) {
|
||||||
if (motion.comments[i] && canSeeComment(i)) {
|
if (motion.comments[id]) {
|
||||||
var title = gettextCatalog.getString('Comment') + ' ' + fields[i].name;
|
var title = gettextCatalog.getString('Comment') + ' ' + field.name;
|
||||||
if (!fields[i].public) {
|
if (!field.public) {
|
||||||
title += ' (' + gettextCatalog.getString('internal') + ')';
|
title += ' (' + gettextCatalog.getString('internal') + ')';
|
||||||
}
|
}
|
||||||
var comment = motion.comments[i];
|
var comment = motion.comments[id];
|
||||||
if (comment.indexOf('<p>') !== 0) {
|
if (comment.indexOf('<p>') !== 0) {
|
||||||
comment = '<p>' + comment + '</p>';
|
comment = '<p>' + comment + '</p>';
|
||||||
}
|
}
|
||||||
@ -174,7 +172,7 @@ angular.module('OpenSlidesApp.motions.docx', ['OpenSlidesApp.core.docx'])
|
|||||||
comment: comment,
|
comment: comment,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
return comments;
|
return comments;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -134,14 +134,14 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions',
|
|||||||
editors: []
|
editors: []
|
||||||
};
|
};
|
||||||
var options = Editor.getOptions('inline', 'YOffset');
|
var options = Editor.getOptions('inline', 'YOffset');
|
||||||
_.forEach($scope.commentsFieldsNoSpecialComments, function (field) {
|
_.forEach($scope.noSpecialCommentsFields, function (field, id) {
|
||||||
var inlineEditing = MotionInlineEditing.createInstance($scope, motion,
|
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) {
|
function (obj) {
|
||||||
return motion['comment ' + field.name];
|
return motion['comment_' + id];
|
||||||
},
|
},
|
||||||
function (obj) {
|
function (obj) {
|
||||||
motion['comment ' + field.name] = obj.editor.getData();
|
motion['comment_' + id] = obj.editor.getData();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
commentsInlineEditing.editors.push(inlineEditing);
|
commentsInlineEditing.editors.push(inlineEditing);
|
||||||
|
@ -15,7 +15,9 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
|
|||||||
'Category',
|
'Category',
|
||||||
'Config',
|
'Config',
|
||||||
'Motion',
|
'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
|
* Provides the content as JS objects for Motions in pdfMake context
|
||||||
* @constructor
|
* @constructor
|
||||||
@ -27,10 +29,19 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
|
|||||||
var converter;
|
var converter;
|
||||||
|
|
||||||
// Query all image sources from motion text and reason
|
// Query all image sources from motion text and reason
|
||||||
// TODO: Do we need images for comments here too??
|
|
||||||
var getImageSources = function () {
|
var getImageSources = function () {
|
||||||
var text = motion.getTextByMode(changeRecommendationMode, null);
|
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);
|
var map = Function.prototype.call.bind([].map);
|
||||||
return map($(content).find('img'), function(element) {
|
return map($(content).find('img'), function(element) {
|
||||||
return element.getAttribute('src');
|
return element.getAttribute('src');
|
||||||
@ -298,16 +309,12 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
|
|||||||
// motion comments handling
|
// motion comments handling
|
||||||
var motionComments = function () {
|
var motionComments = function () {
|
||||||
if (includeComments) {
|
if (includeComments) {
|
||||||
var fields = Config.get('motions_comments').value;
|
var fields = MotionComment.getNoSpecialCommentsFields();
|
||||||
var canSeeComment = function (index) {
|
|
||||||
var specialComment = fields[index].forState || fields[index].forRecommendation;
|
|
||||||
return (fields[index].public || operator.hasPerms('motions.can_manage')) && !specialComment;
|
|
||||||
};
|
|
||||||
var comments = [];
|
var comments = [];
|
||||||
for (var i = 0; i < fields.length; i++) {
|
_.forEach(fields, function (field, id) {
|
||||||
if (motion.comments[i] && canSeeComment(i)) {
|
if (motion.comments[id]) {
|
||||||
var title = gettextCatalog.getString('Comment') + ' ' + fields[i].name;
|
var title = gettextCatalog.getString('Comment') + ' ' + field.name;
|
||||||
if (!fields[i].public) {
|
if (!field.public) {
|
||||||
title += ' (' + gettextCatalog.getString('internal') + ')';
|
title += ' (' + gettextCatalog.getString('internal') + ')';
|
||||||
}
|
}
|
||||||
comments.push({
|
comments.push({
|
||||||
@ -315,9 +322,9 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
|
|||||||
style: 'heading3',
|
style: 'heading3',
|
||||||
marginTop: 25,
|
marginTop: 25,
|
||||||
});
|
});
|
||||||
comments.push(converter.convertHTML(motion.comments[i]));
|
comments.push(converter.convertHTML(motion.comments[id]));
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
return comments;
|
return comments;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -859,12 +866,13 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
|
|||||||
'PdfCreate',
|
'PdfCreate',
|
||||||
'PDFLayout',
|
'PDFLayout',
|
||||||
'PersonalNoteManager',
|
'PersonalNoteManager',
|
||||||
|
'MotionComment',
|
||||||
'Messaging',
|
'Messaging',
|
||||||
'FileSaver',
|
'FileSaver',
|
||||||
function ($http, $q, operator, Config, gettextCatalog, MotionChangeRecommendation, HTMLValidizer,
|
function ($http, $q, operator, Config, gettextCatalog, MotionChangeRecommendation, HTMLValidizer,
|
||||||
PdfMakeConverter, MotionContentProvider, MotionCatalogContentProvider, PdfMakeDocumentProvider,
|
PdfMakeConverter, MotionContentProvider, MotionCatalogContentProvider, PdfMakeDocumentProvider,
|
||||||
PollContentProvider, PdfMakeBallotPaperProvider, MotionPartialContentProvider, PdfCreate,
|
PollContentProvider, PdfMakeBallotPaperProvider, MotionPartialContentProvider, PdfCreate,
|
||||||
PDFLayout, PersonalNoteManager, Messaging, FileSaver) {
|
PDFLayout, PersonalNoteManager, MotionComment, Messaging, FileSaver) {
|
||||||
return {
|
return {
|
||||||
getDocumentProvider: function (motions, params, singleMotion) {
|
getDocumentProvider: function (motions, params, singleMotion) {
|
||||||
params = _.clone(params || {}); // Clone this to avoid sideeffects.
|
params = _.clone(params || {}); // Clone this to avoid sideeffects.
|
||||||
@ -1008,24 +1016,20 @@ angular.module('OpenSlidesApp.motions.pdf', ['OpenSlidesApp.core.pdf'])
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
exportComments: function (motion, filename) {
|
exportComments: function (motion, filename) {
|
||||||
var fields = Config.get('motions_comments').value;
|
var fields = MotionComment.getNoSpecialCommentsFields();
|
||||||
var canSeeComment = function (index) {
|
|
||||||
var specialComment = fields[index].forState || fields[index].forRecommendation;
|
|
||||||
return (fields[index].public || operator.hasPerms('motions.can_manage')) && !specialComment;
|
|
||||||
};
|
|
||||||
var content = [];
|
var content = [];
|
||||||
for (var i = 0; i < fields.length; i++) {
|
_.forEach(fields, function (field, id) {
|
||||||
if (motion.comments[i] && canSeeComment(i)) {
|
if (motion.comments[id]) {
|
||||||
var title = gettextCatalog.getString('Comment') + ' ' + fields[i].name;
|
var title = gettextCatalog.getString('Comment') + ' ' + field.name;
|
||||||
if (!fields[i].public) {
|
if (!field.public) {
|
||||||
title += ' (' + gettextCatalog.getString('internal') + ')';
|
title += ' (' + gettextCatalog.getString('internal') + ')';
|
||||||
}
|
}
|
||||||
content.push({
|
content.push({
|
||||||
heading: title,
|
heading: title,
|
||||||
text: motion.comments[i],
|
text: motion.comments[id],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
MotionPartialContentProvider.createInstance(motion, content).then(function (contentProvider) {
|
MotionPartialContentProvider.createInstance(motion, content).then(function (contentProvider) {
|
||||||
PdfMakeDocumentProvider.createInstance(contentProvider).then(function (documentProvider) {
|
PdfMakeDocumentProvider.createInstance(contentProvider).then(function (documentProvider) {
|
||||||
PdfCreate.download(documentProvider.getDocument(), filename);
|
PdfCreate.download(documentProvider.getDocument(), filename);
|
||||||
|
@ -910,11 +910,10 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
$scope.alert = {};
|
$scope.alert = {};
|
||||||
|
|
||||||
// Motion comments
|
// Motion comments
|
||||||
$scope.commentsFields = Config.get('motions_comments').value;
|
$scope.noSpecialCommentsFields = MotionComment.getNoSpecialCommentsFields();
|
||||||
$scope.commentsFieldsNoSpecialComments = _.filter($scope.commentsFields, function (field) {
|
$scope.showCommentsFilter = function () {
|
||||||
var specialComment = field.forState || field.forRecommendation;
|
return _.keys($scope.noSpecialCommentsFields).length > 0;
|
||||||
return !specialComment;
|
};
|
||||||
});
|
|
||||||
|
|
||||||
// collect all states and all recommendations of all workflows
|
// collect all states and all recommendations of all workflows
|
||||||
$scope.states = [];
|
$scope.states = [];
|
||||||
@ -1289,13 +1288,10 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$scope.commentsFields = Config.get('motions_comments').value;
|
$scope.commentsFields = MotionComment.getCommentsFields();
|
||||||
$scope.commentsFieldsNoSpecialComments = _.filter($scope.commentsFields, function (field) {
|
$scope.noSpecialCommentsFields = MotionComment.getNoSpecialCommentsFields();
|
||||||
var specialComment = field.forState || field.forRecommendation;
|
$scope.commentFieldForStateId = MotionComment.getFieldIdForFlag('forState');
|
||||||
return !specialComment;
|
$scope.commentFieldForRecommendationId = MotionComment.getFieldIdForFlag('forRecommendation');
|
||||||
});
|
|
||||||
$scope.commentFieldForState = MotionComment.getFieldNameForFlag('forState');
|
|
||||||
$scope.commentFieldForRecommendation = MotionComment.getFieldNameForFlag('forRecommendation');
|
|
||||||
$scope.version = motion.active_version;
|
$scope.version = motion.active_version;
|
||||||
$scope.isCollapsed = true;
|
$scope.isCollapsed = true;
|
||||||
$scope.lineNumberMode = Config.get('motions_default_line_numbering').value;
|
$scope.lineNumberMode = Config.get('motions_default_line_numbering').value;
|
||||||
@ -1402,14 +1398,14 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
// save additional state field
|
// save additional state field
|
||||||
$scope.saveAdditionalStateField = function (stateExtension) {
|
$scope.saveAdditionalStateField = function (stateExtension) {
|
||||||
if (stateExtension) {
|
if (stateExtension) {
|
||||||
motion["comment " + $scope.commentFieldForState] = stateExtension;
|
motion['comment_' + $scope.commentFieldForStateId] = stateExtension;
|
||||||
$scope.save(motion);
|
$scope.save(motion);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// save additional recommendation field
|
// save additional recommendation field
|
||||||
$scope.saveAdditionalRecommendationField = function (recommendationExtension) {
|
$scope.saveAdditionalRecommendationField = function (recommendationExtension) {
|
||||||
if (recommendationExtension) {
|
if (recommendationExtension) {
|
||||||
motion["comment " + $scope.commentFieldForRecommendation] = recommendationExtension;
|
motion['comment_' + $scope.commentFieldForRecommendationId] = recommendationExtension;
|
||||||
$scope.save(motion);
|
$scope.save(motion);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1471,18 +1467,9 @@ angular.module('OpenSlidesApp.motions.site', [
|
|||||||
$scope.showVersion({id: motion.active_version});
|
$scope.showVersion({id: motion.active_version});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// check if user is allowed to see at least one comment field
|
// check if there is at least one comment field
|
||||||
$scope.isAllowedToSeeCommentField = function () {
|
$scope.commentFieldsAvailable = function () {
|
||||||
var isAllowed = false;
|
return _.keys($scope.noSpecialCommentsFields).length > 0;
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
// personal note
|
// personal note
|
||||||
// For pinning the personal note container we need to adjust the width with JS. We
|
// For pinning the personal note container we need to adjust the width with JS. We
|
||||||
|
@ -161,10 +161,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div os-perms="motions.can_manage" class="input-group spacer"
|
<div os-perms="motions.can_manage" class="input-group spacer"
|
||||||
ng-show="motion.state.show_state_extension_field">
|
ng-show="motion.state.show_state_extension_field">
|
||||||
<label class="sr-only" for="stateExtensionField">{{ commentFieldForState }}</label>
|
<label class="sr-only" for="stateExtensionField">{{ commentsFields[commentFieldForStateId] }}</label>
|
||||||
<input type="text" ng-model="stateExtension"
|
<input type="text" ng-model="stateExtension"
|
||||||
id="stateNameExtensionField" class="form-control input-sm"
|
id="stateNameExtensionField" class="form-control input-sm"
|
||||||
placeholder="{{ commentFieldForState }}">
|
placeholder="{{ commentsFields[commentFieldForStateId] }}">
|
||||||
<span class="input-group-btn">
|
<span class="input-group-btn">
|
||||||
<button ng-click="saveAdditionalStateField(stateExtension)" class="btn btn-default btn-sm">
|
<button ng-click="saveAdditionalStateField(stateExtension)" class="btn btn-default btn-sm">
|
||||||
<i class="fa fa-check"></i>
|
<i class="fa fa-check"></i>
|
||||||
@ -203,10 +203,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="input-group spacer"
|
<div class="input-group spacer"
|
||||||
ng-if="motion.recommendation.show_recommendation_extension_field">
|
ng-if="motion.recommendation.show_recommendation_extension_field">
|
||||||
<label class="sr-only" for="recommendationExtensionField">{{ commentFieldForRecommendation }}</label>
|
<label class="sr-only" for="recommendationExtensionField">
|
||||||
|
{{ commentsFields[commentFieldForRecommendationId] }}
|
||||||
|
</label>
|
||||||
<input type="text" ng-model="recommendationExtension"
|
<input type="text" ng-model="recommendationExtension"
|
||||||
id="recommendationExtensionField" class="form-control input-sm"
|
id="recommendationExtensionField" class="form-control input-sm"
|
||||||
placeholder="{{ commentFieldForRecommendation }}">
|
placeholder="{{ commentsFields[commentFieldForRecommendationId] }}">
|
||||||
<span class="input-group-btn">
|
<span class="input-group-btn">
|
||||||
<button ng-click="saveAdditionalRecommendationField(recommendationExtension)" class="btn btn-default btn-sm">
|
<button ng-click="saveAdditionalRecommendationField(recommendationExtension)" class="btn btn-default btn-sm">
|
||||||
<i class="fa fa-check"></i>
|
<i class="fa fa-check"></i>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
<div class="details" ng-if="isAllowedToSeeCommentField() && commentsFieldsNoSpecialComments.length">
|
<div class="details" ng-if="commentFieldsAvailable()">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- inline editing toolbar -->
|
<!-- inline editing toolbar -->
|
||||||
<div class="motion-toolbar">
|
<div class="motion-toolbar">
|
||||||
@ -25,15 +25,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- comment fields -->
|
<!-- comment fields -->
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<div ng-repeat="field in commentsFieldsNoSpecialComments">
|
<div ng-repeat="(id, field) in noSpecialCommentsFields">
|
||||||
<div ng-if="operator.hasPerms('motions.can_see_and_manage_comments') && !field.forState && !field.forRecommendation">
|
<h4>
|
||||||
<h4>
|
{{ field.name }}
|
||||||
{{ field.name }}
|
<span ng-if="!field.public" class="label label-warning" translate>internal</span>
|
||||||
<span ng-if="!field.public" class="label label-warning" translate>internal</span>
|
</h4>
|
||||||
</h4>
|
<div id="view-original-comment-inline-editor-{{ id }}" style="min-height: 14px;"
|
||||||
<div id="view-original-comment-inline-editor-{{ field.name }}" style="min-height: 14px;"
|
ng-bind-html="motion.comments[id] | trusted" contenteditable="{{ commentsInlineEditing.editors[$index].isEditable }}"></div>
|
||||||
ng-bind-html="motion.comments[$index] | trusted" contenteditable="{{ commentsInlineEditing.editors[$index].isEditable }}"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- save toolbar -->
|
<!-- save toolbar -->
|
||||||
<div class="motion-save-toolbar" ng-class="{ 'visible': commentsInlineEditing.saveToolbarVisible() }">
|
<div class="motion-save-toolbar" ng-class="{ 'visible': commentsInlineEditing.saveToolbarVisible() }">
|
||||||
|
@ -169,30 +169,28 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
<!-- Comment filter -->
|
<!-- Comment filter -->
|
||||||
<span os-perms="motions.can_see_and_manage_comments">
|
<span uib-dropdown ng-if="showCommentsFilter()">
|
||||||
<span uib-dropdown ng-if="commentsFieldsNoSpecialComments.length > 0">
|
<span class="pointer" id="dropdownComment" uib-dropdown-toggle
|
||||||
<span class="pointer" id="dropdownComment" uib-dropdown-toggle
|
ng-class="{'bold': filter.multiselectFilters.comment.length > 0, 'disabled': isSelectMode}"
|
||||||
ng-class="{'bold': filter.multiselectFilters.comment.length > 0, 'disabled': isSelectMode}"
|
ng-disabled="isSelectMode">
|
||||||
ng-disabled="isSelectMode">
|
<translate>Comment</translate>
|
||||||
<translate>Comment</translate>
|
<span class="caret"></span>
|
||||||
<span class="caret"></span>
|
|
||||||
</span>
|
|
||||||
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownComment">
|
|
||||||
<li ng-repeat="commentsField in commentsFieldsNoSpecialComments">
|
|
||||||
<a href ng-click="filter.operateMultiselectFilter('comment', commentsField.name, isSelectMode)">
|
|
||||||
<i class="fa fa-check" ng-if="filter.multiselectFilters.comment.indexOf(commentsField.name) > -1"></i>
|
|
||||||
{{ commentsField.name }} <translate>is set</translate>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="divider"></li>
|
|
||||||
<li>
|
|
||||||
<a href ng-click="filter.operateMultiselectFilter('comment', -1, isSelectMode)">
|
|
||||||
<i class="fa fa-check" ng-if="filter.multiselectFilters.comment.indexOf(-1) > -1"></i>
|
|
||||||
<translate>No comments set</translate>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</span>
|
</span>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-left" aria-labelledby="dropdownComment">
|
||||||
|
<li ng-repeat="commentsField in noSpecialCommentsFields">
|
||||||
|
<a href ng-click="filter.operateMultiselectFilter('comment', commentsField.name, isSelectMode)">
|
||||||
|
<i class="fa fa-check" ng-if="filter.multiselectFilters.comment.indexOf(commentsField.name) > -1"></i>
|
||||||
|
{{ commentsField.name }} <translate>is set</translate>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="divider"></li>
|
||||||
|
<li>
|
||||||
|
<a href ng-click="filter.operateMultiselectFilter('comment', -1, isSelectMode)">
|
||||||
|
<i class="fa fa-check" ng-if="filter.multiselectFilters.comment.indexOf(-1) > -1"></i>
|
||||||
|
<translate>No comments set</translate>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
<!-- recommendation filter -->
|
<!-- recommendation filter -->
|
||||||
<span uib-dropdown ng-if="config('motions_recommendations_by') != ''">
|
<span uib-dropdown ng-if="config('motions_recommendations_by') != ''">
|
||||||
|
@ -238,10 +238,9 @@ class CreateMotion(TestCase):
|
|||||||
self.assertEqual(motion.tags.get().name, 'test_tag_iRee3kiecoos4rorohth')
|
self.assertEqual(motion.tags.get().name, 'test_tag_iRee3kiecoos4rorohth')
|
||||||
|
|
||||||
def test_with_multiple_comments(self):
|
def test_with_multiple_comments(self):
|
||||||
config['motions_comments'] = [
|
comments = {
|
||||||
{'name': 'comment1', 'public': True},
|
'1': 'comemnt1_sdpoiuffo3%7dwDwW)',
|
||||||
{'name': 'comment2', 'public': False}]
|
'2': 'comment2_iusd_D/TdskDWH(5DWas46WAd078'}
|
||||||
comments = ['comemnt1_sdpoiuffo3%7dwDwW)', 'comment2_iusd_D/TdskDWH(5DWas46WAd078']
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
reverse('motion-list'),
|
reverse('motion-list'),
|
||||||
{'title': 'title_test_sfdAaufd56HR7sd5FDq7av',
|
{'title': 'title_test_sfdAaufd56HR7sd5FDq7av',
|
||||||
@ -252,6 +251,31 @@ class CreateMotion(TestCase):
|
|||||||
motion = Motion.objects.get()
|
motion = Motion.objects.get()
|
||||||
self.assertEqual(motion.comments, comments)
|
self.assertEqual(motion.comments, comments)
|
||||||
|
|
||||||
|
def test_wrong_comment_format(self):
|
||||||
|
comments = [
|
||||||
|
'comemnt1_wpcjlwgj$§ks)skj2LdmwKDWSLw6',
|
||||||
|
'comment2_dq2Wd)Jwdlmm:,w82DjwQWSSiwjd']
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('motion-list'),
|
||||||
|
{'title': 'title_test_sfdAaufd56HR7sd5FDq7av',
|
||||||
|
'text': 'text_test_fiuhefF86()ew1Ef346AF6W',
|
||||||
|
'comments': comments},
|
||||||
|
format='json')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(response.data, {'comments': {'detail': 'Data must be a dict.'}})
|
||||||
|
|
||||||
|
def test_wrong_comment_id(self):
|
||||||
|
comment = {
|
||||||
|
'string': 'comemnt1_wpcjlwgj$§ks)skj2LdmwKDWSLw6'}
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('motion-list'),
|
||||||
|
{'title': 'title_test_sfdAaufd56HR7sd5FDq7av',
|
||||||
|
'text': 'text_test_fiuhefF86()ew1Ef346AF6W',
|
||||||
|
'comments': comment},
|
||||||
|
format='json')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(response.data, {'comments': {'detail': 'Id must be an int.'}})
|
||||||
|
|
||||||
def test_with_workflow(self):
|
def test_with_workflow(self):
|
||||||
"""
|
"""
|
||||||
Test to create a motion with a specific workflow.
|
Test to create a motion with a specific workflow.
|
||||||
@ -298,13 +322,12 @@ class CreateMotion(TestCase):
|
|||||||
reverse('motion-list'),
|
reverse('motion-list'),
|
||||||
{'title': 'test_title_peiJozae0luew9EeL8bo',
|
{'title': 'test_title_peiJozae0luew9EeL8bo',
|
||||||
'text': 'test_text_eHohS8ohr5ahshoah8Oh',
|
'text': 'test_text_eHohS8ohr5ahshoah8Oh',
|
||||||
'comments': ['comment_for_field_one__xiek1Euhae9xah2wuuraaaa'],
|
'comments': {'1': 'comment_for_field_one__xiek1Euhae9xah2wuuraaaa'}},
|
||||||
'comment_field_one': 'comment_for_field_one__xiek1Euhae9xah2wuuraaaa'},
|
|
||||||
format='json',
|
format='json',
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
self.assertEqual(Motion.objects.get().comments, ['comment_for_field_one__xiek1Euhae9xah2wuuraaaa'])
|
self.assertEqual(Motion.objects.get().comments, {'1': 'comment_for_field_one__xiek1Euhae9xah2wuuraaaa'})
|
||||||
|
|
||||||
def test_amendment_motion(self):
|
def test_amendment_motion(self):
|
||||||
"""
|
"""
|
||||||
|
Loading…
Reference in New Issue
Block a user