Merge pull request #2344 from normanjaeckel/MotionRecommendation
Added recommendations for motions.
This commit is contained in:
commit
f2c3e535a5
@ -25,6 +25,8 @@ Motions:
|
|||||||
- Added button to sort and number all motions in a category.
|
- Added button to sort and number all motions in a category.
|
||||||
- Introduced pdfMake for clientside generation of PDFs.
|
- Introduced pdfMake for clientside generation of PDFs.
|
||||||
- Added configurable fields for comments.
|
- Added configurable fields for comments.
|
||||||
|
- Added recommendations for motions.
|
||||||
|
- Changed label of former state "commited a bill" to "refered to committee".
|
||||||
|
|
||||||
Users:
|
Users:
|
||||||
- Added field is_committee and new default group Committees.
|
- Added field is_committee and new default group Committees.
|
||||||
|
@ -27,6 +27,7 @@ def get_config_variables():
|
|||||||
'value': "WITHOUT_ABSTAIN",
|
'value': "WITHOUT_ABSTAIN",
|
||||||
'display_name': 'Yes and No votes'},)
|
'display_name': 'Yes and No votes'},)
|
||||||
PERCENT_BASE_CHOICES_MOTION += PERCENT_BASE_CHOICES
|
PERCENT_BASE_CHOICES_MOTION += PERCENT_BASE_CHOICES
|
||||||
|
|
||||||
# General
|
# General
|
||||||
yield ConfigVariable(
|
yield ConfigVariable(
|
||||||
name='motions_workflow',
|
name='motions_workflow',
|
||||||
@ -102,6 +103,16 @@ def get_config_variables():
|
|||||||
group='Motions',
|
group='Motions',
|
||||||
subgroup='General')
|
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
|
# Amendments
|
||||||
yield ConfigVariable(
|
yield ConfigVariable(
|
||||||
name='motions_amendments_enabled',
|
name='motions_amendments_enabled',
|
||||||
|
86
openslides/motions/migrations/0004_auto_20160907_2343.py
Normal file
86
openslides/motions/migrations/0004_auto_20160907_2343.py
Normal file
@ -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
|
||||||
|
),
|
||||||
|
]
|
@ -51,6 +51,7 @@ class Motion(RESTModelMixin, models.Model):
|
|||||||
|
|
||||||
state = models.ForeignKey(
|
state = models.ForeignKey(
|
||||||
'State',
|
'State',
|
||||||
|
related_name='+',
|
||||||
on_delete=models.SET_NULL,
|
on_delete=models.SET_NULL,
|
||||||
null=True) # TODO: Check whether null=True is necessary.
|
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.
|
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,
|
identifier = models.CharField(max_length=255, null=True, blank=True,
|
||||||
unique=True)
|
unique=True)
|
||||||
"""
|
"""
|
||||||
@ -471,6 +481,16 @@ class Motion(RESTModelMixin, models.Model):
|
|||||||
Workflow.objects.get(pk=config['motions_workflow']).states.all()[0])
|
Workflow.objects.get(pk=config['motions_workflow']).states.all()[0])
|
||||||
self.set_state(new_state)
|
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):
|
def get_agenda_title(self):
|
||||||
"""
|
"""
|
||||||
Return a simple title string for the agenda.
|
Return a simple title string for the agenda.
|
||||||
@ -523,6 +543,7 @@ class Motion(RESTModelMixin, models.Model):
|
|||||||
* unsupport
|
* unsupport
|
||||||
* change_state
|
* change_state
|
||||||
* reset_state
|
* reset_state
|
||||||
|
* change_recommendation
|
||||||
|
|
||||||
NOTE: If you update this function please also update the
|
NOTE: If you update this function please also update the
|
||||||
'isAllowed' function on client side in motions/site.js.
|
'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'),
|
'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']
|
actions['edit'] = actions['update']
|
||||||
|
|
||||||
@ -800,11 +825,16 @@ class State(RESTModelMixin, models.Model):
|
|||||||
"""
|
"""
|
||||||
Defines a state for a motion.
|
Defines a state for a motion.
|
||||||
|
|
||||||
Every state belongs to a workflow. All states of a workflow are linked together
|
Every state belongs to a workflow. All states of a workflow are linked
|
||||||
via 'next_states'. One of these states is the first state, but this
|
together via 'next_states'. One of these states is the first state, but
|
||||||
is saved in the workflow table (one-to-one relation). In every state
|
this is saved in the workflow table (one-to-one relation). In every
|
||||||
you can configure some handling of a motion. See the following fields
|
state you can configure some handling of a motion. See the following
|
||||||
for more information.
|
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)
|
name = models.CharField(max_length=255)
|
||||||
@ -813,6 +843,9 @@ class State(RESTModelMixin, models.Model):
|
|||||||
action_word = models.CharField(max_length=255)
|
action_word = models.CharField(max_length=255)
|
||||||
"""An alternative string to be used for a button to switch to this state."""
|
"""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 = models.ForeignKey(
|
||||||
'Workflow',
|
'Workflow',
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@ -876,9 +909,13 @@ class State(RESTModelMixin, models.Model):
|
|||||||
def save(self, **kwargs):
|
def save(self, **kwargs):
|
||||||
"""Saves a state in the database.
|
"""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()
|
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)
|
super(State, self).save(**kwargs)
|
||||||
|
|
||||||
def get_action_word(self):
|
def get_action_word(self):
|
||||||
|
@ -51,6 +51,7 @@ class StateSerializer(ModelSerializer):
|
|||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
'action_word',
|
'action_word',
|
||||||
|
'recommendation_label',
|
||||||
'css_class',
|
'css_class',
|
||||||
'required_permission_to_see',
|
'required_permission_to_see',
|
||||||
'allow_support',
|
'allow_support',
|
||||||
@ -263,12 +264,13 @@ class MotionSerializer(ModelSerializer):
|
|||||||
'comments',
|
'comments',
|
||||||
'state',
|
'state',
|
||||||
'workflow_id',
|
'workflow_id',
|
||||||
|
'recommendation',
|
||||||
'tags',
|
'tags',
|
||||||
'attachments',
|
'attachments',
|
||||||
'polls',
|
'polls',
|
||||||
'agenda_item_id',
|
'agenda_item_id',
|
||||||
'log_messages',)
|
'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
|
@transaction.atomic
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
|
@ -6,8 +6,8 @@ from .models import State, Workflow
|
|||||||
def create_builtin_workflows(sender, **kwargs):
|
def create_builtin_workflows(sender, **kwargs):
|
||||||
"""
|
"""
|
||||||
Receiver function to create a simple and a complex workflow. It is
|
Receiver function to create a simple and a complex workflow. It is
|
||||||
connected to the signal openslides.core.signals.post_database_setup
|
connected to the signal django.db.models.signals.post_migrate during
|
||||||
during app loading.
|
app loading.
|
||||||
"""
|
"""
|
||||||
if Workflow.objects.exists():
|
if Workflow.objects.exists():
|
||||||
# If there is at least one workflow, then do nothing.
|
# 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'),
|
state_1_2 = State.objects.create(name=ugettext_noop('accepted'),
|
||||||
workflow=workflow_1,
|
workflow=workflow_1,
|
||||||
action_word='Accept',
|
action_word='Accept',
|
||||||
|
recommendation_label='Acceptance',
|
||||||
css_class='success')
|
css_class='success')
|
||||||
state_1_3 = State.objects.create(name=ugettext_noop('rejected'),
|
state_1_3 = State.objects.create(name=ugettext_noop('rejected'),
|
||||||
workflow=workflow_1,
|
workflow=workflow_1,
|
||||||
action_word='Reject',
|
action_word='Reject',
|
||||||
|
recommendation_label='Rejection',
|
||||||
css_class='danger')
|
css_class='danger')
|
||||||
state_1_4 = State.objects.create(name=ugettext_noop('not decided'),
|
state_1_4 = State.objects.create(name=ugettext_noop('not decided'),
|
||||||
workflow=workflow_1,
|
workflow=workflow_1,
|
||||||
action_word='Do not decide',
|
action_word='Do not decide',
|
||||||
|
recommendation_label='No decision',
|
||||||
css_class='default')
|
css_class='default')
|
||||||
state_1_1.next_states.add(state_1_2, state_1_3, state_1_4)
|
state_1_1.next_states.add(state_1_2, state_1_3, state_1_4)
|
||||||
workflow_1.first_state = state_1_1
|
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'),
|
state_2_2 = State.objects.create(name=ugettext_noop('permitted'),
|
||||||
workflow=workflow_2,
|
workflow=workflow_2,
|
||||||
action_word='Permit',
|
action_word='Permit',
|
||||||
|
recommendation_label='Permission',
|
||||||
allow_create_poll=True,
|
allow_create_poll=True,
|
||||||
allow_submitter_edit=True,
|
allow_submitter_edit=True,
|
||||||
versioning=True,
|
versioning=True,
|
||||||
@ -51,11 +55,13 @@ def create_builtin_workflows(sender, **kwargs):
|
|||||||
state_2_3 = State.objects.create(name=ugettext_noop('accepted'),
|
state_2_3 = State.objects.create(name=ugettext_noop('accepted'),
|
||||||
workflow=workflow_2,
|
workflow=workflow_2,
|
||||||
action_word='Accept',
|
action_word='Accept',
|
||||||
|
recommendation_label='Acceptance',
|
||||||
versioning=True,
|
versioning=True,
|
||||||
css_class='success')
|
css_class='success')
|
||||||
state_2_4 = State.objects.create(name=ugettext_noop('rejected'),
|
state_2_4 = State.objects.create(name=ugettext_noop('rejected'),
|
||||||
workflow=workflow_2,
|
workflow=workflow_2,
|
||||||
action_word='Reject',
|
action_word='Reject',
|
||||||
|
recommendation_label='Rejection',
|
||||||
versioning=True,
|
versioning=True,
|
||||||
css_class='danger')
|
css_class='danger')
|
||||||
state_2_5 = State.objects.create(name=ugettext_noop('withdrawed'),
|
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'),
|
state_2_6 = State.objects.create(name=ugettext_noop('adjourned'),
|
||||||
workflow=workflow_2,
|
workflow=workflow_2,
|
||||||
action_word='Adjourn',
|
action_word='Adjourn',
|
||||||
|
recommendation_label='Adjournment',
|
||||||
versioning=True,
|
versioning=True,
|
||||||
css_class='default')
|
css_class='default')
|
||||||
state_2_7 = State.objects.create(name=ugettext_noop('not concerned'),
|
state_2_7 = State.objects.create(name=ugettext_noop('not concerned'),
|
||||||
workflow=workflow_2,
|
workflow=workflow_2,
|
||||||
action_word='Do not concern',
|
action_word='Do not concern',
|
||||||
|
recommendation_label='No concernment',
|
||||||
versioning=True,
|
versioning=True,
|
||||||
css_class='default')
|
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,
|
workflow=workflow_2,
|
||||||
action_word='Commit a bill',
|
action_word='Refer to committee',
|
||||||
|
recommendation_label='Referral to committee',
|
||||||
versioning=True,
|
versioning=True,
|
||||||
css_class='default')
|
css_class='default')
|
||||||
state_2_9 = State.objects.create(name=ugettext_noop('needs review'),
|
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)'),
|
state_2_10 = State.objects.create(name=ugettext_noop('rejected (not authorized)'),
|
||||||
workflow=workflow_2,
|
workflow=workflow_2,
|
||||||
action_word='Reject (not authorized)',
|
action_word='Reject (not authorized)',
|
||||||
|
recommendation_label='Rejection (not authorized)',
|
||||||
versioning=True,
|
versioning=True,
|
||||||
css_class='default')
|
css_class='default')
|
||||||
state_2_1.next_states.add(state_2_2, state_2_5, state_2_10)
|
state_2_1.next_states.add(state_2_2, state_2_5, state_2_10)
|
||||||
|
@ -15,11 +15,25 @@ angular.module('OpenSlidesApp.motions', [
|
|||||||
name: 'motions/workflowstate',
|
name: 'motions/workflowstate',
|
||||||
methods: {
|
methods: {
|
||||||
getNextStates: function () {
|
getNextStates: function () {
|
||||||
|
// TODO: Use filter with params with operator 'in'.
|
||||||
var states = [];
|
var states = [];
|
||||||
_.forEach(this.next_states_id, function (stateId) {
|
_.forEach(this.next_states_id, function (stateId) {
|
||||||
states.push(DS.get('motions/workflowstate', stateId));
|
states.push(DS.get('motions/workflowstate', stateId));
|
||||||
});
|
});
|
||||||
return states;
|
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
|
* - unsupport
|
||||||
* - change_state
|
* - change_state
|
||||||
* - reset_state
|
* - reset_state
|
||||||
|
* - change_recommendation
|
||||||
*
|
*
|
||||||
* NOTE: If you update this function please also update the
|
* NOTE: If you update this function please also update the
|
||||||
* 'get_allowed_actions' function on server side in motions/models.py.
|
* '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');
|
return operator.hasPerms('motions.can_manage');
|
||||||
case 'reset_state':
|
case 'reset_state':
|
||||||
return operator.hasPerms('motions.can_manage');
|
return operator.hasPerms('motions.can_manage');
|
||||||
|
case 'change_recommendation':
|
||||||
|
return operator.hasPerms('motions.can_manage');
|
||||||
case 'can_manage':
|
case 'can_manage':
|
||||||
return operator.hasPerms('motions.can_manage');
|
return operator.hasPerms('motions.can_manage');
|
||||||
default:
|
default:
|
||||||
@ -279,10 +296,16 @@ angular.module('OpenSlidesApp.motions', [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
hasOne: {
|
hasOne: {
|
||||||
'motions/workflowstate': {
|
'motions/workflowstate': [
|
||||||
localField: 'state',
|
{
|
||||||
localKey: 'state_id',
|
localField: 'state',
|
||||||
}
|
localKey: 'state_id',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
localField: 'recommendation',
|
||||||
|
localKey: 'recommendation_id',
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -330,31 +353,41 @@ angular.module('OpenSlidesApp.motions', [
|
|||||||
gettext('submitted');
|
gettext('submitted');
|
||||||
gettext('accepted');
|
gettext('accepted');
|
||||||
gettext('Accept');
|
gettext('Accept');
|
||||||
|
gettext('Acceptance');
|
||||||
gettext('rejected');
|
gettext('rejected');
|
||||||
gettext('Reject');
|
gettext('Reject');
|
||||||
|
gettext('Rejection');
|
||||||
gettext('not decided');
|
gettext('not decided');
|
||||||
gettext('Do not decide');
|
gettext('Do not decide');
|
||||||
|
gettext('No decision');
|
||||||
// workflow 2
|
// workflow 2
|
||||||
gettext('Complex Workflow');
|
gettext('Complex Workflow');
|
||||||
gettext('published');
|
gettext('published');
|
||||||
gettext('permitted');
|
gettext('permitted');
|
||||||
gettext('Permit');
|
gettext('Permit');
|
||||||
|
gettext('Permission');
|
||||||
gettext('accepted');
|
gettext('accepted');
|
||||||
gettext('Accept');
|
gettext('Accept');
|
||||||
|
gettext('Acceptance');
|
||||||
gettext('rejected');
|
gettext('rejected');
|
||||||
gettext('Reject');
|
gettext('Reject');
|
||||||
|
gettext('Rejection');
|
||||||
gettext('withdrawed');
|
gettext('withdrawed');
|
||||||
gettext('Withdraw');
|
gettext('Withdraw');
|
||||||
gettext('adjourned');
|
gettext('adjourned');
|
||||||
gettext('Adjourn');
|
gettext('Adjourn');
|
||||||
|
gettext('Adjournment');
|
||||||
gettext('not concerned');
|
gettext('not concerned');
|
||||||
gettext('Do not concern');
|
gettext('Do not concern');
|
||||||
gettext('commited a bill');
|
gettext('No concernment');
|
||||||
gettext('Commit a bill');
|
gettext('refered to committee');
|
||||||
|
gettext('Refer to committee');
|
||||||
|
gettext('Referral to committee');
|
||||||
gettext('needs review');
|
gettext('needs review');
|
||||||
gettext('Needs review');
|
gettext('Needs review');
|
||||||
gettext('rejected (not authorized)');
|
gettext('rejected (not authorized)');
|
||||||
gettext('Reject (not authorized)');
|
gettext('Reject (not authorized)');
|
||||||
|
gettext('Rejection (not authorized)');
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -1152,9 +1152,17 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions', 'OpenSlid
|
|||||||
$http.put('/rest/motions/motion/' + motion.id + '/set_state/', {'state': state_id});
|
$http.put('/rest/motions/motion/' + motion.id + '/set_state/', {'state': state_id});
|
||||||
};
|
};
|
||||||
// reset state
|
// reset state
|
||||||
$scope.reset_state = function (state_id) {
|
$scope.reset_state = function () {
|
||||||
$http.put('/rest/motions/motion/' + motion.id + '/set_state/', {});
|
$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
|
// create poll
|
||||||
$scope.create_poll = function () {
|
$scope.create_poll = function () {
|
||||||
$http.post('/rest/motions/motion/' + motion.id + '/create_poll/', {});
|
$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('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('Stop submitting new motions by non-staff users');
|
||||||
gettext('Allow to disable versioning');
|
gettext('Allow to disable versioning');
|
||||||
|
gettext('Name of recommendation committee');
|
||||||
|
gettext('Use an empty value to disable the recommendation system.');
|
||||||
|
|
||||||
// subgroup Amendments
|
// subgroup Amendments
|
||||||
gettext('Amendments');
|
gettext('Amendments');
|
||||||
|
@ -117,9 +117,9 @@
|
|||||||
<ul uib-dropdown-menu aria-labelledby="state-dropdown">
|
<ul uib-dropdown-menu aria-labelledby="state-dropdown">
|
||||||
<li ng-repeat="state in motion.state.getNextStates()">
|
<li ng-repeat="state in motion.state.getNextStates()">
|
||||||
<a href ng-click="updateState(state.id)">{{ state.action_word | translate }}</a>
|
<a href ng-click="updateState(state.id)">{{ state.action_word | translate }}</a>
|
||||||
<li class="divider" ng-if="motion.state.getNextStates().length">
|
<li class="divider" ng-if="motion.state.getNextStates().length && motion.isAllowed('reset_state')">
|
||||||
<li>
|
<li ng-if="motion.isAllowed('reset_state')">
|
||||||
<a href ng-if="motion.isAllowed('reset_state')" ng-click="reset_state()">
|
<a href ng-click="reset_state()">
|
||||||
<i class="fa fa-exclamation-triangle"></i>
|
<i class="fa fa-exclamation-triangle"></i>
|
||||||
<translate>Reset state</translate>
|
<translate>Reset state</translate>
|
||||||
</a>
|
</a>
|
||||||
@ -130,6 +130,34 @@
|
|||||||
{{ motion.state.name | translate }}
|
{{ motion.state.name | translate }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recommendation -->
|
||||||
|
<div ng-if="config('motions_recommendations_by') != ''">
|
||||||
|
<h3 ng-if="!motion.isAllowed('change_recommendation')" class="heading" translate>Recommendation</h3>
|
||||||
|
<div ng-if="motion.isAllowed('change_recommendation')" class="heading">
|
||||||
|
<span uib-dropdown>
|
||||||
|
<a href id="recommendation-dropdown" class="drop-down-name" uib-dropdown-toggle>
|
||||||
|
<translate>Recommendation</translate>
|
||||||
|
<i class="fa fa-cog"></i>
|
||||||
|
</a>
|
||||||
|
<ul uib-dropdown-menu aria-labelledby="recommendation-dropdown">
|
||||||
|
<li ng-repeat="recommendation in motion.state.getRecommendations()">
|
||||||
|
<a href ng-click="updateRecommendation(recommendation.id)">
|
||||||
|
{{ recommendation.recommendation_label | translate }}
|
||||||
|
</a>
|
||||||
|
<li class="divider" ng-if="motion.state.getRecommendations().length && motion.recommendation">
|
||||||
|
<li ng-if="motion.recommendation">
|
||||||
|
<a href ng-click="resetRecommendation()">
|
||||||
|
<i class="fa fa-exclamation-triangle"></i>
|
||||||
|
<translate>Reset recommendation</translate>
|
||||||
|
</a>
|
||||||
|
</ul>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="label" ng-class="'label-'+motion.recommendation.css_class">
|
||||||
|
{{ motion.recommendation.recommendation_label | translate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Origin -->
|
<!-- Origin -->
|
||||||
<h3 ng-if="motion.origin" translate>Origin</h3>
|
<h3 ng-if="motion.origin" translate>Origin</h3>
|
||||||
{{ motion.origin }}
|
{{ motion.origin }}
|
||||||
|
@ -24,7 +24,14 @@ from .access_permissions import (
|
|||||||
WorkflowAccessPermissions,
|
WorkflowAccessPermissions,
|
||||||
)
|
)
|
||||||
from .exceptions import WorkflowError
|
from .exceptions import WorkflowError
|
||||||
from .models import Category, Motion, MotionPoll, MotionVersion, Workflow
|
from .models import (
|
||||||
|
Category,
|
||||||
|
Motion,
|
||||||
|
MotionPoll,
|
||||||
|
MotionVersion,
|
||||||
|
State,
|
||||||
|
Workflow,
|
||||||
|
)
|
||||||
from .pdf import motion_poll_to_pdf, motion_to_pdf, motions_to_pdf
|
from .pdf import motion_poll_to_pdf, motion_to_pdf, motions_to_pdf
|
||||||
from .serializers import MotionPollSerializer
|
from .serializers import MotionPollSerializer
|
||||||
|
|
||||||
@ -57,7 +64,7 @@ class MotionViewSet(ModelViewSet):
|
|||||||
self.request.user.has_perm('motions.can_create') and
|
self.request.user.has_perm('motions.can_create') and
|
||||||
(not config['motions_stop_submitting'] or
|
(not config['motions_stop_submitting'] or
|
||||||
self.request.user.has_perm('motions.can_manage')))
|
self.request.user.has_perm('motions.can_manage')))
|
||||||
elif self.action in ('destroy', 'manage_version', 'set_state', 'create_poll'):
|
elif self.action in ('destroy', 'manage_version', 'set_state', 'set_recommendation', 'create_poll'):
|
||||||
result = (self.request.user.has_perm('motions.can_see') and
|
result = (self.request.user.has_perm('motions.can_see') and
|
||||||
self.request.user.has_perm('motions.can_manage'))
|
self.request.user.has_perm('motions.can_manage'))
|
||||||
elif self.action == 'support':
|
elif self.action == 'support':
|
||||||
@ -278,6 +285,46 @@ class MotionViewSet(ModelViewSet):
|
|||||||
person=request.user)
|
person=request.user)
|
||||||
return Response({'detail': message})
|
return Response({'detail': message})
|
||||||
|
|
||||||
|
@detail_route(methods=['put'])
|
||||||
|
def set_recommendation(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Special view endpoint to set a recommendation of a motion.
|
||||||
|
|
||||||
|
Send PUT {'recommendation': <state_id>} to set and just PUT {} to
|
||||||
|
reset the recommendation. Only managers can use this view.
|
||||||
|
"""
|
||||||
|
# Retrieve motion and recommendation state.
|
||||||
|
motion = self.get_object()
|
||||||
|
recommendation_state = request.data.get('recommendation')
|
||||||
|
|
||||||
|
# Set or reset recommendation.
|
||||||
|
if recommendation_state is not None:
|
||||||
|
# Check data and set recommendation.
|
||||||
|
try:
|
||||||
|
recommendation_state_id = int(recommendation_state)
|
||||||
|
except ValueError:
|
||||||
|
raise ValidationError({'detail': _('Invalid data. Recommendation must be an integer.')})
|
||||||
|
recommendable_states = State.objects.filter(workflow=motion.workflow, recommendation_label__isnull=False)
|
||||||
|
if recommendation_state_id not in [item.id for item in recommendable_states]:
|
||||||
|
raise ValidationError(
|
||||||
|
{'detail': _('You can not set the recommendation to {recommendation_state_id}.').format(
|
||||||
|
recommendation_state_id=recommendation_state_id)})
|
||||||
|
motion.set_recommendation(recommendation_state_id)
|
||||||
|
else:
|
||||||
|
# Reset recommendation.
|
||||||
|
motion.recommendation = None
|
||||||
|
|
||||||
|
# Save motion.
|
||||||
|
motion.save(update_fields=['recommendation'])
|
||||||
|
label = motion.recommendation.recommendation_label if motion.recommendation else 'None'
|
||||||
|
message = _('The recommendation of the motion was set to %s.') % label
|
||||||
|
|
||||||
|
# Write the log message and initiate response.
|
||||||
|
motion.write_log(
|
||||||
|
message_list=[ugettext_noop('Recommendation set to'), ' ', label],
|
||||||
|
person=request.user)
|
||||||
|
return Response({'detail': message})
|
||||||
|
|
||||||
@detail_route(methods=['post'])
|
@detail_route(methods=['post'])
|
||||||
def create_poll(self, request, pk=None):
|
def create_poll(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
|
@ -379,6 +379,82 @@ class SetState(TestCase):
|
|||||||
self.assertEqual(Motion.objects.get(pk=self.motion.pk).state.name, 'submitted')
|
self.assertEqual(Motion.objects.get(pk=self.motion.pk).state.name, 'submitted')
|
||||||
|
|
||||||
|
|
||||||
|
class SetRecommendation(TestCase):
|
||||||
|
"""
|
||||||
|
Tests setting a recommendation.
|
||||||
|
"""
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.client.login(username='admin', password='admin')
|
||||||
|
self.motion = Motion(
|
||||||
|
title='test_title_ahfooT5leilahcohJ2uz',
|
||||||
|
text='test_text_enoogh7OhPoo6eohoCus')
|
||||||
|
self.motion.save()
|
||||||
|
self.state_id_accepted = 2 # This should be the id of the state 'accepted'.
|
||||||
|
|
||||||
|
def test_set_recommendation(self):
|
||||||
|
response = self.client.put(
|
||||||
|
reverse('motion-set-recommendation', args=[self.motion.pk]),
|
||||||
|
{'recommendation': self.state_id_accepted})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data, {'detail': 'The recommendation of the motion was set to Acceptance.'})
|
||||||
|
self.assertEqual(Motion.objects.get(pk=self.motion.pk).recommendation.name, 'accepted')
|
||||||
|
|
||||||
|
def test_set_state_with_string(self):
|
||||||
|
# Using a string is not allowed even if it is the correct name of the state.
|
||||||
|
response = self.client.put(
|
||||||
|
reverse('motion-set-recommendation', args=[self.motion.pk]),
|
||||||
|
{'recommendation': 'accepted'})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(response.data, {'detail': 'Invalid data. Recommendation must be an integer.'})
|
||||||
|
|
||||||
|
def test_set_unknown_recommendation(self):
|
||||||
|
invalid_state_id = 0
|
||||||
|
response = self.client.put(
|
||||||
|
reverse('motion-set-recommendation', args=[self.motion.pk]),
|
||||||
|
{'recommendation': invalid_state_id})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(response.data, {'detail': 'You can not set the recommendation to %d.' % invalid_state_id})
|
||||||
|
|
||||||
|
def test_set_invalid_recommendation(self):
|
||||||
|
# This is a valid state id, but this state is not recommendable because it belongs to a different workflow.
|
||||||
|
invalid_state_id = 6 # State 'permitted'
|
||||||
|
response = self.client.put(
|
||||||
|
reverse('motion-set-recommendation', args=[self.motion.pk]),
|
||||||
|
{'recommendation': invalid_state_id})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(response.data, {'detail': 'You can not set the recommendation to %d.' % invalid_state_id})
|
||||||
|
|
||||||
|
def test_set_invalid_recommendation_2(self):
|
||||||
|
# This is a valid state id, but this state is not recommendable because it has not recommendation label
|
||||||
|
invalid_state_id = 1 # State 'submitted'
|
||||||
|
self.motion.set_state(self.state_id_accepted)
|
||||||
|
self.motion.save()
|
||||||
|
response = self.client.put(
|
||||||
|
reverse('motion-set-recommendation', args=[self.motion.pk]),
|
||||||
|
{'recommendation': invalid_state_id})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(response.data, {'detail': 'You can not set the recommendation to %d.' % invalid_state_id})
|
||||||
|
|
||||||
|
def test_reset(self):
|
||||||
|
self.motion.set_recommendation(self.state_id_accepted)
|
||||||
|
self.motion.save()
|
||||||
|
response = self.client.put(reverse('motion-set-recommendation', args=[self.motion.pk]))
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data, {'detail': 'The recommendation of the motion was set to None.'})
|
||||||
|
self.assertTrue(Motion.objects.get(pk=self.motion.pk).recommendation is None)
|
||||||
|
|
||||||
|
def test_set_recommendation_to_current_state(self):
|
||||||
|
self.motion.set_state(self.state_id_accepted)
|
||||||
|
self.motion.save()
|
||||||
|
response = self.client.put(
|
||||||
|
reverse('motion-set-recommendation', args=[self.motion.pk]),
|
||||||
|
{'recommendation': self.state_id_accepted})
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data, {'detail': 'The recommendation of the motion was set to Acceptance.'})
|
||||||
|
self.assertEqual(Motion.objects.get(pk=self.motion.pk).recommendation.name, 'accepted')
|
||||||
|
|
||||||
|
|
||||||
class CreateMotionPoll(TestCase):
|
class CreateMotionPoll(TestCase):
|
||||||
"""
|
"""
|
||||||
Tests creating polls of motions.
|
Tests creating polls of motions.
|
||||||
|
Loading…
Reference in New Issue
Block a user