From bcb1ee12130f3433efaf5241ee04aec50de82baf Mon Sep 17 00:00:00 2001 From: Oskar Hahn Date: Fri, 26 Dec 2014 13:45:13 +0100 Subject: [PATCH] Tags for motions, agenda items and assignments --- CHANGELOG | 3 + openslides/agenda/forms.py | 2 +- openslides/agenda/models.py | 6 + .../agenda/templates/agenda/overview.html | 18 ++- openslides/assignment/models.py | 2 + .../templates/assignment/assignment_list.html | 6 + openslides/config/api.py | 1 + openslides/core/exceptions.py | 7 ++ openslides/core/models.py | 17 +++ openslides/core/static/js/config_tags.js | 111 ++++++++++++++++++ openslides/core/static/js/utils.js | 23 +++- openslides/core/templates/base.html | 13 ++ openslides/core/templates/core/tag_list.html | 54 +++++++++ openslides/core/urls.py | 4 + openslides/core/views.py | 82 ++++++++++++- openslides/motion/forms.py | 10 +- openslides/motion/models.py | 6 + .../templates/motion/motion_detail.html | 2 + .../motion/templates/motion/motion_form.html | 17 --- .../motion/templates/motion/motion_list.html | 11 +- openslides/motion/views.py | 4 + openslides/participant/signals.py | 5 +- .../templates/participant/edit.html | 18 --- openslides/utils/views.py | 4 + tests/core/test_views.py | 21 ++++ 25 files changed, 400 insertions(+), 47 deletions(-) create mode 100644 openslides/core/exceptions.py create mode 100644 openslides/core/static/js/config_tags.js create mode 100644 openslides/core/templates/core/tag_list.html diff --git a/CHANGELOG b/CHANGELOG index 6a68e5eaa..96ed68d64 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -8,6 +8,9 @@ Version 1.7 (unreleased) ======================== [https://github.com/OpenSlides/OpenSlides/milestones/1.7] +Core: +- New feature to tag motions, agenda and assignments. + motion: - New Feature to create amendments, which are related to a parent motion. diff --git a/openslides/agenda/forms.py b/openslides/agenda/forms.py index a503d6b93..02748443c 100644 --- a/openslides/agenda/forms.py +++ b/openslides/agenda/forms.py @@ -32,7 +32,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 f86b7ce03..98298a621 100644 --- a/openslides/agenda/models.py +++ b/openslides/agenda/models.py @@ -13,6 +13,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) @@ -109,6 +110,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 ca855777d..45ac1c463 100644 --- a/openslides/assignment/models.py +++ b/openslides/assignment/models.py @@ -8,6 +8,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, @@ -58,6 +59,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 9e8e3a1f1..dd8c6f0c3 100644 --- a/openslides/config/api.py +++ b/openslides/config/api.py @@ -154,6 +154,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..c86a375d3 --- /dev/null +++ b/openslides/core/exceptions.py @@ -0,0 +1,7 @@ +# -*- coding: utf-8 -*- + +from openslides.utils.exceptions import OpenSlidesError + + +class TagException(OpenSlidesError): + pass diff --git a/openslides/core/models.py b/openslides/core/models.py index a40e9d0c1..fb4a8d040 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -38,3 +38,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 __unicode__(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 0fcde33b1..51e24b613 100644 --- a/openslides/core/urls.py +++ b/openslides/core/urls.py @@ -41,4 +41,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 b172a4279..6bd874d55 100644 --- a/openslides/core/views.py +++ b/openslides/core/views.py @@ -4,6 +4,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 @@ -19,7 +20,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): @@ -213,3 +215,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(TagListView, self).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(TagListView, self).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 e55a9e261..09d8b9cc7 100644 --- a/openslides/motion/forms.py +++ b/openslides/motion/forms.py @@ -10,7 +10,7 @@ from openslides.utils.person import MultiplePersonFormField, PersonFormField from ckeditor.widgets import CKEditorWidget -from .models import Category, Motion, Workflow +from .models import Category, Motion, Workflow, Tag class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm): @@ -48,6 +48,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 = () @@ -55,7 +60,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', {}) @@ -65,6 +70,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() + 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 093815fd6..c2582a553 100644 --- a/openslides/motion/models.py +++ b/openslides/motion/models.py @@ -8,6 +8,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 @@ -78,6 +79,11 @@ class Motion(SlideMixin, AbsoluteUrlMixin, models.Model): Null if the motion is not an amendment. """ + tags = models.ManyToManyField(Tag) + """ + Tags to categorise motions. + """ + class Meta: permissions = ( ('can_see_motion', ugettext_noop('Can see motions')), diff --git a/openslides/motion/templates/motion/motion_detail.html b/openslides/motion/templates/motion/motion_detail.html index 99929a5fa..306a9193c 100644 --- a/openslides/motion/templates/motion/motion_detail.html +++ b/openslides/motion/templates/motion/motion_detail.html @@ -71,6 +71,8 @@

+{{ motion.tags.all|join:', ' }} +
{# TODO: show only for workflow with versioning #} 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 0b5313387..f47280cbe 100644 --- a/openslides/motion/templates/motion/motion_list.html +++ b/openslides/motion/templates/motion/motion_list.html @@ -36,8 +36,17 @@ {% 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 diff --git a/openslides/motion/views.py b/openslides/motion/views.py index 168929064..1b5bca9bf 100644 --- a/openslides/motion/views.py +++ b/openslides/motion/views.py @@ -140,6 +140,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() diff --git a/openslides/participant/signals.py b/openslides/participant/signals.py index b21cfb7f9..a4b30201d 100644 --- a/openslides/participant/signals.py +++ b/openslides/participant/signals.py @@ -173,11 +173,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_participant permission group_staff.permissions.add(perm_17) # TODO: Remove this redundancy after cleanup of the permission system diff --git a/openslides/participant/templates/participant/edit.html b/openslides/participant/templates/participant/edit.html index 4807d013b..9368fa7a7 100644 --- a/openslides/participant/templates/participant/edit.html +++ b/openslides/participant/templates/participant/edit.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 be43de7e0..27439761d 100644 --- a/openslides/utils/views.py +++ b/openslides/utils/views.py @@ -295,8 +295,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 eb7a1daf2..65ead69fd 100644 --- a/tests/core/test_views.py +++ b/tests/core/test_views.py @@ -143,3 +143,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')