diff --git a/.gitignore b/.gitignore
index e6c181a27..9460a3600 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
*.pyc
*.swp
*.swo
+*.log
*~
# Virtual Environment
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 2d7153c7a..4a3f39b92 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,6 +12,7 @@ Motions:
- New possibility to sort submitters [#3647].
- New representation of amendments (paragraph based creation, new diff
and list views for amendments) [#3637].
+ - New feature to customize workflows and states [#3772].
Version 2.2 (2018-06-06)
diff --git a/openslides/core/static/css/_mediaqueries.scss b/openslides/core/static/css/_mediaqueries.scss
index 129068b8d..62e1f5906 100644
--- a/openslides/core/static/css/_mediaqueries.scss
+++ b/openslides/core/static/css/_mediaqueries.scss
@@ -30,15 +30,18 @@
#nav .navbar li a { padding: 24px 5px; }
- #groups-table .perm-head {
+ #multi-table .info-head {
width: 200px;
+ &.small {
+ width: 250px;
+ }
}
- /* hide text in groups-table earlier */
- #groups-table .optional { display: none; }
+ /* hide text in multi-table earlier */
+ #multi-table .optional { display: none; }
/* show replacement elements, if any */
- #groups-table .optional-show { display: block !important; }
+ #multi-table .optional-show { display: block !important; }
/* hide searchbar input */
#nav .searchbar input { display: none !important; }
@@ -99,8 +102,11 @@
width: 100%;
}
- #groups-table .perm-head {
+ #multi-table .info-head {
width: 150px;
+ &.small {
+ width: 100px;
+ }
}
.personalNoteFixed {
diff --git a/openslides/core/static/css/core/_multi-table.scss b/openslides/core/static/css/core/_multi-table.scss
new file mode 100644
index 000000000..ce2141e2f
--- /dev/null
+++ b/openslides/core/static/css/core/_multi-table.scss
@@ -0,0 +1,47 @@
+/* multi list */
+#multi-table {
+ table-layout: fixed;
+ text-align: center;
+
+ thead tr th {
+ vertical-align: top;
+ text-align: center;
+ min-width: 40px;
+ overflow: hidden;
+ }
+
+ .info-head {
+ width: 300px;
+ &.small {
+ width: 200px;
+ }
+ }
+
+ tbody tr:hover {
+ background-color: #f5f5f5 !important;
+ }
+
+ tbody tr.bg-grey {
+ background-color: #f9f9f9;
+ }
+
+ tbody tr td .no-overflow{
+ overflow: hidden;
+ }
+
+ tbody tr td:first-child {
+ text-align: left;
+ }
+
+ tbody tr td div {
+ text-align: center;
+ }
+
+ .optional-show { /* hide optional-show column */
+ display: none;
+ }
+
+ .editable-click {
+ color: #000;
+ }
+}
diff --git a/openslides/core/static/css/core/_site.scss b/openslides/core/static/css/core/_site.scss
index d2d75e775..df85cc729 100644
--- a/openslides/core/static/css/core/_site.scss
+++ b/openslides/core/static/css/core/_site.scss
@@ -3,6 +3,7 @@
@import "config";
@import "search";
@import "os-table";
+@import "multi-table";
@import "csv-import";
@import "chatbox";
@import "countdown";
diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py
index bc43885b0..5969c8ff3 100644
--- a/openslides/motions/apps.py
+++ b/openslides/motions/apps.py
@@ -29,6 +29,7 @@ class MotionsAppConfig(AppConfig):
MotionBlockViewSet,
MotionPollViewSet,
MotionChangeRecommendationViewSet,
+ StateViewSet,
WorkflowViewSet,
)
@@ -55,6 +56,7 @@ class MotionsAppConfig(AppConfig):
router.register(self.get_model('MotionChangeRecommendation').get_collection_string(),
MotionChangeRecommendationViewSet)
router.register(self.get_model('MotionPoll').get_collection_string(), MotionPollViewSet)
+ router.register(self.get_model('State').get_collection_string(), StateViewSet)
def get_startup_elements(self):
"""
diff --git a/openslides/motions/migrations/0008_auto_20180702_1128.py b/openslides/motions/migrations/0008_auto_20180702_1128.py
new file mode 100644
index 000000000..3d2f2f059
--- /dev/null
+++ b/openslides/motions/migrations/0008_auto_20180702_1128.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.8 on 2018-07-02 09:28
+from __future__ import unicode_literals
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('motions', '0007_motionversion_amendment_data'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='workflow',
+ name='first_state',
+ field=models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name='+',
+ to='motions.State'),
+ ),
+ migrations.AlterField(
+ model_name='motion',
+ name='state',
+ field=models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name='+',
+ to='motions.State'),
+ ),
+ migrations.AlterField(
+ model_name='state',
+ name='next_states',
+ field=models.ManyToManyField(blank=True, to='motions.State'),
+ ),
+ migrations.AlterField(
+ model_name='state',
+ name='action_word',
+ field=models.CharField(blank=True, max_length=255),
+ ),
+ ]
diff --git a/openslides/motions/models.py b/openslides/motions/models.py
index b8308bc9c..802b38f0f 100644
--- a/openslides/motions/models.py
+++ b/openslides/motions/models.py
@@ -83,7 +83,7 @@ class Motion(RESTModelMixin, models.Model):
state = models.ForeignKey(
'State',
related_name='+',
- on_delete=models.SET_NULL,
+ on_delete=models.PROTECT, # Do not let the user delete states, that are used for motions
null=True) # TODO: Check whether null=True is necessary.
"""
The related state object.
@@ -1196,7 +1196,7 @@ class State(RESTModelMixin, models.Model):
name = models.CharField(max_length=255)
"""A string representing the state."""
- action_word = models.CharField(max_length=255)
+ action_word = models.CharField(max_length=255, blank=True)
"""An alternative string to be used for a button to switch to this state."""
recommendation_label = models.CharField(max_length=255, null=True)
@@ -1208,7 +1208,7 @@ class State(RESTModelMixin, models.Model):
related_name='states')
"""A many-to-one relation to a workflow."""
- next_states = models.ManyToManyField('self', symmetrical=False)
+ next_states = models.ManyToManyField('self', symmetrical=False, blank=True)
"""A many-to-many relation to all states, that can be choosen from this state."""
css_class = models.CharField(max_length=255, default='primary')
@@ -1338,7 +1338,8 @@ class Workflow(RESTModelMixin, models.Model):
State,
on_delete=models.SET_NULL,
related_name='+',
- null=True)
+ null=True,
+ blank=True)
"""A one-to-one relation to a state, the starting point for the workflow."""
class Meta:
diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py
index ebb99fe85..9703a6aa1 100644
--- a/openslides/motions/serializers.py
+++ b/openslides/motions/serializers.py
@@ -101,12 +101,43 @@ class WorkflowSerializer(ModelSerializer):
Serializer for motion.models.Workflow objects.
"""
states = StateSerializer(many=True, read_only=True)
- first_state = PrimaryKeyRelatedField(read_only=True)
+ # The first_state is checked in the update() method
+ first_state = PrimaryKeyRelatedField(queryset=State.objects.all(), required=False)
class Meta:
model = Workflow
fields = ('id', 'name', 'states', 'first_state',)
+ @transaction.atomic
+ def create(self, validated_data):
+ """
+ Customized create method. Creating a new workflow does always create a
+ new state which is used as first state.
+ """
+ workflow = super().create(validated_data)
+ first_state = State.objects.create(
+ name='new',
+ action_word='new',
+ workflow=workflow,
+ allow_create_poll=True,
+ allow_support=True,
+ allow_submitter_edit=True
+ )
+ workflow.first_state = first_state
+ workflow.save()
+ return workflow
+
+ @transaction.atomic
+ def update(self, workflow, validated_data):
+ """
+ Check, if the first state is in the right workflow.
+ """
+ first_state = validated_data.get('first_state')
+ if first_state is not None:
+ if workflow.pk != first_state.workflow.pk:
+ raise ValidationError({'detail': 'You cannot select a state which is not in the workflow as the first state.'})
+ return super().update(workflow, validated_data)
+
class MotionCommentsJSONSerializerField(Field):
"""
diff --git a/openslides/motions/static/js/motions/base.js b/openslides/motions/static/js/motions/base.js
index f62526e82..f4183f6cd 100644
--- a/openslides/motions/static/js/motions/base.js
+++ b/openslides/motions/static/js/motions/base.js
@@ -10,19 +10,16 @@ angular.module('OpenSlidesApp.motions', [
'OpenSlidesApp.users',
])
-.factory('WorkflowState', [
+.factory('MotionState', [
'DS',
function (DS) {
return DS.defineResource({
- name: 'motions/workflowstate',
+ name: 'motions/state',
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 _.map(this.next_states_id, function (stateId) {
+ return DS.get('motions/state', stateId);
});
- return states;
},
getRecommendations: function () {
var params = {
@@ -35,22 +32,34 @@ angular.module('OpenSlidesApp.motions', [
}
}
};
- return DS.filter('motions/workflowstate', params);
+ return DS.filter('motions/state', params);
}
- }
+ },
+ relations: {
+ hasOne: {
+ 'motions/workflow': {
+ localField: 'workflow',
+ localKey: 'workflow_id',
+ }
+ }
+ },
});
}
])
.factory('Workflow', [
'DS',
- 'WorkflowState',
- function (DS, WorkflowState) {
+ function (DS) {
return DS.defineResource({
name: 'motions/workflow',
+ methods: {
+ getFirstState: function () {
+ return DS.get('motions/state', this.first_state);
+ },
+ },
relations: {
hasMany: {
- 'motions/workflowstate': {
+ 'motions/state': {
localField: 'states',
foreignKey: 'workflow_id',
}
@@ -1212,7 +1221,7 @@ angular.module('OpenSlidesApp.motions', [
},
},
hasOne: {
- 'motions/workflowstate': [
+ 'motions/state': [
{
localField: 'state',
localKey: 'state_id',
@@ -1523,9 +1532,10 @@ angular.module('OpenSlidesApp.motions', [
'Motion',
'Category',
'Workflow',
+ 'MotionState',
'MotionChangeRecommendation',
'Submitter',
- function(Motion, Category, Workflow, MotionChangeRecommendation, Submitter) {}
+ function(Motion, Category, Workflow, MotionState, MotionChangeRecommendation, Submitter) {}
])
diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js
index 2971c2d06..3a31f356b 100644
--- a/openslides/motions/static/js/motions/site.js
+++ b/openslides/motions/static/js/motions/site.js
@@ -10,6 +10,7 @@ angular.module('OpenSlidesApp.motions.site', [
'OpenSlidesApp.motions.docx',
'OpenSlidesApp.motions.pdf',
'OpenSlidesApp.motions.csv',
+ 'OpenSlidesApp.motions.workflow',
])
.config([
@@ -188,6 +189,24 @@ angular.module('OpenSlidesApp.motions.site', [
});
}
],
+ })
+ // Workflows and states
+ .state('motions.workflow', {
+ url: '/workflow',
+ abstract: true,
+ template: '
+ Permissions+ | + + {{ state.name | translate }} + + + {{ state.name | translate | limitTo: 1 }}... + + + | +
---|---|
+ Action word + | +
+
+
+
+
+ {{ state.action_word | translate }}
+
+
+ —
+
+
+
+ |
+
+ Recommendation label + | +
+
+
+
+
+ {{ state.recommendation_label | translate }}
+
+
+ —
+
+
+
+ |
+
+ {{ member.displayName | translate }} + | ++ + + | +
+ Label color + | ++ + + + {{ cssClasses[state.css_class] }} + + + + + + | +
+ Required permission to see + | +
+
+
+
+
+ {{ getPermissionDisplayName(state.required_permission_to_see) | translate }}
+
+
+ —
+
+
+
+
+
+
+ |
+
+ Next states + | +
+
+ —
+
+
+
+ {{ nextState.name | translate }},
+
+
+
+
+
+
+
+
+ |
+
+ |
+
---|
+ {{ workflow.name | translate }} + + | +
All your changes are saved immediately. Changes you make are only effective once you (or the users concerned) reload the page.
-+ |
Permissions |
@@ -49,7 +49,7 @@
-
{{ app.app_name | translate}}
diff --git a/openslides/utils/rest_api.py b/openslides/utils/rest_api.py
index a9d509428..80c1bbdbe 100644
--- a/openslides/utils/rest_api.py
+++ b/openslides/utils/rest_api.py
@@ -7,7 +7,11 @@ from rest_framework.decorators import detail_route, list_route # noqa
from rest_framework.metadata import SimpleMetadata # noqa
from rest_framework.mixins import ListModelMixin as _ListModelMixin
from rest_framework.mixins import RetrieveModelMixin as _RetrieveModelMixin
-from rest_framework.mixins import DestroyModelMixin, UpdateModelMixin # noqa
+from rest_framework.mixins import ( # noqa
+ CreateModelMixin,
+ DestroyModelMixin,
+ UpdateModelMixin,
+)
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.serializers import ModelSerializer as _ModelSerializer
diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py
index 9b1558627..8f04e3223 100644
--- a/tests/integration/motions/test_viewset.py
+++ b/tests/integration/motions/test_viewset.py
@@ -1237,3 +1237,117 @@ class FollowRecommendationsForMotionBlock(TestCase):
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)
+
+
+class CreateWorkflow(TestCase):
+ """
+ Tests the creating of workflows.
+ """
+ def setUp(self):
+ self.client = APIClient()
+ self.client.login(username='admin', password='admin')
+
+ def test_creation(self):
+ Workflow.objects.all().delete()
+ response = self.client.post(
+ reverse('workflow-list'),
+ {'name': 'test_name_OoCoo3MeiT9li5Iengu9'})
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+ workflow = Workflow.objects.get()
+ self.assertEqual(workflow.name, 'test_name_OoCoo3MeiT9li5Iengu9')
+ first_state = workflow.first_state
+ self.assertEqual(type(first_state), State)
+
+ def test_creation_with_wrong_first_state(self):
+ response = self.client.post(
+ reverse('workflow-list'),
+ {'name': 'test_name_OoCoo3MeiT9li5Iengu9',
+ 'first_state': 1})
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+ def test_creation_with_not_existing_first_state(self):
+ Workflow.objects.all().delete()
+ response = self.client.post(
+ reverse('workflow-list'),
+ {'name': 'test_name_OoCoo3MeiT9li5Iengu9',
+ 'first_state': 49})
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+
+
+class UpdateWorkflow(TestCase):
+ """
+ Tests the updating of workflows.
+ """
+ def setUp(self):
+ self.client = APIClient()
+ self.client.login(username='admin', password='admin')
+ self.workflow = Workflow.objects.first()
+
+ def test_rename_workflow(self):
+ response = self.client.patch(
+ reverse('workflow-detail', args=[self.workflow.pk]),
+ {'name': 'test_name_wofi38DiWLT"8d3lwfo3'})
+
+ workflow = Workflow.objects.get(pk=self.workflow.id)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(workflow.name, 'test_name_wofi38DiWLT"8d3lwfo3')
+
+ def test_change_first_state_correct(self):
+ first_state = self.workflow.first_state
+ other_workflow_state = self.workflow.states.exclude(pk=first_state.pk).first()
+ response = self.client.patch(
+ reverse('workflow-detail', args=[self.workflow.pk]),
+ {'first_state': other_workflow_state.pk})
+
+ workflow = Workflow.objects.get(pk=self.workflow.id)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(workflow.first_state, other_workflow_state)
+
+ def test_change_first_state_not_existing(self):
+ first_state = self.workflow.first_state
+ response = self.client.patch(
+ reverse('workflow-detail', args=[self.workflow.pk]),
+ {'first_state': 42})
+
+ workflow = Workflow.objects.get(pk=self.workflow.id)
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(workflow.first_state, first_state)
+
+ def test_change_first_state_wrong_workflow(self):
+ first_state = self.workflow.first_state
+ other_workflow = Workflow.objects.exclude(pk=self.workflow.pk).first()
+ response = self.client.patch(
+ reverse('workflow-detail', args=[self.workflow.pk]),
+ {'first_state': other_workflow.first_state.pk})
+
+ workflow = Workflow.objects.get(pk=self.workflow.id)
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(workflow.first_state, first_state)
+
+
+class DeleteWorkflow(TestCase):
+ """
+ Tests the deletion of workflows.
+ """
+ def setUp(self):
+ self.client = APIClient()
+ self.client.login(username='admin', password='admin')
+ self.workflow = Workflow.objects.first()
+
+ def test_simple_delete(self):
+ response = self.client.delete(
+ reverse('workflow-detail', args=[self.workflow.pk]))
+ self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
+ self.assertEqual(Workflow.objects.count(), 1) # Just the other default one
+
+ def test_delete_with_assigned_motions(self):
+ self.motion = Motion(
+ title='test_title_chee7ahCha6bingaew4e',
+ text='test_text_birah1theL9ooseeFaip')
+ self.motion.reset_state(self.workflow)
+ self.motion.save()
+
+ response = self.client.delete(
+ reverse('workflow-detail', args=[self.workflow.pk]))
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(Workflow.objects.count(), 2)
| |
---|