Merge pull request #2495 from normanjaeckel/MotionBlock

Motion block
This commit is contained in:
Norman Jäckel 2016-10-17 21:29:31 +02:00 committed by GitHub
commit f2fa8de7be
24 changed files with 1017 additions and 50 deletions

View File

@ -32,6 +32,9 @@ Motions:
- Changed label of former state "commited a bill" to "refered to committee".
- Added options to calculate percentages on different bases.
- Added majority calculation.
- Added blocks for motions which can set as agenda item.
Set states for multiple motions of a motion block by following
the recommendations of each motion.
Users:
- Added field is_committee and new default group Committees.

View File

@ -295,8 +295,9 @@ angular.module('OpenSlidesApp.agenda', ['OpenSlidesApp.users'])
'Assignment', // TODO: Remove this after refactoring of data loading on start.
'Topic', // TODO: Remove this after refactoring of data loading on start.
'Motion', // TODO: Remove this after refactoring of data loading on start.
'MotionBlock', // TODO: Remove this after refactoring of data loading on start.
'Agenda',
function (Projector, Assignment, Topic, Motion, Agenda) {
function (Projector, Assignment, Topic, Motion, MotionBlock, Agenda) {
return {
getItem: function (projectorId) {
var elementPromise;
@ -311,6 +312,13 @@ angular.module('OpenSlidesApp.agenda', ['OpenSlidesApp.users'])
});
});
break;
case 'motions/motion-block':
elementPromise = MotionBlock.find(element.id).then(function(motionBlock) {
return MotionBlock.loadRelations(motionBlock, 'agenda_item').then(function() {
return motionBlock.agenda_item;
});
});
break;
case 'topics/topic':
elementPromise = Topic.find(element.id).then(function(topic) {
return Topic.loadRelations(topic, 'agenda_item').then(function() {

View File

@ -64,6 +64,10 @@ def create_builtin_projection_defaults(**kwargs):
name='motions',
display_name='Motions',
projector=default_projector)
ProjectionDefault.objects.create(
name='motionBlocks',
display_name='Motion Blocks',
projector=default_projector)
ProjectionDefault.objects.create(
name='assignments',
display_name='Elections',

View File

@ -165,10 +165,8 @@ angular.module('OpenSlidesApp.core.site', [
}
angular.forEach(views, function(config, name) {
// Sets default values for templateUrl
var patterns = state.name.split('.'),
templateUrl,
// Sets additional default values for templateUrl
var templateUrl,
controller,
defaultControllers = {
create: 'CreateCtrl',
@ -177,22 +175,43 @@ angular.module('OpenSlidesApp.core.site', [
detail: 'DetailCtrl',
};
// templateUrl
if (_.last(patterns).match(/(create|update)/)) {
// When state_patterns is in the form "app.module.create" or
// "app.module.update", use the form template.
templateUrl = 'static/templates/' + patterns[0] + '/' + patterns[1] + '-form.html';
} else {
// Replaces the first point through a slash (the app name)
var appName = state.name.replace('.', '/');
// Replaces any folowing points though a -
templateUrl = 'static/templates/' + appName.replace(/\./g, '-') + '.html';
// Split up state name
// example: "motions.motion.detail.update" -> ['motions', 'motion', 'detail', 'update']
var patterns = state.name.split('.');
// set app and module name from state
// - appName: patterns[0] (e.g. "motions")
// - moduleNames: patterns without first element (e.g. ["motion", "detail", "update"])
var appName = '';
var moduleName = '';
var moduleNames = [];
if (patterns.length > 0) {
appName = patterns[0];
moduleNames = patterns.slice(1);
}
if (moduleNames.length > 0) {
// convert from camcelcase to dash notation
// example: ["motionBlock", "detail"] -> ["motion-block", "detail"]
for (var i = 0; i < moduleNames.length; i++) {
moduleNames[i] = moduleNames[i].replace(/([a-z\d])([A-Z])/g, '$1-$2').toLowerCase();
}
// use special templateUrl for create and update view
// example: ["motion", "detail", "update"] -> "motion-form"
if (_.last(moduleNames).match(/(create|update)/)) {
moduleName = '/' + moduleNames[0] + '-form';
} else {
// convert modelNames array to url string
// example: ["motion-block", "detail"] -> "motion-block-detail"
moduleName = '/' + moduleNames.join('-');
}
}
templateUrl = 'static/templates/' + appName + moduleName + '.html';
config.templateUrl = state.templateUrl || templateUrl;
// controller
if (patterns.length >= 3) {
controller = _.capitalize(patterns[1]) + defaultControllers[_.last(patterns)];
controller = _.upperFirst(patterns[1]) + defaultControllers[_.last(patterns)];
config.controller = state.controller || controller;
}
result[name] = config;
@ -1417,6 +1436,20 @@ angular.module('OpenSlidesApp.core.site', [
}
])
.filter('toArray', function(){
/*
* Transforms an object to an array. Items of the array are the values of
* the object elements.
*/
return function(obj) {
var result = [];
angular.forEach(obj, function(val, key) {
result.push(val);
});
return result;
};
})
//Mark all core config strings for translation in Javascript
.config([
'gettext',

View File

@ -93,6 +93,25 @@ class CategoryAccessPermissions(BaseAccessPermissions):
return CategorySerializer
class MotionBlockAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Category and CategoryViewSet.
"""
def check_permissions(self, user):
"""
Returns True if the user has read access model instances.
"""
return user.has_perm('motions.can_see')
def get_serializer_class(self, user=None):
"""
Returns serializer class.
"""
from .serializers import MotionBlockSerializer
return MotionBlockSerializer
class WorkflowAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Workflow and WorkflowViewSet.

View File

@ -18,7 +18,7 @@ class MotionsAppConfig(AppConfig):
from openslides.utils.rest_api import router
from .config_variables import get_config_variables
from .signals import create_builtin_workflows
from .views import CategoryViewSet, MotionViewSet, MotionPollViewSet, MotionChangeRecommendationViewSet, WorkflowViewSet
from .views import CategoryViewSet, MotionViewSet, MotionBlockViewSet, MotionPollViewSet, MotionChangeRecommendationViewSet, WorkflowViewSet
# Define config variables
config.update_config_variables(get_config_variables())
@ -29,6 +29,7 @@ class MotionsAppConfig(AppConfig):
# Register viewsets.
router.register(self.get_model('Category').get_collection_string(), CategoryViewSet)
router.register(self.get_model('Motion').get_collection_string(), MotionViewSet)
router.register(self.get_model('MotionBlock').get_collection_string(), MotionBlockViewSet)
router.register(self.get_model('Workflow').get_collection_string(), WorkflowViewSet)
router.register(self.get_model('MotionChangeRecommendation').get_collection_string(),
MotionChangeRecommendationViewSet)

View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2016-10-17 18:20
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
import openslides.utils.models
class Migration(migrations.Migration):
dependencies = [
('motions', '0005_motionchangerecommendation'),
]
operations = [
migrations.CreateModel(
name='MotionBlock',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
],
options={
'default_permissions': (),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.AddField(
model_name='motion',
name='motion_block',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='motions.MotionBlock'),
),
]

View File

@ -17,12 +17,14 @@ from openslides.poll.models import (
BaseVote,
CollectDefaultVotesMixin,
)
from openslides.utils.autoupdate import inform_changed_data
from openslides.utils.models import RESTModelMixin
from openslides.utils.search import user_name_helper
from .access_permissions import (
CategoryAccessPermissions,
MotionAccessPermissions,
MotionBlockAccessPermissions,
MotionChangeRecommendationAccessPermissions,
WorkflowAccessPermissions,
)
@ -116,6 +118,15 @@ class Motion(RESTModelMixin, models.Model):
ForeignKey to one category of motions.
"""
motion_block = models.ForeignKey(
'MotionBlock',
on_delete=models.SET_NULL,
null=True,
blank=True)
"""
ForeignKey to one block of motions.
"""
origin = models.CharField(max_length=255, blank=True)
"""
A string to describe the origin of this motion e. g. that it was
@ -182,7 +193,7 @@ class Motion(RESTModelMixin, models.Model):
return self.title
# TODO: Use transaction
def save(self, use_version=None, *args, **kwargs):
def save(self, use_version=None, skip_autoupdate=False, *args, **kwargs):
"""
Save the motion.
@ -215,14 +226,19 @@ class Motion(RESTModelMixin, models.Model):
if not self.identifier and isinstance(self.identifier, str):
self.identifier = None
super(Motion, self).save(*args, **kwargs)
# Always skip autoupdate. Maybe we run it later in this method.
super(Motion, self).save(skip_autoupdate=True, *args, **kwargs)
if 'update_fields' in kwargs:
# Do not save the version data if only some motion fields are updated.
if not skip_autoupdate:
inform_changed_data(self)
return
if use_version is False:
# We do not need to save the version.
if not skip_autoupdate:
inform_changed_data(self)
return
elif use_version is None:
use_version = self.get_last_version()
@ -239,6 +255,8 @@ class Motion(RESTModelMixin, models.Model):
if use_version.id is None:
if not self.version_data_changed(use_version):
# We do not need to save the version.
if not skip_autoupdate:
inform_changed_data(self)
return
version_number = self.versions.aggregate(Max('version_number'))['version_number__max'] or 0
use_version.version_number = version_number + 1
@ -246,16 +264,22 @@ class Motion(RESTModelMixin, models.Model):
# Necessary line if the version was set before the motion got an id.
use_version.motion = use_version.motion
use_version.save()
# Always skip autoupdate. Maybe we run it later in this method.
use_version.save(skip_autoupdate=True)
# Set the active version of this motion. This has to be done after the
# version is saved in the database.
# TODO: Move parts of these last lines of code outside the save method
# when other versions than the last ones should be edited later on.
# when other versions than the last one should be edited later on.
if self.active_version is None or not self.state.leave_old_version_active:
# TODO: Don't call this if it was not a new version
self.active_version = use_version
self.save(update_fields=['active_version'])
# Always skip autoupdate. Maybe we run it later in this method.
self.save(update_fields=['active_version'], skip_autoupdate=True)
# Finally run autoupdate if it is not skipped by caller.
if not skip_autoupdate:
inform_changed_data(self)
def version_data_changed(self, version):
"""
@ -520,6 +544,13 @@ class Motion(RESTModelMixin, models.Model):
recommendation = State.objects.get(pk=recommendation)
self.recommendation = recommendation
def follow_recommendation(self):
"""
Set the state of this motion to its recommendation.
"""
if self.recommendation is not None:
self.set_state(self.recommendation)
def get_agenda_title(self):
"""
Return a simple title string for the agenda.
@ -614,7 +645,7 @@ class Motion(RESTModelMixin, models.Model):
return actions
def write_log(self, message_list, person=None):
def write_log(self, message_list, person=None, skip_autoupdate=False):
"""
Write a log message.
@ -623,7 +654,8 @@ class Motion(RESTModelMixin, models.Model):
"""
if person and not person.is_authenticated():
person = None
MotionLog.objects.create(motion=self, message_list=message_list, person=person)
motion_log = MotionLog(motion=self, message_list=message_list, person=person)
motion_log.save(skip_autoupdate=skip_autoupdate)
def is_amendment(self):
"""
@ -783,6 +815,66 @@ class Category(RESTModelMixin, models.Model):
return self.name
class MotionBlockManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all motion blocks. In the
background the related agenda item is prefetched from the database.
"""
return self.get_queryset().prefetch_related('agenda_items')
class MotionBlock(RESTModelMixin, models.Model):
"""
Model for blocks of motions.
"""
access_permissions = MotionBlockAccessPermissions()
objects = MotionBlockManager()
title = models.CharField(max_length=255)
# In theory there could be one then more agenda_item. But we support only
# one. See the property agenda_item.
agenda_items = GenericRelation(Item, related_name='topics')
class Meta:
default_permissions = ()
def __str__(self):
return self.title
@property
def agenda_item(self):
"""
Returns the related agenda item.
"""
# We support only one agenda item so just return the first element of
# the queryset.
return self.agenda_items.all()[0]
@property
def agenda_item_id(self):
"""
Returns the id of the agenda item object related to this object.
"""
return self.agenda_item.pk
def get_agenda_title(self):
return self.title
def get_agenda_list_view_title(self):
return self.title
@classmethod
def get_collection_string(cls):
# TODO: Fix generic method to support camelCase, #2480.
return 'motions/motion-block'
class MotionLog(RESTModelMixin, models.Model):
"""Save a logmessage for a motion."""

View File

@ -1,7 +1,7 @@
from ..core.exceptions import ProjectorException
from ..utils.collection import CollectionElement
from ..utils.projector import ProjectorElement
from .models import Motion
from .models import Motion, MotionBlock
class MotionSlide(ProjectorElement):
@ -48,3 +48,44 @@ class MotionSlide(ProjectorElement):
else:
data = {'agenda_item_id': motion.agenda_item_id}
return data
class MotionBlockSlide(ProjectorElement):
"""
Slide definitions for a block of motions (MotionBlock model).
"""
name = 'motions/motion-block'
def check_data(self):
if not MotionBlock.objects.filter(pk=self.config_entry.get('id')).exists():
raise ProjectorException('MotionBlock does not exist.')
def get_requirements(self, config_entry):
try:
motion_block = MotionBlock.objects.get(pk=config_entry.get('id'))
except MotionBlock.DoesNotExist:
# MotionBlock does not exist. Just do nothing.
pass
else:
yield motion_block
yield motion_block.agenda_item
yield from motion_block.motion_set.all()
def get_collection_elements_required_for_this(self, collection_element, config_entry):
output = super().get_collection_elements_required_for_this(collection_element, config_entry)
# Full update if a motion changes because then it may be appended to
# or removed from the block.
if collection_element.collection_string == Motion.get_collection_string():
output.extend(self.get_requirements_as_collection_elements(config_entry))
return output
def update_data(self):
data = None
try:
motion_block = MotionBlock.objects.get(pk=self.config_entry.get('id'))
except MotionBlock.DoesNotExist:
# MotionBlock does not exist, so just do nothing.
pass
else:
data = {'agenda_item_id': motion_block.agenda_item_id}
return data

View File

@ -16,6 +16,7 @@ from openslides.utils.rest_api import (
from .models import (
Category,
Motion,
MotionBlock,
MotionChangeRecommendation,
MotionLog,
MotionPoll,
@ -42,6 +43,15 @@ class CategorySerializer(ModelSerializer):
fields = ('id', 'name', 'prefix',)
class MotionBlockSerializer(ModelSerializer):
"""
Serializer for motion.models.Category objects.
"""
class Meta:
model = MotionBlock
fields = ('id', 'title', 'agenda_item_id',)
class StateSerializer(ModelSerializer):
"""
Serializer for motion.models.State objects.
@ -275,6 +285,7 @@ class MotionSerializer(ModelSerializer):
'active_version',
'parent',
'category',
'motion_block',
'origin',
'submitters',
'supporters',
@ -300,6 +311,7 @@ class MotionSerializer(ModelSerializer):
motion.reason = validated_data.get('reason', '')
motion.identifier = validated_data.get('identifier')
motion.category = validated_data.get('category')
motion.motion_block = validated_data.get('motion_block')
motion.origin = validated_data.get('origin', '')
motion.comments = validated_data.get('comments')
motion.parent = validated_data.get('parent')
@ -319,8 +331,8 @@ class MotionSerializer(ModelSerializer):
"""
Customized method to update a motion.
"""
# Identifier, category, origin and comments.
for key in ('identifier', 'category', 'origin', 'comments'):
# Identifier, category, motion_block, origin and comments.
for key in ('identifier', 'category', 'motion_block', 'origin', 'comments'):
if key in validated_data.keys():
setattr(motion, key, validated_data[key])

View File

@ -3,10 +3,11 @@
"use strict";
angular.module('OpenSlidesApp.motions', [
'OpenSlidesApp.users',
'OpenSlidesApp.motions.lineNumbering',
'OpenSlidesApp.motions.diff',
'OpenSlidesApp.motions.DOCX'
'OpenSlidesApp.motions.motionBlock',
'OpenSlidesApp.motions.lineNumbering',
'OpenSlidesApp.motions.diff',
'OpenSlidesApp.motions.DOCX',
'OpenSlidesApp.users',
])
.factory('WorkflowState', [
@ -115,7 +116,7 @@ angular.module('OpenSlidesApp.motions', [
if (type == 'yes' || type == 'no') {
base = this.yes + this.no;
}
} else if (config == "VALID" && type !== 'votescast' && type !== 'votesinvalid' &&
} else if (config == "VALID" && type !== 'votescast' && type !== 'votesinvalid' &&
this.votesvalid > 0) {
base = this.votesvalid;
} else if (config == "CAST" && this.votescast > 0) {
@ -389,6 +390,10 @@ angular.module('OpenSlidesApp.motions', [
localField: 'category',
localKey: 'category_id',
},
'motions/motion-block': {
localField: 'motionBlock',
localKey: 'motion_block_id',
},
'agenda/item': {
localKey: 'agenda_item_id',
localField: 'agenda_item',

View File

@ -0,0 +1,32 @@
(function () {
'use strict';
angular.module('OpenSlidesApp.motions.motionBlockProjector', [])
// MotionBlock projector elements
.config([
'slidesProvider',
function(slidesProvider) {
slidesProvider.registerSlide('motions/motion-block', {
template: 'static/templates/motions/slide_motion_block.html',
});
}
])
.controller('SlideMotionBlockCtrl', [
'$scope',
'Motion',
'MotionBlock',
function($scope, Motion, MotionBlock) {
// Attention! Each object that is used here has to be dealt on server side.
// Add it to the coresponding get_requirements method of the ProjectorElement
// class.
var id = $scope.element.id;
MotionBlock.bindOne(id, $scope, 'motionBlock');
}
]);
}());

View File

@ -0,0 +1,259 @@
(function () {
'use strict';
angular.module('OpenSlidesApp.motions.motionBlock', [])
// MotionBlock model
.factory('MotionBlock', [
'DS',
'jsDataModel',
'gettext',
function(DS, jsDataModel, gettext) {
var name = 'motions/motion-block';
return DS.defineResource({
name: name,
useClass: jsDataModel,
verboseName: gettext('Motion block'),
methods: {
getResourceName: function () {
return name;
},
getAgendaTitle: function () {
return this.title;
},
},
relations: {
belongsTo: {
'agenda/item': {
localKey: 'agenda_item_id',
localField: 'agenda_item',
}
},
hasMany: {
'motions/motion': {
localField: 'motions',
foreignKey: 'motion_block_id',
}
},
}
});
}
])
.run(['MotionBlock', function(MotionBlock) {}])
// MotionBlock views (list view, create dialog, update dialog)
.factory('MotionBlockForm', [
'$http',
'gettextCatalog',
'Agenda',
'AgendaTree',
function ($http, gettextCatalog, Agenda, AgendaTree) {
return {
// Get ngDialog configuration.
getDialog: function (motionBlock) {
return {
template: 'static/templates/motions/motion-block-form.html',
controller: (motionBlock) ? 'MotionBlockUpdateCtrl' : 'MotionBlockCreateCtrl',
className: 'ngdialog-theme-default wide-form',
closeByEscape: false,
closeByDocument: false,
resolve: {
motionBlock: function () {
return motionBlock;
}
}
};
},
// Get angular-formly fields.
getFormFields: function () {
return [
{
key: 'title',
type: 'input',
templateOptions: {
label: gettextCatalog.getString('Title')
}
},
{
key: 'showAsAgendaItem',
type: 'checkbox',
templateOptions: {
label: gettextCatalog.getString('Show as agenda item'),
description: gettextCatalog.getString('If deactivated it appears as internal item on agenda.')
}
},
{
key: 'agenda_parent_item_id',
type: 'select-single',
templateOptions: {
label: gettextCatalog.getString('Parent item'),
options: AgendaTree.getFlatTree(Agenda.getAll()),
ngOptions: 'item.id as item.getListViewTitle() for item in to.options | notself : model.agenda_item_id',
placeholder: gettextCatalog.getString('Select a parent item ...')
}
}
];
}
};
}
])
.controller('MotionBlockListCtrl', [
'$scope',
'ngDialog',
'MotionBlock',
'MotionBlockForm',
function ($scope, ngDialog, MotionBlock, MotionBlockForm) {
// Two-way data binding for all MotionBlock instances.
MotionBlock.bindAll({}, $scope, 'motionBlocks');
// Dialog with a form to create or update a MotionBlock instance.
$scope.openFormDialog = function (motionBlock) {
ngDialog.open(MotionBlockForm.getDialog(motionBlock));
};
// Confirm dialog to delete a MotionBlock instance.
$scope.delete = function (motionBlock) {
MotionBlock.destroy(motionBlock.id);
};
}
])
.controller('MotionBlockDetailCtrl', [
'$scope',
'$http',
'ngDialog',
'Motion',
'MotionBlockForm',
'MotionBlock',
'motionBlock',
'Projector',
'ProjectionDefault',
function($scope, $http, ngDialog, Motion, MotionBlockForm, MotionBlock, motionBlock, Projector, ProjectionDefault) {
MotionBlock.bindOne(motionBlock.id, $scope, 'motionBlock');
Motion.bindAll({}, $scope, 'motions');
$scope.$watch(function () {
return Projector.lastModified();
}, function () {
var projectiondefault = ProjectionDefault.filter({name: 'motionBlocks'})[0];
if (projectiondefault) {
$scope.defaultProjectorId = projectiondefault.projector_id;
}
});
$scope.openDialog = function (topic) {
ngDialog.open(MotionBlockForm.getDialog(motionBlock));
};
$scope.followRecommendations = function () {
$http.post('/rest/motions/motion-block/' + motionBlock.id + '/follow_recommendations/')
.success(function(data) {
$scope.alert = { type: 'success', msg: data.detail, show: true };
})
.error(function(data) {
$scope.alert = { type: 'danger', msg: data.detail, show: true };
});
};
$scope.delete = function (motion) {
motion.motion_block_id = null;
motion.title = motion.getTitle(-1);
motion.text = motion.getText(-1);
motion.reason = motion.getReason(-1);
Motion.save(motion);
};
}
])
.controller('MotionBlockCreateCtrl', [
'$scope',
'MotionBlock',
'MotionBlockForm',
'AgendaUpdate',
function($scope, MotionBlock, MotionBlockForm, AgendaUpdate) {
// Prepare form.
$scope.model = {};
$scope.model.showAsAgendaItem = true;
// Get all form fields.
$scope.formFields = MotionBlockForm.getFormFields();
// Save form.
$scope.save = function (motionBlock) {
MotionBlock.create(motionBlock).then(
function (success) {
// type: Value 1 means a non hidden agenda item, value 2 means a hidden agenda item,
// see openslides.agenda.models.Item.ITEM_TYPE.
var changes = [{key: 'type', value: (motionBlock.showAsAgendaItem ? 1 : 2)},
{key: 'parent_id', value: motionBlock.agenda_parent_item_id}];
AgendaUpdate.saveChanges(success.agenda_item_id, changes);
$scope.closeThisDialog();
},
function (error) {
var message = '';
for (var e in error.data) {
message += e + ': ' + error.data[e] + ' ';
}
$scope.alert = {type: 'danger', msg: message, show: true};
}
);
};
}
])
.controller('MotionBlockUpdateCtrl', [
'$scope',
'$state',
'MotionBlock',
'MotionBlockForm',
'AgendaUpdate',
'motionBlock',
function($scope, $state, MotionBlock, MotionBlockForm, AgendaUpdate, motionBlock) {
// TODO: Check #2486 and remove some agenda related code.
//MotionBlock.loadRelations(motionBlock, 'agenda_item');
$scope.alert = {};
// Prepare form. Set initial values by creating a deep copy of
// motionBlock object so list/detail view is not updated while editing.
$scope.model = angular.copy(motionBlock);
// Get all form fields.
$scope.formFields = MotionBlockForm.getFormFields();
for (var i = 0; i < $scope.formFields.length; i++) {
if ($scope.formFields[i].key == 'showAsAgendaItem') {
// Get state from agenda item (hidden/internal or agenda item).
$scope.formFields[i].defaultValue = !motionBlock.agenda_item.is_hidden;
} else if ($scope.formFields[i].key == 'agenda_parent_item_id') {
$scope.formFields[i].defaultValue = motionBlock.agenda_item.parent_id;
}
}
// Save form.
$scope.save = function (motionBlock) {
MotionBlock.create(motionBlock).then(
function (success) {
// type: Value 1 means a non hidden agenda item, value 2 means a hidden agenda item,
// see openslides.agenda.models.Item.ITEM_TYPE.
var changes = [{key: 'type', value: (motionBlock.showAsAgendaItem ? 1 : 2)},
{key: 'parent_id', value: motionBlock.agenda_parent_item_id}];
AgendaUpdate.saveChanges(success.agenda_item_id,changes);
$scope.closeThisDialog();
},
function (error) {
// Save error: revert all changes by restore
// (refresh) original motionBlock object from server
MotionBlock.refresh(motionBlock); // TODO: Why do we need a refresh here?
var message = '';
for (var e in error.data) {
message += e + ': ' + error.data[e] + ' ';
}
$scope.alert = {type: 'danger', msg: message, show: true};
}
);
};
}
]);
}());

View File

@ -2,7 +2,10 @@
'use strict';
angular.module('OpenSlidesApp.motions.projector', ['OpenSlidesApp.motions'])
angular.module('OpenSlidesApp.motions.projector', [
'OpenSlidesApp.motions',
'OpenSlidesApp.motions.motionBlockProjector',
])
.config([
'slidesProvider',

View File

@ -4,7 +4,6 @@
angular.module('OpenSlidesApp.motions.site', [
'OpenSlidesApp.motions',
'OpenSlidesApp.motions.diff',
'OpenSlidesApp.motions.motionservices',
'OpenSlidesApp.poll.majority',
'OpenSlidesApp.core.pdf',
@ -50,6 +49,9 @@ angular.module('OpenSlidesApp.motions.site', [
categories: function(Category) {
return Category.findAll();
},
motionBlocks: function(MotionBlock) {
return MotionBlock.findAll();
},
tags: function(Tag) {
return Tag.findAll();
},
@ -81,6 +83,9 @@ angular.module('OpenSlidesApp.motions.site', [
categories: function(Category) {
return Category.findAll();
},
motionBlocks: function(MotionBlock) {
return MotionBlock.findAll();
},
users: function(User) {
return User.findAll().catch(
function () {
@ -198,6 +203,72 @@ angular.module('OpenSlidesApp.motions.site', [
},
controller: 'CategorySortCtrl',
templateUrl: 'static/templates/motions/category-sort.html'
})
// MotionBlock
.state('motions.motionBlock', {
url: '/blocks',
abstract: true,
template: '<ui-view/>',
})
.state('motions.motionBlock.list', {
resolve: {
motionBlocks: function (MotionBlock) {
return MotionBlock.findAll();
},
motions: function(Motion) {
return Motion.findAll();
},
items: function(Agenda) {
return Agenda.findAll().catch(
function () {
return null;
}
);
}
}
})
.state('motions.motionBlock.detail', {
resolve: {
motionBlock: function(MotionBlock, $stateParams) {
return MotionBlock.find($stateParams.id);
},
motions: function(Motion) {
return Motion.findAll();
},
items: function(Agenda) {
return Agenda.findAll().catch(
function () {
return null;
}
);
}
}
})
// redirects to motionBlock detail and opens motionBlock edit form dialog, uses edit url,
// used by ui-sref links from agenda only
// (from motionBlock controller use MotionBlockForm factory instead to open dialog in front
// of current view without redirect)
.state('motions.motionBlock.detail.update', {
onEnter: ['$stateParams', '$state', 'ngDialog', 'MotionBlock',
function($stateParams, $state, ngDialog, MotionBlock) {
ngDialog.open({
template: 'static/templates/motions/motion-block-form.html',
controller: 'MotionBlockUpdateCtrl',
className: 'ngdialog-theme-default wide-form',
closeByEscape: false,
closeByDocument: false,
resolve: {
motionBlock: function () {
return motionBlock;
}
},
preCloseCallback: function() {
$state.go('motions.motionBlock.detail', {motionBlock: $stateParams.id});
return true;
}
});
}
],
});
}
])
@ -306,12 +377,13 @@ angular.module('OpenSlidesApp.motions.site', [
'Category',
'Config',
'Mediafile',
'MotionBlock',
'Tag',
'User',
'Workflow',
'Agenda',
'AgendaTree',
function (gettextCatalog, operator, Editor, MotionComment, Category, Config, Mediafile, Tag, User, Workflow, Agenda, AgendaTree) {
function (gettextCatalog, operator, Editor, MotionComment, Category, Config, Mediafile, MotionBlock, Tag, User, Workflow, Agenda, AgendaTree) {
return {
// ngDialog for motion form
getDialog: function (motion) {
@ -453,6 +525,17 @@ angular.module('OpenSlidesApp.motions.site', [
},
hideExpression: '!model.more'
},
{
key: 'motion_block_id',
type: 'select-single',
templateOptions: {
label: gettextCatalog.getString('Motion block'),
options: MotionBlock.getAll(),
ngOptions: 'option.id as option.title for option in to.options',
placeholder: gettextCatalog.getString('Select or search a motion block ...')
},
hideExpression: '!model.more'
},
{
key: 'origin',
type: 'input',
@ -630,6 +713,7 @@ angular.module('OpenSlidesApp.motions.site', [
'Workflow',
'User',
'Agenda',
'MotionBlock',
'MotionDocxExport',
'MotionContentProvider',
'MotionCatalogContentProvider',
@ -639,11 +723,12 @@ angular.module('OpenSlidesApp.motions.site', [
'HTMLValidizer',
'Projector',
'ProjectionDefault',
function($scope, $state, $http, ngDialog, MotionForm, Motion, Category, Tag, Workflow, User, Agenda, MotionDocxExport,
MotionContentProvider, MotionCatalogContentProvider, PdfMakeConverter, PdfMakeDocumentProvider,
function($scope, $state, $http, ngDialog, MotionForm, Motion, Category, Tag, Workflow, User, Agenda, MotionBlock,
MotionDocxExport, MotionContentProvider, MotionCatalogContentProvider, PdfMakeConverter, PdfMakeDocumentProvider,
gettextCatalog, HTMLValidizer, Projector, ProjectionDefault) {
Motion.bindAll({}, $scope, 'motions');
Category.bindAll({}, $scope, 'categories');
MotionBlock.bindAll({}, $scope, 'motionBlocks');
Tag.bindAll({}, $scope, 'tags');
Workflow.bindAll({}, $scope, 'workflows');
User.bindAll({}, $scope, 'users');
@ -666,11 +751,13 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.multiselectFilter = {
state: [],
category: [],
motionBlock: [],
tag: []
};
$scope.getItemId = {
state: function (motion) {return motion.state_id;},
category: function (motion) {return motion.category_id;},
motionBlock: function (motion) {return motion.motion_block_id;},
tag: function (motion) {return motion.tags_id;}
};
// function to operate the multiselectFilter
@ -700,6 +787,10 @@ angular.module('OpenSlidesApp.motions.site', [
if (motion.category) {
category = motion.category.name;
}
var motionBlock = '';
if (motion.motionBlock) {
motionBlock = motion.motionBlock.title;
}
return [
motion.identifier,
motion.getTitle(),
@ -725,6 +816,7 @@ angular.module('OpenSlidesApp.motions.site', [
}
).join(" "),
category,
motionBlock
].join(" ");
};
// for reset-button
@ -732,6 +824,7 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.multiselectFilter = {
state: [],
category: [],
motionBlock: [],
tag: []
};
if ($scope.filter) {
@ -741,6 +834,7 @@ angular.module('OpenSlidesApp.motions.site', [
$scope.are_filters_set = function () {
return $scope.multiselectFilter.state.length > 0 ||
$scope.multiselectFilter.category.length > 0 ||
$scope.multiselectFilter.motionBlock.length > 0 ||
$scope.multiselectFilter.tag.length > 0 ||
($scope.filter ? $scope.filter.search : false);
};
@ -801,6 +895,14 @@ angular.module('OpenSlidesApp.motions.site', [
}
save(motion);
};
$scope.toggle_motionBlock = function (motion, block) {
if (motion.motion_block_id == block.id) {
motion.motion_block_id = null;
} else {
motion.motion_block_id = block.id;
}
save(motion);
};
// open new/edit dialog
$scope.openDialog = function (motion) {

View File

@ -0,0 +1,75 @@
<div class="header">
<div class="title">
<div class="submenu">
<a ui-sref="motions.motionBlock.list" class="btn btn-sm btn-default">
<i class="fa fa-angle-double-left fa-lg"></i>
<translate>Back to overview</translate>
</a>
<!-- List of speakers -->
<a ui-sref="agenda.item.detail({id: motionBlock.agenda_item_id})" class="btn btn-sm btn-default">
<i class="fa fa-microphone fa-lg"></i>
<translate>List of speakers</translate>
</a>
<!-- project -->
<projector-button model="motionBlock" default-projector-id="defaultProjectorId">
</projector-button>
<!-- edit -->
<a ng-click="openDialog(motionBlock)"
class="btn btn-default btn-sm"
title="{{ 'Edit' | translate}}">
<i class="fa fa-pencil"></i>
</a>
</div>
<h1>{{ motionBlock.agenda_item.getTitle() }}</h1>
<h2 translate>Motion block</h2>
</div>
</div>
<div class="details">
<a os-perms="motions.can_manage" class="btn btn-default btn"
ng-bootbox-confirm="{{ 'Are you sure you want to override the state of all motions of this motion block?' | translate }}"
ng-bootbox-confirm-action="followRecommendations()" translate>
<i class="fa fa-magic fa-lg"></i>
<translate>Follow recommendations for all motions</translate>
</a>
<div class="row spacer form-group">
<div class="col-sm-4 pull-right">
<input type="text" ng-model="filter.search" class="form-control"
placeholder="{{ 'Filter' | translate }}">
</div>
<div class="col-sm-4 italic">
{{ motionsFiltered.length }} /
{{ motionBlock.motions.length }} {{ "motions" | translate }}
</div>
</div>
<table class="table table-striped table-bordered table-hover">
<thead>
<tr>
<th><translate>Motion</translate>
<th><translate>State</translate>
<th><translate>Recommendation</translate>
<tbody>
<tr ng-repeat="motion in motionsFiltered = (motionBlock.motions | filter: filter.search | orderBy: 'identifier')">
<td ng-mouseover="motion.hover=true" ng-mouseleave="motion.hover=false">
<strong>
<a ui-sref="motions.motion.detail({id: motion.id})">{{ motion.identifier }} {{ motion.getTitle() }}</a>
</strong>
<div os-perms="motions.can_manage" class="hoverActions" ng-class="{'hiddenDiv': !motion.hover}">
<!-- delete -->
<a href="" class="text-danger"
ng-bootbox-confirm="{{ 'Are you sure you want to remove this motion from motion block?' | translate }}<br>
<b>{{ motion.getTitle() }}</b>"
ng-bootbox-confirm-action="delete(motion)" translate>Remove from motion block</a>
</div>
<td>
<div class="label" ng-class="'label-'+motion.state.css_class">
{{ motion.state.name | translate }}
</div>
<td>
<div class="label" ng-class="'label-'+motion.recommendation.css_class">
{{ motion.recommendation.recommendation_label | translate }}
</div>
</table>
</div>

View File

@ -0,0 +1,17 @@
<h1 ng-if="model.id" translate>Edit motion block</h1>
<h1 ng-if="!model.id" translate>New motion block</h1>
<div uib-alert ng-show="alert.show" ng-class="'alert-' + (alert.type || 'warning')" ng-click="alert={}" close="alert={}">
{{ alert.msg }}
</div>
<form name="motionBlockForm" ng-submit="save(model)">
<formly-form model="model" fields="formFields">
<button type="submit" ng-disabled="motionBlockForm.$invalid" class="btn btn-primary" translate>
Save
</button>
<button ng-click="closeThisDialog()" class="btn btn-default" translate>
Cancel
</button>
</formly-form>
</form>

View File

@ -0,0 +1,48 @@
<div class="header">
<div class="title">
<div class="submenu">
<a ng-click="openFormDialog()" os-perms="motions.can_manage" class="btn btn-primary btn-sm">
<i class="fa fa-plus fa-lg"></i>
<translate>New</translate>
</a>
<a ui-sref="motions.motion.list" class="btn btn-sm btn-default">
<i class="fa fa-angle-double-left fa-lg"></i>
<translate>Back to overview</translate>
</a>
</div>
<h1 translate>Motion blocks</h1>
</div>
</div>
<div class="details">
<div class="row form-group">
<div class="col-sm-4 pull-right">
<input type="text" ng-model="filter.search" class="form-control"
placeholder="{{ 'Filter' | translate }}">
</div>
</div>
<table class="table table-striped table-bordered table-hover">
<thead>
<tr>
<th><translate>Name</translate>
<th><translate>Motions</translate>
<tbody>
<tr ng-repeat="motionBlock in motionBlocks | filter: filter.search | orderBy: 'title'">
<td ng-mouseover="motionBlock.hover=true" ng-mouseleave="motionBlock.hover=false">
<strong>
<a ui-sref="motions.motionBlock.detail({id: motionBlock.id})">{{ motionBlock.title }}</a>
</strong>
<div os-perms="motions.can_manage" class="hoverActions" ng-class="{'hiddenDiv': !motionBlock.hover}">
<!-- edit -->
<a ng-click="openFormDialog(motionBlock)" translate>Edit</a> |
<!-- delete -->
<a href="" class="text-danger"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this entry?' | translate }}<br>
<b>{{ motionBlock.name }}</b>"
ng-bootbox-confirm-action="delete(motionBlock)" translate>Delete</a>
</div>
<td>
{{ motionBlock.motions.length }}
</table>
</div>

View File

@ -150,6 +150,12 @@
<h3 ng-if="motion.category" translate>Category</h3>
{{ motion.category.name }}
<!-- Motion block -->
<h3 translate>Motion block</h3>
<a ui-sref="motions.motionBlock.detail({id: motion.motionBlock.id})"
os-perms="motions.can_manage">{{ motion.motionBlock.title }}</a>
<span os-perms="!motions.can_manage">{{ motion.motionBlock.title }}</span>
<!-- Tags -->
<h3 ng-if="motion.tags.length > 0" translate>Tags</h3>
<span ng-repeat="tag in motion.tags">
@ -159,6 +165,7 @@
<!-- Origin -->
<h3 ng-if="motion.origin" translate>Origin</h3>
{{ motion.origin }}
</div>
<div class="col-md-4">
<h3 ng-if="motion.polls.length > 0" translate>Voting result</h3>

View File

@ -9,6 +9,10 @@
<i class="fa fa-sitemap fa-lg"></i>
<translate>Categories</translate>
</a>
<a ui-sref="motions.motionBlock.list" os-perms="motions.can_manage" class="btn btn-default btn-sm">
<i class="fa fa-th-large fa-lg"></i>
<translate>Motion blocks</translate>
</a>
<a ui-sref="core.tag.list" os-perms="core.can_manage_tags" class="btn btn-default btn-sm">
<i class="fa fa-tags fa-lg"></i>
<translate>Tags</translate>
@ -105,7 +109,7 @@
<i class="fa fa-times-circle"></i>
<translate>Filter</translate>
</span>
<!-- Statefilter -->
<!-- State filter -->
<span class="dropdown" uib-dropdown>
<span class="pointer" id="dropdownState" uib-dropdown-toggle
ng-class="{'bold': multiselectFilter.state.length > 0, 'disabled': isDeleteMode}"
@ -126,7 +130,7 @@
</li>
</ul>
</span>
<!-- Categoryfilter -->
<!-- Category filter -->
<span class="dropdown" uib-dropdown ng-if="categories.length > 0">
<span class="pointer" id="dropdownCategory" uib-dropdown-toggle
ng-class="{'bold': multiselectFilter.category.length > 0, 'disabled': isDeleteMode}"
@ -145,7 +149,26 @@
</li>
</ul>
</span>
<!-- Tagfilter -->
<!-- Motion block filter -->
<span class="dropdown" uib-dropdown ng-if="motionBlocks.length > 0">
<span class="pointer" id="dropdownBlock" uib-dropdown-toggle
ng-class="{'bold': multiselectFilter.motionBlock.length > 0, 'disabled': isDeleteMode}"
ng-disabled="isDeleteMode">
<translate>Motion block</translate>
<span class="caret"></span>
</span>
<ul class="dropdown-menu dropdown-menu-right"
aria-labelledby="dropdownBlock">
<li ng-repeat="block in motionBlocks">
<div class="dropdown-entry pointer"
ng-click="operateMultiselectFilter('motionBlock', block.id)">
<i class="fa fa-check" ng-if="multiselectFilter.motionBlock.indexOf(block.id) > -1"></i>
{{ block.title }}
</div>
</li>
</ul>
</span>
<!-- Tag filter -->
<span class="dropdown" uib-dropdown ng-if="tags.length > 0">
<span class="pointer" id="dropdownTag" uib-dropdown-toggle
ng-class="{'bold': multiselectFilter.tag.length > 0, 'disabled': isDeleteMode}"
@ -219,11 +242,22 @@
</li>
<li>
<!-- category -->
<div class="pointer dropdown-entry" ng-click="toggleSort('category')">
<div class="pointer dropdown-entry" ng-click="toggleSort('category.name')">
<translate>Category</translate>
<span class="spacer-right pull-right"></span>
<i class="pull-right fa"
ng-style="{'visibility': sortColumn === 'category' && header.sortable != false ? 'visible' : 'hidden'}"
ng-style="{'visibility': sortColumn === 'category.name' && header.sortable != false ? 'visible' : 'hidden'}"
ng-class="reverse ? 'fa-sort-desc' : 'fa-sort-asc'">
</i>
</div>
</li>
<li>
<!-- motion block -->
<div class="pointer dropdown-entry" ng-click="toggleSort('motionBlock.title')">
<translate>Motion block</translate>
<span class="spacer-right pull-right"></span>
<i class="pull-right fa"
ng-style="{'visibility': sortColumn === 'motionBlock.title' && header.sortable != false ? 'visible' : 'hidden'}"
ng-class="reverse ? 'fa-sort-desc' : 'fa-sort-asc'">
</i>
</div>
@ -270,6 +304,15 @@
{{ category.name }}
</span>
</span>
<span ng-repeat="motionBlock in motionBlocks" class="pointer spacer-left-lg"
ng-if="multiselectFilter.motionBlock.indexOf(motionBlock.id) > -1"
ng-click="operateMultiselectFilter('motionBlock', motionBlock.id)"
ng-class="{'disabled': isDeleteMode}">
<span class="nobr">
<i class="fa fa-times-circle"></i>
{{ motionBlock.title }}
</span>
</span>
<span ng-repeat="tag in tags" class="pointer spacer-left-lg"
ng-if="multiselectFilter.tag.indexOf(tag.id) > -1"
ng-click="operateMultiselectFilter('tag', tag.id)"
@ -292,7 +335,9 @@
| osFilter: filter.search : getFilterString
| SelectMultipleFilter: multiselectFilter.state : getItemId.state
| SelectMultipleFilter: multiselectFilter.category : getItemId.category
| SelectMultipleFilter: multiselectFilter.motionBlock : getItemId.motionBlock
| SelectMultipleFilter: multiselectFilter.tag : getItemId.tag
| toArray
| orderBy: sortColumn : reverse)">
<!-- select column -->
@ -310,7 +355,7 @@
<div class="col-xs-6 content">
<div class="identifier-col">
<div class="nobr" ng-show="motion.identifier">
{{ motion.identifier }}:
{{ motion.identifier }}
</div>
</div>
<div class="title-col">
@ -411,6 +456,41 @@
{{ motion.category.name }}
</div>
<!-- Motion block dropdown for manage user -->
<div os-perms="motions.can_manage" ng-show="motionBlocks.length > 0"
ng-mouseover="motion.motionBlockHover=true"
ng-mouseleave="motion.motionBlockHover=false">
<span uib-dropdown >
<span id="dropdown-motionBlock{{ motion.id }}" class="pointer"
uib-dropdown-toggle uib-tooltip="{{ 'Set a motion block' | translate }}"
tooltip-class="nobr">
<span ng-if="motion.motionBlock == null" ng-show="motion.hover">
<i class="fa fa-th-large"></i>
<i class="fa fa-plus"></i>
</span>
<span ng-if="motion.motionBlock != null">
<i class="fa fa-th-large spacer-right"></i>
{{ motion.motionBlock.title }}
<i class="fa fa-cog fa-lg spacer-left" ng-show="motion.motionBlockHover"></i>
</span>
</span>
<ul class="dropdown-menu" aria-labelledby="dropdown-motionBlock{{ motion.id }}">
<li ng-repeat="motionBlock in motionBlocks">
<div class="dropdown-entry pointer"
ng-click="toggle_motionBlock(motion, motionBlock)">
<i class="fa fa-check" ng-if="motionBlock.id == motion.motionBlock.id"></i>
{{ motionBlock.title }}
</div>
</li>
</ul>
</span>
</div>
<!-- Motion block string for normal user -->
<div os-perms="!motions.can_manage" ng-show="motion.motionBlock != null">
<i class="fa fa-sitemap spacer-right"></i>
{{ motion.motionBlock.title }}
</div>
<!-- Tag dropdown for manage user -->
<div os-perms="motions.can_manage" ng-show="tags.length > 0"
ng-mouseover="motion.tagHover=true"

View File

@ -0,0 +1,4 @@
<div ng-controller="SlideMotionBlockCtrl" class="content scrollcontent">
<h1>{{ motionBlock.agenda_item.getTitle() }}</h1>
<div ng-repeat="motion in motionBlock.motions">{{ motion.identifier }}</div>
</div>

View File

@ -9,8 +9,9 @@ from django.utils.translation import ugettext_noop
from reportlab.platypus import SimpleDocTemplate
from rest_framework import status
from openslides.core.config import config
from openslides.utils.rest_api import (
from ..core.config import config
from ..utils.autoupdate import inform_changed_data
from ..utils.rest_api import (
DestroyModelMixin,
GenericViewSet,
ModelViewSet,
@ -19,11 +20,11 @@ from openslides.utils.rest_api import (
ValidationError,
detail_route,
)
from openslides.utils.views import APIView, PDFView, SingleObjectMixin
from ..utils.views import APIView, PDFView, SingleObjectMixin
from .access_permissions import (
CategoryAccessPermissions,
MotionAccessPermissions,
MotionBlockAccessPermissions,
MotionChangeRecommendationAccessPermissions,
WorkflowAccessPermissions,
)
@ -31,6 +32,7 @@ from .exceptions import WorkflowError
from .models import (
Category,
Motion,
MotionBlock,
MotionChangeRecommendation,
MotionPoll,
MotionVersion,
@ -89,6 +91,8 @@ class MotionViewSet(ModelViewSet):
# Non-staff users are not allowed to send submitter or supporter data.
self.permission_denied(request)
# TODO: Should non staff users be allowed to set motions to blocks or send categories, ...? #2506
# Check permission to send comment data.
if not request.user.has_perm('motions.can_see_and_manage_comments'):
try:
@ -373,7 +377,7 @@ class CategoryViewSet(ModelViewSet):
API endpoint for categories.
There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy.
partial_update, update, destroy and numbering.
"""
access_permissions = CategoryAccessPermissions()
queryset = Category.objects.all()
@ -445,6 +449,54 @@ class CategoryViewSet(ModelViewSet):
return response
class MotionBlockViewSet(ModelViewSet):
"""
API endpoint for motion blocks.
There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy.
"""
access_permissions = MotionBlockAccessPermissions()
queryset = MotionBlock.objects.all()
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == 'metadata':
result = self.request.user.has_perm('motions.can_see')
elif self.action in ('create', 'partial_update', 'update', 'destroy', 'follow_recommendations'):
result = (self.request.user.has_perm('motions.can_see') and
self.request.user.has_perm('motions.can_manage'))
else:
result = False
return result
@detail_route(methods=['post'])
def follow_recommendations(self, request, pk=None):
"""
View to set the states of all motions of this motion block each to
its recommendation. It is a POST request without any data.
"""
motion_block = self.get_object()
instances = []
with transaction.atomic():
for motion in motion_block.motion_set.all():
# Follow recommendation.
motion.follow_recommendation()
motion.save(skip_autoupdate=True)
# Write the log message.
motion.write_log(
message_list=[ugettext_noop('State set to'), ' ', motion.state.name],
person=request.user,
skip_autoupdate=True)
instances.append(motion)
inform_changed_data(instances)
return Response({'detail': _('Followed recommendations successfully.')})
class WorkflowViewSet(ModelViewSet):
"""
API endpoint for workflows.

View File

@ -145,7 +145,7 @@ def inform_changed_data(instances, information=None):
"""
root_instances = set()
if not isinstance(instances, Iterable):
# Make surce instance is an iterable
# Make sure instances is an iterable
instances = (instances, )
for instance in instances:
try:

View File

@ -7,7 +7,7 @@ from rest_framework.test import APIClient
from openslides.core.config import config
from openslides.core.models import Tag
from openslides.motions.models import Category, Motion, State
from openslides.motions.models import Category, Motion, MotionBlock, State
from openslides.users.models import User
from openslides.utils.test import TestCase
@ -726,3 +726,40 @@ class NumberMotionsInCategory(TestCase):
self.assertEqual(Motion.objects.get(pk=self.motion.pk).identifier, None)
self.assertEqual(Motion.objects.get(pk=self.motion_2.pk).identifier, 'test_prefix_ahz6tho2mooH8 2')
self.assertEqual(Motion.objects.get(pk=self.motion_3.pk).identifier, 'test_prefix_ahz6tho2mooH8 1')
class FollowRecommendationsForMotionBlock(TestCase):
"""
Tests following the recommendations of motions in an motion block.
"""
def setUp(self):
self.state_id_accepted = 2 # This should be the id of the state 'accepted'.
self.state_id_rejected = 3 # This should be the id of the state 'rejected'.
self.client = APIClient()
self.client.login(username='admin', password='admin')
self.motion_block = MotionBlock.objects.create(
title='test_motion_block_name_Ufoopiub7quaezaepeic')
self.motion = Motion(
title='test_title_yo8ohy5eifeiyied2AeD',
text='test_text_chi1aeth5faPhueQu8oh',
motion_block=self.motion_block)
self.motion.save()
self.motion.set_recommendation(self.state_id_accepted)
self.motion.save()
self.motion_2 = Motion(
title='test_title_eith0EemaW8ahZa9Piej',
text='test_text_haeho1ohk3ou7pau2Jee',
motion_block=self.motion_block)
self.motion_2.save()
self.motion_2.set_recommendation(self.state_id_rejected)
self.motion_2.save()
def test_follow_recommendations_for_motion_block(self):
response = self.client.post(reverse('motionblock-follow-recommendations', args=[self.motion_block.pk]))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(Motion.objects.get(pk=self.motion.pk).state.id, self.state_id_accepted)
self.assertEqual(Motion.objects.get(pk=self.motion_2.pk).state.id, self.state_id_rejected)