diff --git a/CHANGELOG b/CHANGELOG index abcf59ad5..81a48f0ee 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -25,6 +25,8 @@ Motions: - Added button to sort and number all motions in a category. - Introduced pdfMake for clientside generation of PDFs. - Added configurable fields for comments. +- Added recommendations for motions. +- Changed label of former state "commited a bill" to "refered to committee". Users: - Added field is_committee and new default group Committees. diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index 8be4fa638..1b1dfe849 100644 --- a/openslides/motions/config_variables.py +++ b/openslides/motions/config_variables.py @@ -27,6 +27,7 @@ def get_config_variables(): 'value': "WITHOUT_ABSTAIN", 'display_name': 'Yes and No votes'},) PERCENT_BASE_CHOICES_MOTION += PERCENT_BASE_CHOICES + # General yield ConfigVariable( name='motions_workflow', @@ -102,6 +103,16 @@ def get_config_variables(): group='Motions', subgroup='General') + yield ConfigVariable( + name='motions_recommendations_by', + default_value='Recommendation committee', + label='Name of recommendation committee', + help_text='Use an empty value to disable the recommendation system.', + weight=332, + group='Motions', + subgroup='General', + translatable=True) + # Amendments yield ConfigVariable( name='motions_amendments_enabled', diff --git a/openslides/motions/migrations/0004_auto_20160907_2343.py b/openslides/motions/migrations/0004_auto_20160907_2343.py new file mode 100644 index 000000000..646b7a191 --- /dev/null +++ b/openslides/motions/migrations/0004_auto_20160907_2343.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-09-07 23:43 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +def change_label_of_state(apps, schema_editor): + """ + Changes the label of former state "commited a bill" to "refered to committee". + """ + # Disconnect autoupdate. We do not want to trigger it here. + models.signals.post_save.disconnect(dispatch_uid='inform_changed_data_receiver') + + # We get the model from the versioned app registry; + # if we directly import it, it will be the wrong version. + State = apps.get_model('motions', 'State') + + try: + state = State.objects.get(name='commited a bill') + except State.DoesNotExist: + # State does not exists, there is nothing to change. + pass + else: + state.name = 'refered to committee' + state.action_word = 'Refer to committee' + state.save() + + +def add_recommendation_labels(apps, schema_editor): + """ + Adds recommendation labels to some of the built-in states. + """ + # Disconnect autoupdate. We do not want to trigger it here. + models.signals.post_save.disconnect(dispatch_uid='inform_changed_data_receiver') + + # We get the model from the versioned app registry; + # if we directly import it, it will be the wrong version. + State = apps.get_model('motions', 'State') + + name_label_map = { + 'accepted': 'Acceptance', + 'rejected': 'Rejection', + 'not decided': 'No decision', + 'permitted': 'Permission', + 'adjourned': 'Adjournment', + 'not concerned': 'No concernment', + 'refered to committee': 'Referral to committee', + 'rejected (not authorized)': 'Rejection (not authorized)', + } + for state in State.objects.all(): + if name_label_map.get(state.name): + state.recommendation_label = name_label_map[state.name] + state.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('motions', '0003_auto_20160819_0925'), + ] + + operations = [ + migrations.AddField( + model_name='motion', + name='recommendation', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='motions.State'), + ), + migrations.AddField( + model_name='state', + name='recommendation_label', + field=models.CharField(max_length=255, null=True), + ), + migrations.AlterField( + model_name='motion', + name='state', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='motions.State'), + ), + migrations.RunPython( + change_label_of_state + ), + migrations.RunPython( + add_recommendation_labels + ), + ] diff --git a/openslides/motions/models.py b/openslides/motions/models.py index f573fa7a9..302de06df 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -51,6 +51,7 @@ class Motion(RESTModelMixin, models.Model): state = models.ForeignKey( 'State', + related_name='+', on_delete=models.SET_NULL, null=True) # TODO: Check whether null=True is necessary. """ @@ -59,6 +60,15 @@ class Motion(RESTModelMixin, models.Model): This attribute is to get the current state of the motion. """ + recommendation = models.ForeignKey( + 'State', + related_name='+', + on_delete=models.SET_NULL, + null=True) + """ + The recommendation of a person or committee for this motion. + """ + identifier = models.CharField(max_length=255, null=True, blank=True, unique=True) """ @@ -471,6 +481,16 @@ class Motion(RESTModelMixin, models.Model): Workflow.objects.get(pk=config['motions_workflow']).states.all()[0]) self.set_state(new_state) + def set_recommendation(self, recommendation): + """ + Set the recommendation of the motion. + + 'recommendation' can be the id of a state object or a state object. + """ + if type(recommendation) is int: + recommendation = State.objects.get(pk=recommendation) + self.recommendation = recommendation + def get_agenda_title(self): """ Return a simple title string for the agenda. @@ -523,6 +543,7 @@ class Motion(RESTModelMixin, models.Model): * unsupport * change_state * reset_state + * change_recommendation NOTE: If you update this function please also update the 'isAllowed' function on client side in motions/site.js. @@ -553,7 +574,11 @@ class Motion(RESTModelMixin, models.Model): 'change_state': person.has_perm('motions.can_manage'), - 'reset_state': person.has_perm('motions.can_manage')} + 'reset_state': person.has_perm('motions.can_manage'), + + 'change_recommendation': person.has_perm('motions.can_manage'), + + } actions['edit'] = actions['update'] @@ -800,11 +825,16 @@ class State(RESTModelMixin, models.Model): """ Defines a state for a motion. - Every state belongs to a workflow. All states of a workflow are linked together - via 'next_states'. One of these states is the first state, but this - is saved in the workflow table (one-to-one relation). In every state - you can configure some handling of a motion. See the following fields - for more information. + Every state belongs to a workflow. All states of a workflow are linked + together via 'next_states'. One of these states is the first state, but + this is saved in the workflow table (one-to-one relation). In every + state you can configure some handling of a motion. See the following + fields for more information. + + Additionally every motion can refer to one state as recommendation of + an person or committee (see config 'motions_recommendations_by'). This + means that the person or committee recommends to set the motion to this + state. """ name = models.CharField(max_length=255) @@ -813,6 +843,9 @@ class State(RESTModelMixin, models.Model): action_word = models.CharField(max_length=255) """An alternative string to be used for a button to switch to this state.""" + recommendation_label = models.CharField(max_length=255, null=True) + """A string for a recommendation to set the motion to this state.""" + workflow = models.ForeignKey( 'Workflow', on_delete=models.CASCADE, @@ -876,9 +909,13 @@ class State(RESTModelMixin, models.Model): def save(self, **kwargs): """Saves a state in the database. - Used to check the integrity before saving. + Used to check the integrity before saving. Also used to check that + recommendation_label is not an empty string. """ self.check_next_states() + if self.recommendation_label == '': + raise WorkflowError('The field recommendation_label of {} must not ' + 'be an empty string.'.format(self)) super(State, self).save(**kwargs) def get_action_word(self): diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index 1a24ae1aa..59494700e 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -51,6 +51,7 @@ class StateSerializer(ModelSerializer): 'id', 'name', 'action_word', + 'recommendation_label', 'css_class', 'required_permission_to_see', 'allow_support', @@ -263,12 +264,13 @@ class MotionSerializer(ModelSerializer): 'comments', 'state', 'workflow_id', + 'recommendation', 'tags', 'attachments', 'polls', 'agenda_item_id', 'log_messages',) - read_only_fields = ('state',) # Some other fields are also read_only. See definitions above. + read_only_fields = ('state', 'recommendation',) # Some other fields are also read_only. See definitions above. @transaction.atomic def create(self, validated_data): diff --git a/openslides/motions/signals.py b/openslides/motions/signals.py index 15cc0fa78..360d113ed 100644 --- a/openslides/motions/signals.py +++ b/openslides/motions/signals.py @@ -6,8 +6,8 @@ from .models import State, Workflow def create_builtin_workflows(sender, **kwargs): """ Receiver function to create a simple and a complex workflow. It is - connected to the signal openslides.core.signals.post_database_setup - during app loading. + connected to the signal django.db.models.signals.post_migrate during + app loading. """ if Workflow.objects.exists(): # If there is at least one workflow, then do nothing. @@ -22,14 +22,17 @@ def create_builtin_workflows(sender, **kwargs): state_1_2 = State.objects.create(name=ugettext_noop('accepted'), workflow=workflow_1, action_word='Accept', + recommendation_label='Acceptance', css_class='success') state_1_3 = State.objects.create(name=ugettext_noop('rejected'), workflow=workflow_1, action_word='Reject', + recommendation_label='Rejection', css_class='danger') state_1_4 = State.objects.create(name=ugettext_noop('not decided'), workflow=workflow_1, action_word='Do not decide', + recommendation_label='No decision', css_class='default') state_1_1.next_states.add(state_1_2, state_1_3, state_1_4) workflow_1.first_state = state_1_1 @@ -44,6 +47,7 @@ def create_builtin_workflows(sender, **kwargs): state_2_2 = State.objects.create(name=ugettext_noop('permitted'), workflow=workflow_2, action_word='Permit', + recommendation_label='Permission', allow_create_poll=True, allow_submitter_edit=True, versioning=True, @@ -51,11 +55,13 @@ def create_builtin_workflows(sender, **kwargs): state_2_3 = State.objects.create(name=ugettext_noop('accepted'), workflow=workflow_2, action_word='Accept', + recommendation_label='Acceptance', versioning=True, css_class='success') state_2_4 = State.objects.create(name=ugettext_noop('rejected'), workflow=workflow_2, action_word='Reject', + recommendation_label='Rejection', versioning=True, css_class='danger') state_2_5 = State.objects.create(name=ugettext_noop('withdrawed'), @@ -66,16 +72,19 @@ def create_builtin_workflows(sender, **kwargs): state_2_6 = State.objects.create(name=ugettext_noop('adjourned'), workflow=workflow_2, action_word='Adjourn', + recommendation_label='Adjournment', versioning=True, css_class='default') state_2_7 = State.objects.create(name=ugettext_noop('not concerned'), workflow=workflow_2, action_word='Do not concern', + recommendation_label='No concernment', versioning=True, css_class='default') - state_2_8 = State.objects.create(name=ugettext_noop('commited a bill'), + state_2_8 = State.objects.create(name=ugettext_noop('refered to committee'), workflow=workflow_2, - action_word='Commit a bill', + action_word='Refer to committee', + recommendation_label='Referral to committee', versioning=True, css_class='default') state_2_9 = State.objects.create(name=ugettext_noop('needs review'), @@ -86,6 +95,7 @@ def create_builtin_workflows(sender, **kwargs): state_2_10 = State.objects.create(name=ugettext_noop('rejected (not authorized)'), workflow=workflow_2, action_word='Reject (not authorized)', + recommendation_label='Rejection (not authorized)', versioning=True, css_class='default') state_2_1.next_states.add(state_2_2, state_2_5, state_2_10) diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js index 3511c376c..fd04d5384 100644 --- a/openslides/motions/static/js/motions/base.js +++ b/openslides/motions/static/js/motions/base.js @@ -15,11 +15,25 @@ angular.module('OpenSlidesApp.motions', [ name: 'motions/workflowstate', methods: { getNextStates: function () { + // TODO: Use filter with params with operator 'in'. var states = []; _.forEach(this.next_states_id, function (stateId) { states.push(DS.get('motions/workflowstate', stateId)); }); return states; + }, + getRecommendations: function () { + var params = { + where: { + 'workflow_id': { + '==': this.workflow_id + }, + 'recommendation_label': { + '!=': null + } + } + }; + return DS.filter('motions/workflowstate', params); } } }); @@ -188,6 +202,7 @@ angular.module('OpenSlidesApp.motions', [ * - unsupport * - change_state * - reset_state + * - change_recommendation * * NOTE: If you update this function please also update the * 'get_allowed_actions' function on server side in motions/models.py. @@ -236,6 +251,8 @@ angular.module('OpenSlidesApp.motions', [ return operator.hasPerms('motions.can_manage'); case 'reset_state': return operator.hasPerms('motions.can_manage'); + case 'change_recommendation': + return operator.hasPerms('motions.can_manage'); case 'can_manage': return operator.hasPerms('motions.can_manage'); default: @@ -279,10 +296,16 @@ angular.module('OpenSlidesApp.motions', [ } }, hasOne: { - 'motions/workflowstate': { - localField: 'state', - localKey: 'state_id', - } + 'motions/workflowstate': [ + { + localField: 'state', + localKey: 'state_id', + }, + { + localField: 'recommendation', + localKey: 'recommendation_id', + } + ] } } }); @@ -330,31 +353,41 @@ angular.module('OpenSlidesApp.motions', [ gettext('submitted'); gettext('accepted'); gettext('Accept'); + gettext('Acceptance'); gettext('rejected'); gettext('Reject'); + gettext('Rejection'); gettext('not decided'); gettext('Do not decide'); + gettext('No decision'); // workflow 2 gettext('Complex Workflow'); gettext('published'); gettext('permitted'); gettext('Permit'); + gettext('Permission'); gettext('accepted'); gettext('Accept'); + gettext('Acceptance'); gettext('rejected'); gettext('Reject'); + gettext('Rejection'); gettext('withdrawed'); gettext('Withdraw'); gettext('adjourned'); gettext('Adjourn'); + gettext('Adjournment'); gettext('not concerned'); gettext('Do not concern'); - gettext('commited a bill'); - gettext('Commit a bill'); + gettext('No concernment'); + gettext('refered to committee'); + gettext('Refer to committee'); + gettext('Referral to committee'); gettext('needs review'); gettext('Needs review'); gettext('rejected (not authorized)'); gettext('Reject (not authorized)'); + gettext('Rejection (not authorized)'); } ]); diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index 21fb8853d..9670933ae 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -1152,9 +1152,17 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid $http.put('/rest/motions/motion/' + motion.id + '/set_state/', {'state': state_id}); }; // reset state - $scope.reset_state = function (state_id) { + $scope.reset_state = function () { $http.put('/rest/motions/motion/' + motion.id + '/set_state/', {}); }; + // update recommendation + $scope.updateRecommendation = function (recommendation_id) { + $http.put('/rest/motions/motion/' + motion.id + '/set_recommendation/', {'recommendation': recommendation_id}); + }; + // reset state + $scope.resetRecommendation = function () { + $http.put('/rest/motions/motion/' + motion.id + '/set_recommendation/', {}); + }; // create poll $scope.create_poll = function () { $http.post('/rest/motions/motion/' + motion.id + '/create_poll/', {}); @@ -1916,6 +1924,8 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid gettext('The maximum number of characters per line. Relevant when line numbering is enabled. Min: 40'); gettext('Stop submitting new motions by non-staff users'); gettext('Allow to disable versioning'); + gettext('Name of recommendation committee'); + gettext('Use an empty value to disable the recommendation system.'); // subgroup Amendments gettext('Amendments'); diff --git a/openslides/motions/static/templates/motions/motion-detail.html b/openslides/motions/static/templates/motions/motion-detail.html index 4ade8077b..295326d9f 100644 --- a/openslides/motions/static/templates/motions/motion-detail.html +++ b/openslides/motions/static/templates/motions/motion-detail.html @@ -117,9 +117,9 @@