Merge pull request #3772 from FinnStutzenstein/custom-state-workflow

Custom workflows and states
This commit is contained in:
Emanuel Schütze 2018-07-13 10:44:16 +02:00 committed by GitHub
commit d9d0c84a1a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 973 additions and 67 deletions

1
.gitignore vendored
View File

@ -2,6 +2,7 @@
*.pyc *.pyc
*.swp *.swp
*.swo *.swo
*.log
*~ *~
# Virtual Environment # Virtual Environment

View File

@ -12,6 +12,7 @@ Motions:
- New possibility to sort submitters [#3647]. - New possibility to sort submitters [#3647].
- New representation of amendments (paragraph based creation, new diff - New representation of amendments (paragraph based creation, new diff
and list views for amendments) [#3637]. and list views for amendments) [#3637].
- New feature to customize workflows and states [#3772].
Version 2.2 (2018-06-06) Version 2.2 (2018-06-06)

View File

@ -30,15 +30,18 @@
#nav .navbar li a { padding: 24px 5px; } #nav .navbar li a { padding: 24px 5px; }
#groups-table .perm-head { #multi-table .info-head {
width: 200px; width: 200px;
&.small {
width: 250px;
}
} }
/* hide text in groups-table earlier */ /* hide text in multi-table earlier */
#groups-table .optional { display: none; } #multi-table .optional { display: none; }
/* show replacement elements, if any */ /* show replacement elements, if any */
#groups-table .optional-show { display: block !important; } #multi-table .optional-show { display: block !important; }
/* hide searchbar input */ /* hide searchbar input */
#nav .searchbar input { display: none !important; } #nav .searchbar input { display: none !important; }
@ -99,8 +102,11 @@
width: 100%; width: 100%;
} }
#groups-table .perm-head { #multi-table .info-head {
width: 150px; width: 150px;
&.small {
width: 100px;
}
} }
.personalNoteFixed { .personalNoteFixed {

View File

@ -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;
}
}

View File

@ -3,6 +3,7 @@
@import "config"; @import "config";
@import "search"; @import "search";
@import "os-table"; @import "os-table";
@import "multi-table";
@import "csv-import"; @import "csv-import";
@import "chatbox"; @import "chatbox";
@import "countdown"; @import "countdown";

View File

@ -29,6 +29,7 @@ class MotionsAppConfig(AppConfig):
MotionBlockViewSet, MotionBlockViewSet,
MotionPollViewSet, MotionPollViewSet,
MotionChangeRecommendationViewSet, MotionChangeRecommendationViewSet,
StateViewSet,
WorkflowViewSet, WorkflowViewSet,
) )
@ -55,6 +56,7 @@ class MotionsAppConfig(AppConfig):
router.register(self.get_model('MotionChangeRecommendation').get_collection_string(), router.register(self.get_model('MotionChangeRecommendation').get_collection_string(),
MotionChangeRecommendationViewSet) MotionChangeRecommendationViewSet)
router.register(self.get_model('MotionPoll').get_collection_string(), MotionPollViewSet) 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): def get_startup_elements(self):
""" """

View File

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

View File

@ -83,7 +83,7 @@ class Motion(RESTModelMixin, models.Model):
state = models.ForeignKey( state = models.ForeignKey(
'State', 'State',
related_name='+', 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. null=True) # TODO: Check whether null=True is necessary.
""" """
The related state object. The related state object.
@ -1196,7 +1196,7 @@ class State(RESTModelMixin, models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
"""A string representing the state.""" """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.""" """An alternative string to be used for a button to switch to this state."""
recommendation_label = models.CharField(max_length=255, null=True) recommendation_label = models.CharField(max_length=255, null=True)
@ -1208,7 +1208,7 @@ class State(RESTModelMixin, models.Model):
related_name='states') related_name='states')
"""A many-to-one relation to a workflow.""" """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.""" """A many-to-many relation to all states, that can be choosen from this state."""
css_class = models.CharField(max_length=255, default='primary') css_class = models.CharField(max_length=255, default='primary')
@ -1338,7 +1338,8 @@ class Workflow(RESTModelMixin, models.Model):
State, State,
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
related_name='+', related_name='+',
null=True) null=True,
blank=True)
"""A one-to-one relation to a state, the starting point for the workflow.""" """A one-to-one relation to a state, the starting point for the workflow."""
class Meta: class Meta:

View File

@ -101,12 +101,43 @@ class WorkflowSerializer(ModelSerializer):
Serializer for motion.models.Workflow objects. Serializer for motion.models.Workflow objects.
""" """
states = StateSerializer(many=True, read_only=True) 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: class Meta:
model = Workflow model = Workflow
fields = ('id', 'name', 'states', 'first_state',) 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): class MotionCommentsJSONSerializerField(Field):
""" """

View File

@ -10,19 +10,16 @@ angular.module('OpenSlidesApp.motions', [
'OpenSlidesApp.users', 'OpenSlidesApp.users',
]) ])
.factory('WorkflowState', [ .factory('MotionState', [
'DS', 'DS',
function (DS) { function (DS) {
return DS.defineResource({ return DS.defineResource({
name: 'motions/workflowstate', name: 'motions/state',
methods: { methods: {
getNextStates: function () { getNextStates: function () {
// TODO: Use filter with params with operator 'in'. return _.map(this.next_states_id, function (stateId) {
var states = []; return DS.get('motions/state', stateId);
_.forEach(this.next_states_id, function (stateId) {
states.push(DS.get('motions/workflowstate', stateId));
}); });
return states;
}, },
getRecommendations: function () { getRecommendations: function () {
var params = { 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', [ .factory('Workflow', [
'DS', 'DS',
'WorkflowState', function (DS) {
function (DS, WorkflowState) {
return DS.defineResource({ return DS.defineResource({
name: 'motions/workflow', name: 'motions/workflow',
methods: {
getFirstState: function () {
return DS.get('motions/state', this.first_state);
},
},
relations: { relations: {
hasMany: { hasMany: {
'motions/workflowstate': { 'motions/state': {
localField: 'states', localField: 'states',
foreignKey: 'workflow_id', foreignKey: 'workflow_id',
} }
@ -1212,7 +1221,7 @@ angular.module('OpenSlidesApp.motions', [
}, },
}, },
hasOne: { hasOne: {
'motions/workflowstate': [ 'motions/state': [
{ {
localField: 'state', localField: 'state',
localKey: 'state_id', localKey: 'state_id',
@ -1523,9 +1532,10 @@ angular.module('OpenSlidesApp.motions', [
'Motion', 'Motion',
'Category', 'Category',
'Workflow', 'Workflow',
'MotionState',
'MotionChangeRecommendation', 'MotionChangeRecommendation',
'Submitter', 'Submitter',
function(Motion, Category, Workflow, MotionChangeRecommendation, Submitter) {} function(Motion, Category, Workflow, MotionState, MotionChangeRecommendation, Submitter) {}
]) ])

View File

@ -10,6 +10,7 @@ angular.module('OpenSlidesApp.motions.site', [
'OpenSlidesApp.motions.docx', 'OpenSlidesApp.motions.docx',
'OpenSlidesApp.motions.pdf', 'OpenSlidesApp.motions.pdf',
'OpenSlidesApp.motions.csv', 'OpenSlidesApp.motions.csv',
'OpenSlidesApp.motions.workflow',
]) ])
.config([ .config([
@ -188,6 +189,24 @@ angular.module('OpenSlidesApp.motions.site', [
}); });
} }
], ],
})
// Workflows and states
.state('motions.workflow', {
url: '/workflow',
abstract: true,
template: '<ui-view/>',
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; $scope.model.motion_block_id = parentMotion.motion_block_id;
Motion.bindOne($scope.model.parent_id, $scope, 'parent'); Motion.bindOne($scope.model.parent_id, $scope, 'parent');
} }
// ... preselect default workflow // ... preselect default workflow if exist
$scope.model.workflow_id = Config.get('motions_workflow').value; 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 // get all form fields
$scope.formFields = MotionForm.getFormFields(true, isParagraphBasedAmendment); $scope.formFields = MotionForm.getFormFields(true, isParagraphBasedAmendment);

View File

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

View File

@ -41,9 +41,9 @@
<strong>{{ category.name }}</strong> <strong>{{ category.name }}</strong>
<div class="hoverActions" ng-class="{'hiddenDiv': !category.hover}"> <div class="hoverActions" ng-class="{'hiddenDiv': !category.hover}">
<!-- sort --> <!-- sort -->
<a ui-sref="motions.category.sort({ id: category.id })" translate>Sort</a> | <a ui-sref="motions.category.sort({ id: category.id })" translate>Sort</a> &middot;
<!-- edit --> <!-- edit -->
<a href="" ng-click="editOrCreate(category)" translate>Edit</a> | <a href="" ng-click="editOrCreate(category)" translate>Edit</a> &middot;
<!-- delete --> <!-- delete -->
<a href="" class="text-danger" <a href="" class="text-danger"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this entry?' | translate }}<br> ng-bootbox-confirm="{{ 'Are you sure you want to delete this entry?' | translate }}<br>

View File

@ -17,6 +17,10 @@
<i class="fa fa-th-large fa-lg"></i> <i class="fa fa-th-large fa-lg"></i>
<translate>Motion blocks</translate> <translate>Motion blocks</translate>
</a> </a>
<a ui-sref="motions.workflow.list" os-perms="motions.can_manage" class="btn btn-default btn-sm">
<i class="fa fa-code-fork fa-lg"></i>
<translate>Workflows</translate>
</a>
<a ui-sref="core.tag.list" os-perms="core.can_manage_tags" class="btn btn-default btn-sm"> <a ui-sref="core.tag.list" os-perms="core.can_manage_tags" class="btn btn-default btn-sm">
<i class="fa fa-tags fa-lg"></i> <i class="fa fa-tags fa-lg"></i>
<translate>Tags</translate> <translate>Tags</translate>

View File

@ -0,0 +1,28 @@
<h1 ng-if="state" translate>Edit state</h1>
<h1 ng-if="!state" translate>Create new state</h1>
<div uib-alert ng-show="alert.show" class="alert-danger" ng-click="alert={}" close="alert={}">
{{ alert.msg }}
</div>
<form name="stateForm" ng-submit="save()">
<label for="name" translate>
Name
</label>
<div>
<input class="form-control" id="name" type="text" ng-model="newName">
</div>
<label for="actionword" class="spacer-top" translate>
Action word
</label>
<div>
<input class="form-control" id="actionword" type="text" ng-model="actionWord">
</div>
<div class="spacer-top-lg">
<button type="submit" ng-disabled="newName == ''" class="btn btn-primary" translate>
Save
</button>
<button ng-click="closeThisDialog()" class="btn btn-default" translate>
Cancel
</button>
</div>
</form>

View File

@ -0,0 +1,212 @@
<div class="header">
<div class="title">
<div class="submenu">
<a ui-sref="motions.workflow.list" class="btn btn-sm btn-default">
<i class="fa fa-angle-double-left fa-lg"></i>
<translate>Back to overview</translate>
</a>
<a os-perms="motions.can_manage" class="btn btn-primary btn-sm"
ng-click="openStateDialog()" title="add new state">
<i class="fa fa-plus fa-lg"></i>
<translate>New</translate>
</a>
<button type="button" class="btn btn-sm"
ng-class="expandContent ? 'btn-primary' : 'btn-default'"
ng-click="toggleExpandContent(); saveExpandState(expandContent)">
<i class="fa fa-arrows-h fa-lg"></i>
<span ng-if="!expandContent" translate>Expand</span>
<span ng-if="expandContent" translate>Reduce</span>
</button>
</div>
<h1>
{{ workflow.name | translate }}
<i class="fa fa-pencil pointer" ng-click="openWorkflowDialog()"></i>
</h1>
</div>
<div class="title">
<h3 ng-mouseover="firstStateHover=true" ng-mouseleave="firstStateHover=false">
<translate>First state</translate>:
{{ workflow.getFirstState().name | translate }}
<span uib-dropdown>
<span id="firstStateDropdown" class="pointer" uib-dropdown-toggle>
<i class="fa fa-cog" ng-if="firstStateHover"></i>
</span>
<ul class="dropdown-menu" aria-labelledby="firstStateDropdown">
<li ng-repeat="state in workflow.states">
<a href ng-click="setFirstState(state)">
<i class="fa fa-check" ng-if="workflow.first_state === state.id"></i>
{{ state.name | translate }}
</a>
</li>
</ul>
</span>
</h3>
</div>
</div>
<div class="details">
<div uib-alert ng-show="alert.show" class="alert-danger" ng-click="alert={}" close="alert={}">
{{ alert.msg }}
</div>
<table id="multi-table" class="table table-bordered">
<thead>
<tr>
<th class="info-head small">
<h4 translate>Permissions</h4>
<th ng-repeat="state in workflow.states" ng-mouseover="thHover=true" ng-mouseleave="thHover=false">
<span class="optional">
{{ state.name | translate }}
</span>
<span class="optional-show" uib-tooltip="{{ state.name | translate }}">
{{ state.name | translate | limitTo: 1 }}...
</span>
<div os-perms="motions.can_manage" class="hoverActions text-center"
ng-class="{'hiddenDiv': !thHover}">
<!--edit name-->
<a href="" ng-click="openStateDialog(state)">
<i class="fa fa-pencil fa-lg"></i></a>
&nbsp;
<!--delete-->
<a href="" class="text-danger" ng-if="state.id !== workflow.first_state"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this entry?' | translate }}<br>
<b>{{ state.name | translate }}</b>"
ng-bootbox-confirm-action="delete(state)">
<i class="fa fa-trash fa-lg"></i>
</a>
</div>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<b translate>Action word</b>
</td>
<td ng-repeat="state in workflow.states" ng-mouseover="tdHover=true" ng-mouseleave="tdHover=false">
<div class="popover-wrapper">
<span editable-text="state.newActionWord"
onaftersave="setMember(state, 'action_word', state.newActionWord)">
<div class="no-overflow" ng-if="state.action_word">
{{ state.action_word | translate }}
</div>
<span class="text-muted" ng-if="!state.action_word">
&mdash;
</span>
<i class="fa fa-pencil" ng-if="tdHover"></i>
</span>
</div>
</td>
</tr>
<tr>
<td>
<b translate>Recommendation label</b>
</td>
<td ng-repeat="state in workflow.states" ng-mouseover="tdHover=true" ng-mouseleave="tdHover=false">
<div class="popover-wrapper">
<span editable-text="state.newRecommendationLabel"
onaftersave="setMember(state, 'recommendation_label', state.newRecommendationLabel)">
<div class="no-overflow" ng-if="state.recommendation_label">
{{ state.recommendation_label | translate }}
</div>
<span class="text-muted" ng-if="!state.recommendation_label">
&mdash;
</span>
<i class="fa fa-pencil" ng-if="tdHover"></i>
</span>
</div>
</td>
</tr>
<tr ng-repeat="member in booleanMembers">
<td>
<b>{{ member.displayName | translate }}</b>
</td>
<td ng-repeat="state in workflow.states" class="pointer"
ng-click="changeBooleanMember(state, member.name)">
<!-- Simulating a checkbox with FontAwesome icons. -->
<i class="fa"
ng-class="xor(state[member.name], member.inverse) ? 'fa-check-square-o' : 'fa-square-o'"></i>
</td>
</tr>
<tr>
<td>
<b translate>Label color</b>
</td>
<td ng-repeat="state in workflow.states" ng-mouseover="tdHover=true" ng-mouseleave="tdHover=false">
<span uib-dropdown>
<span id="dropdownCssClass{{ state.id }}" class="pointer" uib-dropdown-toggle>
<span class="label" ng-class="'label-' + state.css_class">
{{ cssClasses[state.css_class] }}
</span>
<i class="fa fa-cog" ng-if="tdHover"></i>
</span>
<ul class="dropdown-menu" aria-labelledby="dropdownCssClass{{ state.id }}">
<li ng-repeat="(class, name) in cssClasses">
<a href ng-click="setMember(state, 'css_class', class)">
<i class="fa fa-check" ng-if="state.css_class === class"></i>
{{ name | translate }}
</a>
</li>
</ul>
</span>
</td>
</tr>
<tr>
<td>
<b translate>Required permission to see</b>
</td>
<td ng-repeat="state in workflow.states" ng-mouseover="tdHover=true" ng-mouseleave="tdHover=false">
<span uib-dropdown>
<span id="dropdownPermission{{ state.id }}" class="pointer" uib-dropdown-toggle>
<div class="no-overflow">
<span ng-if="state.required_permission_to_see">
{{ getPermissionDisplayName(state.required_permission_to_see) | translate }}
</span>
<span class="text-muted" ng-if="!state.required_permission_to_see">
&mdash;
</span>
<i class="fa fa-cog" ng-if="tdHover"></i>
</div>
</span>
<ul class="dropdown-menu" aria-labelledby="dropdownPermission{{ state.id }}">
<li ng-repeat="permission in permissions">
<a href ng-click="clickPermission(state, permission)">
<i class="fa fa-check" ng-if="state.required_permission_to_see === permission.value"></i>
{{ permission.display_name | translate }}
</a>
</li>
</ul>
</span>
</td>
</tr>
<tr>
<td>
<b translate>Next states</b>
</td>
<td ng-repeat="state in workflow.states" ng-mouseover="tdHover=true" ng-mouseleave="tdHover=false">
<span ng-if="state.getNextStates().length === 0" class="text-muted" translate>
&mdash;
</span>
<div class="no-overflow">
<span ng-repeat="nextState in state.getNextStates()">
{{ nextState.name | translate }}<span ng-if="!$last">,</br></span>
</span>
</div>
<span uib-dropdown>
<span id="dropdownNextStates{{ state.id }}" class="pointer"
uib-dropdown-toggle>
<i class="fa fa-cog" ng-if="tdHover"></i>
</span>
<ul class="dropdown-menu" aria-labelledby="dropdownNextStates{{ state.id }}">
<li ng-repeat="s in workflow.states">
<a href ng-click="clickNextStateEntry(state, s.id)">
<i class="fa fa-check" ng-if="state.next_states_id.indexOf(s.id) > -1"></i>
{{ s.name | translate }}
</a>
</li>
</ul>
</span>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -0,0 +1,29 @@
<h1 ng-if="workflow" translate>Edit name</h1>
<h1 ng-if="!workflow" translate>Create new workflow</h1>
<div uib-alert ng-show="alert.show" class="alert-danger" ng-click="alert={}" close="alert={}">
{{ alert.msg }}
</div>
<form name="workflowForm" ng-submit="save()">
<label for="name_1">
<span ng-if="workflow" translate>
Please enter a new workflow name:
</span>
<span ng-if="!workflow" translate>
Please enter a name for the new workflow:
</span>
</label>
<div>
<div>
<input class="form-control" id="name_1" type="text" ng-model="newName">
</div>
<div class="spacer-top-lg">
<button type="submit" ng-disabled="newName == ''" class="btn btn-primary" translate>
Save
</button>
<button ng-click="closeThisDialog()" class="btn btn-default" translate>
Cancel
</button>
</div>
</div>
</form>

View File

@ -0,0 +1,46 @@
<div class="header">
<div class="title">
<div class="submenu">
<a ui-sref="motions.motion.list" class="btn btn-sm btn-default">
<i class="fa fa-angle-double-left fa-lg"></i>
<translate>Back to overview</translate>
</a>
<a href="" os-perms="motions.can_manage" class="btn btn-primary btn-sm" ng-click="create()">
<i class="fa fa-plus fa-lg"></i>
<translate>New</translate>
</a>
</div>
<h1 translate>Workflows</h1>
</div>
</div>
<div class="details">
<div uib-alert ng-show="alert.show" class="alert-danger" ng-click="alert={}" close="alert={}">
{{ alert.msg }}
</div>
<table class="table table-striped table-bordered table-hover">
<thead>
<tr>
<th>
<translate>Name</translate>
</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="workflow in workflows | orderBy: 'name'">
<td ng-mouseover="workflow.hover=true" ng-mouseleave="workflow.hover=false">
<strong>{{ workflow.name | translate }}</strong>
<div class="hoverActions" ng-class="{'hiddenDiv': !workflow.hover}">
<!-- edit -->
<a ui-sref="motions.workflow.detail({id: workflow.id})" translate>Edit</a> &middot;
<!-- delete -->
<a href="" class="text-danger"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this entry?' | translate }}<br>
<b>{{ workflow.name }}</b>"
ng-bootbox-confirm-action="delete(workflow)" translate>Delete</a>
</div>
</td>
</tr>
</tbody>
</table>
</div>

View File

@ -5,6 +5,7 @@ from django.conf import settings
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.db.models.deletion import ProtectedError
from django.http import Http404 from django.http import Http404
from django.http.request import QueryDict from django.http.request import QueryDict
from django.utils.translation import ugettext as _ 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.collection import CollectionElement
from ..utils.exceptions import OpenSlidesError from ..utils.exceptions import OpenSlidesError
from ..utils.rest_api import ( from ..utils.rest_api import (
CreateModelMixin,
DestroyModelMixin, DestroyModelMixin,
GenericViewSet, GenericViewSet,
ModelViewSet, ModelViewSet,
@ -45,7 +47,7 @@ from .models import (
Submitter, Submitter,
Workflow, Workflow,
) )
from .serializers import MotionPollSerializer from .serializers import MotionPollSerializer, StateSerializer
# Viewsets for the REST API # Viewsets for the REST API
@ -868,7 +870,23 @@ class MotionBlockViewSet(ModelViewSet):
return Response({'detail': _('Followed recommendations successfully.')}) 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. API endpoint for workflows.
@ -893,6 +911,56 @@ class WorkflowViewSet(ModelViewSet):
result = False result = False
return result 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 # Special views

View File

@ -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;
}
}

View File

@ -19,10 +19,10 @@
<p translate> <p translate>
All your changes are saved immediately. Changes you make are only effective once you (or the users concerned) reload the page. All your changes are saved immediately. Changes you make are only effective once you (or the users concerned) reload the page.
</p> </p>
<table id="groups-table" class="table table-bordered"> <table id="multi-table" class="table table-bordered">
<thead> <thead>
<tr> <tr>
<th class="perm-head"> <th class="info-head">
<h4 translate>Permissions</h4> <h4 translate>Permissions</h4>
<th ng-repeat="group in groups" ng-mouseover="group.hover=true" ng-mouseleave="group.hover=false"> <th ng-repeat="group in groups" ng-mouseover="group.hover=true" ng-mouseleave="group.hover=false">
<span class="optional"> <span class="optional">
@ -49,7 +49,7 @@
</a> </a>
</div> </div>
<tbody ng-repeat="app in apps" os-perms="users.can_manage"> <tbody ng-repeat="app in apps" os-perms="users.can_manage">
<tr class="pointer" ng-click="app.app_visible=!app.app_visible"> <tr class="pointer bg-grey" ng-click="app.app_visible=!app.app_visible">
<td> <td>
<b>{{ app.app_name | translate}}</b> <b>{{ app.app_name | translate}}</b>
<i class="fa" ng-class="app.app_visible ? 'fa-minus-square' : 'fa-plus-square'"> <i class="fa" ng-class="app.app_visible ? 'fa-minus-square' : 'fa-plus-square'">

View File

@ -7,7 +7,11 @@ from rest_framework.decorators import detail_route, list_route # noqa
from rest_framework.metadata import SimpleMetadata # noqa from rest_framework.metadata import SimpleMetadata # noqa
from rest_framework.mixins import ListModelMixin as _ListModelMixin from rest_framework.mixins import ListModelMixin as _ListModelMixin
from rest_framework.mixins import RetrieveModelMixin as _RetrieveModelMixin 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.response import Response
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from rest_framework.serializers import ModelSerializer as _ModelSerializer from rest_framework.serializers import ModelSerializer as _ModelSerializer

View File

@ -1237,3 +1237,117 @@ class FollowRecommendationsForMotionBlock(TestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) 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.pk).state.id, self.state_id_accepted)
self.assertEqual(Motion.objects.get(pk=self.motion_2.pk).state.id, self.state_id_rejected) 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)