Custom workflows and states:
- Added new workflow list view - Added state table for each workflow - Added new StateViewSet to handle states of workflows
This commit is contained in:
parent
56a7bd6840
commit
9e4cafd0f0
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
|
*.log
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# Virtual Environment
|
# Virtual Environment
|
||||||
|
@ -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)
|
||||||
|
@ -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 {
|
||||||
|
47
openslides/core/static/css/core/_multi-table.scss
Normal file
47
openslides/core/static/css/core/_multi-table.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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";
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
45
openslides/motions/migrations/0008_auto_20180702_1128.py
Normal file
45
openslides/motions/migrations/0008_auto_20180702_1128.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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:
|
||||||
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
@ -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) {}
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
257
openslides/motions/static/js/motions/workflow.js
Normal file
257
openslides/motions/static/js/motions/workflow.js
Normal 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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
}());
|
@ -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> ·
|
||||||
<!-- edit -->
|
<!-- edit -->
|
||||||
<a href="" ng-click="editOrCreate(category)" translate>Edit</a> |
|
<a href="" ng-click="editOrCreate(category)" translate>Edit</a> ·
|
||||||
<!-- 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>
|
||||||
|
@ -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>
|
||||||
|
28
openslides/motions/static/templates/motions/state-edit.html
Normal file
28
openslides/motions/static/templates/motions/state-edit.html
Normal 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>
|
212
openslides/motions/static/templates/motions/workflow-detail.html
Normal file
212
openslides/motions/static/templates/motions/workflow-detail.html
Normal 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>
|
||||||
|
|
||||||
|
<!--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">
|
||||||
|
—
|
||||||
|
</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">
|
||||||
|
—
|
||||||
|
</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">
|
||||||
|
—
|
||||||
|
</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>
|
||||||
|
—
|
||||||
|
</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>
|
@ -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>
|
@ -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> ·
|
||||||
|
<!-- 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>
|
@ -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
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -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'">
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
Loading…
Reference in New Issue
Block a user