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: '', + data: { + title: gettext('Workflows'), + basePerm: 'motions.can_manage', + }, + }) + .state('motions.workflow.list', {}) + .state('motions.workflow.detail', { + resolve: { + workflowId: ['$stateParams', function($stateParams) { + return $stateParams.id; + }], + } }); } ]) @@ -2216,8 +2235,21 @@ angular.module('OpenSlidesApp.motions.site', [ $scope.model.motion_block_id = parentMotion.motion_block_id; Motion.bindOne($scope.model.parent_id, $scope, 'parent'); } - // ... preselect default workflow - $scope.model.workflow_id = Config.get('motions_workflow').value; + // ... preselect default workflow if exist + var workflow = Workflow.get(Config.get('motions_workflow').value); + if (!workflow) { + workflow = _.first(Workflow.getAll()); + } + if (workflow) { + $scope.model.workflow_id = workflow.id; + } else { + $scope.alert = { + type: 'danger', + msg: gettextCatalog.getString('No workflows exists. You will not ' + + 'be able to create a motion.'), + show: true, + }; + } // get all form fields $scope.formFields = MotionForm.getFormFields(true, isParagraphBasedAmendment); diff --git a/openslides/motions/static/js/motions/workflow.js b/openslides/motions/static/js/motions/workflow.js new file mode 100644 index 000000000..d210f82af --- /dev/null +++ b/openslides/motions/static/js/motions/workflow.js @@ -0,0 +1,257 @@ +(function () { + +'use strict'; + +angular.module('OpenSlidesApp.motions.workflow', []) + +.controller('WorkflowListCtrl', [ + '$scope', + 'Workflow', + 'ngDialog', + 'ErrorMessage', + function ($scope, Workflow, ngDialog, ErrorMessage) { + $scope.alert = {}; + Workflow.bindAll({}, $scope, 'workflows'); + $scope.create = function () { + ngDialog.open({ + template: 'static/templates/motions/workflow-edit.html', + controller: 'WorkflowCreateCtrl', + className: 'ngdialog-theme-default wide-form', + closeByEscape: false, + closeByDocument: false, + }); + }; + $scope.delete = function (workflow) { + Workflow.destroy(workflow).then(null, function (error) { + $scope.alert = ErrorMessage.forAlert(error); + }); + }; + } +]) + +.controller('WorkflowDetailCtrl', [ + '$scope', + '$sessionStorage', + 'permissions', + 'Workflow', + 'MotionState', + 'workflowId', + 'ngDialog', + 'gettextCatalog', + 'ErrorMessage', + function ($scope, $sessionStorage, permissions, Workflow, MotionState, workflowId, + ngDialog, gettextCatalog, ErrorMessage) { + $scope.permissions = permissions; + $scope.alert = {}; + + $scope.$watch(function () { + return Workflow.lastModified(workflowId); + }, function () { + $scope.workflow = Workflow.get(workflowId); + _.forEach($scope.workflow.states, function (state) { + state.newActionWord = gettextCatalog.getString(state.action_word); + state.newRecommendationLabel = gettextCatalog.getString(state.recommendation_label); + }); + }); + + $scope.booleanMembers = [ + {name: 'allow_support', + displayName: 'Allow support',}, + {name: 'allow_create_poll', + displayName: 'Allow create poll',}, + {name: 'allow_submitter_edit', + displayName: 'Allow submitter edit',}, + {name: 'versioning', + displayName: 'Versioning',}, + {name: 'leave_old_version_active', + displayName: 'Leave old version active',}, + {name: 'dont_set_identifier', + displayName: 'Set identifier', + inverse: true,}, + {name: 'show_state_extension_field', + displayName: 'Show state extension field',}, + {name: 'show_recommendation_extension_field', + displayName: 'Show recommendation extension field',} + ]; + $scope.cssClasses = { + 'danger': 'Red', + 'success': 'Green', + 'warning': 'Yellow', + 'default': 'Grey', + 'primary': 'Blue', + }; + $scope.getPermissionDisplayName = function (permission) { + if (permission) { + return _.find($scope.permissions, function (perm) { + return perm.value === permission; + }).display_name; + } + }; + $scope.clickPermission = function (state, permission) { + state.required_permission_to_see = + state.required_permission_to_see === permission.value ? '' : permission.value; + $scope.save(state); + }; + $scope.xor = function (a, b) { + return (a && !b) || (!a && b); + }; + + $scope.changeBooleanMember = function (state, memberName) { + state[memberName] = !state[memberName]; + $scope.save(state); + }; + $scope.setMember = function (state, member, value) { + state[member] = value; + $scope.save(state); + }; + $scope.clickNextStateEntry = function (state, clickedStateId) { + var index = state.next_states_id.indexOf(clickedStateId); + if (index > -1) { // remove now + state.next_states_id.splice(index, 1); + } else { // add + state.next_states_id.push(clickedStateId); + } + $scope.save(state); + }; + $scope.save = function (state) { + MotionState.save(state).then(null, function (error) { + $scope.alert = ErrorMessage.forAlert(error); + }); + }; + + $scope.setFirstState = function (state) { + $scope.workflow.first_state = state.id; + Workflow.save($scope.workflow).then(null, function (error) { + $scope.alert = ErrorMessage.forAlert(error); + }); + }; + + // Save expand state so the session + if ($sessionStorage.motionStateTableExpandState) { + $scope.toggleExpandContent(); + } + $scope.saveExpandState = function (state) { + $sessionStorage.motionStateTableExpandState = state; + }; + + $scope.openStateDialog = function (state) { + ngDialog.open({ + template: 'static/templates/motions/state-edit.html', + controller: state ? 'StateRenameCtrl' : 'StateCreateCtrl', + className: 'ngdialog-theme-default wide-form', + closeByEscape: false, + closeByDocument: false, + resolve: { + state: function () {return state;}, + workflow: function () {return $scope.workflow;}, + } + }); + }; + $scope.openWorkflowDialog = function () { + ngDialog.open({ + template: 'static/templates/motions/workflow-edit.html', + controller: 'WorkflowRenameCtrl', + className: 'ngdialog-theme-default wide-form', + closeByEscape: false, + closeByDocument: false, + resolve: { + workflow: function () {return $scope.workflow;}, + } + }); + }; + + $scope.delete = function (state) { + MotionState.destroy(state).then(null, function (error) { + $scope.alert = ErrorMessage.forAlert(error); + }); + }; + } +]) + +.controller('WorkflowCreateCtrl', [ + '$scope', + 'Workflow', + 'ErrorMessage', + function ($scope, Workflow, ErrorMessage) { + $scope.save = function () { + var workflow = { + name: $scope.newName, + }; + Workflow.create(workflow).then(function (success) { + $scope.closeThisDialog(); + }, function (error) { + $scope.alert = ErrorMessage.forAlert(error); + }); + }; + } +]) + +.controller('WorkflowRenameCtrl', [ + '$scope', + 'workflow', + 'Workflow', + 'gettextCatalog', + 'ErrorMessage', + function ($scope, workflow, Workflow, gettextCatalog, ErrorMessage) { + $scope.workflow = workflow; + $scope.newName = gettextCatalog.getString(workflow.name); + $scope.save = function () { + workflow.name = $scope.newName; + Workflow.save(workflow).then(function (success) { + $scope.closeThisDialog(); + }, function (error) { + $scope.alert = ErrorMessage.forAlert(error); + }); + }; + } +]) + +.controller('StateCreateCtrl', [ + '$scope', + 'workflow', + 'MotionState', + 'ErrorMessage', + function ($scope, workflow, MotionState, ErrorMessage) { + $scope.newName = ''; + $scope.actionWord = ''; + $scope.save = function () { + var state = { + name: $scope.newName, + action_word: $scope.actionWord, + workflow_id: workflow.id, + allow_create_poll: true, + allow_support: true, + allow_submitter_edit: true, + }; + MotionState.create(state).then(function () { + $scope.closeThisDialog(); + }, function (error) { + $scope.alert = ErrorMessage.forAlert(error); + }); + }; + } +]) + +.controller('StateRenameCtrl', [ + '$scope', + 'MotionState', + 'state', + 'gettextCatalog', + 'ErrorMessage', + function ($scope, MotionState, state, gettextCatalog, ErrorMessage) { + $scope.state = state; + $scope.newName = gettextCatalog.getString(state.name); + $scope.actionWord = gettextCatalog.getString(state.action_word); + $scope.save = function () { + state.name = $scope.newName; + state.action_word = $scope.actionWord; + MotionState.save(state).then(function () { + $scope.closeThisDialog(); + }, function (error) { + $scope.alert = ErrorMessage.forAlert(error); + }); + }; + } +]); + +}()); diff --git a/openslides/motions/static/templates/motions/category-list.html b/openslides/motions/static/templates/motions/category-list.html index 2e3791d2f..489c98c1c 100644 --- a/openslides/motions/static/templates/motions/category-list.html +++ b/openslides/motions/static/templates/motions/category-list.html @@ -41,9 +41,9 @@ {{ category.name }}
- Sort | + Sort · - Edit | + Edit · Motion blocks + + + Workflows + Tags diff --git a/openslides/motions/static/templates/motions/state-edit.html b/openslides/motions/static/templates/motions/state-edit.html new file mode 100644 index 000000000..37a4f960b --- /dev/null +++ b/openslides/motions/static/templates/motions/state-edit.html @@ -0,0 +1,28 @@ +

Edit state

+

Create new state

+
+ {{ alert.msg }} +
+ +
+ +
+ +
+ +
+ +
+
+ + +
+
diff --git a/openslides/motions/static/templates/motions/workflow-detail.html b/openslides/motions/static/templates/motions/workflow-detail.html new file mode 100644 index 000000000..cb7b5f93c --- /dev/null +++ b/openslides/motions/static/templates/motions/workflow-detail.html @@ -0,0 +1,212 @@ +
+
+ +

+ {{ workflow.name | translate }} + +

+
+
+

+ First state: + {{ workflow.getFirstState().name | translate }} + + + + + + +

+
+
+ +
+
+ {{ alert.msg }} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

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 }},
+
+
+ + + + + + +
+
diff --git a/openslides/motions/static/templates/motions/workflow-edit.html b/openslides/motions/static/templates/motions/workflow-edit.html new file mode 100644 index 000000000..4772757ba --- /dev/null +++ b/openslides/motions/static/templates/motions/workflow-edit.html @@ -0,0 +1,29 @@ +

Edit name

+

Create new workflow

+
+ {{ alert.msg }} +
+ +
+ +
+
+ +
+
+ + +
+
+
diff --git a/openslides/motions/static/templates/motions/workflow-list.html b/openslides/motions/static/templates/motions/workflow-list.html new file mode 100644 index 000000000..5abb8a7bd --- /dev/null +++ b/openslides/motions/static/templates/motions/workflow-list.html @@ -0,0 +1,46 @@ +
+ +
+ +
+
+ {{ alert.msg }} +
+ + + + + + + + + + + +
+ Name +
+ {{ workflow.name | translate }} +
+ + Edit · + + Delete +
+
+
diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 3dc5ea87a..5e7bb59d0 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -5,6 +5,7 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError as DjangoValidationError from django.db import IntegrityError, transaction +from django.db.models.deletion import ProtectedError from django.http import Http404 from django.http.request import QueryDict from django.utils.translation import ugettext as _ @@ -17,6 +18,7 @@ from ..utils.autoupdate import inform_changed_data from ..utils.collection import CollectionElement from ..utils.exceptions import OpenSlidesError from ..utils.rest_api import ( + CreateModelMixin, DestroyModelMixin, GenericViewSet, ModelViewSet, @@ -45,7 +47,7 @@ from .models import ( Submitter, Workflow, ) -from .serializers import MotionPollSerializer +from .serializers import MotionPollSerializer, StateSerializer # Viewsets for the REST API @@ -868,7 +870,23 @@ class MotionBlockViewSet(ModelViewSet): return Response({'detail': _('Followed recommendations successfully.')}) -class WorkflowViewSet(ModelViewSet): +class ProtectedErrorMessageMixin: + def getProtectedErrorMessage(self, name, error): + # The protected objects can just be motions.. + motions = ['"' + str(m) + '"' for m in error.protected_objects.all()] + count = len(motions) + motions_verbose = ', '.join(motions[:3]) + if count > 3: + motions_verbose += ', ...' + + if count == 1: + msg = _('This {} is assigned to motion {}.').format(name, motions_verbose) + else: + msg = _('This {} is assigned to motions {}.').format(name, motions_verbose) + return msg + ' ' + _('Please remove all assignments before deletion.') + + +class WorkflowViewSet(ModelViewSet, ProtectedErrorMessageMixin): """ API endpoint for workflows. @@ -893,6 +911,56 @@ class WorkflowViewSet(ModelViewSet): result = False return result + def create(self, *args, **kwargs): + try: + response = super().create(*args, **kwargs) + except WorkflowError as e: + raise ValidationError({'detail': e.args[0]}) + return response + + def destroy(self, *args, **kwargs): + """ + Customized view endpoint to delete a motion poll. + """ + try: + result = super().destroy(*args, **kwargs) + except ProtectedError as e: + msg = self.getProtectedErrorMessage('workflow', e) + raise ValidationError({'detail': msg}) + return result + + +class StateViewSet(CreateModelMixin, UpdateModelMixin, DestroyModelMixin, GenericViewSet, + ProtectedErrorMessageMixin): + """ + API endpoint for workflow states. + + There are the following views: create, update, partial_update and destroy. + """ + queryset = State.objects.all() + serializer_class = StateSerializer + + def check_view_permissions(self): + """ + Returns True if the user has required permissions. + """ + return (has_perm(self.request.user, 'motions.can_see') and + has_perm(self.request.user, 'motions.can_manage')) + + def destroy(self, *args, **kwargs): + """ + Customized view endpoint to delete a motion poll. + """ + state = self.get_object() + if state.workflow.first_state.pk == state.pk: # is this the first state of the workflow? + raise ValidationError({'detail': _('You cannot delete the first state of the workflow.')}) + try: + result = super().destroy(*args, **kwargs) + except ProtectedError as e: + msg = self.getProtectedErrorMessage('workflow', e) + raise ValidationError({'detail': msg}) + return result + # Special views diff --git a/openslides/users/static/css/users/_site.scss b/openslides/users/static/css/users/_site.scss index 0b98903b7..b21f9e5c3 100644 --- a/openslides/users/static/css/users/_site.scss +++ b/openslides/users/static/css/users/_site.scss @@ -16,36 +16,3 @@ } } } - -/* group list */ -#groups-table { - table-layout: fixed; - text-align: center; - - thead tr th { - vertical-align: top; - text-align: center; - min-width: 40px; - overflow: hidden; - } - - .perm-head { - width: 300px; - } - - tbody tr:hover { - background-color: #f5f5f5 !important; - } - - tbody tr:first-child { - background-color: #f9f9f9; - } - - tbody tr td:first-child { - text-align: left; - } - - .optional-show { /* hide optional-show column */ - display: none; - } -} diff --git a/openslides/users/static/templates/users/group-list.html b/openslides/users/static/templates/users/group-list.html index d4b62ca43..79f86cf38 100644 --- a/openslides/users/static/templates/users/group-list.html +++ b/openslides/users/static/templates/users/group-list.html @@ -19,10 +19,10 @@

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)