Merge pull request #2344 from normanjaeckel/MotionRecommendation

Added recommendations for motions.
This commit is contained in:
Norman Jäckel 2016-09-07 23:58:14 +02:00 committed by GitHub
commit f2c3e535a5
11 changed files with 366 additions and 24 deletions

View File

@ -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.

View File

@ -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',

View 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
),
]

View File

@ -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):

View File

@ -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):

View File

@ -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)

View File

@ -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)');
}
]);

View File

@ -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');

View File

@ -117,9 +117,9 @@
<ul uib-dropdown-menu aria-labelledby="state-dropdown">
<li ng-repeat="state in motion.state.getNextStates()">
<a href ng-click="updateState(state.id)">{{ state.action_word | translate }}</a>
<li class="divider" ng-if="motion.state.getNextStates().length">
<li>
<a href ng-if="motion.isAllowed('reset_state')" ng-click="reset_state()">
<li class="divider" ng-if="motion.state.getNextStates().length && motion.isAllowed('reset_state')">
<li ng-if="motion.isAllowed('reset_state')">
<a href ng-click="reset_state()">
<i class="fa fa-exclamation-triangle"></i>
<translate>Reset state</translate>
</a>
@ -130,6 +130,34 @@
{{ motion.state.name | translate }}
</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 -->
<h3 ng-if="motion.origin" translate>Origin</h3>
{{ motion.origin }}

View File

@ -24,7 +24,14 @@ from .access_permissions import (
WorkflowAccessPermissions,
)
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 .serializers import MotionPollSerializer
@ -57,7 +64,7 @@ class MotionViewSet(ModelViewSet):
self.request.user.has_perm('motions.can_create') and
(not config['motions_stop_submitting'] or
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
self.request.user.has_perm('motions.can_manage'))
elif self.action == 'support':
@ -278,6 +285,46 @@ class MotionViewSet(ModelViewSet):
person=request.user)
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'])
def create_poll(self, request, pk=None):
"""

View File

@ -379,6 +379,82 @@ class SetState(TestCase):
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):
"""
Tests creating polls of motions.