New feature blocks for motions.

- Added ListView, DetailView, UpdateForm and connection to
  agenda item for MotionBlock.
- Added slide and projection default.
- Added custom manager for motion blocks.
- Enabled current list of speakers slide and overlay for motion block.
This commit is contained in:
Norman Jäckel 2016-10-01 20:42:44 +02:00 committed by Emanuel Schütze
parent 34b074faec
commit 700c86a24c
20 changed files with 703 additions and 14 deletions

View File

@ -295,8 +295,9 @@ angular.module('OpenSlidesApp.agenda', ['OpenSlidesApp.users'])
'Assignment', // TODO: Remove this after refactoring of data loading on start. 'Assignment', // TODO: Remove this after refactoring of data loading on start.
'Topic', // TODO: Remove this after refactoring of data loading on start. 'Topic', // TODO: Remove this after refactoring of data loading on start.
'Motion', // TODO: Remove this after refactoring of data loading on start. 'Motion', // TODO: Remove this after refactoring of data loading on start.
'MotionBlock', // TODO: Remove this after refactoring of data loading on start.
'Agenda', 'Agenda',
function (Projector, Assignment, Topic, Motion, Agenda) { function (Projector, Assignment, Topic, Motion, MotionBlock, Agenda) {
return { return {
getItem: function (projectorId) { getItem: function (projectorId) {
var elementPromise; var elementPromise;
@ -311,6 +312,13 @@ angular.module('OpenSlidesApp.agenda', ['OpenSlidesApp.users'])
}); });
}); });
break; break;
case 'motions/motion-block':
elementPromise = MotionBlock.find(element.id).then(function(motionBlock) {
return MotionBlock.loadRelations(motionBlock, 'agenda_item').then(function() {
return motionBlock.agenda_item;
});
});
break;
case 'topics/topic': case 'topics/topic':
elementPromise = Topic.find(element.id).then(function(topic) { elementPromise = Topic.find(element.id).then(function(topic) {
return Topic.loadRelations(topic, 'agenda_item').then(function() { return Topic.loadRelations(topic, 'agenda_item').then(function() {

View File

@ -64,6 +64,10 @@ def create_builtin_projection_defaults(**kwargs):
name='motions', name='motions',
display_name='Motions', display_name='Motions',
projector=default_projector) projector=default_projector)
ProjectionDefault.objects.create(
name='motionBlocks',
display_name='Motion Blocks',
projector=default_projector)
ProjectionDefault.objects.create( ProjectionDefault.objects.create(
name='assignments', name='assignments',
display_name='Elections', display_name='Elections',

View File

@ -93,6 +93,25 @@ class CategoryAccessPermissions(BaseAccessPermissions):
return CategorySerializer return CategorySerializer
class MotionBlockAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for Category and CategoryViewSet.
"""
def check_permissions(self, user):
"""
Returns True if the user has read access model instances.
"""
return user.has_perm('motions.can_see')
def get_serializer_class(self, user=None):
"""
Returns serializer class.
"""
from .serializers import MotionBlockSerializer
return MotionBlockSerializer
class WorkflowAccessPermissions(BaseAccessPermissions): class WorkflowAccessPermissions(BaseAccessPermissions):
""" """
Access permissions container for Workflow and WorkflowViewSet. Access permissions container for Workflow and WorkflowViewSet.

View File

@ -18,7 +18,7 @@ class MotionsAppConfig(AppConfig):
from openslides.utils.rest_api import router from openslides.utils.rest_api import router
from .config_variables import get_config_variables from .config_variables import get_config_variables
from .signals import create_builtin_workflows from .signals import create_builtin_workflows
from .views import CategoryViewSet, MotionViewSet, MotionPollViewSet, MotionChangeRecommendationViewSet, WorkflowViewSet from .views import CategoryViewSet, MotionViewSet, MotionBlockViewSet, MotionPollViewSet, MotionChangeRecommendationViewSet, WorkflowViewSet
# Define config variables # Define config variables
config.update_config_variables(get_config_variables()) config.update_config_variables(get_config_variables())
@ -29,6 +29,7 @@ class MotionsAppConfig(AppConfig):
# Register viewsets. # Register viewsets.
router.register(self.get_model('Category').get_collection_string(), CategoryViewSet) router.register(self.get_model('Category').get_collection_string(), CategoryViewSet)
router.register(self.get_model('Motion').get_collection_string(), MotionViewSet) router.register(self.get_model('Motion').get_collection_string(), MotionViewSet)
router.register(self.get_model('MotionBlock').get_collection_string(), MotionBlockViewSet)
router.register(self.get_model('Workflow').get_collection_string(), WorkflowViewSet) router.register(self.get_model('Workflow').get_collection_string(), WorkflowViewSet)
router.register(self.get_model('MotionChangeRecommendation').get_collection_string(), router.register(self.get_model('MotionChangeRecommendation').get_collection_string(),
MotionChangeRecommendationViewSet) MotionChangeRecommendationViewSet)

View File

@ -0,0 +1,34 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.1 on 2016-10-04 21:50
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
import openslides.utils.models
class Migration(migrations.Migration):
dependencies = [
('motions', '0004_auto_20160907_2343'),
]
operations = [
migrations.CreateModel(
name='MotionBlock',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
],
options={
'default_permissions': (),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.AddField(
model_name='motion',
name='motion_block',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='motions.MotionBlock'),
),
]

View File

@ -23,6 +23,7 @@ from openslides.utils.search import user_name_helper
from .access_permissions import ( from .access_permissions import (
CategoryAccessPermissions, CategoryAccessPermissions,
MotionAccessPermissions, MotionAccessPermissions,
MotionBlockAccessPermissions,
MotionChangeRecommendationAccessPermissions, MotionChangeRecommendationAccessPermissions,
WorkflowAccessPermissions, WorkflowAccessPermissions,
) )
@ -116,6 +117,15 @@ class Motion(RESTModelMixin, models.Model):
ForeignKey to one category of motions. ForeignKey to one category of motions.
""" """
motion_block = models.ForeignKey(
'MotionBlock',
on_delete=models.SET_NULL,
null=True,
blank=True)
"""
ForeignKey to one block of motions.
"""
origin = models.CharField(max_length=255, blank=True) origin = models.CharField(max_length=255, blank=True)
""" """
A string to describe the origin of this motion e. g. that it was A string to describe the origin of this motion e. g. that it was
@ -783,6 +793,66 @@ class Category(RESTModelMixin, models.Model):
return self.name return self.name
class MotionBlockManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
def get_full_queryset(self):
"""
Returns the normal queryset with all motion blocks. In the
background the related agenda item is prefetched from the database.
"""
return self.get_queryset().prefetch_related('agenda_items')
class MotionBlock(RESTModelMixin, models.Model):
"""
Model for blocks of motions.
"""
access_permissions = MotionBlockAccessPermissions()
objects = MotionBlockManager()
title = models.CharField(max_length=255)
# In theory there could be one then more agenda_item. But we support only
# one. See the property agenda_item.
agenda_items = GenericRelation(Item, related_name='topics')
class Meta:
default_permissions = ()
def __str__(self):
return self.title
@property
def agenda_item(self):
"""
Returns the related agenda item.
"""
# We support only one agenda item so just return the first element of
# the queryset.
return self.agenda_items.all()[0]
@property
def agenda_item_id(self):
"""
Returns the id of the agenda item object related to this object.
"""
return self.agenda_item.pk
def get_agenda_title(self):
return self.title
def get_agenda_list_view_title(self):
return self.title
@classmethod
def get_collection_string(cls):
# TODO: Fix generic method to support camelCase, #2480.
return 'motions/motion-block'
class MotionLog(RESTModelMixin, models.Model): class MotionLog(RESTModelMixin, models.Model):
"""Save a logmessage for a motion.""" """Save a logmessage for a motion."""

View File

@ -1,7 +1,7 @@
from ..core.exceptions import ProjectorException from ..core.exceptions import ProjectorException
from ..utils.collection import CollectionElement from ..utils.collection import CollectionElement
from ..utils.projector import ProjectorElement from ..utils.projector import ProjectorElement
from .models import Motion from .models import Motion, MotionBlock
class MotionSlide(ProjectorElement): class MotionSlide(ProjectorElement):
@ -48,3 +48,44 @@ class MotionSlide(ProjectorElement):
else: else:
data = {'agenda_item_id': motion.agenda_item_id} data = {'agenda_item_id': motion.agenda_item_id}
return data return data
class MotionBlockSlide(ProjectorElement):
"""
Slide definitions for a block of motions (MotionBlock model).
"""
name = 'motions/motion-block'
def check_data(self):
if not MotionBlock.objects.filter(pk=self.config_entry.get('id')).exists():
raise ProjectorException('MotionBlock does not exist.')
def get_requirements(self, config_entry):
try:
motion_block = MotionBlock.objects.get(pk=config_entry.get('id'))
except MotionBlock.DoesNotExist:
# MotionBlock does not exist. Just do nothing.
pass
else:
yield motion_block
yield motion_block.agenda_item
yield from motion_block.motion_set.all()
def get_collection_elements_required_for_this(self, collection_element, config_entry):
output = super().get_collection_elements_required_for_this(collection_element, config_entry)
# Full update if a motion changes because then it may be appended to
# or removed from the block.
if collection_element.collection_string == Motion.get_collection_string():
output.extend(self.get_requirements_as_collection_elements(config_entry))
return output
def update_data(self):
data = None
try:
motion_block = MotionBlock.objects.get(pk=self.config_entry.get('id'))
except MotionBlock.DoesNotExist:
# MotionBlock does not exist, so just do nothing.
pass
else:
data = {'agenda_item_id': motion_block.agenda_item_id}
return data

View File

@ -16,6 +16,7 @@ from openslides.utils.rest_api import (
from .models import ( from .models import (
Category, Category,
Motion, Motion,
MotionBlock,
MotionChangeRecommendation, MotionChangeRecommendation,
MotionLog, MotionLog,
MotionPoll, MotionPoll,
@ -42,6 +43,15 @@ class CategorySerializer(ModelSerializer):
fields = ('id', 'name', 'prefix',) fields = ('id', 'name', 'prefix',)
class MotionBlockSerializer(ModelSerializer):
"""
Serializer for motion.models.Category objects.
"""
class Meta:
model = MotionBlock
fields = ('id', 'title', 'agenda_item_id',)
class StateSerializer(ModelSerializer): class StateSerializer(ModelSerializer):
""" """
Serializer for motion.models.State objects. Serializer for motion.models.State objects.
@ -275,6 +285,7 @@ class MotionSerializer(ModelSerializer):
'active_version', 'active_version',
'parent', 'parent',
'category', 'category',
'motion_block',
'origin', 'origin',
'submitters', 'submitters',
'supporters', 'supporters',
@ -300,6 +311,7 @@ class MotionSerializer(ModelSerializer):
motion.reason = validated_data.get('reason', '') motion.reason = validated_data.get('reason', '')
motion.identifier = validated_data.get('identifier') motion.identifier = validated_data.get('identifier')
motion.category = validated_data.get('category') motion.category = validated_data.get('category')
motion.motion_block = validated_data.get('motion_block')
motion.origin = validated_data.get('origin', '') motion.origin = validated_data.get('origin', '')
motion.comments = validated_data.get('comments') motion.comments = validated_data.get('comments')
motion.parent = validated_data.get('parent') motion.parent = validated_data.get('parent')
@ -319,8 +331,8 @@ class MotionSerializer(ModelSerializer):
""" """
Customized method to update a motion. Customized method to update a motion.
""" """
# Identifier, category, origin and comments. # Identifier, category, motion_block, origin and comments.
for key in ('identifier', 'category', 'origin', 'comments'): for key in ('identifier', 'category', 'motion_block', 'origin', 'comments'):
if key in validated_data.keys(): if key in validated_data.keys():
setattr(motion, key, validated_data[key]) setattr(motion, key, validated_data[key])

View File

@ -3,10 +3,11 @@
"use strict"; "use strict";
angular.module('OpenSlidesApp.motions', [ angular.module('OpenSlidesApp.motions', [
'OpenSlidesApp.users', 'OpenSlidesApp.motions.motionBlock',
'OpenSlidesApp.motions.lineNumbering', 'OpenSlidesApp.motions.lineNumbering',
'OpenSlidesApp.motions.diff', 'OpenSlidesApp.motions.diff',
'OpenSlidesApp.motions.DOCX' 'OpenSlidesApp.motions.DOCX',
'OpenSlidesApp.users',
]) ])
.factory('WorkflowState', [ .factory('WorkflowState', [
@ -389,6 +390,10 @@ angular.module('OpenSlidesApp.motions', [
localField: 'category', localField: 'category',
localKey: 'category_id', localKey: 'category_id',
}, },
'motions/motion-block': {
localField: 'motionBlock',
localKey: 'motion_block_id',
},
'agenda/item': { 'agenda/item': {
localKey: 'agenda_item_id', localKey: 'agenda_item_id',
localField: 'agenda_item', localField: 'agenda_item',

View File

@ -0,0 +1,32 @@
(function () {
'use strict';
angular.module('OpenSlidesApp.motions.motionBlockProjector', [])
// MotionBlock projector elements
.config([
'slidesProvider',
function(slidesProvider) {
slidesProvider.registerSlide('motions/motion-block', {
template: 'static/templates/motions/slide_motion_block.html',
});
}
])
.controller('SlideMotionBlockCtrl', [
'$scope',
'Motion',
'MotionBlock',
function($scope, Motion, MotionBlock) {
// Attention! Each object that is used here has to be dealt on server side.
// Add it to the coresponding get_requirements method of the ProjectorElement
// class.
var id = $scope.element.id;
MotionBlock.bindOne(id, $scope, 'motionBlock');
}
]);
}());

View File

@ -0,0 +1,244 @@
(function () {
'use strict';
angular.module('OpenSlidesApp.motions.motionBlock', [])
// MotionBlock model
.factory('MotionBlock', [
'DS',
'jsDataModel',
'gettext',
function(DS, jsDataModel, gettext) {
var name = 'motions/motion-block';
return DS.defineResource({
name: name,
useClass: jsDataModel,
verboseName: gettext('Motion block'),
methods: {
getResourceName: function () {
return name;
},
getAgendaTitle: function () {
return this.title;
},
},
relations: {
belongsTo: {
'agenda/item': {
localKey: 'agenda_item_id',
localField: 'agenda_item',
}
},
hasMany: {
'motions/motion': {
localField: 'motions',
foreignKey: 'motion_block_id',
}
},
}
});
}
])
.run(['MotionBlock', function(MotionBlock) {}])
// MotionBlock views (list view, create dialog, update dialog)
.factory('MotionBlockForm', [
'$http',
'gettextCatalog',
'Agenda',
'AgendaTree',
function ($http, gettextCatalog, Agenda, AgendaTree) {
return {
// Get ngDialog configuration.
getDialog: function (motionBlock) {
return {
template: 'static/templates/motions/motionBlock-form.html',
controller: (motionBlock) ? 'MotionBlockUpdateCtrl' : 'MotionBlockCreateCtrl',
className: 'ngdialog-theme-default wide-form',
closeByEscape: false,
closeByDocument: false,
resolve: {
motionBlock: function () {
return motionBlock;
}
}
};
},
// Get angular-formly fields.
getFormFields: function () {
return [
{
key: 'title',
type: 'input',
templateOptions: {
label: gettextCatalog.getString('Title')
}
},
{
key: 'showAsAgendaItem',
type: 'checkbox',
templateOptions: {
label: gettextCatalog.getString('Show as agenda item'),
description: gettextCatalog.getString('If deactivated it appears as internal item on agenda.')
}
},
{
key: 'agenda_parent_item_id',
type: 'select-single',
templateOptions: {
label: gettextCatalog.getString('Parent item'),
options: AgendaTree.getFlatTree(Agenda.getAll()),
ngOptions: 'item.id as item.getListViewTitle() for item in to.options | notself : model.agenda_item_id',
placeholder: gettextCatalog.getString('Select a parent item ...')
}
}
];
}
};
}
])
// TODO: Rename this to MotionBlockListCtrl after $stateProvider is fixed, see #2479.
.controller('MotionblockListCtrl', [
'$scope',
'ngDialog',
'MotionBlock',
'MotionBlockForm',
function ($scope, ngDialog, MotionBlock, MotionBlockForm) {
// Two-way data binding for all MotionBlock instances.
MotionBlock.bindAll({}, $scope, 'motionBlocks');
// Dialog with a form to create or update a MotionBlock instance.
$scope.openFormDialog = function (motionBlock) {
ngDialog.open(MotionBlockForm.getDialog(motionBlock));
};
// Confirm dialog to delete a MotionBlock instance.
$scope.delete = function (motionBlock) {
MotionBlock.destroy(motionBlock.id);
};
// TODO: In template we have filter toggleSort reverse sortColumn header. Use this stuff or remove it.
}
])
// TODO: Rename this to MotionBlockDetailCtrl after $stateProvider is fixed, see #2479.
.controller('MotionblockDetailCtrl', [
'$scope',
'ngDialog',
'MotionBlockForm',
'MotionBlock',
'motionBlock',
'Projector',
'ProjectionDefault',
function($scope, ngDialog, MotionBlockForm, MotionBlock, motionBlock, Projector, ProjectionDefault) {
MotionBlock.bindOne(motionBlock.id, $scope, 'motionBlock');
$scope.$watch(function () {
return Projector.lastModified();
}, function () {
var projectiondefault = ProjectionDefault.filter({name: 'motionBlocks'})[0];
if (projectiondefault) {
$scope.defaultProjectorId = projectiondefault.projector_id;
}
});
$scope.openDialog = function (topic) {
ngDialog.open(MotionBlockForm.getDialog(motionBlock));
};
}
])
.controller('MotionBlockCreateCtrl', [
'$scope',
'MotionBlock',
'MotionBlockForm',
'AgendaUpdate',
function($scope, MotionBlock, MotionBlockForm, AgendaUpdate) {
// Prepare form.
$scope.model = {};
$scope.model.showAsAgendaItem = true;
// Get all form fields.
$scope.formFields = MotionBlockForm.getFormFields();
// Save form.
$scope.save = function (motionBlock) {
MotionBlock.create(motionBlock).then(
function (success) {
// type: Value 1 means a non hidden agenda item, value 2 means a hidden agenda item,
// see openslides.agenda.models.Item.ITEM_TYPE.
var changes = [{key: 'type', value: (motionBlock.showAsAgendaItem ? 1 : 2)},
{key: 'parent_id', value: motionBlock.agenda_parent_item_id}];
AgendaUpdate.saveChanges(success.agenda_item_id, changes);
$scope.closeThisDialog();
},
function (error) {
var message = '';
for (var e in error.data) {
message += e + ': ' + error.data[e] + ' ';
}
$scope.alert = {type: 'danger', msg: message, show: true};
}
);
};
}
])
.controller('MotionBlockUpdateCtrl', [
'$scope',
'$state',
'MotionBlock',
'MotionBlockForm',
'AgendaUpdate',
'motionBlock',
function($scope, $state, MotionBlock, MotionBlockForm, AgendaUpdate, motionBlock) {
// TODO: Check #2486 and remove some agenda related code.
//MotionBlock.loadRelations(motionBlock, 'agenda_item');
$scope.alert = {};
// Prepare form. Set initial values by creating a deep copy of
// motionBlock object so list/detail view is not updated while editing.
$scope.model = angular.copy(motionBlock);
// Get all form fields.
$scope.formFields = MotionBlockForm.getFormFields();
for (var i = 0; i < $scope.formFields.length; i++) {
if ($scope.formFields[i].key == 'showAsAgendaItem') {
// Get state from agenda item (hidden/internal or agenda item).
$scope.formFields[i].defaultValue = !motionBlock.agenda_item.is_hidden;
} else if ($scope.formFields[i].key == 'agenda_parent_item_id') {
$scope.formFields[i].defaultValue = motionBlock.agenda_item.parent_id;
}
}
// Save form.
$scope.save = function (motionBlock) {
MotionBlock.create(motionBlock).then(
function (success) {
// type: Value 1 means a non hidden agenda item, value 2 means a hidden agenda item,
// see openslides.agenda.models.Item.ITEM_TYPE.
var changes = [{key: 'type', value: (motionBlock.showAsAgendaItem ? 1 : 2)},
{key: 'parent_id', value: motionBlock.agenda_parent_item_id}];
AgendaUpdate.saveChanges(success.agenda_item_id,changes);
$scope.closeThisDialog();
},
function (error) {
// Save error: revert all changes by restore
// (refresh) original motionBlock object from server
MotionBlock.refresh(motionBlock); // TODO: Why do we need a refresh here?
var message = '';
for (var e in error.data) {
message += e + ': ' + error.data[e] + ' ';
}
$scope.alert = {type: 'danger', msg: message, show: true};
}
);
};
}
]);
}());

View File

@ -2,7 +2,10 @@
'use strict'; 'use strict';
angular.module('OpenSlidesApp.motions.projector', ['OpenSlidesApp.motions']) angular.module('OpenSlidesApp.motions.projector', [
'OpenSlidesApp.motions',
'OpenSlidesApp.motions.motionBlockProjector',
])
.config([ .config([
'slidesProvider', 'slidesProvider',

View File

@ -4,7 +4,6 @@
angular.module('OpenSlidesApp.motions.site', [ angular.module('OpenSlidesApp.motions.site', [
'OpenSlidesApp.motions', 'OpenSlidesApp.motions',
'OpenSlidesApp.motions.diff',
'OpenSlidesApp.motions.motionservices', 'OpenSlidesApp.motions.motionservices',
'OpenSlidesApp.poll.majority', 'OpenSlidesApp.poll.majority',
'OpenSlidesApp.core.pdf', 'OpenSlidesApp.core.pdf',
@ -50,6 +49,9 @@ angular.module('OpenSlidesApp.motions.site', [
categories: function(Category) { categories: function(Category) {
return Category.findAll(); return Category.findAll();
}, },
motionBlocks: function(MotionBlock) {
return MotionBlock.findAll();
},
tags: function(Tag) { tags: function(Tag) {
return Tag.findAll(); return Tag.findAll();
}, },
@ -81,6 +83,9 @@ angular.module('OpenSlidesApp.motions.site', [
categories: function(Category) { categories: function(Category) {
return Category.findAll(); return Category.findAll();
}, },
motionBlocks: function(MotionBlock) {
return MotionBlock.findAll();
},
users: function(User) { users: function(User) {
return User.findAll().catch( return User.findAll().catch(
function () { function () {
@ -198,6 +203,66 @@ angular.module('OpenSlidesApp.motions.site', [
}, },
controller: 'CategorySortCtrl', controller: 'CategorySortCtrl',
templateUrl: 'static/templates/motions/category-sort.html' templateUrl: 'static/templates/motions/category-sort.html'
})
// MotionBlock
.state('motions.motionBlock', {
url: '/blocks',
abstract: true,
template: '<ui-view/>',
})
.state('motions.motionBlock.list', {
resolve: {
motionBlocks: function (MotionBlock) {
return MotionBlock.findAll();
},
items: function(Agenda) {
return Agenda.findAll().catch(
function () {
return null;
}
);
}
}
})
.state('motions.motionBlock.detail', {
resolve: {
motionBlock: function(MotionBlock, $stateParams) {
return MotionBlock.find($stateParams.id);
},
items: function(Agenda) {
return Agenda.findAll().catch(
function () {
return null;
}
);
}
}
})
// redirects to motionBlock detail and opens motionBlock edit form dialog, uses edit url,
// used by ui-sref links from agenda only
// (from motionBlock controller use MotionBlockForm factory instead to open dialog in front
// of current view without redirect)
.state('motions.motionBlock.detail.update', {
onEnter: ['$stateParams', '$state', 'ngDialog', 'MotionBlock',
function($stateParams, $state, ngDialog, MotionBlock) {
ngDialog.open({
template: 'static/templates/motions/motionBlock-form.html',
controller: 'MotionBlockUpdateCtrl',
className: 'ngdialog-theme-default wide-form',
closeByEscape: false,
closeByDocument: false,
resolve: {
motionBlock: function () {
return motionBlock;
}
},
preCloseCallback: function() {
$state.go('motions.motionBlock.detail', {motionBlock: $stateParams.id});
return true;
}
});
}
],
}); });
} }
]) ])
@ -306,12 +371,13 @@ angular.module('OpenSlidesApp.motions.site', [
'Category', 'Category',
'Config', 'Config',
'Mediafile', 'Mediafile',
'MotionBlock',
'Tag', 'Tag',
'User', 'User',
'Workflow', 'Workflow',
'Agenda', 'Agenda',
'AgendaTree', 'AgendaTree',
function (gettextCatalog, operator, Editor, MotionComment, Category, Config, Mediafile, Tag, User, Workflow, Agenda, AgendaTree) { function (gettextCatalog, operator, Editor, MotionComment, Category, Config, Mediafile, MotionBlock, Tag, User, Workflow, Agenda, AgendaTree) {
return { return {
// ngDialog for motion form // ngDialog for motion form
getDialog: function (motion) { getDialog: function (motion) {
@ -453,6 +519,17 @@ angular.module('OpenSlidesApp.motions.site', [
}, },
hideExpression: '!model.more' hideExpression: '!model.more'
}, },
{
key: 'motion_block_id',
type: 'select-single',
templateOptions: {
label: gettextCatalog.getString('Motion block'),
options: MotionBlock.getAll(),
ngOptions: 'option.id as option.title for option in to.options',
placeholder: gettextCatalog.getString('Select or search a motion block ...')
},
hideExpression: '!model.more'
},
{ {
key: 'origin', key: 'origin',
type: 'input', type: 'input',

View File

@ -159,6 +159,11 @@
<!-- Origin --> <!-- Origin -->
<h3 ng-if="motion.origin" translate>Origin</h3> <h3 ng-if="motion.origin" translate>Origin</h3>
{{ motion.origin }} {{ motion.origin }}
<!-- Motion block -->
<h3 translate>Motion block</h3>
{{ motion.motionBlock }}
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<h3 ng-if="motion.polls.length > 0" translate>Voting result</h3> <h3 ng-if="motion.polls.length > 0" translate>Voting result</h3>

View File

@ -9,6 +9,10 @@
<i class="fa fa-sitemap fa-lg"></i> <i class="fa fa-sitemap fa-lg"></i>
<translate>Categories</translate> <translate>Categories</translate>
</a> </a>
<a ui-sref="motions.motionBlock.list" os-perms="motions.can_manage" class="btn btn-default btn-sm">
<i class="fa fa-list fa-lg"></i>
<translate>Motion blocks</translate>
</a>
<a ui-sref="core.tag.list" os-perms="core.can_manage_tags" class="btn btn-default btn-sm"> <a ui-sref="core.tag.list" os-perms="core.can_manage_tags" class="btn btn-default btn-sm">
<i class="fa fa-tags fa-lg"></i> <i class="fa fa-tags fa-lg"></i>
<translate>Tags</translate> <translate>Tags</translate>

View File

@ -0,0 +1,30 @@
<div class="header">
<div class="title">
<div class="submenu">
<a ui-sref="motions.motionBlock.list" class="btn btn-sm btn-default">
<i class="fa fa-angle-double-left fa-lg"></i>
<translate>Back to overview</translate>
</a>
<!-- List of speakers -->
<a ui-sref="agenda.item.detail({id: motionBlock.agenda_item_id})" class="btn btn-sm btn-default">
<i class="fa fa-microphone fa-lg"></i>
<translate>List of speakers</translate>
</a>
<!-- project -->
<projector-button model="motionBlock" default-projector-id="defaultProjectorId">
</projector-button>
<!-- edit -->
<a ng-click="openDialog(motionBlock)"
class="btn btn-default btn-sm"
title="{{ 'Edit' | translate}}">
<i class="fa fa-pencil"></i>
</a>
</div>
<h1>{{ motionBlock.agenda_item.getTitle() }}</h1>
<h2 translate>Motion Block</h2>
</div>
</div>
<div class="details">
{{ motionBlock.motions }}
</div>

View File

@ -0,0 +1,17 @@
<h1 ng-if="model.id" translate>Edit motion block</h1>
<h1 ng-if="!model.id" translate>New motion block</h1>
<div uib-alert ng-show="alert.show" ng-class="'alert-' + (alert.type || 'warning')" ng-click="alert={}" close="alert={}">
{{ alert.msg }}
</div>
<form name="motionBlockForm" ng-submit="save(model)">
<formly-form model="model" fields="formFields">
<button type="submit" ng-disabled="motionBlockForm.$invalid" class="btn btn-primary" translate>
Save
</button>
<button ng-click="closeThisDialog()" class="btn btn-default" translate>
Cancel
</button>
</formly-form>
</form>

View File

@ -0,0 +1,49 @@
<div class="header">
<div class="title">
<div class="submenu">
<a ng-click="openFormDialog()" os-perms="motions.can_manage" class="btn btn-primary btn-sm">
<i class="fa fa-plus fa-lg"></i>
<translate>New</translate>
</a>
<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>
</div>
<h1 translate>Motion blocks</h1>
</div>
</div>
<div class="details">
<div class="row form-group">
<div class="col-sm-4 pull-right">
<input type="text" ng-model="filter.search" class="form-control"
placeholder="{{ 'Filter' | translate }}">
</div>
</div>
<table class="table table-striped table-bordered table-hover">
<thead>
<tr>
<th ng-click="toggleSort('name')" class="sortable">
<translate>Name</translate>
<i class="pull-right fa" ng-show="sortColumn === 'name' && header.sortable != false"
ng-class="reverse ? 'fa-sort-desc' : 'fa-sort-asc'">
</i>
<tbody>
<tr ng-repeat="motionBlock in motionBlocks | filter: filter.search | orderBy: sortColumn:reverse">
<td ng-mouseover="motionBlock.hover=true" ng-mouseleave="motionBlock.hover=false">
<strong>
<a ui-sref="motions.motionBlock.detail({id: motionBlock.id})">{{ motionBlock.title }}</a>
</strong>
<div class="hoverActions" ng-class="{'hiddenDiv': !motionBlock.hover}">
<!-- edit -->
<a ng-click="openFormDialog(motionBlock)" translate>Edit</a> |
<!-- delete -->
<a href="" class="text-danger"
ng-bootbox-confirm="{{ 'Are you sure you want to delete this entry?' | translate }}<br>
<b>{{ motionBlock.name }}</b>"
ng-bootbox-confirm-action="delete(motionBlock)" translate>Delete</a>
</div>
</table>
</div>

View File

@ -0,0 +1,4 @@
<div ng-controller="SlideMotionBlockCtrl" class="content scrollcontent">
<h1>{{ motionBlock.agenda_item.getTitle() }}</h1>
<div ng-repeat="motion in motionBlock.motions">{{ motion.identifier }}</div>
</div>

View File

@ -24,6 +24,7 @@ from openslides.utils.views import APIView, PDFView, SingleObjectMixin
from .access_permissions import ( from .access_permissions import (
CategoryAccessPermissions, CategoryAccessPermissions,
MotionAccessPermissions, MotionAccessPermissions,
MotionBlockAccessPermissions,
MotionChangeRecommendationAccessPermissions, MotionChangeRecommendationAccessPermissions,
WorkflowAccessPermissions, WorkflowAccessPermissions,
) )
@ -31,6 +32,7 @@ from .exceptions import WorkflowError
from .models import ( from .models import (
Category, Category,
Motion, Motion,
MotionBlock,
MotionChangeRecommendation, MotionChangeRecommendation,
MotionPoll, MotionPoll,
MotionVersion, MotionVersion,
@ -89,6 +91,8 @@ class MotionViewSet(ModelViewSet):
# Non-staff users are not allowed to send submitter or supporter data. # Non-staff users are not allowed to send submitter or supporter data.
self.permission_denied(request) self.permission_denied(request)
# TODO: Should non staff users be allowed to set motions to blocks or send categories, ...? #2506
# Check permission to send comment data. # Check permission to send comment data.
if not request.user.has_perm('motions.can_see_and_manage_comments'): if not request.user.has_perm('motions.can_see_and_manage_comments'):
try: try:
@ -373,7 +377,7 @@ class CategoryViewSet(ModelViewSet):
API endpoint for categories. API endpoint for categories.
There are the following views: metadata, list, retrieve, create, There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy. partial_update, update, destroy and numbering.
""" """
access_permissions = CategoryAccessPermissions() access_permissions = CategoryAccessPermissions()
queryset = Category.objects.all() queryset = Category.objects.all()
@ -445,6 +449,32 @@ class CategoryViewSet(ModelViewSet):
return response return response
class MotionBlockViewSet(ModelViewSet):
"""
API endpoint for motion blocks.
There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy.
"""
access_permissions = MotionBlockAccessPermissions()
queryset = MotionBlock.objects.all()
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action == 'metadata':
result = self.request.user.has_perm('motions.can_see')
elif self.action in ('create', 'partial_update', 'update', 'destroy'):
result = (self.request.user.has_perm('motions.can_see') and
self.request.user.has_perm('motions.can_manage'))
else:
result = False
return result
class WorkflowViewSet(ModelViewSet): class WorkflowViewSet(ModelViewSet):
""" """
API endpoint for workflows. API endpoint for workflows.