Merge pull request #1663 from emanuelschuetze/motions-template2

Work on motions app.
This commit is contained in:
Norman Jäckel 2015-11-11 11:17:24 +01:00
commit 2417549755
20 changed files with 1171 additions and 372 deletions

View File

@ -2,29 +2,30 @@
"name": "OpenSlides",
"private": true,
"dependencies": {
"lodash": "~3.0.1",
"lodash": "~3.10.0",
"jquery": "~2.1.4",
"jquery.cookie": "~1.4.1",
"bootstrap-css-only": "~3.3.4",
"angular": "~1.3.15",
"angular-bootstrap": "~0.13.0",
"angular-messages": "~1.3.15",
"angular-animate": "~1.3.15",
"angular-csv-import": "~0.0.15",
"angular-loading-bar": "~0.7.1",
"angular-ui-router": "~0.2.13",
"angular-ui-select": "~0.12",
"angular-ui-switch": "~0.1.0",
"angular-ui-tree": "~2.2.0",
"angular-gettext": "~2.0.2",
"angular-sanitize": "~1.3.15",
"bootstrap-css-only": "~3.3.5",
"angular": "~1.4.7",
"angular-messages": "~1.4.7",
"angular-animate": "~1.4.7",
"angular-sanitize": "~1.4.7",
"angular-bootstrap": "~0.14.3",
"angular-csv-import": "~0.0.26",
"angular-formly-templates-bootstrap": "~6.1.5",
"angular-formly": "~7.3.2",
"angular-loading-bar": "~0.8.0",
"angular-ui-router": "~0.2.15",
"angular-ui-select": "~0.13.1",
"angular-ui-switch": "~0.1.1",
"angular-ui-tree": "~2.10.0",
"angular-gettext": "~2.1.2",
"angular-xeditable": "~0.1.9",
"ng-fab-form": "~1.2.7",
"ngBootbox": "~0.0.5",
"ngBootbox": "~0.1.2",
"sockjs": "~0.3.4",
"font-awesome-bower": "4.3.0",
"js-data": "~2.3.0",
"js-data-angular": "~3.0.0",
"font-awesome-bower": "~4.4.0",
"js-data": "~2.8.1",
"js-data-angular": "~3.1.0",
"ng-file-upload": "~9.1.2"
}
}

View File

@ -71,7 +71,7 @@
<thead>
<tr>
<!-- projector column -->
<th ng-show="!isDeleteMode" os-perms="core.can_manage_projector" class="firstColumn">
<th ng-show="!isDeleteMode" os-perms="core.can_manage_projector" class="firstColumn"></th>
<!-- delete selection column -->
<th ng-show="isDeleteMode" os-perms-lite="agenda.can_manage" class="firstColumn deleteColumn"
ng-click="$event.stopPropagation();">

View File

@ -79,7 +79,7 @@ angular.module('OpenSlidesApp.assignments.site', ['OpenSlidesApp.assignments'])
.controller('AssignmentDetailCtrl', function($scope, Assignment, assignment) {
Assignment.bindOne(assignment.id, $scope, 'assignment');
Assignment.loadRelations(assignment);
Assignment.loadRelations(assignment, 'agenda_item');
})
.controller('AssignmentCreateCtrl', function($scope, $state, Assignment) {

View File

@ -49,83 +49,49 @@ body {
color: red;
font-weight: bold;
}
.spacer, .spacer-top {
margin-top: 7px;
}
.spacer-right {
margin-right: 5px;
}
.hoverActions {
font-size: 85%;
}
.hiddenDiv {
visibility: hidden;
}
/* override bootstraps's progress bar for poll results*/
.progress {
height: 12px;
margin-bottom: 0;
}
/* voting results */
.result_label {
margin-top: 5px;
}
/* TODO: used by ng-fab-forms */
.validation-success {
opacity: 1;
display: block;
position: absolute;
right: -7.2px;
bottom: -7.2px;
font-size: 28.8px;
width: 36px;
height: 36px;
line-height: 36px;
text-align: center;
border-radius: 36px;
color: #62b14c;
transition: all ease-out 0.32s; }
.validation-success:after {
display: block;
content: '\e013';
font-family: 'Glyphicons Halflings'; }
.validation-success.ng-hide {
transition-delay: 0s;
transition: all ease-out 0.12s;
opacity: 0;
transform: rotate(360deg); }
.ng-hide-remove li {
opacity: 0; }
/* ngAnimate classes */
.animate-item.ng-enter {
-webkit-animation: fade-in 1s linear;
animation: fade-in 1.5s linear;
}
.animate-item.ng-leave {
-webkit-animation: fade-out 1s linear;
animation: fade-out 1.5s linear;
}
.validation {
color: #fff;
margin: 0;
position: relative;
font-size: 14px;
overflow: visible;
background: #c00640; }
.validation ul {
display: block;
overflow: hidden; }
.validation li {
display: block;
line-height: 1;
background: #c00640;
position: absolute;
right: -4px;
top: -10px;
text-align: center;
font-weight: bold;
padding: 2px 10px;
color: #fff;
transform: rotate(0deg);
transition: all ease-in 0.2s;
opacity: 1;
transition-delay: 0s; }
.validation li.ng-enter {
opacity: 0;
top: 0; }
.validation li.ng-leave {
transition: all ease-in 0s;
opacity: 0; }
*:focus + .validation li {
background-color: #63bff8 !important; }
input.ng-touched.ng-invalid:not(.ng-valid), textarea.ng-touched.ng-invalid:not(.ng-valid), select.ng-touched.ng-invalid:not(.ng-valid) {
border-color: #c00640; }
input:focus, input:focus.ng-touched.ng-invalid:not(.ng-valid), textarea:focus, textarea:focus.ng-touched.ng-invalid:not(.ng-valid), select:focus, select:focus.ng-touched.ng-invalid:not(.ng-valid) {
border-color: #63bff8; }
input.ng-valid-required.ng-valid:not(.ng-invalid), textarea.ng-valid-required.ng-valid:not(.ng-invalid), select.ng-valid-required.ng-valid:not(.ng-invalid) {
border-color: #62b14c; }
form[class*="ng-invalid"] button.btn[type=submit] {
background: #63bff8;
transition: none; }
form button.btn[type=submit] {
transition: all ease-in 0.5s;
background: #62b14c; }
@keyframes fade-out {
0% { opacity: 1; background: none; }
25% { opacity: 1; background: #f8efc0; }
100% { opacity: 0; background: none; }
}
@keyframes fade-in {
0% { opacity: 0; background: none; }
25% { opacity: 1; background: #dff0d8; }
100% { opacity: 1; background: none; }
}
@ -274,14 +240,25 @@ tr.offline td, li.offline {
tr.activeline td, li.activeline, .projected {
background-color: #bed4de;
}
tr.selected td {
background-color: #ff9999;
}
.nopadding {
padding: 0;
}
.alert form {
margin-bottom: 0;
}
tr.total td {
border-top: 1px solid #333333;
.slimlist {
padding-left: 20px;
}
.smallhr {
margin-top: 5px;
margin-bottom: 5px;
border-color: #cccccc;
}
.resultcolumn {
font-weight: bold;
}
.nobr {
white-space: nowrap;

View File

@ -111,7 +111,7 @@ angular.module('OpenSlidesApp.core', [
// Load the global data on startup
.run([
'loadGlobalData',
function(loadGlobalData, operator) {
function(loadGlobalData) {
loadGlobalData();
}
])

View File

@ -6,8 +6,9 @@
angular.module('OpenSlidesApp.core.site', [
'OpenSlidesApp.core',
'ui.router',
'formly',
'formlyBootstrap',
'ngBootbox',
'ngFabForm',
'ngMessages',
'ngCsvImport',
'ngSanitize', // TODO: only use this in functions that need it.
@ -260,13 +261,6 @@ angular.module('OpenSlidesApp.core.site', [
$locationProvider.html5Mode(true);
})
// config for ng-fab-form
.config(function(ngFabFormProvider) {
ngFabFormProvider.extendConfig({
setAsteriskForRequiredLabel: true
});
})
// Helper to add ui.router states at runtime.
// Needed for the django url_patterns.
.provider('runtimeStates', function($stateProvider) {
@ -299,6 +293,26 @@ angular.module('OpenSlidesApp.core.site', [
editableOptions.theme = 'bs3';
})
// angular formly config options
.run([
'formlyConfig',
function (formlyConfig) {
// NOTE: This next line is highly recommended. Otherwise Chrome's autocomplete will appear over your options!
formlyConfig.extras.removeChromeAutoComplete = true;
// Configure custom types
formlyConfig.setType({
name: 'ui-select-single',
extends: 'select',
templateUrl: 'static/templates/core/ui-select-single.html'
});
formlyConfig.setType({
name: 'ui-select-multiple',
extends: 'select',
templateUrl: 'static/templates/core/ui-select-multiple.html'
});
}
])
// html-tag os-form-field to generate generic from fields
// TODO: make it possible to use other fields then config fields
@ -646,7 +660,7 @@ angular.module('OpenSlidesApp.core.site', [
.controller('CustomslideDetailCtrl', function($scope, Customslide, customslide) {
Customslide.bindOne(customslide.id, $scope, 'customslide');
Customslide.loadRelations(customslide);
Customslide.loadRelations(customslide, 'agenda_item');
})
.controller('CustomslideCreateCtrl', function($scope, $state, Customslide) {

View File

@ -0,0 +1,10 @@
<!-- custom angular formly template for ui-select multiple form field -->
<ui-select multiple data-ng-model="model[options.key]" data-required="{{to.required}}"
data-disabled="{{to.disabled}}" theme="bootstrap">
<ui-select-match placeholder="{{to.placeholder}}">
{{$item[to.labelProp]}}
</ui-select-match>
<ui-select-choices data-repeat="{{to.ngOptions}}">
<div ng-bind-html="option[to.labelProp] | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>

View File

@ -0,0 +1,10 @@
<!-- custom angular formly template for ui-select single form field -->
<ui-select data-ng-model="model[options.key]" data-required="{{to.required}}"
data-disabled="{{to.disabled}}" theme="bootstrap">
<ui-select-match placeholder="{{to.placeholder}}" data-allow-clear="true">
{{$select.selected[to.labelProp]}}
</ui-select-match>
<ui-select-choices data-repeat="{{to.ngOptions}}">
<div ng-bind-html="option[to.labelProp] | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('motions', '0003_auto_20150904_2029'),
]
operations = [
migrations.RenameField(
model_name='state',
old_name='icon',
new_name='css_class',
),
migrations.AlterField(
model_name='state',
name='css_class',
field=models.CharField(max_length=255, default='primary'),
),
migrations.AlterField(
model_name='state',
name='workflow',
field=models.ForeignKey(to='motions.Workflow', related_name='states'),
),
]

View File

@ -409,6 +409,7 @@ class Motion(RESTModelMixin, models.Model):
"""
Returns the id of the workflow of the motion.
"""
# TODO: Rename to workflow_id
return self.state.workflow.pk
def set_state(self, state):
@ -442,7 +443,7 @@ class Motion(RESTModelMixin, models.Model):
new_state = self.state.workflow.first_state
else:
new_state = (Workflow.objects.get(pk=config['motions_workflow']).first_state or
Workflow.objects.get(pk=config['motions_workflow']).state_set.all()[0])
Workflow.objects.get(pk=config['motions_workflow']).states.all()[0])
self.set_state(new_state)
def get_agenda_title(self):
@ -729,14 +730,18 @@ class State(RESTModelMixin, models.Model):
action_word = models.CharField(max_length=255)
"""An alternative string to be used for a button to switch to this state."""
workflow = models.ForeignKey('Workflow')
workflow = models.ForeignKey('Workflow', related_name='states')
"""A many-to-one relation to a workflow."""
next_states = models.ManyToManyField('self', symmetrical=False)
"""A many-to-many relation to all states, that can be choosen from this state."""
icon = models.CharField(max_length=255)
"""A string representing the url to the icon-image."""
css_class = models.CharField(max_length=255, default='primary')
"""
A css class string for showing the state name in a coloured label based on bootstrap,
e.g. 'danger' (red), 'success' (green), 'warning' (yellow), 'default' (grey).
Default value is 'primary' (blue).
"""
required_permission_to_see = models.CharField(max_length=255, blank=True)
"""

View File

@ -1,7 +1,6 @@
from django.db import transaction
from django.utils.translation import ugettext as _
from openslides.core.config import config
from openslides.utils.rest_api import (
CharField,
DictField,
@ -50,7 +49,7 @@ class StateSerializer(ModelSerializer):
'id',
'name',
'action_word',
'icon',
'css_class',
'required_permission_to_see',
'allow_support',
'allow_create_poll',
@ -58,28 +57,37 @@ class StateSerializer(ModelSerializer):
'versioning',
'leave_old_version_active',
'dont_set_identifier',
'next_states',)
'next_states',
'workflow')
class WorkflowSerializer(ModelSerializer):
"""
Serializer for motion.models.Workflow objects.
"""
state_set = StateSerializer(many=True, read_only=True)
states = StateSerializer(many=True, read_only=True)
first_state = PrimaryKeyRelatedField(read_only=True)
class Meta:
model = Workflow
fields = ('id', 'name', 'state_set', 'first_state',)
fields = ('id', 'name', 'states', 'first_state',)
class MotionLogSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionLog objects.
"""
message = SerializerMethodField()
class Meta:
model = MotionLog
fields = ('message_list', 'person', 'time',)
fields = ('message_list', 'person', 'time', 'message',)
def get_message(self, obj):
"""
Concats the message parts to one string. Useful for smart template code.
"""
return str(obj)
class MotionPollSerializer(ModelSerializer):
@ -97,6 +105,7 @@ class MotionPollSerializer(ModelSerializer):
model = MotionPoll
fields = (
'id',
'motion',
'yes',
'no',
'abstain',
@ -179,11 +188,14 @@ class MotionSerializer(ModelSerializer):
log_messages = MotionLogSerializer(many=True, read_only=True)
polls = MotionPollSerializer(many=True, read_only=True)
reason = CharField(allow_blank=True, required=False, write_only=True)
state = StateSerializer(read_only=True)
text = CharField(write_only=True)
title = CharField(max_length=255, write_only=True)
versions = MotionVersionSerializer(many=True, read_only=True)
workflow = IntegerField(min_value=1, required=False, validators=[validate_workflow_field])
workflow_id = IntegerField(
min_value=1,
required=False,
validators=[validate_workflow_field],
write_only=True)
class Meta:
model = Motion
@ -200,13 +212,13 @@ class MotionSerializer(ModelSerializer):
'submitters',
'supporters',
'state',
'workflow',
'workflow_id',
'tags',
'attachments',
'polls',
'agenda_item_id',
'log_messages',)
read_only_fields = ('parent',) # Some other fields are also read_only. See definitions above.
read_only_fields = ('parent', 'state') # Some other fields are also read_only. See definitions above.
@transaction.atomic
def create(self, validated_data):
@ -219,7 +231,7 @@ class MotionSerializer(ModelSerializer):
motion.reason = validated_data.get('reason', '')
motion.identifier = validated_data.get('identifier')
motion.category = validated_data.get('category')
motion.reset_state(validated_data.get('workflow', int(config['motions_workflow'])))
motion.reset_state(validated_data.get('workflow_id'))
motion.save()
if validated_data.get('submitters'):
motion.submitters.add(*validated_data['submitters'])
@ -241,9 +253,9 @@ class MotionSerializer(ModelSerializer):
setattr(motion, key, validated_data[key])
# Workflow.
workflow = validated_data.get('workflow')
if workflow is not None and workflow != motion.workflow:
motion.reset_state(workflow)
workflow_id = validated_data.get('workflow_id')
if workflow_id is not None and workflow_id != motion.workflow:
motion.reset_state(workflow_id)
# Decide if a new version is saved to the database.
if (motion.state.versioning and

View File

@ -189,13 +189,16 @@ def create_builtin_workflows(sender, **kwargs):
allow_submitter_edit=True)
state_1_2 = State.objects.create(name=ugettext_noop('accepted'),
workflow=workflow_1,
action_word=ugettext_noop('Accept'))
action_word=ugettext_noop('Accept'),
css_class='success')
state_1_3 = State.objects.create(name=ugettext_noop('rejected'),
workflow=workflow_1,
action_word=ugettext_noop('Reject'))
action_word=ugettext_noop('Reject'),
css_class='danger')
state_1_4 = State.objects.create(name=ugettext_noop('not decided'),
workflow=workflow_1,
action_word=ugettext_noop('Do not decide'))
action_word=ugettext_noop('Do not decide'),
css_class='default')
state_1_1.next_states.add(state_1_2, state_1_3, state_1_4)
workflow_1.first_state = state_1_1
workflow_1.save()
@ -216,35 +219,43 @@ def create_builtin_workflows(sender, **kwargs):
state_2_3 = State.objects.create(name=ugettext_noop('accepted'),
workflow=workflow_2,
action_word=ugettext_noop('Accept'),
versioning=True)
versioning=True,
css_class='success')
state_2_4 = State.objects.create(name=ugettext_noop('rejected'),
workflow=workflow_2,
action_word=ugettext_noop('Reject'),
versioning=True)
versioning=True,
css_class='danger')
state_2_5 = State.objects.create(name=ugettext_noop('withdrawed'),
workflow=workflow_2,
action_word=ugettext_noop('Withdraw'),
versioning=True)
versioning=True,
css_class='default')
state_2_6 = State.objects.create(name=ugettext_noop('adjourned'),
workflow=workflow_2,
action_word=ugettext_noop('Adjourn'),
versioning=True)
versioning=True,
css_class='default')
state_2_7 = State.objects.create(name=ugettext_noop('not concerned'),
workflow=workflow_2,
action_word=ugettext_noop('Do not concern'),
versioning=True)
versioning=True,
css_class='default')
state_2_8 = State.objects.create(name=ugettext_noop('commited a bill'),
workflow=workflow_2,
action_word=ugettext_noop('Commit a bill'),
versioning=True)
versioning=True,
css_class='default')
state_2_9 = State.objects.create(name=ugettext_noop('needs review'),
workflow=workflow_2,
action_word=ugettext_noop('Needs review'),
versioning=True)
versioning=True,
css_class='default')
state_2_10 = State.objects.create(name=ugettext_noop('rejected (not authorized)'),
workflow=workflow_2,
action_word=ugettext_noop('Reject (not authorized)'),
versioning=True)
versioning=True,
css_class='default')
state_2_1.next_states.add(state_2_2, state_2_5, state_2_10)
state_2_2.next_states.add(state_2_3, state_2_4, state_2_5, state_2_6, state_2_7, state_2_8, state_2_9)
workflow_2.first_state = state_2_1

View File

@ -2,10 +2,163 @@
angular.module('OpenSlidesApp.motions', [])
.factory('Motion', [
.factory('WorkflowState', [
'DS',
function (DS) {
return DS.defineResource({
name: 'motions/workflowstate',
methods: {
getNextStates: function () {
var states = [];
_.forEach(this.next_states_id, function (stateId) {
states.push(DS.get('motions/workflowstate', stateId));
})
return states;
}
}
})
}
])
.factory('Workflow', [
'DS',
'jsDataModel',
function(DS, jsDataModel) {
'WorkflowState',
function (DS, jsDataModel, WorkflowState) {
return DS.defineResource({
name: 'motions/workflow',
useClass: jsDataModel,
relations: {
hasMany: {
'motions/workflowstate': {
localField: 'states',
foreignKey: 'workflow_id',
}
}
}
})
}
])
// Load all MotionWorkflows at startup
.run([
'Workflow',
function (Workflow) {
Workflow.findAll();
}
])
.factory('MotionPoll', [
'DS',
'Config',
function (DS, Config) {
return DS.defineResource({
name: 'motions/motionpoll',
relations: {
belongsTo: {
'motions/motion': {
localField: 'motion',
localKey: 'motion_id',
}
}
},
methods: {
getYesPercent: function (valueOnly) {
var config = Config.get('motions_poll_100_percent_base').value;
var returnvalue;
if (config == "WITHOUT_INVALID" && this.votesvalid > 0 && this.yes >= 0) {
returnvalue = Math.round(this.yes * 100 / this.votesvalid * 10) / 10;
} else if (config == "WITH_INVALID" && this.votescast > 0 && this.yes >= 0) {
returnvalue = Math.round(this.yes * 100 / (this.votescast) * 10) / 10;
} else {
returnvalue = null;
}
if (!valueOnly && returnvalue != null) {
returnvalue = "(" + returnvalue + "%)";
}
return returnvalue;
},
getNoPercent: function (valueOnly) {
var config = Config.get('motions_poll_100_percent_base').value;
var returnvalue;
if (config == "WITHOUT_INVALID" && this.votesvalid > 0 && this.no >= 0) {
returnvalue = Math.round(this.no * 100 / this.votesvalid * 10) / 10;
} else if (config == "WITH_INVALID" && this.votescast > 0 && this.no >= 0) {
returnvalue = Math.round(this.no * 100 / (this.votescast) * 10) / 10;
} else {
returnvalue = null;
}
if (!valueOnly && returnvalue != null) {
returnvalue = "(" + returnvalue + "%)";
}
return returnvalue;
},
getAbstainPercent: function (valueOnly) {
var config = Config.get('motions_poll_100_percent_base').value;
var returnvalue;
if (config == "WITHOUT_INVALID" && this.votesvalid > 0 && this.abstain >= 0) {
returnvalue = Math.round(this.abstain * 100 / this.votesvalid * 10) / 10;
} else if (config == "WITH_INVALID" && this.votescast > 0 && this.abstain >= 0) {
returnvalue = Math.round(this.abstain * 100 / (this.votescast) * 10) / 10;
} else {
returnvalue = null;
}
if (!valueOnly && returnvalue != null) {
returnvalue = "(" + returnvalue + "%)";
}
return returnvalue;
},
getVotesValidPercent: function (valueOnly) {
var config = Config.get('motions_poll_100_percent_base').value;
var returnvalue;
if (config == "WITHOUT_INVALID" && this.votevalid >= 0) {
returnvalue = 100;
} else if (config == "WITH_INVALID" && this.votevalid >= 0) {
returnvalue = Math.round(this.votesvalid * 100 / (this.votescast) * 10) / 10;
} else {
returnvalue = null;
}
if (!valueOnly && returnvalue != null) {
returnvalue = "(" + returnvalue + "%)";
}
return returnvalue;
},
getVotesInvalidPercent: function (valueOnly) {
var config = Config.get('motions_poll_100_percent_base').value;
var returnvalue;
if (config == "WITH_INVALID" && this.voteinvalid >= 0) {
returnvalue = Math.round(this.votesinvalid * 100 / (this.votescast) * 10) / 10;
} else {
returnvalue = null;
}
if (!valueOnly && returnvalue != null) {
returnvalue = "(" + returnvalue + "%)";
}
return returnvalue;
},
getVotesCastPercent: function (valueOnly) {
var config = Config.get('motions_poll_100_percent_base').value;
var returnvalue;
if (config == "WITH_INVALID" && this.votecast >= 0) {
returnvalue = 100;
} else {
returnvalue = null;
}
if (!valueOnly && returnvalue != null) {
returnvalue = "(" + returnvalue + "%)";
}
return returnvalue;
}
}
});
}
])
.factory('Motion', [
'DS',
'MotionPoll',
'jsDataModel',
function(DS, MotionPoll, jsDataModel) {
var name = 'motions/motion'
return DS.defineResource({
name: name,
@ -60,6 +213,10 @@ angular.module('OpenSlidesApp.motions', [])
localField: 'tags',
localKeys: 'tags_id',
},
'mediafiles/mediafile': {
localField: 'attachments',
localKeys: 'attachments_id',
},
'users/user': [
{
localField: 'submitters',
@ -70,25 +227,167 @@ angular.module('OpenSlidesApp.motions', [])
localKeys: 'supporters_id',
}
],
'motions/motionpoll': {
localField: 'polls',
foreignKey: 'motion_id',
}
},
hasOne: {
'motions/workflowstate': {
localField: 'state',
localKey: 'state_id',
}
}
}
});
}
])
// Provide generic motion form fields for create and update view
.factory('MotionFormFieldFactory', [
'gettext',
'Category',
'Config',
'Mediafile',
'Tag',
'User',
'Workflow',
function (gettext, Category, Config, Mediafile, Tag, User, Workflow) {
return {
getFormFields: function () {
return [
{
key: 'identifier',
type: 'input',
templateOptions: {
label: gettext('Identifier')
},
hide: true
},
{
key: 'submitters_id',
type: 'ui-select-multiple',
templateOptions: {
label: gettext('Submitters'),
optionsAttr: 'bs-options',
options: User.getAll(),
ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search',
valueProp: 'id',
labelProp: 'full_name',
placeholder: gettext('Select or search a submitter...')
}
},
{
key: 'title',
type: 'input',
templateOptions: {
label: gettext('Title'),
required: true
}
},
{
key: 'text',
type: 'textarea',
templateOptions: {
label: gettext('Text'),
required: true
}
},
{
key: 'reason',
type: 'textarea',
templateOptions: {
label: gettext('Reason')
}
},
{
key: 'more',
type: 'checkbox',
templateOptions: {
label: gettext('Show extended fields')
}
},
{
key: 'attachments_id',
type: 'ui-select-multiple',
templateOptions: {
label: gettext('Attachment'),
optionsAttr: 'bs-options',
options: Mediafile.getAll(),
ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search',
valueProp: 'id',
labelProp: 'title_or_filename',
placeholder: gettext('Select or search an attachment...')
},
hideExpression: '!model.more'
},
{
key: 'category_id',
type: 'ui-select-single',
templateOptions: {
label: gettext('Category'),
optionsAttr: 'bs-options',
options: Category.getAll(),
ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search',
valueProp: 'id',
labelProp: 'name',
placeholder: gettext('Select or search a category...')
},
hideExpression: '!model.more'
},
{
key: 'tags_id',
type: 'ui-select-multiple',
templateOptions: {
label: gettext('Tags'),
optionsAttr: 'bs-options',
options: Tag.getAll(),
ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search',
valueProp: 'id',
labelProp: 'name',
placeholder: gettext('Select or search a tag...')
},
hideExpression: '!model.more'
},
{
key: 'supporters_id',
type: 'ui-select-multiple',
templateOptions: {
label: gettext('Supporters'),
optionsAttr: 'bs-options',
options: User.getAll(),
ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search',
valueProp: 'id',
labelProp: 'full_name',
placeholder: gettext('Select or search a supporter...')
},
hideExpression: '!model.more'
},
{
key: 'workflow_id',
type: 'ui-select-single',
templateOptions: {
label: gettext('Workflow'),
optionsAttr: 'bs-options',
options: Workflow.getAll(),
ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search',
valueProp: 'id',
labelProp: 'name',
placeholder: gettext('Select or search a workflow...')
},
hideExpression: '!model.more',
}];
}
}
}
])
.factory('Category', ['DS', function(DS) {
return DS.defineResource({
name: 'motions/category',
});
}])
.factory('Workflow', ['DS', function(DS) {
return DS.defineResource({
name: 'motions/workflow',
});
}])
.run(['Motion', 'Category', 'Workflow', function(Motion, Category, Workflow) {}]);
.run(['Motion', 'Category', function(Motion, Category) {}]);
angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
@ -125,6 +424,9 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
categories: function(Category) {
return Category.findAll();
},
tags: function(Tag) {
return Tag.findAll();
},
users: function(User) {
return User.findAll();
}
@ -132,15 +434,9 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
})
.state('motions.motion.create', {
resolve: {
items: function(Agenda) {
return Agenda.findAll();
},
categories: function(Category) {
return Category.findAll();
},
workflows: function(Workflow) {
return Workflow.findAll();
},
tags: function(Tag) {
return Tag.findAll();
},
@ -149,6 +445,9 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
},
mediafiles: function(Mediafile) {
return Mediafile.findAll();
},
workflows: function(Workflow) {
return Workflow.findAll();
}
}
})
@ -163,9 +462,12 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
users: function(User) {
return User.findAll();
},
mediafiles: function(Mediafile) {
return Mediafile.findAll();
},
tags: function(Tag) {
return Tag.findAll();
},
}
}
})
.state('motions.motion.detail.update', {
@ -173,15 +475,9 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
'@motions.motion': {}
},
resolve: {
items: function(Agenda) {
return Agenda.findAll();
},
categories: function(Category) {
return Category.findAll();
},
workflows: function(Workflow) {
return Workflow.findAll();
},
tags: function(Tag) {
return Tag.findAll();
},
@ -190,6 +486,9 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
},
mediafiles: function(Mediafile) {
return Mediafile.findAll();
},
workflows: function(Workflow) {
return Workflow.findAll();
}
}
})
@ -225,92 +524,283 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
})
})
.controller('MotionListCtrl', function($scope, Motion, Category, User) {
Motion.bindAll({}, $scope, 'motions');
Category.bindAll({}, $scope, 'categories');
User.bindAll({}, $scope, 'users');
// setup table sorting
$scope.sortColumn = 'identifier';
$scope.filterPresent = '';
$scope.reverse = false;
// function to sort by clicked column
$scope.toggleSort = function ( column ) {
if ( $scope.sortColumn === column ) {
$scope.reverse = !$scope.reverse;
}
$scope.sortColumn = column;
};
// save changed motion
$scope.save = function (motion) {
Motion.save(motion);
};
// delete selected motion
$scope.delete = function (motion) {
Motion.destroy(motion.id);
};
})
.controller('MotionDetailCtrl', function($scope, Motion, Category, User, motion) {
Motion.bindOne(motion.id, $scope, 'motion');
Category.bindAll({}, $scope, 'categories');
User.bindAll({}, $scope, 'users');
Motion.loadRelations(motion);
})
.controller('MotionCreateCtrl',
function($scope, $state, $http, Motion, Agenda, User, Category, Workflow, Tag, Mediafile) {
Agenda.bindAll({}, $scope, 'items');
User.bindAll({}, $scope, 'users');
Category.bindAll({}, $scope, 'categories');
Workflow.bindAll({}, $scope, 'workflows');
Tag.bindAll({}, $scope, 'tags');
Mediafile.bindAll({}, $scope, 'mediafiles');
$scope.motion = {};
$scope.save = function (motion) {
Motion.create(motion).then(
function(success) {
$state.go('motions.motion.list');
}
);
};
})
.controller('MotionUpdateCtrl', [
.controller('MotionListCtrl', [
'$scope',
'$state',
'Motion',
'Category',
'Tag',
'Workflow',
'User',
function($scope, $state, Motion, Category, Tag, Workflow, User) {
Motion.bindAll({}, $scope, 'motions');
Category.bindAll({}, $scope, 'categories');
Tag.bindAll({}, $scope, 'tags');
Workflow.bindAll({}, $scope, 'workflows');
User.bindAll({}, $scope, 'users');
$scope.alert = {};
// setup table sorting
$scope.sortColumn = 'identifier';
$scope.filterPresent = '';
$scope.reverse = false;
// function to sort by clicked column
$scope.toggleSort = function ( column ) {
if ( $scope.sortColumn === column ) {
$scope.reverse = !$scope.reverse;
}
$scope.sortColumn = column;
};
// collect all states of all workflows
// TODO: regard workflows only which are used by motions
$scope.states = [];
var workflows = Workflow.getAll();
angular.forEach(workflows, function (workflow) {
if (workflows.length > 1) {
var wf = {}
wf.name = "# "+workflow.name;
$scope.states.push(wf);
}
angular.forEach(workflow.states, function (state) {
$scope.states.push(state);
});
});
// hover edit actions
$scope.hoverIn = function () {
$scope.showEditActions = true;
};
$scope.hoverOut = function () {
$scope.showEditActions = false;
};
// save changed motion
$scope.update = function (motion) {
// get (unchanged) values from latest version for update method
motion.title = motion.getTitle(-1);
motion.text = motion.getText(-1);
motion.reason = motion.getReason(-1);
Motion.save(motion).then(
function(success) {
motion.quickEdit = false;
$scope.alert.show = false;
},
function(error){
var message = '';
for (var e in error.data) {
message += e + ': ' + error.data[e] + ' ';
}
$scope.alert = { type: 'danger', msg: message, show: true };
});
};
// *** delete mode functions ***
$scope.isDeleteMode = false;
// check all checkboxes
$scope.checkAll = function () {
angular.forEach($scope.motions, function (motion) {
motion.selected = $scope.selectedAll;
});
};
// uncheck all checkboxes if isDeleteMode is closed
$scope.uncheckAll = function () {
if (!$scope.isDeleteMode) {
$scope.selectedAll = false;
angular.forEach($scope.motions, function (motion) {
motion.selected = false;
});
}
};
// delete selected motions
$scope.delete = function () {
angular.forEach($scope.motions, function (motion) {
if (motion.selected)
Motion.destroy(motion.id);
});
$scope.isDeleteMode = false;
$scope.uncheckAll();
};
// delete single motion
$scope.deleteSingleMotion = function (motion) {
Motion.destroy(motion.id);
};
}
])
.controller('MotionDetailCtrl', [
'$scope',
'$http',
'Motion',
'Agenda',
'User',
'Category',
'Workflow',
'Tag',
'Mediafile',
'Tag',
'User',
'Workflow',
'motion',
function ($scope, $state, $http, Motion, Agenda, User, Category, Workflow, Tag, Mediafile, motion) {
Agenda.bindAll({}, $scope, 'items');
User.bindAll({}, $scope, 'users');
function($scope, $http, Motion, Category, Mediafile, Tag, User, Workflow, motion) {
Motion.bindOne(motion.id, $scope, 'motion');
Category.bindAll({}, $scope, 'categories');
Workflow.bindAll({}, $scope, 'workflows');
Tag.bindAll({}, $scope, 'tags');
Mediafile.bindAll({}, $scope, 'mediafiles');
Tag.bindAll({}, $scope, 'tags');
User.bindAll({}, $scope, 'users');
Workflow.bindAll({}, $scope, 'workflows');
Motion.loadRelations(motion, 'agenda_item');
// TODO: make 'motion.attachments' useable and itteratable in template
// Motion.loadRelations(motion, 'attachments');
$scope.motion = motion;
// get latest version for edit
$scope.motion.title = $scope.motion.getTitle(-1);
$scope.motion.text = $scope.motion.getText(-1);
$scope.motion.reason = $scope.motion.getReason(-1);
$scope.alert = {};
$scope.isCollapsed = true;
$scope.support = function () {
$http.post('/rest/motions/motion/' + motion.id + '/support/');
}
$scope.unsupport = function () {
$http.delete('/rest/motions/motion/' + motion.id + '/support/');
console.log(motion);
}
$scope.update_state = function (state) {
$http.put('/rest/motions/motion/' + motion.id + '/set_state/', {'state': state.id});
}
$scope.reset_state = function (state_id) {
$http.put('/rest/motions/motion/' + motion.id + '/set_state/', {});
}
$scope.create_poll = function () {
$http.post('/rest/motions/motion/' + motion.id + '/create_poll/', {})
.success(function(data){
$scope.alert.show = false;
})
.error(function(data){
$scope.alert = { type: 'danger', msg: data.detail, show: true };
});
}
$scope.delete_poll = function (poll) {
poll.DSDestroy();
}
$scope.update_poll = function (poll) {
poll.DSUpdate({
motion_id: motion.id,
votes: {"Yes": poll.yes, "No": poll.no, "Abstain": poll.abstain},
votesvalid: poll.votesvalid,
votesinvalid: poll.votesinvalid,
votescast: poll.votescast
})
.then(function(success) {
$scope.alert.show = false;
poll.isEditMode = false;
})
.catch(function(error) {
var message = '';
for (var e in error.data) {
message += e + ': ' + error.data[e] + ' ';
}
$scope.alert = { type: 'danger', msg: message, show: true };
});
}
}
])
.controller('MotionCreateCtrl', [
'$scope',
'$state',
'gettext',
'Motion',
'MotionFormFieldFactory',
'Category',
'Config',
'Mediafile',
'Tag',
'User',
'Workflow',
function($scope, $state, gettext, Motion, MotionFormFieldFactory, Category, Config, Mediafile, Tag, User, Workflow) {
Category.bindAll({}, $scope, 'categories');
Mediafile.bindAll({}, $scope, 'mediafiles');
Tag.bindAll({}, $scope, 'tags');
User.bindAll({}, $scope, 'users');
Workflow.bindAll({}, $scope, 'workflows');
// get all form fields
$scope.formFields = MotionFormFieldFactory.getFormFields();
// override default values for create form
for (var i = 0; i < $scope.formFields.length; i++) {
if ($scope.formFields[i].key == "identifier") {
$scope.formFields[i].hide = true;
}
if ($scope.formFields[i].key == "workflow_id") {
// preselect default workflow
$scope.formFields[i].defaultValue = Config.get('motions_workflow').value;
}
}
$scope.save = function (motion) {
Motion.save(motion).then(
Motion.create(motion).then(
function(success) {
$state.go('motions.motion.list');
}
);
};
}
])
.controller('MotionUpdateCtrl', [
'$scope',
'$state',
'gettext',
'Motion',
'Category',
'Config',
'Mediafile',
'MotionFormFieldFactory',
'Tag',
'User',
'Workflow',
'motion',
function($scope, $state, gettext, Motion, Category, Config, Mediafile, MotionFormFieldFactory, Tag, User, Workflow, motion) {
Category.bindAll({}, $scope, 'categories');
Mediafile.bindAll({}, $scope, 'mediafiles');
Tag.bindAll({}, $scope, 'tags');
User.bindAll({}, $scope, 'users');
Workflow.bindAll({}, $scope, 'workflows');
// set initial values for form model
$scope.model = motion;
$scope.model.more = false;
// get all form fields
$scope.formFields = MotionFormFieldFactory.getFormFields();
// override default values for update form
for (var i = 0; i < $scope.formFields.length; i++) {
if ($scope.formFields[i].key == "identifier") {
// show identifier field
$scope.formFields[i].hide = false;
}
if ($scope.formFields[i].key == "title") {
// get title of latest version
$scope.formFields[i].defaultValue = motion.getTitle(-1);
}
if ($scope.formFields[i].key == "text") {
// get text of latest version
$scope.formFields[i].defaultValue = motion.getText(-1);
}
if ($scope.formFields[i].key == "reason") {
// get reason of latest version
$scope.formFields[i].defaultValue = motion.getReason(-1);
}
if ($scope.formFields[i].key == "workflow_id") {
// get saved workflow id from state
$scope.formFields[i].defaultValue = motion.state.workflow_id;
}
}
// save form
$scope.save = function (model) {
Motion.save(model)
.then(function(success) {
$state.go('motions.motion.detail', {id: motion.id});
})
.catch(function(fallback) {
console.log(fallback);
});
};
}
])

View File

@ -5,9 +5,8 @@
<span ng-if="motion.versions.length > 1" >| Version {{ motion.active_version }}</span>
</small>
</h1>
{{ motion.tags }}
{{ motion.agenda_item }}
<!-- TODO: show list of speakers controls for { motion.agenda_item } -->
<div id="submenu">
<a ui-sref="motions.motion.list" class="btn btn-sm btn-default">
@ -26,7 +25,7 @@
<i class="fa fa-video-camera"></i>
</a>
<!-- edit -->
<a ui-sref="motions.motion.detail.update({id: motion.id })" os-perms="motions.can_manage"
<a ng-if="motion.allowed_actions.update" ui-sref="motions.motion.detail.update({id: motion.id })"
class="btn btn-default btn-sm"
title="{{ 'Edit' | translate}}">
<i class="fa fa-pencil"></i>
@ -36,24 +35,212 @@
<div class="row">
<div class="col-sm-8">
<h3 translate>Text</h3>
<div class="white-space-pre-line">{{ motion.getText() }}</div>
<div class="white-space-pre-line" ng-bind-html="motion.getText()"></div>
<h3 translate>Reason</h3>
<div class="white-space-pre-line">{{ motion.getReason() }}</div>
<!-- reason -->
<div ng-if="motion.getReason() != ''">
<h3 translate>Reason</h3>
<div class="white-space-pre-line" ng-bind-html="motion.getReason()"></div>
</div>
<!-- attachments
TODO: make 'motion.attachments' useable and itteratable in template
<div ng-if="motion.attachments">
<h3 translate>Attachments</h3>
</div>
-->
<!-- log -->
<button type="button" class="btn btn-default spacer" ng-click="isCollapsed = !isCollapsed" translate>
Show log
</button>
<div uib-collapse="isCollapsed">
<div class="well well-sm">
<ul class="list-unstyled">
<li ng-repeat="message in motion.log_messages">
<small>{{ message.message }}</small>
</div>
</div>
</div>
<div class="col-sm-4">
<div class="well">
<!-- submitters -->
<h3 translate>Submitters</h3>
<div ng-repeat="submitter in motion.submitters">
{{ submitter.get_full_name() }}<br>
</div>
<!-- supporters -->
<div ng-if="config('motions_min_supporters') > 0">
<h3 translate>Supporters</h3>
<ol>
<li ng-repeat="supporters in motion.supporters">
{{ supporters.get_full_name() }}
</ol>
<!-- support button -->
<button ng-if="motion.allowed_actions.support" ng-click="support()" class="btn btn-primary btn-sm">
<i class="fa fa-heart"></i>
<translate>Support motion</translate>
</button>
<!-- unsupport button -->
<button ng-if="motion.allowed_actions.unsupport" ng-click="unsupport()" class="btn btn-default btn-sm">
<i class="fa fa-heart-o"></i>
<translate>Unsupport motion</translate>
</button>
</div>
<h3 translate>State</h3>
<span class="label" ng-class="'label-'+motion.state.css_class">
{{ motion.state.name | translate }}
</span>
<div ng-if="motion.allowed_actions.change_state" class="spacer">
<div class="btn-group-vertical spacer" role="group">
<button ng-repeat="state in motion.state.getNextStates()" ng-click="update_state(state)"
class="btn btn-default btn-sm">
{{state.action_word}}
</button>
<button ng-if="motion.allowed_actions.reset_state" ng-click="reset_state()"
class="btn btn-danger btn-xs">
<i class="fa fa-exclamation-triangle"></i>
<translate>Reset state</translate>
</button>
</div>
</div>
<h3 translate>Voting result</h3>
<ol class="slimlist">
<li ng-repeat="poll in motion.polls" class="spacer">
<translate>Vote</translate>
<button os-perms-lite="motions.can_manage" ng-click="poll.isEditMode=true;"
class="btn btn-default btn-xs">
<i class="fa fa-pencil"></i>
</button>
<button os-perms="motions.can_manage" ng-click="delete_poll(poll)"
class="btn btn-default btn-xs">
<i class="fa fa-times"></i>
</button>
<div ng-show="poll.isEditMode" class="spacer">
<form>
<p>
<translate>Special values</translate>:<br>
<span class="badge badge-success">-1</span> = <translate>majority</translate><br>
<span class="badge">-2</span> = <translate>undocumented</translate>
</p>
<alert ng-show="alert.show" type="{{ alert.type }}" ng-click="alert={}" close="alert={}">
{{alert.msg}}
</alert>
<!-- yes -->
<div class="input-group col-sm-8 spacer">
<div class="input-group-addon" title="{{ 'Yes' | translate }}"><i class="fa fa-thumbs-up"></i></div>
<input type="number" ng-model="poll.yes" class="form-control input-sm" placeholder="{{ 'Yes' | translate }}">
</div>
<!-- no -->
<div class="input-group col-sm-8">
<div class="input-group-addon" title="{{ 'No' | translate }}"><i class="fa fa-thumbs-down"></i></div>
<input type="number" ng-model="poll.no" class="form-control input-sm" placeholder="{{ 'No' | translate }}">
</div>
<!-- abstain -->
<div class="input-group col-sm-8">
<div class="input-group-addon" title="{{ 'Abstain' | translate }}"><b>&empty;</b></div>
<input type="number" ng-model="poll.abstain" class="form-control input-sm" placeholder="{{ 'Abstain' | translate }}">
</div>
<!-- valid votes -->
<div class="input-group col-sm-8 spacer">
<div class="input-group-addon" title="{{ 'Valid votes' | translate }}"><i class="fa fa-check"></i></div>
<input type="number" ng-model="poll.votesvalid" class="form-control input-sm" placeholder="{{ 'Valid votes' | translate }}">
</div>
<!-- invalid votes -->
<div class="input-group col-sm-8">
<div class="input-group-addon" title="{{ 'Invalid votes' | translate }}"><i class="fa fa-ban"></i></div>
<input type="number" ng-model="poll.votesinvalid" class="form-control input-sm" placeholder="{{ 'Invalid votes' | translate }}">
</div>
<!-- votes cast -->
<div class="input-group col-sm-8 spacer">
<div class="input-group-addon" title="{{ 'Votes cast' | translate }}"><b>&sum;</b></div>
<input type="number" ng-model="poll.votescast" class="form-control input-sm" placeholder="{{ 'Votes cast' | translate }}">
</div>
<!-- buttons -->
<div class="spacer">
<button type="submit" ng-click="update_poll(poll)" class="btn btn-primary" translate>
Save
</button>
<button ng-click="poll.isEditMode=false;" class="btn btn-default" translate>
Cancel
</button>
</div>
</form>
</div>
<div ng-show="!poll.isEditMode && poll.yes >= -2">
<!-- yes -->
<div class="result_label">
<i class="fa fa-thumbs-up"></i>
<translate>Yes</translate>:
</div>
<div ng-if="poll.getYesPercent(true)">
<uib-progressbar value="poll.getYesPercent(true)" type="success"></uib-progressbar>
</div>
<div class="result_value">{{ poll.yes }} {{ poll.getYesPercent() }}</div>
<!-- no -->
<div class="result_label">
<i class="fa fa-thumbs-down"></i>
<translate>No</translate>:
</div>
<div ng-if="poll.getNoPercent(true)">
<uib-progressbar value="poll.getNoPercent(true)" type="danger"></uib-progressbar>
</div>
<div class="result_value">{{ poll.no }} {{ poll.getNoPercent() }}</div>
<!-- abstain -->
<div class="result_label">
<b>&empty;</b>
<translate>Abstain</translate>:
</div>
<div ng-if="poll.getAbstainPercent(true)">
<uib-progressbar value="poll.getAbstainPercent(true)" type="warning"></uib-progressbar>
</div>
<div class="result_value">{{ poll.abstain }} {{ poll.getAbstainPercent() }}</div>
<hr class="smallhr" ng-if="poll.votesvalid || poll.votesinvalid">
<!-- valid votes -->
<div ng-if="poll.votesvalid">
<div class="result_label">
<i class="fa fa-check"></i>
<translate>Valid votes</translate>:
</div>
<div class="result_value">{{ poll.votesvalid }} {{ poll.getVotesValidPercent() }}</div>
</div>
<!-- invalid votes -->
<div ng-if="poll.votesinvalid">
<div class="result_label">
<i class="fa fa-ban"></i>
<translate>Invalid votes</translate>:
</div>
<div class="result_value">{{ poll.votesinvalid }} {{ poll.getVotesInvalidPercent() }}</div>
</div>
<hr class="smallhr" ng-if="poll.votescast">
<!-- votes cast -->
<div ng-if="poll.votescast">
<div class="result_label">
<b>&sum;</b>
<translate>Votes cast</translate>:
</div>
<div class="result_value">{{ poll.votescast }} {{ poll.getVotesCastPercent() }}</div>
</div>
</div>
</ol>
<button ng-if="motion.allowed_actions.create_poll" ng-click="create_poll()" class="btn btn-default btn-sm">
<i class="fa fa-bar-chart fa-lg"></i>
<translate>New poll</translate>
</button>
<h3 translate>Category</h3>
{{ motion.category.name }}</a>
<h3 translate>Voting result</h3>
-
<h3 translate>Tags</h3>
<p ng-repeat="tag in motion.tags">
<span class="label label-default spacer-top">
{{ tag.name }}
</span>
</p>
</div>
</div>
</div>

View File

@ -8,88 +8,14 @@
</a>
</div>
<form name="motionForm">
<div ng-if="motion.id" class="form-group">
<label for="inputIdentifier" translate>Identifier</label>
<input type="text" ng-model="motion.identifier" class="form-control" name="inputIdentifier">
</div>
<div class="form-group">
<label for="selectItem" translate>Agenda item</label>
<select ng-options="item.id as item.get_title for item in items"
ng-model="motion.item" class="form-control" name="selectItem">
</select>
</div>
<div class="form-group">
<label for="selectSubmitter" translate>Submitter</label>
<select multiple size="3" ng-options="user.id as user.get_short_name() for user in users"
ng-model="motion.submitters_id" class="form-control" name="selectSubmitter" required>
</select>
<!-- TODO: use modern ui-select component with more information per user
<ui-select multipe ng-model="motion.submitter" theme="bootstrap" name="selectSubmitter">
<ui-select-match placeholder="{{ 'Select or search a participant...' | translate }}">
{{ $select.selected.get_short_name() }}
</ui-select-match>
<ui-select-choices repeat="user.id as user in users | filter: $select.search">
<div ng-bind-html="user.get_short_name() | highlight: $select.search"></div>
<small ng-bind-html="user.structure_level | highlight: $select.search"></small>
</ui-select-choices>
</ui-select>-->
</div>
<div class="form-group">
<label for="inputName" translate>Title</label>
<input type="text" ng-model="motion.title" class="form-control" name="inputTitle" required>
</div>
<div class="form-group">
<label for="textText" translate>Text</label>
<textarea ng-model="motion.text" class="form-control" name="textText" required />
</div>
<div class="form-group">
<label for="textReason" translate>Reason</label>
<textarea ng-model="motion.reason" class="form-control" name="textReason" />
</div>
<div class="form-group">
<label for="selectCategory" translate>Category</label>
<select ng-options="category.id as category.name for category in categories"
ng-model="motion.category_id" class="form-control" name="selectCategory">
</select>
</div>
<div class="form-group">
<label for="selectTags" translate>Tags</label>
<select multiple ng-options="tag.id as tag.name for tag in tags"
ng-model="motion.tags_id" class="form-control" name="selectTags">
</select>
</div>
<div class="form-group">
<label for="selectAttachments" translate>Attachments</label>
<select ng-options="file.id as file.title for file in mediafiles"
ng-model="motion.attachments_id" class="form-control" name="selectAttachments">
</select>
</div>
<!-- TODO: show only if supporters is enabled -->
<div class="form-group">
<label for="selectSupporter" translate>Supporters</label>
<ui-select multiple ng-model="motion.supporters_id" theme="bootstrap" name="selectSupporter">
<ui-select-match placeholder="{{ 'Select or search a participant...' | translate }}">
{{ $select.selected.get_short_name() }}
</ui-select-match>
<ui-select-choices repeat="user.id as user in users | filter: $select.search">
<div ng-bind-html="user.get_short_name() | highlight: $select.search"></div>
<small ng-bind-html="user.structure_level | highlight: $select.search"></small>
</ui-select-choices>
</ui-select>
</div>
<!-- TODO: preselect default workflow -->
<div class="form-group">
<label for="selectWorkflow" translate>Workflow</label>
<select ng-options="workflow.id as workflow.name for workflow in workflows"
ng-model="motion.workflow" class="form-control" name="selectWorkflow">
</select>
</div>
<button type="submit" ng-click="save(motion)" class="btn btn-primary" translate>
Save
</button>
<button ui-sref="motions.motion.list" class="btn btn-default" translate>
Cancel
</button>
<form name="motionForm" ng-submit="save(model)">
<formly-form model="model" fields="formFields">
<button type="submit" ng-disabled="motionForm.$invalid" class="btn btn-primary" translate>
Submit
</button>
<button ui-sref="motions.motion.list" class="btn btn-default" translate>
Cancel
</button>
</formly-form>
</form>

View File

@ -1,7 +1,7 @@
<h1 translate>Motions</h1>
<div id="submenu">
<a ui-sref="motions.motion.create" os-perms="motions.can_manage" class="btn btn-primary btn-sm">
<a ui-sref="motions.motion.create" os-perms="motions.can_create" class="btn btn-primary btn-sm">
<i class="fa fa-plus fa-lg"></i>
<translate>New</translate>
</a>
@ -21,78 +21,182 @@
<div class="row form-group">
<div class="col-sm-8">
<!-- TODO: add filters -->
<form class="form-inline">
<!-- delete mode -->
<div os-perms-lite="motions.can_manage" class="form-group">
<label for="deleteSwitcher" translate>Delete mode</label>
<switch id="deleteSwitcher" ng-model="isDeleteMode" ng-change="uncheckAll()"
on="{{'On'|translate}}" off="{{'Off'|translate}}"
class="green wide form-control">
</switch>
</div>
<!-- delete button -->
<a ng-show="isDeleteMode && (motions|filter:{selected:true}).length > 0"
os-perms="motions.can_manage" ng-click="delete()"
class="btn btn-primary btn-sm form-control">
<i class="fa fa-trash fa-lg"></i>
<translate>Delete selected motions</translate>
</a>
<!-- state filter -->
&nbsp;
<select ng-model="stateFilter" class="form-control" id="stateFilter">
<option value="" translate>--- Select state ---</option>
<option ng-repeat="state in states" value="{{ state.id }}">{{ state.name }}</option>
</select>
</form>
</div>
<div class="col-sm-4">
<input type="text" os-focus-me ng-model="filter.search" class="form-control"
placeholder="{{ 'Filter' | translate}}">
<div class="input-group">
<div class="input-group-addon"><i class="fa fa-filter"></i></div>
<input type="text" os-focus-me ng-model="filter.search" class="form-control"
placeholder="{{ 'Filter' | translate}}">
</div>
</div>
</div>
<table class="table table-striped table-bordered table-hover">
<thead>
<tr>
<!-- TODO: Add motion.agenda_item to rest api -->
<th ng-click="toggleSort('agenda_item')" class="sortable minimum">
<translate>Agenda item</translate>
<i class="pull-right fa" ng-show="sortColumn === 'agenda_item' && header.sortable != false"
ng-class="reverse ? 'fa-sort-desc' : 'fa-sort-asc'">
</i>
<!-- projector column -->
<th ng-show="!isDeleteMode" os-perms="core.can_manage_projector" class="firstColumn">
<!-- delete selection column -->
<th ng-show="isDeleteMode" os-perms-lite="motions.can_manage" class="firstColumn deleteColumn">
<input type="checkbox" ng-model="selectedAll" ng-change="checkAll()">
<th ng-click="toggleSort('identifier')" class="sortable minimum">
<translate>Identifier</translate>
<i class="pull-right fa" ng-show="sortColumn === 'identifier' && header.sortable != false"
ng-class="reverse ? 'fa-sort-desc' : 'fa-sort-asc'">
</i>
<th ng-click="toggleSort('title')" class="sortable">
<th ng-click="toggleSort('getTitle()')" class="sortable">
<translate>Title</translate>
<i class="pull-right fa" ng-show="sortColumn === 'title' && header.sortable != false"
<i class="pull-right fa" ng-show="sortColumn === 'getTitle()' && header.sortable != false"
ng-class="reverse ? 'fa-sort-desc' : 'fa-sort-asc'">
</i>
<th ng-click="toggleSort('submitters')" class="sortable">
<th ng-click="toggleSort('submitters')" class="sortable optional">
<translate>Submitters</translate>
<i class="pull-right fa" ng-show="sortColumn === 'submitters' && header.sortable != false"
ng-class="reverse ? 'fa-sort-desc' : 'fa-sort-asc'">
</i>
<th ng-click="toggleSort('category')" class="sortable">
<th ng-click="toggleSort('category')" class="sortable optional">
<translate>Category</translate>
<i class="pull-right fa" ng-show="sortColumn === 'category' && header.sortable != false"
ng-class="reverse ? 'fa-sort-desc' : 'fa-sort-asc'">
</i>
<th os-perms="motions.can_manage core.can_manage_projector" class="minimum">
<translate>Actions</translate>
<th ng-click="toggleSort('state.name')" class="sortable optional">
<translate>State</translate>
<i class="pull-right fa" ng-show="sortColumn === 'state.name' && header.sortable != false"
ng-class="reverse ? 'fa-sort-desc' : 'fa-sort-asc'">
</i>
<tbody>
<tr ng-repeat="motion in motions | filter: filter.search |
orderBy: sortColumn:reverse" ng-class="{ 'activeline': motion.isProjected() }">
<td> <!--TOOD: add agenda item reference -->
<td><a ui-sref="motions.motion.detail({id: motion.id})">{{ motion.identifier }}</a>
<td><a ui-sref="motions.motion.detail({id: motion.id})">
{{ motion.getTitle() }}
</a>
<td class="optional">
<div ng-repeat="submitter in motion.submitters">
{{ submitter.get_full_name() }}<br>
</div>
<td class="optional">
{{ motion.category.name }}
<td os-perms="motions.can_manage core.can_manage_projector" class="nobr">
<!-- project -->
<a os-perms="core.can_manage_projector" class="btn btn-default btn-sm"
<tr ng-repeat="motion in motions | filter: filter.search | filter: {state_id: stateFilter} |
orderBy: sortColumn:reverse"
class="animate-item"
ng-class="{ 'activeline': motion.isProjected(), 'selected': motion.selected }">
<!-- projector column -->
<td ng-show="!isDeleteMode" os-perms-lite="core.can_manage_projector">
<a class="btn btn-default btn-sm"
ng-class="{ 'btn-primary': motion.isProjected() }"
ng-click="motion.project()"
title="{{ 'Project motion' | translate }}">
<i class="fa fa-video-camera"></i>
</a>
<!-- edit -->
<a ui-sref="motions.motion.detail.update({id: motion.id })" os-perms="motions.can_manage"
class="btn btn-default btn-sm"
title="{{ 'Edit' | translate}}">
<i class="fa fa-pencil"></i>
</a>
<!-- delete -->
<a os-perms="motions.can_manage" class="btn btn-danger btn-sm"
ng-bootbox-confirm="Are you sure you want to delete <b>{{ motion.getTitle() }}</b>?"
ng-bootbox-confirm-action="delete(motion)"
title="{{ 'Delete' | translate }}">
<i class="fa fa-trash-o"></i>
</a>
<!-- delete selection column -->
<td ng-show="isDeleteMode" os-perms="motions.can_manage" class="deleteColumn">
<input type="checkbox" ng-model="motion.selected">
<!-- motion data colums -->
<td ng-if="!motion.quickEdit">{{ motion.identifier }}
<td ng-if="!motion.quickEdit" ng-mouseover="motion.hover=true" ng-mouseleave="motion.hover=false">
<strong><a ui-sref="motions.motion.detail({id: motion.id})">{{ motion.getTitle() }}</a></strong>
<div class="hoverActions" ng-class="{'hiddenDiv': !motion.hover}">
<span ng-if="motion.allowed_actions.update">
<a ui-sref="motions.motion.detail.update({ id: motion.id })" translate>Edit</a> |
</span>
<span ng-if="motion.allowed_actions.update">
<a href="" os-perms="motions.can_manage" ng-click="motion.quickEdit=true" translate>QuickEdit</a> |
</span>
<span ng-if="motion.allowed_actions.delete">
<!-- TODO: translate confirm message -->
<a href="" class="text-danger"
ng-bootbox-confirm="Are you sure you want to delete <b>{{ motion.getTitle() }}</b>?"
ng-bootbox-confirm-action="deleteSingleMotion(motion)" translate>Delete</a>
</span>
</div>
<td ng-if="!motion.quickEdit" class="optional">
<div ng-repeat="submitter in motion.submitters">
{{ submitter.get_full_name() }}<br>
</div>
<td ng-if="!motion.quickEdit" class="optional">
{{ motion.category.name }}
<td ng-if="!motion.quickEdit" class="optional">
<span class="label" ng-class="'label-'+motion.state.css_class">
{{ motion.state.name | translate }}
</span>
<!-- quickEdit columns -->
<td ng-if="motion.quickEdit && motion.allowed_actions.update" colspan="5">
<h4>{{ motion.getTitle() }} <span class="text-muted">&ndash; Quick Edit</span></h4>
<alert ng-show="alert.show" type="{{ alert.type }}" ng-click="alert={}" close="alert={}">
{{alert.msg}}
</alert>
<div class="row">
<div class="col-xs-6">
<label for="inputIdentifier" translate>Identifier</label>
<input type="text" ng-model="motion.identifier" class="form-control input-sm"
name="inputIdentifier">
</div>
<div class="col-xs-6">
<label for="selectCategory" translate>Category</label>
<select ng-options="category.id as category.name for category in categories"
ng-model="motion.category_id" class="form-control" name="selectCategory">
</select>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<label for="selectSubmitter" translate>Submitters</label>
<ui-select multiple ng-model="motion.submitters_id" name="selectSubmitter">
<ui-select-match placeholder="{{ 'Select or search a submitter...' | translate }}">
{{ $item.get_full_name() }}
</ui-select-match>
<ui-select-choices repeat="user.id as user in users | filter: $select.search">
<div ng-bind-html="user.get_full_name() | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>
</div>
<div class="col-xs-6">
<label for="selectTags" translate>Tags</label>
<ui-select multiple ng-model="motion.tags_id">
<ui-select-match placeholder="{{ 'Select or search a tag...' | translate }}">
{{ $item.name }}
</ui-select-match>
<ui-select-choices repeat="tag.id as tag in tags | filter: $select.search">
{{ tag.name }}
</ui-select-choices>
</ui-select>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<div ng-if="config('motions_min_supporters') > 0">
<label for="selectSubmitter" translate>Supporters</label>
<ui-select multiple ng-model="motion.supporters_id">
<ui-select-match placeholder="{{ 'Select or search a supporter...' | translate }}">
{{ $item.get_full_name() }}
</ui-select-match>
<ui-select-choices repeat="user.id as user in users | filter: $select.search">
<div ng-bind-html="user.get_full_name() | highlight: $select.search"></div>
</ui-select-choices>
</ui-select>
</div>
</div>
</div>
<div class="spacer">
<button ng-click="motion.quickEdit=false" class="btn btn-default pull-left" translate>
Cancel
</button> &nbsp;
<button ng-if="motion.allowed_actions.update" ng-click="update(motion)" class="btn btn-primary" translate>
Update
</button>
<a ng-if="motion.allowed_actions.update" ui-sref="motions.motion.detail.update({id: motion.id })"
class="pull-right" translate>Edit motion...</a>
</div>
</table>

View File

@ -66,6 +66,16 @@ class MotionViewSet(ModelViewSet):
result = False
return result
def retrieve(self, request, *args, **kwargs):
"""
Customized view endpoint to retrieve a motion.
Adds the allowed actions for the motion.
"""
response = super().retrieve(request, *args, **kwargs)
response.data['allowed_actions'] = self.get_object().get_allowed_actions(request.user)
return response
def create(self, request, *args, **kwargs):
"""
Customized view endpoint to create a new motion.

View File

@ -9,6 +9,14 @@ angular.module('OpenSlidesApp.users', [])
return DS.defineResource({
name: name,
useClass: jsDataModel,
computed: {
full_name: function () {
return this.get_full_name();
},
short_name: function () {
return this.get_short_name();
},
},
methods: {
getResourceName: function () {
return name;

View File

@ -40,7 +40,7 @@
<div class="col-sm-8">
<form class="form-inline">
<!-- delete mode -->
<div os-perms-lite="agenda.can_manage" class="form-group">
<div os-perms-lite="users.can_manage" class="form-group">
<label for="deleteSwitcher" translate>Delete mode</label>
<switch id="deleteSwitcher" ng-model="isDeleteMode" ng-change="uncheckAll()"
on="{{'On'|translate}}" off="{{'Off'|translate}}"

View File

@ -108,15 +108,17 @@ class CreateMotion(TestCase):
self.assertEqual(motion.tags.get().name, 'test_tag_iRee3kiecoos4rorohth')
def test_with_workflow(self):
self.assertEqual(config['motions_workflow'], '1')
"""
Test to create a motion with a specific workflow.
"""
response = self.client.post(
reverse('motion-list'),
{'title': 'test_title_eemuR5hoo4ru2ahgh5EJ',
'text': 'test_text_ohviePopahPhoili7yee',
'workflow': '2'})
'workflow_id': '2'})
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
motion = Motion.objects.get()
self.assertEqual(motion.state.workflow.pk, 2)
self.assertEqual(Motion.objects.get().state.workflow_id, 2)
class UpdateMotion(TestCase):
@ -141,12 +143,15 @@ class UpdateMotion(TestCase):
self.assertEqual(motion.identifier, 'test_identifier_jieseghohj7OoSah1Ko9')
def test_patch_workflow(self):
self.assertEqual(config['motions_workflow'], '1')
"""
Tests to only update the workflow of a motion.
"""
response = self.client.patch(
reverse('motion-detail', args=[self.motion.pk]),
{'workflow': '2'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
{'workflow_id': '2'})
motion = Motion.objects.get()
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(motion.title, 'test_title_aeng7ahChie3waiR8xoh')
self.assertEqual(motion.workflow, 2)