diff --git a/CHANGELOG b/CHANGELOG index f9e59d8d4..d2fad35f5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -16,11 +16,14 @@ Other: template signals and slides. -Version 1.7.0 (unreleased) -========================== +Version 1.7 (unreleased) +======================== [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. Other: - Cleaned up utils.views to increase performance when fetching single objects diff --git a/openslides/agenda/forms.py b/openslides/agenda/forms.py index 62c5420e1..aab531b27 100644 --- a/openslides/agenda/forms.py +++ b/openslides/agenda/forms.py @@ -30,7 +30,7 @@ class ItemForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm): class Meta: 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')} diff --git a/openslides/agenda/models.py b/openslides/agenda/models.py index 06f618fbb..51b5098ed 100644 --- a/openslides/agenda/models.py +++ b/openslides/agenda/models.py @@ -11,6 +11,7 @@ from django.utils.translation import ugettext_lazy, ugettext_noop from mptt.models import MPTTModel, TreeForeignKey from openslides.config.api import config +from openslides.core.models import Tag from openslides.projector.api import (get_active_slide, reset_countdown, start_countdown, stop_countdown, update_projector, update_projector_overlay) @@ -107,6 +108,11 @@ class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel): True, if the list of speakers is closed. """ + tags = models.ManyToManyField(Tag, blank=True) + """ + Tags to categorise agenda items. + """ + class Meta: permissions = ( ('can_see_agenda', ugettext_noop("Can see agenda")), diff --git a/openslides/agenda/templates/agenda/overview.html b/openslides/agenda/templates/agenda/overview.html index f044a185f..eaee7a1f2 100644 --- a/openslides/agenda/templates/agenda/overview.html +++ b/openslides/agenda/templates/agenda/overview.html @@ -34,8 +34,22 @@

{% trans "Agenda" %} {% if perms.agenda.can_manage_agenda %} - {% trans "New" %} - {% trans "Import" %} + + + {% trans "New" %} + + {% endif %} + {% if perms.core.can_manage_tags %} + + + {% trans 'Tags' %} + + {% endif %} + {% if perms.agenda.can_manage_agenda %} + + + {% trans "Import" %} + {% endif %} PDF {% if perms.core.can_see_projector %} diff --git a/openslides/assignment/models.py b/openslides/assignment/models.py index dd9cd17ef..8f5a20905 100644 --- a/openslides/assignment/models.py +++ b/openslides/assignment/models.py @@ -6,6 +6,7 @@ from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy, ugettext_noop from openslides.agenda.models import Item, Speaker +from openslides.core.models import Tag from openslides.config.api import config from openslides.poll.models import (BaseOption, BasePoll, BaseVote, CollectDefaultVotesMixin, @@ -56,6 +57,7 @@ class Assignment(SlideMixin, AbsoluteUrlMixin, models.Model): max_length=79, null=True, blank=True, verbose_name=ugettext_lazy("Default comment on the ballot paper")) status = models.CharField(max_length=3, choices=STATUS, default='sea') + tags = models.ManyToManyField(Tag, blank=True) class Meta: permissions = ( diff --git a/openslides/assignment/templates/assignment/assignment_list.html b/openslides/assignment/templates/assignment/assignment_list.html index 5ea62fcdf..8af8c8977 100644 --- a/openslides/assignment/templates/assignment/assignment_list.html +++ b/openslides/assignment/templates/assignment/assignment_list.html @@ -21,6 +21,12 @@ {% if perms.assignment.can_manage_assignment %} {% trans "New" %} {% endif %} + {% if perms.core.can_manage_tags %} + + + {% trans 'Tags' %} + + {% endif %} {% if perms.assignment.can_see_assignment %} PDF {% endif %} diff --git a/openslides/config/api.py b/openslides/config/api.py index 331b000fe..a6d5f3f30 100644 --- a/openslides/config/api.py +++ b/openslides/config/api.py @@ -152,6 +152,7 @@ class ConfigGroup(object): A simple object class representing a group of variables (tuple) with a special title. """ + def __init__(self, title, variables): self.title = title self.variables = variables diff --git a/openslides/core/exceptions.py b/openslides/core/exceptions.py new file mode 100644 index 000000000..08bd4f270 --- /dev/null +++ b/openslides/core/exceptions.py @@ -0,0 +1,5 @@ +from openslides.utils.exceptions import OpenSlidesError + + +class TagException(OpenSlidesError): + pass diff --git a/openslides/core/models.py b/openslides/core/models.py index f9a8aef42..4688609ff 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -44,3 +44,20 @@ class CustomSlide(SlideMixin, AbsoluteUrlMixin, models.Model): else: url = super(CustomSlide, self).get_absolute_url(link) 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 diff --git a/openslides/core/static/js/config_tags.js b/openslides/core/static/js/config_tags.js new file mode 100644 index 000000000..6f017abe6 --- /dev/null +++ b/openslides/core/static/js/config_tags.js @@ -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'); + }; + }, + }); + } + }); +}); diff --git a/openslides/core/static/js/utils.js b/openslides/core/static/js/utils.js index a6a99cb6e..fe129a89a 100644 --- a/openslides/core/static/js/utils.js +++ b/openslides/core/static/js/utils.js @@ -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 $(window).scroll(function(){ - var el = $('.leftmenu > ul'); + var el = $('.leftmenu > ul'); if($(window).width() > 479) { if ( ($(this).scrollTop() > 80) && ($(this).scrollLeft() < 10)) { el.css({'position':'fixed','top':'10px','width':'14.15%'}); @@ -188,12 +203,12 @@ $(document).ready(function(){ }); $('#content').removeClass('span10').addClass('span11'); } - + function fullmenu(){ $('.leftmenu').removeClass('span1').removeClass('lefticon').addClass('span2'); $('.leftmenu > ul > li > a').each(function(){ $(this).attr({'rel':'','title':''}); }); - $('#content').removeClass('span11').addClass('span10'); + $('#content').removeClass('span11').addClass('span10'); } -}); \ No newline at end of file +}); diff --git a/openslides/core/templates/base.html b/openslides/core/templates/base.html index ff8d1d0b8..c2ea0e954 100644 --- a/openslides/core/templates/base.html +++ b/openslides/core/templates/base.html @@ -15,6 +15,7 @@ + {% for stylefile in extra_stylefiles %} {% endfor %} @@ -132,6 +133,18 @@ + + {% for javascript in extra_javascript %} {% endfor %} diff --git a/openslides/core/templates/core/tag_list.html b/openslides/core/templates/core/tag_list.html new file mode 100644 index 000000000..f6e19e2c5 --- /dev/null +++ b/openslides/core/templates/core/tag_list.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% load i18n %} +{% load staticfiles %} + +{% block title %}{% trans "Tags" %} – {{ block.super }}{% endblock %} + +{% block content %} +

{% trans "Tags" %}

+ +
+ + + {% trans 'Save' %} +
+ + + + + + + + + + + {% for tag in tag_list %} + + + + + {% endfor %} +
{% trans "Tag name" %}{% trans "Actions" %}
{{ tag }} + + + + + + + + +
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/openslides/core/urls.py b/openslides/core/urls.py index b1f3cb1a0..acddec2d7 100644 --- a/openslides/core/urls.py +++ b/openslides/core/urls.py @@ -39,4 +39,8 @@ urlpatterns = patterns( url(r'^customslide/(?P\d+)/del/$', views.CustomSlideDeleteView.as_view(), name='customslide_delete'), + + url(r'tags/$', + views.TagListView.as_view(), + name='core_tag_list'), ) diff --git a/openslides/core/views.py b/openslides/core/views.py index b33b72774..1e41107ec 100644 --- a/openslides/core/views.py +++ b/openslides/core/views.py @@ -2,6 +2,7 @@ from django.conf import settings from django.contrib import messages from django.core.exceptions import PermissionDenied from django.core.urlresolvers import reverse +from django.db import IntegrityError from django.shortcuts import redirect, render_to_response from django.template import RequestContext 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 .forms import SelectWidgetsForm -from .models import CustomSlide +from .models import CustomSlide, Tag +from .exceptions import TagException 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' def get_context_data(self, **kwargs): - context = super(DashboardView, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) widgets = [] for widget in Widget.get_all(self.request): if widget.is_active(): @@ -51,7 +53,7 @@ class SelectWidgetsView(utils_views.TemplateView): template_name = 'core/select_widgets.html' 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) for widget in widgets: initial = {'widget': widget.is_active()} @@ -93,7 +95,7 @@ class VersionView(utils_views.TemplateView): """ 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. if RELEASE: description = '' @@ -119,7 +121,7 @@ class SearchView(_SearchView): def __call__(self, request): if not request.user.is_authenticated() and not config['system_enable_anonymous']: raise PermissionDenied - return super(SearchView, self).__call__(request) + return super().__call__(request) def extra_context(self): """ @@ -211,3 +213,81 @@ class CustomSlideDeleteView(CustomSlideViewMixin, utils_views.DeleteView): Delete a custom slide. """ 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) diff --git a/openslides/motion/forms.py b/openslides/motion/forms.py index 028a602d6..4fd340ddb 100644 --- a/openslides/motion/forms.py +++ b/openslides/motion/forms.py @@ -1,7 +1,6 @@ from django import forms from django.utils.translation import ugettext_lazy -from openslides.config.api import config from openslides.mediafile.models import Mediafile from openslides.utils.forms import (CleanHtmlFormMixin, CssClassMixin, CSVImportForm, LocalizedModelChoiceField) @@ -9,7 +8,7 @@ from openslides.users.models import User from ckeditor.widgets import CKEditorWidget -from .models import Category, Motion, Workflow +from .models import Category, Motion, Workflow, Tag class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm): @@ -47,6 +46,11 @@ class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm): Attachments of the motion. """ + tags = forms.ModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False, + label=ugettext_lazy('Tags')) + class Meta: model = Motion fields = () @@ -54,7 +58,7 @@ class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm): def __init__(self, *args, **kwargs): """ 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.initial = kwargs.setdefault('initial', {}) @@ -64,8 +68,7 @@ class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm): self.initial['text'] = last_version.text self.initial['reason'] = last_version.reason self.initial['attachments'] = self.motion.attachments.all() - else: - self.initial['text'] = config['motion_preamble'] + self.initial['tags'] = self.motion.tags.all() super(BaseMotionForm, self).__init__(*args, **kwargs) diff --git a/openslides/motion/models.py b/openslides/motion/models.py index ee4299475..14f4ad639 100644 --- a/openslides/motion/models.py +++ b/openslides/motion/models.py @@ -6,6 +6,7 @@ from django.utils.translation import ugettext as _ from django.utils.translation import ugettext_lazy, ugettext_noop from openslides.config.api import config +from openslides.core.models import Tag from openslides.mediafile.models import Mediafile from openslides.poll.models import (BaseOption, BasePoll, BaseVote, CollectDefaultVotesMixin) from openslides.projector.models import RelatedModelMixin, SlideMixin @@ -69,8 +70,17 @@ class Motion(SlideMixin, AbsoluteUrlMixin, models.Model): Many to many relation to mediafile objects. """ - # TODO: proposal - # master = models.ForeignKey('self', null=True, blank=True) + parent = models.ForeignKey('self', null=True, blank=True, related_name='amendments') + """ + 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: permissions = ( @@ -204,19 +214,31 @@ class Motion(SlideMixin, AbsoluteUrlMixin, models.Model): def set_identifier(self): """ - Sets the motion identifier automaticly according to the config - value if it is not set yet. + Sets the motion identifier automaticly according to the config value if + it is not set yet. """ + # The identifier is already set or should be set manually if config['motion_identifier'] == 'manually' or self.identifier: # Do not set an identifier. 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': 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() 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 = '' else: 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) + 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): """ @@ -810,10 +841,12 @@ class State(models.Model): """If true, new versions are not automaticly set active.""" 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 - this one, else it does.""" + this one, else it does. + """ def __str__(self): """Returns the name of the state.""" diff --git a/openslides/motion/signals.py b/openslides/motion/signals.py index 69d1ebf1b..8bbfb706a 100644 --- a/openslides/motion/signals.py +++ b/openslides/motion/signals.py @@ -1,6 +1,6 @@ from django import forms 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.poll.models import PERCENT_BASE_CHOICES @@ -62,6 +62,25 @@ def setup_motion_config(sender, **kwargs): motion_stop_submitting, 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 motion_min_supporters = ConfigVariable( name='motion_min_supporters', @@ -143,7 +162,8 @@ def setup_motion_config(sender, **kwargs): title=ugettext_noop('Motion'), url='motion', 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): diff --git a/openslides/motion/templates/motion/motion_detail.html b/openslides/motion/templates/motion/motion_detail.html index 9eaa029fd..306a9193c 100644 --- a/openslides/motion/templates/motion/motion_detail.html +++ b/openslides/motion/templates/motion/motion_detail.html @@ -29,6 +29,9 @@ {% endif %} {% endif %} + {% if motion.is_amendment %} + (Amendment of {{ motion.parent.identifier|default:motion.parent }}) + {% endif %}
@@ -68,6 +71,8 @@

+{{ motion.tags.all|join:', ' }} +
{# TODO: show only for workflow with versioning #} @@ -280,6 +285,23 @@ {{ version.creation_time }} + {% if 'motion_amendments_enabled'|get_config %} +
{% trans 'Amendments' %}:
+ {% with amendments=motion.amendments.all %} + {% if amendments %} +
+ {% endif %} + {% endwith %} + + + {% trans 'New amendment' %} + + {% endif %} + {% if perms.motion.can_support_motion and 'motion_min_supporters'|get_config > 0 %} {% if allowed_actions.unsupport %} diff --git a/openslides/motion/templates/motion/motion_form.html b/openslides/motion/templates/motion/motion_form.html index e6831009f..0db01228a 100644 --- a/openslides/motion/templates/motion/motion_form.html +++ b/openslides/motion/templates/motion/motion_form.html @@ -7,25 +7,8 @@ {% block header %} {{ block.super }} - {% endblock %} -{% block javascript %} - {{ block.super }} - - - -{% endblock %} {% block title %} {% if motion %} diff --git a/openslides/motion/templates/motion/motion_list.html b/openslides/motion/templates/motion/motion_list.html index 53be1324c..f47280cbe 100644 --- a/openslides/motion/templates/motion/motion_list.html +++ b/openslides/motion/templates/motion/motion_list.html @@ -33,11 +33,20 @@ {% if perms.motion.can_create_motion %} {% if not 'motion_stop_submitting'|get_config or perms.motion.can_manage_motion %} - {% trans 'New' %} + {% trans 'New' %} {% endif %} {% endif %} + {% if perms.core.can_manage_tags %} + + + {% trans 'Tags' %} + + {% endif %} {% if perms.motion.can_manage_motion %} - {% trans 'Categories' %} + + + {% trans 'Categories' %} + {% trans 'Import' %} {% endif %} PDF @@ -64,7 +73,14 @@ {% for motion in motion_list %} {{ motion.identifier|default:'' }} - {{ motion.title }} + + {{ motion.title }} + {% if motion.is_amendment %} + + {{ 'motion_amendments_prefix'|get_config }} + + {% endif %} + {% if motion.category %}{{ motion.category }}{% else %}–{% endif %} {% trans motion.state.name %} diff --git a/openslides/motion/templates/motion/slide.html b/openslides/motion/templates/motion/slide.html index ae64d6696..ab7ca9190 100644 --- a/openslides/motion/templates/motion/slide.html +++ b/openslides/motion/templates/motion/slide.html @@ -77,6 +77,9 @@ {{ motion.active_version.title }} {% 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 %} diff --git a/openslides/motion/urls.py b/openslides/motion/urls.py index 96b5e27c3..3052c42e8 100644 --- a/openslides/motion/urls.py +++ b/openslides/motion/urls.py @@ -1,106 +1,111 @@ from django.conf.urls import patterns, url +from . import views + # TODO: define the Views inhere urlpatterns = patterns( 'openslides.motion.views', url(r'^$', - 'motion_list', + views.MotionListView.as_view(), name='motion_list'), url(r'^new/$', - 'motion_create', - # TODO: rename to motion_create - name='motion_new'), + views.MotionCreateView.as_view(), + name='motion_create'), url(r'^(?P\d+)/$', - 'motion_detail', + views.MotionDetailView.as_view(), name='motion_detail'), url(r'^(?P\d+)/edit/$', - 'motion_update', + views.MotionUpdateView.as_view(), name='motion_update'), url(r'^(?P\d+)/del/$', - 'motion_delete', + views.MotionDeleteView.as_view(), name='motion_delete'), + url(r'^(?P\d+)/new_amendment/$', + views.MotionCreateAmendmentView.as_view(), + name='motion_create_amendment'), + url(r'^(?P\d+)/version/(?P\d+)/$', - 'motion_detail', + views.MotionDetailView.as_view(), name='motion_version_detail'), url(r'^(?P\d+)/version/(?P\d+)/permit/$', - 'version_permit', + views.VersionPermitView.as_view(), name='motion_version_permit'), url(r'^(?P\d+)/version/(?P\d+)/del/$', - 'version_delete', + views.VersionDeleteView.as_view(), name='motion_version_delete'), url(r'^(?P\d+)/diff/$', - 'version_diff', + views.VersionDiffView.as_view(), name='motion_version_diff'), url(r'^(?P\d+)/support/$', - 'motion_support', + views.SupportView.as_view(support=True), name='motion_support'), url(r'^(?P\d+)/unsupport/$', - 'motion_unsupport', + views.SupportView.as_view(support=False), name='motion_unsupport'), url(r'^(?P\d+)/create_poll/$', - 'poll_create', + views.PollCreateView.as_view(), name='motionpoll_create'), url(r'^(?P\d+)/poll/(?P\d+)/edit/$', - 'poll_update', + views.PollUpdateView.as_view(), name='motionpoll_update'), url(r'^(?P\d+)/poll/(?P\d+)/del/$', - 'poll_delete', + views.PollDeleteView.as_view(), name='motionpoll_delete'), url(r'^(?P\d+)/poll/(?P\d+)/pdf/$', - 'poll_pdf', + views.PollPDFView.as_view(), name='motionpoll_pdf'), url(r'^(?P\d+)/set_state/(?P\d+)/$', - 'set_state', + views.MotionSetStateView.as_view(), name='motion_set_state'), url(r'^(?P\d+)/reset_state/$', - 'reset_state', + views.MotionSetStateView.as_view(reset=True), name='motion_reset_state'), url(r'^(?P\d+)/agenda/$', - 'create_agenda_item', + views.CreateRelatedAgendaItemView.as_view(), name='motion_create_agenda'), url(r'^pdf/$', - 'motion_list_pdf', + views.MotionPDFView.as_view(print_all_motions=True), name='motion_list_pdf'), url(r'^(?P\d+)/pdf/$', - 'motion_detail_pdf', + views.MotionPDFView.as_view(print_all_motions=False), name='motion_detail_pdf'), url(r'^category/$', - 'category_list', + views.CategoryListView.as_view(), name='motion_category_list'), url(r'^category/new/$', - 'category_create', + views.CategoryCreateView.as_view(), name='motion_category_create'), url(r'^category/(?P\d+)/edit/$', - 'category_update', + views.CategoryUpdateView.as_view(), name='motion_category_update'), url(r'^category/(?P\d+)/del/$', - 'category_delete', + views.CategoryDeleteView.as_view(), name='motion_category_delete'), url(r'^csv_import/$', - 'motion_csv_import', + views.MotionCSVImportView.as_view(), name='motion_csv_import'), ) diff --git a/openslides/motion/views.py b/openslides/motion/views.py index 7f0609d50..d4ee9165e 100644 --- a/openslides/motion/views.py +++ b/openslides/motion/views.py @@ -47,7 +47,7 @@ class MotionListView(ListView): Returns not a QuerySet but a filtered list of motions. Excludes motions that the user is not allowed to see. """ - queryset = super(MotionListView, self).get_queryset(*args, **kwargs) + queryset = super().get_queryset(*args, **kwargs) motions = [] for motion in queryset: if (not motion.state.required_permission_to_see or @@ -55,8 +55,6 @@ class MotionListView(ListView): motions.append(motion) return motions -motion_list = MotionListView.as_view() - class MotionDetailView(DetailView): """ @@ -92,9 +90,7 @@ class MotionDetailView(DetailView): 'title': version.title, 'text': version.text, 'reason': version.reason}) - return super(MotionDetailView, self).get_context_data(**kwargs) - -motion_detail = MotionDetailView.as_view() + return super().get_context_data(**kwargs) class MotionEditMixin(object): @@ -142,6 +138,10 @@ class MotionEditMixin(object): self.object.attachments.clear() 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 # the model, because bulk_create does not call the save method. active_slide = get_active_slide() @@ -203,17 +203,26 @@ class MotionCreateView(MotionEditMixin, CreateView): 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) + + # Set submitter to request.user if no submitter is set yet if ('submitter' not in form.cleaned_data or not form.cleaned_data['submitter']): self.object.add_submitter(self.request.user) return response 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'): initial['workflow'] = config['motion_workflow'] return initial @@ -227,7 +236,53 @@ class MotionCreateView(MotionEditMixin, CreateView): self.object.reset_state(workflow) 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): @@ -246,7 +301,7 @@ class MotionUpdateView(MotionEditMixin, UpdateView): """ 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() if (config['motion_remove_supporters'] and self.object.state.allow_support and not self.request.user.has_perm('motion.can_manage_motion')): @@ -271,7 +326,7 @@ class MotionUpdateView(MotionEditMixin, UpdateView): self.request.user) def get_initial(self): - initial = super(MotionUpdateView, self).get_initial() + initial = super().get_initial() if self.request.user.has_perm('motion.can_manage_motion'): initial['workflow'] = self.object.state.workflow return initial @@ -295,8 +350,6 @@ class MotionUpdateView(MotionEditMixin, UpdateView): self.version = self.object.get_last_version() self.used_new_version = False -motion_update = MotionUpdateView.as_view() - class MotionDeleteView(DeleteView): """ @@ -314,8 +367,6 @@ class MotionDeleteView(DeleteView): def get_final_message(self): return _('%s was successfully deleted.') % _('Motion') -motion_delete = MotionDeleteView.as_view() - class VersionDeleteView(DeleteView): """ @@ -347,8 +398,6 @@ class VersionDeleteView(DeleteView): def get_url_name_args(self): return (self.get_object().motion_id, ) -version_delete = VersionDeleteView.as_view() - class VersionPermitView(SingleObjectMixin, QuestionView): """ @@ -368,7 +417,7 @@ class VersionPermitView(SingleObjectMixin, QuestionView): self.version = self.get_object().versions.get(version_number=int(version_number)) except MotionVersion.DoesNotExist: 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): """ @@ -394,8 +443,6 @@ class VersionPermitView(SingleObjectMixin, QuestionView): ugettext_noop('permitted')], person=self.request.user) -version_permit = VersionPermitView.as_view() - class VersionDiffView(DetailView): """ @@ -427,7 +474,7 @@ class VersionDiffView(DetailView): version_rev2 = None diff_text = None diff_reason = None - context = super(VersionDiffView, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) context.update({ 'version_rev1': version_rev1, 'version_rev2': version_rev2, @@ -436,8 +483,6 @@ class VersionDiffView(DetailView): }) return context -version_diff = VersionDiffView.as_view() - class SupportView(SingleObjectMixin, QuestionView): """ @@ -500,9 +545,6 @@ class SupportView(SingleObjectMixin, QuestionView): else: 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): """ @@ -526,8 +568,6 @@ class PollCreateView(SingleObjectMixin, RedirectView): """ return reverse('motionpoll_update', args=[self.get_object().pk, self.poll.poll_number]) -poll_create = PollCreateView.as_view() - class PollMixin(object): """ @@ -579,7 +619,7 @@ class PollUpdateView(PollMixin, PollFormView): Append the motion object to the context. """ - context = super(PollUpdateView, self).get_context_data(**kwargs) + context = super().get_context_data(**kwargs) context.update({ 'motion': self.poll.motion, 'poll': self.poll}) @@ -589,12 +629,10 @@ class PollUpdateView(PollMixin, PollFormView): """ 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) return value -poll_update = PollUpdateView.as_view() - class PollDeleteView(PollMixin, DeleteView): """ @@ -607,7 +645,7 @@ class PollDeleteView(PollMixin, DeleteView): """ 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) def get_redirect_url(self, **kwargs): @@ -616,8 +654,6 @@ class PollDeleteView(PollMixin, DeleteView): """ return reverse('motion_detail', args=[self.get_object().motion.pk]) -poll_delete = PollDeleteView.as_view() - class PollPDFView(PollMixin, PDFView): """ @@ -647,8 +683,6 @@ class PollPDFView(PollMixin, PDFView): """ motion_poll_to_pdf(pdf, self.get_object()) -poll_pdf = PollPDFView.as_view() - class MotionSetStateView(SingleObjectMixin, RedirectView): """ @@ -686,9 +720,6 @@ class MotionSetStateView(SingleObjectMixin, RedirectView): _('The state of the motion was set to %s.') % html_strong(_(self.get_object().state.name))) -set_state = MotionSetStateView.as_view() -reset_state = MotionSetStateView.as_view(reset=True) - class CreateRelatedAgendaItemView(_CreateRelatedAgendaItemView): """ @@ -700,11 +731,9 @@ class CreateRelatedAgendaItemView(_CreateRelatedAgendaItemView): """ 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) -create_agenda_item = CreateRelatedAgendaItemView.as_view() - class MotionPDFView(SingleObjectMixin, PDFView): """ @@ -734,7 +763,7 @@ class MotionPDFView(SingleObjectMixin, PDFView): if self.print_all_motions: obj = None else: - obj = super(MotionPDFView, self).get_object(*args, **kwargs) + obj = super().get_object(*args, **kwargs) return obj def get_filename(self): @@ -765,16 +794,11 @@ class MotionPDFView(SingleObjectMixin, PDFView): else: 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): required_permission = 'motion.can_manage_motion' model = Category -category_list = CategoryListView.as_view() - class CategoryCreateView(CreateView): required_permission = 'motion.can_manage_motion' @@ -782,8 +806,6 @@ class CategoryCreateView(CreateView): success_url_name = 'motion_category_list' url_name_args = [] -category_create = CategoryCreateView.as_view() - class CategoryUpdateView(UpdateView): required_permission = 'motion.can_manage_motion' @@ -791,8 +813,6 @@ class CategoryUpdateView(UpdateView): success_url_name = 'motion_category_list' url_name_args = [] -category_update = CategoryUpdateView.as_view() - class CategoryDeleteView(DeleteView): required_permission = 'motion.can_manage_motion' @@ -801,8 +821,6 @@ class CategoryDeleteView(DeleteView): url_name_args = [] success_url_name = 'motion_category_list' -category_delete = CategoryDeleteView.as_view() - class MotionCSVImportView(CSVImportView): """ @@ -817,7 +835,7 @@ class MotionCSVImportView(CSVImportView): """ 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 return_value @@ -828,5 +846,3 @@ class MotionCSVImportView(CSVImportView): messages.error(self.request, error) # Overleap method of CSVImportView return super(CSVImportView, self).form_valid(form) - -motion_csv_import = MotionCSVImportView.as_view() diff --git a/openslides/users/signals.py b/openslides/users/signals.py index af0be40f9..141086ba9 100644 --- a/openslides/users/signals.py +++ b/openslides/users/signals.py @@ -170,11 +170,14 @@ def create_builtin_groups_and_admin(sender, **kwargs): ct_config = ContentType.objects.get(app_label='config', model='configstore') 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) # add delegate permissions (without can_support_motion) group_staff.permissions.add(perm_31, perm_33, perm_34, perm_35) # 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 group_staff.permissions.add(perm_17) # TODO: Remove this redundancy after cleanup of the permission system diff --git a/openslides/users/templates/users/user_form.html b/openslides/users/templates/users/user_form.html index 38df1d19c..6f649c0ef 100644 --- a/openslides/users/templates/users/user_form.html +++ b/openslides/users/templates/users/user_form.html @@ -3,24 +3,6 @@ {% load i18n %} {% load staticfiles %} -{% block header %} - -{% endblock %} - -{% block javascript %} - - -{% endblock %} {% block title %} {% if edit_user %} diff --git a/openslides/utils/views.py b/openslides/utils/views.py index 7a7e4f202..af9c55b53 100644 --- a/openslides/utils/views.py +++ b/openslides/utils/views.py @@ -292,8 +292,12 @@ class AjaxView(PermissionMixin, AjaxMixin, View): View for ajax requests. """ 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) + def post(self, *args, **kwargs): + return self.get(*args, **kwargs) + class RedirectView(PermissionMixin, AjaxMixin, UrlMixin, django_views.RedirectView): """ diff --git a/tests/core/test_views.py b/tests/core/test_views.py index c55a109ce..cc1253772 100644 --- a/tests/core/test_views.py +++ b/tests/core/test_views.py @@ -142,3 +142,24 @@ class CustomSlidesTest(TestCase): response = self.admin_client.post(url, {'yes': 'true'}) self.assertRedirects(response, '/dashboard/') 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') diff --git a/tests/motion/test_models.py b/tests/motion/test_models.py index 9c48b21a2..fd2b8fb64 100644 --- a/tests/motion/test_models.py +++ b/tests/motion/test_models.py @@ -145,6 +145,64 @@ class ModelTest(TestCase): # motion.__unicode__() raised an AttributeError 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): def test_stop_submitting(self): diff --git a/tests/motion/test_views.py b/tests/motion/test_views.py index 67f9d140b..24dbeea31 100644 --- a/tests/motion/test_views.py +++ b/tests/motion/test_views.py @@ -1,11 +1,14 @@ import os import tempfile +from unittest.mock import MagicMock from django.conf import settings +from django.test import RequestFactory from django.test.client import Client from openslides.config.api import config from openslides.mediafile.models import Mediafile +from openslides.motion import views from openslides.motion.models import Category, Motion, MotionLog, State from openslides.users.models import Group, User from openslides.utils.test import TestCase @@ -241,6 +244,67 @@ class TestMotionCreateView(MotionViewTestCase): 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): url = '/motion/1/edit/'