diff --git a/CHANGELOG b/CHANGELOG index e7bb8e99a..a32c10cef 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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. diff --git a/openslides/agenda/static/js/agenda/base.js b/openslides/agenda/static/js/agenda/base.js index a399a6afd..132f01b7a 100644 --- a/openslides/agenda/static/js/agenda/base.js +++ b/openslides/agenda/static/js/agenda/base.js @@ -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() { diff --git a/openslides/core/signals.py b/openslides/core/signals.py index f2667ca21..bb08c9918 100644 --- a/openslides/core/signals.py +++ b/openslides/core/signals.py @@ -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', diff --git a/openslides/core/static/js/core/site.js b/openslides/core/static/js/core/site.js index 20274ee22..14a2e1191 100644 --- a/openslides/core/static/js/core/site.js +++ b/openslides/core/static/js/core/site.js @@ -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', diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py index 2ac7b4a2a..4f46c8b89 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -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. diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py index 413e12024..e82732e31 100644 --- a/openslides/motions/apps.py +++ b/openslides/motions/apps.py @@ -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) diff --git a/openslides/motions/migrations/0006_auto_20161017_2020.py b/openslides/motions/migrations/0006_auto_20161017_2020.py new file mode 100644 index 000000000..90a0fa6d0 --- /dev/null +++ b/openslides/motions/migrations/0006_auto_20161017_2020.py @@ -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'), + ), + ] diff --git a/openslides/motions/models.py b/openslides/motions/models.py index f415fc01b..fdde434ef 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -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.""" diff --git a/openslides/motions/projector.py b/openslides/motions/projector.py index 862961662..78750b463 100644 --- a/openslides/motions/projector.py +++ b/openslides/motions/projector.py @@ -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 diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index c094e3b3c..8a8373fce 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -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]) diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js index 55a3df574..3e2cdbe2f 100644 --- a/openslides/motions/static/js/motions/base.js +++ b/openslides/motions/static/js/motions/base.js @@ -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', diff --git a/openslides/motions/static/js/motions/motion-block-projector.js b/openslides/motions/static/js/motions/motion-block-projector.js new file mode 100644 index 000000000..e1e791b0c --- /dev/null +++ b/openslides/motions/static/js/motions/motion-block-projector.js @@ -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'); + } +]); + +}()); diff --git a/openslides/motions/static/js/motions/motion-block.js b/openslides/motions/static/js/motions/motion-block.js new file mode 100644 index 000000000..19e596b23 --- /dev/null +++ b/openslides/motions/static/js/motions/motion-block.js @@ -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}; + } + ); + }; + } +]); + +}()); diff --git a/openslides/motions/static/js/motions/projector.js b/openslides/motions/static/js/motions/projector.js index fa315b458..b5054dc8e 100644 --- a/openslides/motions/static/js/motions/projector.js +++ b/openslides/motions/static/js/motions/projector.js @@ -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', diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index 44aacb6a8..a671d12aa 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -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: '', + }) + .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) { diff --git a/openslides/motions/static/templates/motions/motion-block-detail.html b/openslides/motions/static/templates/motions/motion-block-detail.html new file mode 100644 index 000000000..e71364ffa --- /dev/null +++ b/openslides/motions/static/templates/motions/motion-block-detail.html @@ -0,0 +1,75 @@ +
+
+ +

{{ motionBlock.agenda_item.getTitle() }}

+

Motion block

+
+
+ +
+ + + Follow recommendations for all motions + + +
+
+ +
+
+ {{ motionsFiltered.length }} / + {{ motionBlock.motions.length }} {{ "motions" | translate }} +
+
+ + + + + + +
Motion + State + Recommendation +
+ + {{ motion.identifier }} {{ motion.getTitle() }} + + + +
+ {{ motion.state.name | translate }} +
+
+
+ {{ motion.recommendation.recommendation_label | translate }} +
+
+
diff --git a/openslides/motions/static/templates/motions/motion-block-form.html b/openslides/motions/static/templates/motions/motion-block-form.html new file mode 100644 index 000000000..e28d0df3c --- /dev/null +++ b/openslides/motions/static/templates/motions/motion-block-form.html @@ -0,0 +1,17 @@ +

Edit motion block

+

New motion block

+ +
+ {{ alert.msg }} +
+ +
+ + + + +
diff --git a/openslides/motions/static/templates/motions/motion-block-list.html b/openslides/motions/static/templates/motions/motion-block-list.html new file mode 100644 index 000000000..6993bf333 --- /dev/null +++ b/openslides/motions/static/templates/motions/motion-block-list.html @@ -0,0 +1,48 @@ +
+
+ +

Motion blocks

+
+
+ +
+
+
+ +
+
+ + + + + + +
Name + Motions +
+ + {{ motionBlock.title }} + +
+ + Edit | + + Delete +
+
+ {{ motionBlock.motions.length }} +
+
diff --git a/openslides/motions/static/templates/motions/motion-detail.html b/openslides/motions/static/templates/motions/motion-detail.html index e622df746..11cda9ea9 100644 --- a/openslides/motions/static/templates/motions/motion-detail.html +++ b/openslides/motions/static/templates/motions/motion-detail.html @@ -150,6 +150,12 @@

Category

{{ motion.category.name }} + +

Motion block

+ {{ motion.motionBlock.title }} + {{ motion.motionBlock.title }} +

Tags

@@ -159,6 +165,7 @@

Origin

{{ motion.origin }} +

Voting result

diff --git a/openslides/motions/static/templates/motions/motion-list.html b/openslides/motions/static/templates/motions/motion-list.html index ed4e64e12..49132380b 100644 --- a/openslides/motions/static/templates/motions/motion-list.html +++ b/openslides/motions/static/templates/motions/motion-list.html @@ -9,6 +9,10 @@ Categories + + + Motion blocks + Tags @@ -105,7 +109,7 @@ Filter - + - + - + + + + Motion block + + + + +
  • -
    +
    Category + +
    +
  • +
  • + +
    + Motion block + +
    @@ -270,6 +304,15 @@ {{ category.name }} + + + + {{ motionBlock.title }} + + @@ -310,7 +355,7 @@
    - {{ motion.identifier }}: + {{ motion.identifier }}
    @@ -411,6 +456,41 @@ {{ motion.category.name }}
    + +
    + + + + + + + + + {{ motion.motionBlock.title }} + + + + + +
    + +
    + + {{ motion.motionBlock.title }} +
    +
    +

    {{ motionBlock.agenda_item.getTitle() }}

    +
    {{ motion.identifier }}
    +
    diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 4af58bd2b..ce77b21fb 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -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. diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index e3c0e6a57..9f656668f 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -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: diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py index d60c8dd92..c0d165392 100644 --- a/tests/integration/motions/test_viewset.py +++ b/tests/integration/motions/test_viewset.py @@ -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)