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
*.swp
*.swo
*.log
*~
# Virtual Environment

View File

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

View File

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

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 "search";
@import "os-table";
@import "multi-table";
@import "csv-import";
@import "chatbox";
@import "countdown";

View File

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

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

View File

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

View File

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

View File

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

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>
<div class="hoverActions" ng-class="{'hiddenDiv': !category.hover}">
<!-- 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 -->
<a href="" ng-click="editOrCreate(category)" translate>Edit</a> |
<a href="" ng-click="editOrCreate(category)" translate>Edit</a> &middot;
<!-- delete -->
<a href="" class="text-danger"
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>
<translate>Motion blocks</translate>
</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">
<i class="fa fa-tags fa-lg"></i>
<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.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

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>
All your changes are saved immediately. Changes you make are only effective once you (or the users concerned) reload the page.
</p>
<table id="groups-table" class="table table-bordered">
<table id="multi-table" class="table table-bordered">
<thead>
<tr>
<th class="perm-head">
<th class="info-head">
<h4 translate>Permissions</h4>
<th ng-repeat="group in groups" ng-mouseover="group.hover=true" ng-mouseleave="group.hover=false">
<span class="optional">
@ -49,7 +49,7 @@
</a>
</div>
<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>
<b>{{ app.app_name | translate}}</b>
<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.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

View File

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