Merge branch 'stable/1.6.x'

Conflicts:
	CHANGELOG
	openslides/users/signals.py
This commit is contained in:
Oskar Hahn 2015-01-05 17:14:29 +01:00
commit 34b6ca80f3
30 changed files with 719 additions and 155 deletions

View File

@ -16,11 +16,14 @@ Other:
template signals and slides. template signals and slides.
Version 1.7.0 (unreleased) Version 1.7 (unreleased)
========================== ========================
[https://github.com/OpenSlides/OpenSlides/milestones/1.7] [https://github.com/OpenSlides/OpenSlides/milestones/1.7]
Motions: Core:
- New feature to tag motions, agenda and assignments.
Motion:
- New Feature to create amendments, which are related to a parent motion.
- Added possibility to hide motions from non staff users in some states. - Added possibility to hide motions from non staff users in some states.
Other: Other:
- Cleaned up utils.views to increase performance when fetching single objects - Cleaned up utils.views to increase performance when fetching single objects

View File

@ -30,7 +30,7 @@ class ItemForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm):
class Meta: class Meta:
model = Item model = Item
fields = ('item_number', 'title', 'text', 'comment', 'type', 'duration', 'parent', 'speaker_list_closed') fields = ('item_number', 'title', 'text', 'comment', 'tags', 'type', 'duration', 'parent', 'speaker_list_closed')
widgets = {'text': CKEditorWidget(config_name='images')} widgets = {'text': CKEditorWidget(config_name='images')}

View File

@ -11,6 +11,7 @@ from django.utils.translation import ugettext_lazy, ugettext_noop
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from openslides.config.api import config from openslides.config.api import config
from openslides.core.models import Tag
from openslides.projector.api import (get_active_slide, reset_countdown, from openslides.projector.api import (get_active_slide, reset_countdown,
start_countdown, stop_countdown, start_countdown, stop_countdown,
update_projector, update_projector_overlay) update_projector, update_projector_overlay)
@ -107,6 +108,11 @@ class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel):
True, if the list of speakers is closed. True, if the list of speakers is closed.
""" """
tags = models.ManyToManyField(Tag, blank=True)
"""
Tags to categorise agenda items.
"""
class Meta: class Meta:
permissions = ( permissions = (
('can_see_agenda', ugettext_noop("Can see agenda")), ('can_see_agenda', ugettext_noop("Can see agenda")),

View File

@ -34,8 +34,22 @@
<h1>{% trans "Agenda" %} <h1>{% trans "Agenda" %}
<small class="pull-right"> <small class="pull-right">
{% if perms.agenda.can_manage_agenda %} {% if perms.agenda.can_manage_agenda %}
<a href="{% url 'item_new' %}" class="btn btn-mini btn-primary" rel="tooltip" data-original-title="{% trans 'New item' %}"><i class="icon-plus icon-white"></i> {% trans "New" %}</a> <a href="{% url 'item_new' %}" class="btn btn-mini btn-primary" rel="tooltip" data-original-title="{% trans 'New item' %}">
<a href="{% url 'item_csv_import' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Import agenda items' %}"><i class="icon-import"></i> {% trans "Import" %}</a> <i class="icon-plus icon-white"></i>
{% trans "New" %}
</a>
{% endif %}
{% if perms.core.can_manage_tags %}
<a href="{% url 'core_tag_list' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Manage tags' %}">
<i class="icon-th"></i>
<span class="optional-small"> {% trans 'Tags' %}</span>
</a>
{% endif %}
{% if perms.agenda.can_manage_agenda %}
<a href="{% url 'item_csv_import' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Import agenda items' %}">
<i class="icon-import"></i>
{% trans "Import" %}
</a>
{% endif %} {% endif %}
<a href="{% url 'print_agenda' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Print agenda as PDF' %}" target="_blank"><i class="icon-print"></i> PDF</a> <a href="{% url 'print_agenda' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Print agenda as PDF' %}" target="_blank"><i class="icon-print"></i> PDF</a>
{% if perms.core.can_see_projector %} {% if perms.core.can_see_projector %}

View File

@ -6,6 +6,7 @@ from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy, ugettext_noop from django.utils.translation import ugettext_lazy, ugettext_noop
from openslides.agenda.models import Item, Speaker from openslides.agenda.models import Item, Speaker
from openslides.core.models import Tag
from openslides.config.api import config from openslides.config.api import config
from openslides.poll.models import (BaseOption, BasePoll, BaseVote, from openslides.poll.models import (BaseOption, BasePoll, BaseVote,
CollectDefaultVotesMixin, CollectDefaultVotesMixin,
@ -56,6 +57,7 @@ class Assignment(SlideMixin, AbsoluteUrlMixin, models.Model):
max_length=79, null=True, blank=True, max_length=79, null=True, blank=True,
verbose_name=ugettext_lazy("Default comment on the ballot paper")) verbose_name=ugettext_lazy("Default comment on the ballot paper"))
status = models.CharField(max_length=3, choices=STATUS, default='sea') status = models.CharField(max_length=3, choices=STATUS, default='sea')
tags = models.ManyToManyField(Tag, blank=True)
class Meta: class Meta:
permissions = ( permissions = (

View File

@ -21,6 +21,12 @@
{% if perms.assignment.can_manage_assignment %} {% if perms.assignment.can_manage_assignment %}
<a href="{% url 'assignment_create' %}" class="btn btn-mini btn-primary" rel="tooltip" data-original-title="{% trans 'New election' %}"><i class="icon-plus icon-white"></i> {% trans "New" %}</a> <a href="{% url 'assignment_create' %}" class="btn btn-mini btn-primary" rel="tooltip" data-original-title="{% trans 'New election' %}"><i class="icon-plus icon-white"></i> {% trans "New" %}</a>
{% endif %} {% endif %}
{% if perms.core.can_manage_tags %}
<a href="{% url 'core_tag_list' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Manage tags' %}">
<i class="icon-th"></i>
<span class="optional-small"> {% trans 'Tags' %}</span>
</a>
{% endif %}
{% if perms.assignment.can_see_assignment %} {% if perms.assignment.can_see_assignment %}
<a href="{% url 'assignment_list_pdf' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Print all elections as PDF' %}" target="_blank"><i class="icon-print"></i><span class="optional-small"> PDF</span></a> <a href="{% url 'assignment_list_pdf' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Print all elections as PDF' %}" target="_blank"><i class="icon-print"></i><span class="optional-small"> PDF</span></a>
{% endif %} {% endif %}

View File

@ -152,6 +152,7 @@ class ConfigGroup(object):
A simple object class representing a group of variables (tuple) with A simple object class representing a group of variables (tuple) with
a special title. a special title.
""" """
def __init__(self, title, variables): def __init__(self, title, variables):
self.title = title self.title = title
self.variables = variables self.variables = variables

View File

@ -0,0 +1,5 @@
from openslides.utils.exceptions import OpenSlidesError
class TagException(OpenSlidesError):
pass

View File

@ -44,3 +44,20 @@ class CustomSlide(SlideMixin, AbsoluteUrlMixin, models.Model):
else: else:
url = super(CustomSlide, self).get_absolute_url(link) url = super(CustomSlide, self).get_absolute_url(link)
return url return url
class Tag(AbsoluteUrlMixin, models.Model):
"""
Model to save tags.
"""
name = models.CharField(max_length=255, unique=True,
verbose_name=ugettext_lazy('Tag'))
class Meta:
ordering = ['name']
permissions = (
('can_manage_tags', ugettext_noop('Can manage tags')), )
def __str__(self):
return self.name

View File

@ -0,0 +1,111 @@
/*
* Functions to alter the tags via ajax-commands.
*/
$(function() {
// The HTML-input field, in which the tag-name can be altered
var insert_element = $('#tag-edit');
// Boolean value to block second insert-ajax-requests before the pk was returned
var insert_is_blocked = false;
// Clears the HTML-input field to add new tags
$('#tag-save').click(function(event) {
event.preventDefault();
insert_element.val('');
// This is the important line, where the name-attribute of the html-element is set to new
insert_element.attr('name', 'new');
insert_is_blocked = false;
});
// The same happens, if the enter key (keycode==13) was pressed inside the input element
insert_element.keypress(function(event) {
if ( event.which == 13 ) {
event.preventDefault();
$('#tag-save').trigger('click');
}
});
// Change the tag which will be updated
$('.tag-edit').click(function(event) {
event.preventDefault();
var edit_element = $(this);
insert_element.val(edit_element.parents('.tag-row').children('.tag-name').html());
// This is the important line, where the name-attribute of the html-elemtnt is set to edit
insert_element.attr('name', 'edit-' + edit_element.parents('.tag-row').attr('id'));
insert_is_blocked = false;
});
// Code when the delete button of a tag is clicked. Send the ajax-request and
// remove the tag-element from the DOM, when the request was a success.
$('.tag-del').click(function(event) {
event.preventDefault();
var delete_element = $(this);
$.ajax({
method: 'POST',
data: {
name: 'delete-' + delete_element.parents('.tag-row').attr('id')},
dataType: 'json',
success: function(data) {
if (data.action == 'deleted') {
delete_element.parents('.tag-row').remove();
}
}
});
});
// Send the changed data, when new input is in the insert element.
// Use the 'input'-event instead of the 'change'-event, so new data is send
// event when the element does not loose the focus.
insert_element.on('input', function(event) {
// Only send data, if insert_is_blocked is false
if (!insert_is_blocked) {
// block the insert when a new tag is send to the server
if (insert_element.attr('name') == 'new') {
insert_is_blocked = true;
}
$.ajax({
// Sends the data to the current page
method: 'POST',
data: {
name: insert_element.attr('name'),
value: insert_element.val()},
dataType: 'json',
success: function(data) {
if (data.action == 'created') {
// If a new tag was created, use the hidden dummy-tag as template
// to create a new tag-line
// Known bug: the element is added at the end of the list and
// not in alphabetic order. This will be fixed with angular
var new_element = $('#dummy-tag').clone(withDataAndEvents=true);
new_element.attr('id', 'tag-' + data.pk);
new_element.children('.tag-name').html(insert_element.val());
new_element.appendTo('#tag-table');
new_element.slideDown();
// Set the insert-element to edit the new created tag and unblock the
// ajax-method
insert_element.attr('name', 'edit-tag-' + data.pk);
insert_is_blocked = false;
} else if (data.action == 'updated') {
// If a existing tag was altered, change it.
$('#tag-' + data.pk).children('.tag-name').html(insert_element.val());
}
if (data.error) {
insert_element.parent().addClass('error');
if (insert_element.attr('name') == 'new') {
// Remove the block, even if an error happend, so we can send a
// new name for the tag
insert_is_blocked = false;
}
} else {
insert_element.parent().removeClass('error');
};
},
});
}
});
});

View File

@ -72,6 +72,21 @@ $(function () {
} }
}); });
}); });
// Set the csrftoken to send post-data via ajax. See:
// https://docs.djangoproject.com/en/dev/ref/csrf/#ajax
var csrftoken = $.cookie('csrftoken');
function csrfSafeMethod(method) {
// these HTTP methods do not require CSRF protection
return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
}
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrftoken);
}
}
});
}); });
@ -114,7 +129,7 @@ $(document).ready(function(){
} }
// Sticky navigation // Sticky navigation
$(window).scroll(function(){ $(window).scroll(function(){
var el = $('.leftmenu > ul'); var el = $('.leftmenu > ul');
if($(window).width() > 479) { if($(window).width() > 479) {
if ( ($(this).scrollTop() > 80) && ($(this).scrollLeft() < 10)) { if ( ($(this).scrollTop() > 80) && ($(this).scrollLeft() < 10)) {
el.css({'position':'fixed','top':'10px','width':'14.15%'}); el.css({'position':'fixed','top':'10px','width':'14.15%'});
@ -188,12 +203,12 @@ $(document).ready(function(){
}); });
$('#content').removeClass('span10').addClass('span11'); $('#content').removeClass('span10').addClass('span11');
} }
function fullmenu(){ function fullmenu(){
$('.leftmenu').removeClass('span1').removeClass('lefticon').addClass('span2'); $('.leftmenu').removeClass('span1').removeClass('lefticon').addClass('span2');
$('.leftmenu > ul > li > a').each(function(){ $('.leftmenu > ul > li > a').each(function(){
$(this).attr({'rel':'','title':''}); $(this).attr({'rel':'','title':''});
}); });
$('#content').removeClass('span11').addClass('span10'); $('#content').removeClass('span11').addClass('span10');
} }
}); });

View File

@ -15,6 +15,7 @@
<link href="{% static 'css/base.css' %}" type="text/css" rel="stylesheet" /> <link href="{% static 'css/base.css' %}" type="text/css" rel="stylesheet" />
<link href="{% static 'css/chatbox.css' %}" type="text/css" rel="stylesheet" /> <link href="{% static 'css/chatbox.css' %}" type="text/css" rel="stylesheet" />
<link href="{% static 'img/favicon.png' %}" type="image/png" rel="shortcut icon" /> <link href="{% static 'img/favicon.png' %}" type="image/png" rel="shortcut icon" />
<link href="{% static 'css/jquery.bsmselect.css' %}" type="text/css" rel="stylesheet" />
{% for stylefile in extra_stylefiles %} {% for stylefile in extra_stylefiles %}
<link href="{% static stylefile %}" type="text/css" rel="stylesheet" /> <link href="{% static stylefile %}" type="text/css" rel="stylesheet" />
{% endfor %} {% endfor %}
@ -132,6 +133,18 @@
<script src="{% static 'js/utils.js' %}" type="text/javascript"></script> <script src="{% static 'js/utils.js' %}" type="text/javascript"></script>
<script src="{% static 'js/chatbox.js' %}" type="text/javascript"></script> <script src="{% static 'js/chatbox.js' %}" type="text/javascript"></script>
<script src="{% url 'django.views.i18n.javascript_catalog' %}" type="text/javascript"></script> <script src="{% url 'django.views.i18n.javascript_catalog' %}" type="text/javascript"></script>
<script type="text/javascript" src="{% static 'js/jquery/jquery.bsmselect.js' %}"></script>
<script type="text/javascript">
// use jquery-bsmselect for all <select multiple> form elements
$("select[multiple]").bsmSelect({
removeLabel: '<sup><b>X</b></sup>',
containerClass: 'bsmContainer',
listClass: 'bsmList-custom',
listItemClass: 'bsmListItem-custom',
listItemLabelClass: 'bsmListItemLabel-custom',
removeClass: 'bsmListItemRemove-custom'
});
</script>
{% for javascript in extra_javascript %} {% for javascript in extra_javascript %}
<script src="{% static javascript %}" type="text/javascript"></script> <script src="{% static javascript %}" type="text/javascript"></script>
{% endfor %} {% endfor %}

View File

@ -0,0 +1,54 @@
{% extends "base.html" %}
{% load i18n %}
{% load staticfiles %}
{% block title %}{% trans "Tags" %} {{ block.super }}{% endblock %}
{% block content %}
<h1>{% trans "Tags" %}</h1>
<div class="control-group">
<label for="tag-edit">Name:</label>
<input id="tag-edit" name="new">
<a href="#" id="tag-save" class="btn btn-primary">{% trans 'Save' %}</a>
</div>
<table id="tag-table" class="table table-striped table-bordered">
<tr>
<th>{% trans "Tag name" %}</th>
<th class="mini_width">{% trans "Actions" %}</th>
</tr>
<tr id="dummy-tag" class="tag-row" style="display:none">
<td class="tag-name"></td>
<td>
<span style="width: 1px; white-space: nowrap;">
<a href="#" rel="tooltip" data-original-title="{% trans 'Edit' %}" class="btn btn-mini tag-edit">
<i class="icon-pencil "></i>
</a>
<a href="#" rel="tooltip" data-original-title="{% trans 'Delete' %}" class="btn btn-mini tag-del">
<i class="icon-remove"></i>
</a>
</span>
</td>
</tr>
{% for tag in tag_list %}
<tr id="tag-{{ tag.pk }}" class="tag-row">
<td class="tag-name">{{ tag }}</td>
<td>
<span style="width: 1px; white-space: nowrap;">
<a href="#" rel="tooltip" data-original-title="{% trans 'Edit' %}" class="btn btn-mini tag-edit">
<i class="icon-pencil "></i>
</a>
<a href="#" rel="tooltip" data-original-title="{% trans 'Delete' %}" class="btn btn-mini tag-del">
<i class="icon-remove"></i>
</a>
</span>
</td>
</tr>
{% endfor %}
</table>
{% endblock %}
{% block javascript %}
<script src="{% static 'js/config_tags.js' %}" type="text/javascript"></script>
{% endblock %}

View File

@ -39,4 +39,8 @@ urlpatterns = patterns(
url(r'^customslide/(?P<pk>\d+)/del/$', url(r'^customslide/(?P<pk>\d+)/del/$',
views.CustomSlideDeleteView.as_view(), views.CustomSlideDeleteView.as_view(),
name='customslide_delete'), name='customslide_delete'),
url(r'tags/$',
views.TagListView.as_view(),
name='core_tag_list'),
) )

View File

@ -2,6 +2,7 @@ from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import IntegrityError
from django.shortcuts import redirect, render_to_response from django.shortcuts import redirect, render_to_response
from django.template import RequestContext from django.template import RequestContext
from django.utils.importlib import import_module from django.utils.importlib import import_module
@ -17,7 +18,8 @@ from openslides.utils import views as utils_views
from openslides.utils.widgets import Widget from openslides.utils.widgets import Widget
from .forms import SelectWidgetsForm from .forms import SelectWidgetsForm
from .models import CustomSlide from .models import CustomSlide, Tag
from .exceptions import TagException
class DashboardView(utils_views.AjaxMixin, utils_views.TemplateView): class DashboardView(utils_views.AjaxMixin, utils_views.TemplateView):
@ -30,7 +32,7 @@ class DashboardView(utils_views.AjaxMixin, utils_views.TemplateView):
template_name = 'core/dashboard.html' template_name = 'core/dashboard.html'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(DashboardView, self).get_context_data(**kwargs) context = super().get_context_data(**kwargs)
widgets = [] widgets = []
for widget in Widget.get_all(self.request): for widget in Widget.get_all(self.request):
if widget.is_active(): if widget.is_active():
@ -51,7 +53,7 @@ class SelectWidgetsView(utils_views.TemplateView):
template_name = 'core/select_widgets.html' template_name = 'core/select_widgets.html'
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(SelectWidgetsView, self).get_context_data(**kwargs) context = super().get_context_data(**kwargs)
widgets = Widget.get_all(self.request) widgets = Widget.get_all(self.request)
for widget in widgets: for widget in widgets:
initial = {'widget': widget.is_active()} initial = {'widget': widget.is_active()}
@ -93,7 +95,7 @@ class VersionView(utils_views.TemplateView):
""" """
Adds version strings to the context. Adds version strings to the context.
""" """
context = super(VersionView, self).get_context_data(**kwargs) context = super().get_context_data(**kwargs)
# OpenSlides version. During development the git commit id is added. # OpenSlides version. During development the git commit id is added.
if RELEASE: if RELEASE:
description = '' description = ''
@ -119,7 +121,7 @@ class SearchView(_SearchView):
def __call__(self, request): def __call__(self, request):
if not request.user.is_authenticated() and not config['system_enable_anonymous']: if not request.user.is_authenticated() and not config['system_enable_anonymous']:
raise PermissionDenied raise PermissionDenied
return super(SearchView, self).__call__(request) return super().__call__(request)
def extra_context(self): def extra_context(self):
""" """
@ -211,3 +213,81 @@ class CustomSlideDeleteView(CustomSlideViewMixin, utils_views.DeleteView):
Delete a custom slide. Delete a custom slide.
""" """
pass pass
class TagListView(utils_views.AjaxMixin, utils_views.ListView):
"""
View to list and manipulate tags.
Shows all tags when requested via a GET-request. Manipulates tags with
POST-requests.
"""
model = Tag
required_permission = 'core.can_manage_tags'
def post(self, *args, **kwargs):
return self.ajax_get(*args, **kwargs)
def ajax_get(self, request, *args, **kwargs):
name, value = request.POST['name'], request.POST.get('value', None)
# Create a new tag
if name == 'new':
try:
tag = Tag.objects.create(name=value)
except IntegrityError:
# The name of the tag is already taken. It must be unique.
self.error = 'Tag name is already taken'
else:
self.pk = tag.pk
self.action = 'created'
# Update an existing tag
elif name.startswith('edit-tag-'):
try:
self.get_tag_queryset(name, 9).update(name=value)
except TagException as error:
self.error = str(error)
except IntegrityError:
self.error = 'Tag name is already taken'
except Tag.DoesNotExist:
self.error = 'Tag does not exist'
else:
self.action = 'updated'
# Delete a tag
elif name.startswith('delete-tag-'):
try:
self.get_tag_queryset(name, 11).delete()
except TagException as error:
self.error = str(error)
except Tag.DoesNotExist:
self.error = 'Tag does not exist'
else:
self.action = 'deleted'
return super().ajax_get(request, *args, **kwargs)
def get_tag_queryset(self, name, place_in_str):
"""
Get a django-tag-queryset from a string.
'name' is the string in which the pk is (at the end).
'place_in_str' is the place where to look for the pk. It has to be an int.
Returns a Tag QuerySet or raises TagException.
Also sets self.pk to the pk inside the name.
"""
try:
self.pk = int(name[place_in_str:])
except ValueError:
raise TagException('Invalid name in request')
return Tag.objects.filter(pk=self.pk)
def get_ajax_context(self, **context):
return super().get_ajax_context(
pk=getattr(self, 'pk', None),
action=getattr(self, 'action', None),
error=getattr(self, 'error', None),
**context)

View File

@ -1,7 +1,6 @@
from django import forms from django import forms
from django.utils.translation import ugettext_lazy from django.utils.translation import ugettext_lazy
from openslides.config.api import config
from openslides.mediafile.models import Mediafile from openslides.mediafile.models import Mediafile
from openslides.utils.forms import (CleanHtmlFormMixin, CssClassMixin, from openslides.utils.forms import (CleanHtmlFormMixin, CssClassMixin,
CSVImportForm, LocalizedModelChoiceField) CSVImportForm, LocalizedModelChoiceField)
@ -9,7 +8,7 @@ from openslides.users.models import User
from ckeditor.widgets import CKEditorWidget from ckeditor.widgets import CKEditorWidget
from .models import Category, Motion, Workflow from .models import Category, Motion, Workflow, Tag
class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm): class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm):
@ -47,6 +46,11 @@ class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm):
Attachments of the motion. Attachments of the motion.
""" """
tags = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False,
label=ugettext_lazy('Tags'))
class Meta: class Meta:
model = Motion model = Motion
fields = () fields = ()
@ -54,7 +58,7 @@ class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
""" """
Fill the FormFields related to the version data with initial data. Fill the FormFields related to the version data with initial data.
Fill also the initial data for attachments. Fill also the initial data for attachments and tags.
""" """
self.motion = kwargs.get('instance', None) self.motion = kwargs.get('instance', None)
self.initial = kwargs.setdefault('initial', {}) self.initial = kwargs.setdefault('initial', {})
@ -64,8 +68,7 @@ class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm):
self.initial['text'] = last_version.text self.initial['text'] = last_version.text
self.initial['reason'] = last_version.reason self.initial['reason'] = last_version.reason
self.initial['attachments'] = self.motion.attachments.all() self.initial['attachments'] = self.motion.attachments.all()
else: self.initial['tags'] = self.motion.tags.all()
self.initial['text'] = config['motion_preamble']
super(BaseMotionForm, self).__init__(*args, **kwargs) super(BaseMotionForm, self).__init__(*args, **kwargs)

View File

@ -6,6 +6,7 @@ from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy, ugettext_noop from django.utils.translation import ugettext_lazy, ugettext_noop
from openslides.config.api import config from openslides.config.api import config
from openslides.core.models import Tag
from openslides.mediafile.models import Mediafile from openslides.mediafile.models import Mediafile
from openslides.poll.models import (BaseOption, BasePoll, BaseVote, CollectDefaultVotesMixin) from openslides.poll.models import (BaseOption, BasePoll, BaseVote, CollectDefaultVotesMixin)
from openslides.projector.models import RelatedModelMixin, SlideMixin from openslides.projector.models import RelatedModelMixin, SlideMixin
@ -69,8 +70,17 @@ class Motion(SlideMixin, AbsoluteUrlMixin, models.Model):
Many to many relation to mediafile objects. Many to many relation to mediafile objects.
""" """
# TODO: proposal parent = models.ForeignKey('self', null=True, blank=True, related_name='amendments')
# master = models.ForeignKey('self', null=True, blank=True) """
Field for amendments to reference to the motion that should be altered.
Null if the motion is not an amendment.
"""
tags = models.ManyToManyField(Tag)
"""
Tags to categorise motions.
"""
class Meta: class Meta:
permissions = ( permissions = (
@ -204,19 +214,31 @@ class Motion(SlideMixin, AbsoluteUrlMixin, models.Model):
def set_identifier(self): def set_identifier(self):
""" """
Sets the motion identifier automaticly according to the config Sets the motion identifier automaticly according to the config value if
value if it is not set yet. it is not set yet.
""" """
# The identifier is already set or should be set manually
if config['motion_identifier'] == 'manually' or self.identifier: if config['motion_identifier'] == 'manually' or self.identifier:
# Do not set an identifier. # Do not set an identifier.
return return
# The motion is an amendment
elif self.is_amendment():
motions = self.parent.amendments.all()
# The motions should be counted per category
elif config['motion_identifier'] == 'per_category': elif config['motion_identifier'] == 'per_category':
motions = Motion.objects.filter(category=self.category) motions = Motion.objects.filter(category=self.category)
else: # That means: config['motion_identifier'] == 'serially_numbered'
# The motions should be counted over all.
else:
motions = Motion.objects.all() motions = Motion.objects.all()
number = motions.aggregate(Max('identifier_number'))['identifier_number__max'] or 0 number = motions.aggregate(Max('identifier_number'))['identifier_number__max'] or 0
if self.category is None or not self.category.prefix: if self.is_amendment():
parent_identifier = self.parent.identifier or ''
prefix = '%s %s ' % (parent_identifier, config['motion_amendments_prefix'])
elif self.category is None or not self.category.prefix:
prefix = '' prefix = ''
else: else:
prefix = '%s ' % self.category.prefix prefix = '%s ' % self.category.prefix
@ -521,6 +543,15 @@ class Motion(SlideMixin, AbsoluteUrlMixin, models.Model):
""" """
MotionLog.objects.create(motion=self, message_list=message_list, person=person) MotionLog.objects.create(motion=self, message_list=message_list, person=person)
def is_amendment(self):
"""
Returns True if the motion is an amendment.
A motion is a amendment if amendments are activated in the config and
the motion has a parent.
"""
return config['motion_amendments_enabled'] and self.parent is not None
class MotionVersion(AbsoluteUrlMixin, models.Model): class MotionVersion(AbsoluteUrlMixin, models.Model):
""" """
@ -810,10 +841,12 @@ class State(models.Model):
"""If true, new versions are not automaticly set active.""" """If true, new versions are not automaticly set active."""
dont_set_identifier = models.BooleanField(default=False) dont_set_identifier = models.BooleanField(default=False)
"""Decides if the motion gets an identifier. """
Decides if the motion gets an identifier.
If true, the motion does not get an identifier if the state change to If true, the motion does not get an identifier if the state change to
this one, else it does.""" this one, else it does.
"""
def __str__(self): def __str__(self):
"""Returns the name of the state.""" """Returns the name of the state."""

View File

@ -1,6 +1,6 @@
from django import forms from django import forms
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy, ugettext_noop from django.utils.translation import ugettext_lazy, ugettext_noop, pgettext
from openslides.config.api import ConfigGroup, ConfigGroupedCollection, ConfigVariable from openslides.config.api import ConfigGroup, ConfigGroupedCollection, ConfigVariable
from openslides.poll.models import PERCENT_BASE_CHOICES from openslides.poll.models import PERCENT_BASE_CHOICES
@ -62,6 +62,25 @@ def setup_motion_config(sender, **kwargs):
motion_stop_submitting, motion_stop_submitting,
motion_allow_disable_versioning)) motion_allow_disable_versioning))
# Amendments
motion_amendments_enabled = ConfigVariable(
name='motion_amendments_enabled',
default_value=False,
form_field=forms.BooleanField(
label=ugettext_lazy('Activate amendments'),
required=False))
motion_amendments_prefix = ConfigVariable(
name='motion_amendments_prefix',
default_value=pgettext('Prefix for amendment', 'A'),
form_field=forms.CharField(
required=False,
label=ugettext_lazy('Prefix for the identifier for amendments')))
group_amendments = ConfigGroup(
title=ugettext_lazy('Amendments'),
variables=(motion_amendments_enabled, motion_amendments_prefix))
# Supporters # Supporters
motion_min_supporters = ConfigVariable( motion_min_supporters = ConfigVariable(
name='motion_min_supporters', name='motion_min_supporters',
@ -143,7 +162,8 @@ def setup_motion_config(sender, **kwargs):
title=ugettext_noop('Motion'), title=ugettext_noop('Motion'),
url='motion', url='motion',
weight=30, weight=30,
groups=(group_general, group_supporters, group_ballot_papers, group_pdf)) groups=(group_general, group_amendments, group_supporters,
group_ballot_papers, group_pdf))
def create_builtin_workflows(sender, **kwargs): def create_builtin_workflows(sender, **kwargs):

View File

@ -29,6 +29,9 @@
</span> </span>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if motion.is_amendment %}
(Amendment of <a href="{{ motion.parent|absolute_url }}">{{ motion.parent.identifier|default:motion.parent }}</a>)
{% endif %}
</small> </small>
<small class="pull-right"> <small class="pull-right">
<a href="{% url 'motion_list' %}" class="btn btn-mini"> <a href="{% url 'motion_list' %}" class="btn btn-mini">
@ -68,6 +71,8 @@
</small> </small>
</h1> </h1>
{{ motion.tags.all|join:', ' }}
<div class="row-fluid"> <div class="row-fluid">
<div class="span8"> <div class="span8">
{# TODO: show only for workflow with versioning #} {# TODO: show only for workflow with versioning #}
@ -280,6 +285,23 @@
</h5> </h5>
{{ version.creation_time }} {{ version.creation_time }}
{% if 'motion_amendments_enabled'|get_config %}
<h5>{% trans 'Amendments' %}:</h5>
{% with amendments=motion.amendments.all %}
{% if amendments %}
<ul>
{% for amendment in amendments %}
<li><a href="{{ amendment|absolute_url }}">{{ amendment }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
<a class="btn btn-mini btn-primary" href="{% url 'motion_create_amendment' motion.pk %}">
<i class="icon-plus icon-white"></i>
{% trans 'New amendment' %}
</a>
{% endif %}
<!-- Support/Unsupport button --> <!-- Support/Unsupport button -->
{% if perms.motion.can_support_motion and 'motion_min_supporters'|get_config > 0 %} {% if perms.motion.can_support_motion and 'motion_min_supporters'|get_config > 0 %}
{% if allowed_actions.unsupport %} {% if allowed_actions.unsupport %}

View File

@ -7,25 +7,8 @@
{% block header %} {% block header %}
{{ block.super }} {{ block.super }}
<link type="text/css" rel="stylesheet" media="all" href="{% static 'css/motion.css' %}" /> <link type="text/css" rel="stylesheet" media="all" href="{% static 'css/motion.css' %}" />
<link href="{% static 'css/jquery.bsmselect.css' %}" type="text/css" rel="stylesheet" />
{% endblock %} {% endblock %}
{% block javascript %}
{{ block.super }}
<script type="text/javascript" src="{% static 'js/jquery/jquery.bsmselect.js' %}"></script>
<script type="text/javascript">
// use jquery-bsmselect for all <select multiple> form elements
$("select[multiple]").bsmSelect({
removeLabel: '<sup><b>X</b></sup>',
containerClass: 'bsmContainer',
listClass: 'bsmList-custom',
listItemClass: 'bsmListItem-custom',
listItemLabelClass: 'bsmListItemLabel-custom',
removeClass: 'bsmListItemRemove-custom'
});
</script>
{% endblock %}
{% block title %} {% block title %}
{% if motion %} {% if motion %}

View File

@ -33,11 +33,20 @@
<small class="pull-right"> <small class="pull-right">
{% if perms.motion.can_create_motion %} {% if perms.motion.can_create_motion %}
{% if not 'motion_stop_submitting'|get_config or perms.motion.can_manage_motion %} {% if not 'motion_stop_submitting'|get_config or perms.motion.can_manage_motion %}
<a href="{% url 'motion_new' %}" class="btn btn-mini btn-primary" rel="tooltip" data-original-title="{% trans 'New motion' %}"><i class="icon-plus icon-white"></i> {% trans 'New' %}</a> <a href="{% url 'motion_create' %}" class="btn btn-mini btn-primary" rel="tooltip" data-original-title="{% trans 'New motion' %}"><i class="icon-plus icon-white"></i> {% trans 'New' %}</a>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if perms.core.can_manage_tags %}
<a href="{% url 'core_tag_list' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Manage tags' %}">
<i class="icon-th"></i>
<span class="optional-small"> {% trans 'Tags' %}</span>
</a>
{% endif %}
{% if perms.motion.can_manage_motion %} {% if perms.motion.can_manage_motion %}
<a href="{% url 'motion_category_list' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Manage categories' %}"><i class="icon-th-large"></i><span class="optional-small"> {% trans 'Categories' %}</span></a> <a href="{% url 'motion_category_list' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Manage categories' %}">
<i class="icon-th-large"></i>
<span class="optional-small"> {% trans 'Categories' %}</span>
</a>
<a href="{% url 'motion_csv_import' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Import motions' %}"><i class="icon-import"></i><span class="optional-small"> {% trans 'Import' %}</span></a> <a href="{% url 'motion_csv_import' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Import motions' %}"><i class="icon-import"></i><span class="optional-small"> {% trans 'Import' %}</span></a>
{% endif %} {% endif %}
<a href="{% url 'motion_list_pdf' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Print all motions as PDF' %}" target="_blank"><i class="icon-print"></i><span class="optional-small"> PDF</span></a> <a href="{% url 'motion_list_pdf' %}" class="btn btn-mini" rel="tooltip" data-original-title="{% trans 'Print all motions as PDF' %}" target="_blank"><i class="icon-print"></i><span class="optional-small"> PDF</span></a>
@ -64,7 +73,14 @@
{% for motion in motion_list %} {% for motion in motion_list %}
<tr class="{% if motion.is_active_slide %}activeline{% endif %}"> <tr class="{% if motion.is_active_slide %}activeline{% endif %}">
<td class="nobr">{{ motion.identifier|default:'' }}</td> <td class="nobr">{{ motion.identifier|default:'' }}</td>
<td><a href="{{ motion|absolute_url }}">{{ motion.title }}</a></td> <td>
<a href="{{ motion|absolute_url }}">{{ motion.title }}</a>
{% if motion.is_amendment %}
<a class="label label-success" data-original-title="Amendment" rel="tooltip">
{{ 'motion_amendments_prefix'|get_config }}
</a>
{% endif %}
</td>
<td class="optional">{% if motion.category %}{{ motion.category }}{% else %}{% endif %}</td> <td class="optional">{% if motion.category %}{{ motion.category }}{% else %}{% endif %}</td>
<td class="optional-small"><span class="label label-info">{% trans motion.state.name %}</span></td> <td class="optional-small"><span class="label label-info">{% trans motion.state.name %}</span></td>
<td class="optional"> <td class="optional">

View File

@ -77,6 +77,9 @@
{{ motion.active_version.title }} {{ motion.active_version.title }}
<small> <small>
{% trans "Motion" %} {{ motion.identifier|default:'' }} {% trans "Motion" %} {{ motion.identifier|default:'' }}
{% if motion.is_amendment %}
(Amendment of {{ motion.parent.identifier|default:motion.parent }})
{% endif %}
{% if motion.get_active_version.version_number > 1 %} | {% trans 'Version' %} {{ motion.active_version.version_number }}{% endif %} {% if motion.get_active_version.version_number > 1 %} | {% trans 'Version' %} {{ motion.active_version.version_number }}{% endif %}
</small> </small>
</h1> </h1>

View File

@ -1,106 +1,111 @@
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
from . import views
# TODO: define the Views inhere # TODO: define the Views inhere
urlpatterns = patterns( urlpatterns = patterns(
'openslides.motion.views', 'openslides.motion.views',
url(r'^$', url(r'^$',
'motion_list', views.MotionListView.as_view(),
name='motion_list'), name='motion_list'),
url(r'^new/$', url(r'^new/$',
'motion_create', views.MotionCreateView.as_view(),
# TODO: rename to motion_create name='motion_create'),
name='motion_new'),
url(r'^(?P<pk>\d+)/$', url(r'^(?P<pk>\d+)/$',
'motion_detail', views.MotionDetailView.as_view(),
name='motion_detail'), name='motion_detail'),
url(r'^(?P<pk>\d+)/edit/$', url(r'^(?P<pk>\d+)/edit/$',
'motion_update', views.MotionUpdateView.as_view(),
name='motion_update'), name='motion_update'),
url(r'^(?P<pk>\d+)/del/$', url(r'^(?P<pk>\d+)/del/$',
'motion_delete', views.MotionDeleteView.as_view(),
name='motion_delete'), name='motion_delete'),
url(r'^(?P<pk>\d+)/new_amendment/$',
views.MotionCreateAmendmentView.as_view(),
name='motion_create_amendment'),
url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/$', url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/$',
'motion_detail', views.MotionDetailView.as_view(),
name='motion_version_detail'), name='motion_version_detail'),
url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/permit/$', url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/permit/$',
'version_permit', views.VersionPermitView.as_view(),
name='motion_version_permit'), name='motion_version_permit'),
url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/del/$', url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/del/$',
'version_delete', views.VersionDeleteView.as_view(),
name='motion_version_delete'), name='motion_version_delete'),
url(r'^(?P<pk>\d+)/diff/$', url(r'^(?P<pk>\d+)/diff/$',
'version_diff', views.VersionDiffView.as_view(),
name='motion_version_diff'), name='motion_version_diff'),
url(r'^(?P<pk>\d+)/support/$', url(r'^(?P<pk>\d+)/support/$',
'motion_support', views.SupportView.as_view(support=True),
name='motion_support'), name='motion_support'),
url(r'^(?P<pk>\d+)/unsupport/$', url(r'^(?P<pk>\d+)/unsupport/$',
'motion_unsupport', views.SupportView.as_view(support=False),
name='motion_unsupport'), name='motion_unsupport'),
url(r'^(?P<pk>\d+)/create_poll/$', url(r'^(?P<pk>\d+)/create_poll/$',
'poll_create', views.PollCreateView.as_view(),
name='motionpoll_create'), name='motionpoll_create'),
url(r'^(?P<pk>\d+)/poll/(?P<poll_number>\d+)/edit/$', url(r'^(?P<pk>\d+)/poll/(?P<poll_number>\d+)/edit/$',
'poll_update', views.PollUpdateView.as_view(),
name='motionpoll_update'), name='motionpoll_update'),
url(r'^(?P<pk>\d+)/poll/(?P<poll_number>\d+)/del/$', url(r'^(?P<pk>\d+)/poll/(?P<poll_number>\d+)/del/$',
'poll_delete', views.PollDeleteView.as_view(),
name='motionpoll_delete'), name='motionpoll_delete'),
url(r'^(?P<pk>\d+)/poll/(?P<poll_number>\d+)/pdf/$', url(r'^(?P<pk>\d+)/poll/(?P<poll_number>\d+)/pdf/$',
'poll_pdf', views.PollPDFView.as_view(),
name='motionpoll_pdf'), name='motionpoll_pdf'),
url(r'^(?P<pk>\d+)/set_state/(?P<state>\d+)/$', url(r'^(?P<pk>\d+)/set_state/(?P<state>\d+)/$',
'set_state', views.MotionSetStateView.as_view(),
name='motion_set_state'), name='motion_set_state'),
url(r'^(?P<pk>\d+)/reset_state/$', url(r'^(?P<pk>\d+)/reset_state/$',
'reset_state', views.MotionSetStateView.as_view(reset=True),
name='motion_reset_state'), name='motion_reset_state'),
url(r'^(?P<pk>\d+)/agenda/$', url(r'^(?P<pk>\d+)/agenda/$',
'create_agenda_item', views.CreateRelatedAgendaItemView.as_view(),
name='motion_create_agenda'), name='motion_create_agenda'),
url(r'^pdf/$', url(r'^pdf/$',
'motion_list_pdf', views.MotionPDFView.as_view(print_all_motions=True),
name='motion_list_pdf'), name='motion_list_pdf'),
url(r'^(?P<pk>\d+)/pdf/$', url(r'^(?P<pk>\d+)/pdf/$',
'motion_detail_pdf', views.MotionPDFView.as_view(print_all_motions=False),
name='motion_detail_pdf'), name='motion_detail_pdf'),
url(r'^category/$', url(r'^category/$',
'category_list', views.CategoryListView.as_view(),
name='motion_category_list'), name='motion_category_list'),
url(r'^category/new/$', url(r'^category/new/$',
'category_create', views.CategoryCreateView.as_view(),
name='motion_category_create'), name='motion_category_create'),
url(r'^category/(?P<pk>\d+)/edit/$', url(r'^category/(?P<pk>\d+)/edit/$',
'category_update', views.CategoryUpdateView.as_view(),
name='motion_category_update'), name='motion_category_update'),
url(r'^category/(?P<pk>\d+)/del/$', url(r'^category/(?P<pk>\d+)/del/$',
'category_delete', views.CategoryDeleteView.as_view(),
name='motion_category_delete'), name='motion_category_delete'),
url(r'^csv_import/$', url(r'^csv_import/$',
'motion_csv_import', views.MotionCSVImportView.as_view(),
name='motion_csv_import'), name='motion_csv_import'),
) )

View File

@ -47,7 +47,7 @@ class MotionListView(ListView):
Returns not a QuerySet but a filtered list of motions. Excludes motions Returns not a QuerySet but a filtered list of motions. Excludes motions
that the user is not allowed to see. that the user is not allowed to see.
""" """
queryset = super(MotionListView, self).get_queryset(*args, **kwargs) queryset = super().get_queryset(*args, **kwargs)
motions = [] motions = []
for motion in queryset: for motion in queryset:
if (not motion.state.required_permission_to_see or if (not motion.state.required_permission_to_see or
@ -55,8 +55,6 @@ class MotionListView(ListView):
motions.append(motion) motions.append(motion)
return motions return motions
motion_list = MotionListView.as_view()
class MotionDetailView(DetailView): class MotionDetailView(DetailView):
""" """
@ -92,9 +90,7 @@ class MotionDetailView(DetailView):
'title': version.title, 'title': version.title,
'text': version.text, 'text': version.text,
'reason': version.reason}) 'reason': version.reason})
return super(MotionDetailView, self).get_context_data(**kwargs) return super().get_context_data(**kwargs)
motion_detail = MotionDetailView.as_view()
class MotionEditMixin(object): class MotionEditMixin(object):
@ -142,6 +138,10 @@ class MotionEditMixin(object):
self.object.attachments.clear() self.object.attachments.clear()
self.object.attachments.add(*form.cleaned_data['attachments']) self.object.attachments.add(*form.cleaned_data['attachments'])
# Save the tags
self.object.tags.clear()
self.object.tags.add(*form.cleaned_data['tags'])
# Update the projector if the motion is on it. This can not be done in # Update the projector if the motion is on it. This can not be done in
# the model, because bulk_create does not call the save method. # the model, because bulk_create does not call the save method.
active_slide = get_active_slide() active_slide = get_active_slide()
@ -203,17 +203,26 @@ class MotionCreateView(MotionEditMixin, CreateView):
def form_valid(self, form): def form_valid(self, form):
""" """
Write a log message if the form is valid. Write a log message and set the submitter if necessary.
""" """
response = super(MotionCreateView, self).form_valid(form) # First, validate and process the form and create the motion
response = super().form_valid(form)
# Write the log message
self.object.write_log([ugettext_noop('Motion created')], self.request.user) self.object.write_log([ugettext_noop('Motion created')], self.request.user)
# Set submitter to request.user if no submitter is set yet
if ('submitter' not in form.cleaned_data or if ('submitter' not in form.cleaned_data or
not form.cleaned_data['submitter']): not form.cleaned_data['submitter']):
self.object.add_submitter(self.request.user) self.object.add_submitter(self.request.user)
return response return response
def get_initial(self): def get_initial(self):
initial = super(MotionCreateView, self).get_initial() """
Sets the initial data for the MotionCreateForm.
"""
initial = super().get_initial()
initial['text'] = config['motion_preamble']
if self.request.user.has_perm('motion.can_manage_motion'): if self.request.user.has_perm('motion.can_manage_motion'):
initial['workflow'] = config['motion_workflow'] initial['workflow'] = config['motion_workflow']
return initial return initial
@ -227,7 +236,53 @@ class MotionCreateView(MotionEditMixin, CreateView):
self.object.reset_state(workflow) self.object.reset_state(workflow)
self.version = self.object.get_new_version() self.version = self.object.get_new_version()
motion_create = MotionCreateView.as_view()
class MotionCreateAmendmentView(MotionCreateView):
"""
Create an amendment.
"""
def dispatch(self, *args, **kwargs):
if not config['motion_amendments_enabled']:
raise Http404('Amendments are disabled in the config.')
return super().dispatch(*args, **kwargs)
def get_parent_motion(self):
"""
Gets the parent motion from the url.
Caches the value.
"""
try:
parent = self._object_parent
except AttributeError:
# self.get_object() is the django method, which does not cache the
# object. For now this is not a problem, because get_object() is only
# called once.
parent = self._object_parent = self.get_object()
return parent
def manipulate_object(self, form):
"""
Sets the parent to the motion to which this amendment refers.
"""
self.object.parent = self.get_parent_motion()
super().manipulate_object(form)
def get_initial(self):
"""
Sets the initial values to the form.
This are the values for title, text and reason which are set to the
values from the parent motion.
"""
initial = super().get_initial()
parent = self.get_parent_motion()
initial['title'] = parent.title
initial['text'] = parent.text
initial['reason'] = parent.reason
initial['category'] = parent.category
return initial
class MotionUpdateView(MotionEditMixin, UpdateView): class MotionUpdateView(MotionEditMixin, UpdateView):
@ -246,7 +301,7 @@ class MotionUpdateView(MotionEditMixin, UpdateView):
""" """
Writes a log message and removes supports in some cases if the form is valid. Writes a log message and removes supports in some cases if the form is valid.
""" """
response = super(MotionUpdateView, self).form_valid(form) response = super().form_valid(form)
self.write_log() self.write_log()
if (config['motion_remove_supporters'] and self.object.state.allow_support and if (config['motion_remove_supporters'] and self.object.state.allow_support and
not self.request.user.has_perm('motion.can_manage_motion')): not self.request.user.has_perm('motion.can_manage_motion')):
@ -271,7 +326,7 @@ class MotionUpdateView(MotionEditMixin, UpdateView):
self.request.user) self.request.user)
def get_initial(self): def get_initial(self):
initial = super(MotionUpdateView, self).get_initial() initial = super().get_initial()
if self.request.user.has_perm('motion.can_manage_motion'): if self.request.user.has_perm('motion.can_manage_motion'):
initial['workflow'] = self.object.state.workflow initial['workflow'] = self.object.state.workflow
return initial return initial
@ -295,8 +350,6 @@ class MotionUpdateView(MotionEditMixin, UpdateView):
self.version = self.object.get_last_version() self.version = self.object.get_last_version()
self.used_new_version = False self.used_new_version = False
motion_update = MotionUpdateView.as_view()
class MotionDeleteView(DeleteView): class MotionDeleteView(DeleteView):
""" """
@ -314,8 +367,6 @@ class MotionDeleteView(DeleteView):
def get_final_message(self): def get_final_message(self):
return _('%s was successfully deleted.') % _('Motion') return _('%s was successfully deleted.') % _('Motion')
motion_delete = MotionDeleteView.as_view()
class VersionDeleteView(DeleteView): class VersionDeleteView(DeleteView):
""" """
@ -347,8 +398,6 @@ class VersionDeleteView(DeleteView):
def get_url_name_args(self): def get_url_name_args(self):
return (self.get_object().motion_id, ) return (self.get_object().motion_id, )
version_delete = VersionDeleteView.as_view()
class VersionPermitView(SingleObjectMixin, QuestionView): class VersionPermitView(SingleObjectMixin, QuestionView):
""" """
@ -368,7 +417,7 @@ class VersionPermitView(SingleObjectMixin, QuestionView):
self.version = self.get_object().versions.get(version_number=int(version_number)) self.version = self.get_object().versions.get(version_number=int(version_number))
except MotionVersion.DoesNotExist: except MotionVersion.DoesNotExist:
raise Http404('Version %s not found.' % version_number) raise Http404('Version %s not found.' % version_number)
return super(VersionPermitView, self).get(*args, **kwargs) return super().get(*args, **kwargs)
def get_url_name_args(self): def get_url_name_args(self):
""" """
@ -394,8 +443,6 @@ class VersionPermitView(SingleObjectMixin, QuestionView):
ugettext_noop('permitted')], ugettext_noop('permitted')],
person=self.request.user) person=self.request.user)
version_permit = VersionPermitView.as_view()
class VersionDiffView(DetailView): class VersionDiffView(DetailView):
""" """
@ -427,7 +474,7 @@ class VersionDiffView(DetailView):
version_rev2 = None version_rev2 = None
diff_text = None diff_text = None
diff_reason = None diff_reason = None
context = super(VersionDiffView, self).get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context.update({ context.update({
'version_rev1': version_rev1, 'version_rev1': version_rev1,
'version_rev2': version_rev2, 'version_rev2': version_rev2,
@ -436,8 +483,6 @@ class VersionDiffView(DetailView):
}) })
return context return context
version_diff = VersionDiffView.as_view()
class SupportView(SingleObjectMixin, QuestionView): class SupportView(SingleObjectMixin, QuestionView):
""" """
@ -500,9 +545,6 @@ class SupportView(SingleObjectMixin, QuestionView):
else: else:
return _("You have unsupported this motion successfully.") return _("You have unsupported this motion successfully.")
motion_support = SupportView.as_view(support=True)
motion_unsupport = SupportView.as_view(support=False)
class PollCreateView(SingleObjectMixin, RedirectView): class PollCreateView(SingleObjectMixin, RedirectView):
""" """
@ -526,8 +568,6 @@ class PollCreateView(SingleObjectMixin, RedirectView):
""" """
return reverse('motionpoll_update', args=[self.get_object().pk, self.poll.poll_number]) return reverse('motionpoll_update', args=[self.get_object().pk, self.poll.poll_number])
poll_create = PollCreateView.as_view()
class PollMixin(object): class PollMixin(object):
""" """
@ -579,7 +619,7 @@ class PollUpdateView(PollMixin, PollFormView):
Append the motion object to the context. Append the motion object to the context.
""" """
context = super(PollUpdateView, self).get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context.update({ context.update({
'motion': self.poll.motion, 'motion': self.poll.motion,
'poll': self.poll}) 'poll': self.poll})
@ -589,12 +629,10 @@ class PollUpdateView(PollMixin, PollFormView):
""" """
Write a log message, if the form is valid. Write a log message, if the form is valid.
""" """
value = super(PollUpdateView, self).form_valid(form) value = super().form_valid(form)
self.get_object().write_log([ugettext_noop('Poll updated')], self.request.user) self.get_object().write_log([ugettext_noop('Poll updated')], self.request.user)
return value return value
poll_update = PollUpdateView.as_view()
class PollDeleteView(PollMixin, DeleteView): class PollDeleteView(PollMixin, DeleteView):
""" """
@ -607,7 +645,7 @@ class PollDeleteView(PollMixin, DeleteView):
""" """
Write a log message, if the form is valid. Write a log message, if the form is valid.
""" """
super(PollDeleteView, self).on_clicked_yes() super().on_clicked_yes()
self.get_object().motion.write_log([ugettext_noop('Poll deleted')], self.request.user) self.get_object().motion.write_log([ugettext_noop('Poll deleted')], self.request.user)
def get_redirect_url(self, **kwargs): def get_redirect_url(self, **kwargs):
@ -616,8 +654,6 @@ class PollDeleteView(PollMixin, DeleteView):
""" """
return reverse('motion_detail', args=[self.get_object().motion.pk]) return reverse('motion_detail', args=[self.get_object().motion.pk])
poll_delete = PollDeleteView.as_view()
class PollPDFView(PollMixin, PDFView): class PollPDFView(PollMixin, PDFView):
""" """
@ -647,8 +683,6 @@ class PollPDFView(PollMixin, PDFView):
""" """
motion_poll_to_pdf(pdf, self.get_object()) motion_poll_to_pdf(pdf, self.get_object())
poll_pdf = PollPDFView.as_view()
class MotionSetStateView(SingleObjectMixin, RedirectView): class MotionSetStateView(SingleObjectMixin, RedirectView):
""" """
@ -686,9 +720,6 @@ class MotionSetStateView(SingleObjectMixin, RedirectView):
_('The state of the motion was set to %s.') _('The state of the motion was set to %s.')
% html_strong(_(self.get_object().state.name))) % html_strong(_(self.get_object().state.name)))
set_state = MotionSetStateView.as_view()
reset_state = MotionSetStateView.as_view(reset=True)
class CreateRelatedAgendaItemView(_CreateRelatedAgendaItemView): class CreateRelatedAgendaItemView(_CreateRelatedAgendaItemView):
""" """
@ -700,11 +731,9 @@ class CreateRelatedAgendaItemView(_CreateRelatedAgendaItemView):
""" """
Create the agenda item. Create the agenda item.
""" """
super(CreateRelatedAgendaItemView, self).pre_redirect(request, *args, **kwargs) super().pre_redirect(request, *args, **kwargs)
self.get_object().write_log([ugettext_noop('Agenda item created')], self.request.user) self.get_object().write_log([ugettext_noop('Agenda item created')], self.request.user)
create_agenda_item = CreateRelatedAgendaItemView.as_view()
class MotionPDFView(SingleObjectMixin, PDFView): class MotionPDFView(SingleObjectMixin, PDFView):
""" """
@ -734,7 +763,7 @@ class MotionPDFView(SingleObjectMixin, PDFView):
if self.print_all_motions: if self.print_all_motions:
obj = None obj = None
else: else:
obj = super(MotionPDFView, self).get_object(*args, **kwargs) obj = super().get_object(*args, **kwargs)
return obj return obj
def get_filename(self): def get_filename(self):
@ -765,16 +794,11 @@ class MotionPDFView(SingleObjectMixin, PDFView):
else: else:
motion_to_pdf(pdf, self.get_object()) motion_to_pdf(pdf, self.get_object())
motion_list_pdf = MotionPDFView.as_view(print_all_motions=True)
motion_detail_pdf = MotionPDFView.as_view(print_all_motions=False)
class CategoryListView(ListView): class CategoryListView(ListView):
required_permission = 'motion.can_manage_motion' required_permission = 'motion.can_manage_motion'
model = Category model = Category
category_list = CategoryListView.as_view()
class CategoryCreateView(CreateView): class CategoryCreateView(CreateView):
required_permission = 'motion.can_manage_motion' required_permission = 'motion.can_manage_motion'
@ -782,8 +806,6 @@ class CategoryCreateView(CreateView):
success_url_name = 'motion_category_list' success_url_name = 'motion_category_list'
url_name_args = [] url_name_args = []
category_create = CategoryCreateView.as_view()
class CategoryUpdateView(UpdateView): class CategoryUpdateView(UpdateView):
required_permission = 'motion.can_manage_motion' required_permission = 'motion.can_manage_motion'
@ -791,8 +813,6 @@ class CategoryUpdateView(UpdateView):
success_url_name = 'motion_category_list' success_url_name = 'motion_category_list'
url_name_args = [] url_name_args = []
category_update = CategoryUpdateView.as_view()
class CategoryDeleteView(DeleteView): class CategoryDeleteView(DeleteView):
required_permission = 'motion.can_manage_motion' required_permission = 'motion.can_manage_motion'
@ -801,8 +821,6 @@ class CategoryDeleteView(DeleteView):
url_name_args = [] url_name_args = []
success_url_name = 'motion_category_list' success_url_name = 'motion_category_list'
category_delete = CategoryDeleteView.as_view()
class MotionCSVImportView(CSVImportView): class MotionCSVImportView(CSVImportView):
""" """
@ -817,7 +835,7 @@ class MotionCSVImportView(CSVImportView):
""" """
Sets the request user as initial for the default submitter. Sets the request user as initial for the default submitter.
""" """
return_value = super(MotionCSVImportView, self).get_initial(*args, **kwargs) return_value = super().get_initial(*args, **kwargs)
return_value.update({'default_submitter': self.request.user.person_id}) return_value.update({'default_submitter': self.request.user.person_id})
return return_value return return_value
@ -828,5 +846,3 @@ class MotionCSVImportView(CSVImportView):
messages.error(self.request, error) messages.error(self.request, error)
# Overleap method of CSVImportView # Overleap method of CSVImportView
return super(CSVImportView, self).form_valid(form) return super(CSVImportView, self).form_valid(form)
motion_csv_import = MotionCSVImportView.as_view()

View File

@ -170,11 +170,14 @@ def create_builtin_groups_and_admin(sender, **kwargs):
ct_config = ContentType.objects.get(app_label='config', model='configstore') ct_config = ContentType.objects.get(app_label='config', model='configstore')
perm_48 = Permission.objects.get(content_type=ct_config, codename='can_manage') perm_48 = Permission.objects.get(content_type=ct_config, codename='can_manage')
ct_tag = ContentType.objects.get(app_label='core', model='tag')
can_manage_tags = Permission.objects.get(content_type=ct_tag, codename='can_manage_tags')
group_staff = Group.objects.create(name=ugettext_noop('Staff'), pk=4) group_staff = Group.objects.create(name=ugettext_noop('Staff'), pk=4)
# add delegate permissions (without can_support_motion) # add delegate permissions (without can_support_motion)
group_staff.permissions.add(perm_31, perm_33, perm_34, perm_35) group_staff.permissions.add(perm_31, perm_33, perm_34, perm_35)
# add staff permissions # add staff permissions
group_staff.permissions.add(perm_41, perm_42, perm_43, perm_44, perm_45, perm_46, perm_47, perm_48) group_staff.permissions.add(perm_41, perm_42, perm_43, perm_44, perm_45, perm_46, perm_47, perm_48, can_manage_tags)
# add can_see_user permission # add can_see_user permission
group_staff.permissions.add(perm_17) # TODO: Remove this redundancy after cleanup of the permission system group_staff.permissions.add(perm_17) # TODO: Remove this redundancy after cleanup of the permission system

View File

@ -3,24 +3,6 @@
{% load i18n %} {% load i18n %}
{% load staticfiles %} {% load staticfiles %}
{% block header %}
<link href="{% static 'css/jquery.bsmselect.css' %}" type="text/css" rel="stylesheet" />
{% endblock %}
{% block javascript %}
<script type="text/javascript" src="{% static 'js/jquery/jquery.bsmselect.js' %}"></script>
<script type="text/javascript">
// use jquery-bsmselect for all <select multiple> form elements
$("select[multiple]").bsmSelect({
removeLabel: '<sup><b>X</b></sup>',
containerClass: 'bsmContainer',
listClass: 'bsmList-custom',
listItemClass: 'bsmListItem-custom',
listItemLabelClass: 'bsmListItemLabel-custom',
removeClass: 'bsmListItemRemove-custom'
});
</script>
{% endblock %}
{% block title %} {% block title %}
{% if edit_user %} {% if edit_user %}

View File

@ -292,8 +292,12 @@ class AjaxView(PermissionMixin, AjaxMixin, View):
View for ajax requests. View for ajax requests.
""" """
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
# TODO: Raise an error, if the request is not an ajax-request
return self.ajax_get(request, *args, **kwargs) return self.ajax_get(request, *args, **kwargs)
def post(self, *args, **kwargs):
return self.get(*args, **kwargs)
class RedirectView(PermissionMixin, AjaxMixin, UrlMixin, django_views.RedirectView): class RedirectView(PermissionMixin, AjaxMixin, UrlMixin, django_views.RedirectView):
""" """

View File

@ -142,3 +142,24 @@ class CustomSlidesTest(TestCase):
response = self.admin_client.post(url, {'yes': 'true'}) response = self.admin_client.post(url, {'yes': 'true'})
self.assertRedirects(response, '/dashboard/') self.assertRedirects(response, '/dashboard/')
self.assertFalse(CustomSlide.objects.exists()) self.assertFalse(CustomSlide.objects.exists())
class TagListViewTest(TestCase):
def test_get_tag_queryset(self):
view = views.TagListView()
with patch('openslides.core.views.Tag') as mock_tag:
view.get_tag_queryset('some_name_with_123', 15)
self.assertEqual(view.pk, 123)
mock_tag.objects.filter.assert_called_with(pk=123)
def test_get_tag_queryset_wrong_name(self):
view = views.TagListView()
with patch('openslides.core.views.Tag'):
with self.assertRaises(views.TagException) as context:
view.get_tag_queryset('some_name_with_', 15)
self.assertFalse(hasattr(view, 'pk'))
self.assertEqual(str(context.exception), 'Invalid name in request')

View File

@ -145,6 +145,64 @@ class ModelTest(TestCase):
# motion.__unicode__() raised an AttributeError # motion.__unicode__() raised an AttributeError
self.assertEqual(str(motion), 'test_identifier_VohT1hu9uhiSh6ooVBFS | test_title_Koowoh1ISheemeey1air') self.assertEqual(str(motion), 'test_identifier_VohT1hu9uhiSh6ooVBFS | test_title_Koowoh1ISheemeey1air')
def test_is_amendment(self):
config['motion_amendments_enabled'] = True
amendment = Motion.objects.create(title='amendment', parent=self.motion)
self.assertTrue(amendment.is_amendment())
self.assertFalse(self.motion.is_amendment())
def test_set_identifier_allready_set(self):
"""
If the motion already has a identifier, the method does nothing.
"""
motion = Motion(identifier='My test identifier')
motion.set_identifier()
self.assertEqual(motion.identifier, 'My test identifier')
def test_set_identifier_manually(self):
"""
If the config is set to manually, the method does nothing.
"""
config['motion_identifier'] = 'manually'
motion = Motion()
motion.set_identifier()
# If the identifier should be set manually, the method does nothing
self.assertIsNone(motion.identifier)
def test_set_identifier_amendment(self):
"""
If the motion is an amendment, the identifier is the identifier from the
parent + a suffix.
"""
config['motion_amendments_enabled'] = True
self.motion.identifier = 'Parent identifier'
self.motion.save()
motion = Motion(parent=self.motion)
motion.set_identifier()
self.assertEqual(motion.identifier, 'Parent identifier A 1')
def test_set_identifier_second_amendment(self):
"""
If a motion has already an amendment, the second motion gets another
identifier.
"""
config['motion_amendments_enabled'] = True
self.motion.identifier = 'Parent identifier'
self.motion.save()
Motion.objects.create(title='Amendment1', parent=self.motion)
motion = Motion(parent=self.motion)
motion.set_identifier()
self.assertEqual(motion.identifier, 'Parent identifier A 2')
class ConfigTest(TestCase): class ConfigTest(TestCase):
def test_stop_submitting(self): def test_stop_submitting(self):

View File

@ -1,11 +1,14 @@
import os import os
import tempfile import tempfile
from unittest.mock import MagicMock
from django.conf import settings from django.conf import settings
from django.test import RequestFactory
from django.test.client import Client from django.test.client import Client
from openslides.config.api import config from openslides.config.api import config
from openslides.mediafile.models import Mediafile from openslides.mediafile.models import Mediafile
from openslides.motion import views
from openslides.motion.models import Category, Motion, MotionLog, State from openslides.motion.models import Category, Motion, MotionLog, State
from openslides.users.models import Group, User from openslides.users.models import Group, User
from openslides.utils.test import TestCase from openslides.utils.test import TestCase
@ -241,6 +244,67 @@ class TestMotionCreateView(MotionViewTestCase):
self.assertEqual(MotionLog.objects.get(pk=1).message_list, ['Motion created']) self.assertEqual(MotionLog.objects.get(pk=1).message_list, ['Motion created'])
class TestMotionCreateAmendmentView(MotionViewTestCase):
url = '/motion/1/new_amendment/'
def test_get_amendment_active(self):
config['motion_amendments_enabled'] = True
self.check_url(self.url, self.admin_client, 200)
def test_get_amendment_inactive(self):
config['motion_amendments_enabled'] = False
self.check_url(self.url, self.admin_client, 404)
def test_get_parent_motion(self):
motion = Motion.objects.create(title='Test Motion')
view = views.MotionCreateAmendmentView()
view.request = RequestFactory().get(self.url)
view.kwargs = {'pk': motion.pk}
self.assertEqual(view.get_parent_motion(), motion)
def test_manipulate_object(self):
motion = Motion.objects.create(title='Test Motion')
view = views.MotionCreateAmendmentView()
view.request = RequestFactory().get(self.url)
view.kwargs = {'pk': motion.pk}
view.object = MagicMock()
view.manipulate_object(MagicMock())
self.assertEqual(view.object.parent, motion)
def test_get_initial(self):
motion = Motion.objects.create(
title='Test Motion', text='Parent Motion text', reason='test reason')
view = views.MotionCreateAmendmentView()
view.request = MagicMock()
view.kwargs = {'pk': motion.pk}
self.assertEqual(view.get_initial(), {
'reason': u'test reason',
'text': u'Parent Motion text',
'title': u'Test Motion',
'category': None,
'workflow': '1'})
def test_get_initial_with_category(self):
category = Category.objects.create(name='test category')
motion = Motion.objects.create(
title='Test Motion', text='Parent Motion text', reason='test reason',
category=category)
view = views.MotionCreateAmendmentView()
view.request = MagicMock()
view.kwargs = {'pk': motion.pk}
self.assertEqual(view.get_initial(), {
'reason': u'test reason',
'text': u'Parent Motion text',
'title': u'Test Motion',
'category': category,
'workflow': '1'})
class TestMotionUpdateView(MotionViewTestCase): class TestMotionUpdateView(MotionViewTestCase):
url = '/motion/1/edit/' url = '/motion/1/edit/'