From 2951b4b38c41b4a54bdf36afc2f4e6bf4071750b Mon Sep 17 00:00:00 2001 From: Oskar Hahn Date: Thu, 25 Dec 2014 10:58:52 +0100 Subject: [PATCH 1/2] Feature: amendments --- CHANGELOG | 12 ++- openslides/motion/forms.py | 3 - openslides/motion/models.py | 43 ++++++-- openslides/motion/signals.py | 24 ++++- .../templates/motion/motion_detail.html | 20 ++++ .../motion/templates/motion/motion_list.html | 11 +- openslides/motion/templates/motion/slide.html | 3 + openslides/motion/urls.py | 59 +++++----- openslides/motion/views.py | 102 ++++++++++-------- tests/motion/test_models.py | 58 ++++++++++ tests/motion/test_views.py | 64 +++++++++++ 11 files changed, 310 insertions(+), 89 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 5ba0f2c3d..6a68e5eaa 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,8 +4,16 @@ http://openslides.org -Version 1.6.2 (unreleased) -========================== +Version 1.7 (unreleased) +======================== +[https://github.com/OpenSlides/OpenSlides/milestones/1.7] + +motion: +- New Feature to create amendments, which are related to a parent motion. + + +Version 1.6.2 +============= [https://github.com/OpenSlides/OpenSlides/milestones/1.6.2] Motions: - Added possibility to hide motions from non staff users in some states. diff --git a/openslides/motion/forms.py b/openslides/motion/forms.py index 9ecae40a8..e55a9e261 100644 --- a/openslides/motion/forms.py +++ b/openslides/motion/forms.py @@ -3,7 +3,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) @@ -66,8 +65,6 @@ 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'] super(BaseMotionForm, self).__init__(*args, **kwargs) diff --git a/openslides/motion/models.py b/openslides/motion/models.py index 54a5b3e08..093815fd6 100644 --- a/openslides/motion/models.py +++ b/openslides/motion/models.py @@ -71,8 +71,12 @@ 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. + """ class Meta: permissions = ( @@ -206,19 +210,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 @@ -524,6 +540,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): """ @@ -819,10 +844,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 __unicode__(self): """Returns the name of the state.""" diff --git a/openslides/motion/signals.py b/openslides/motion/signals.py index aba69d1bb..f6b9a4778 100644 --- a/openslides/motion/signals.py +++ b/openslides/motion/signals.py @@ -3,7 +3,7 @@ from django import forms from django.dispatch import receiver 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.signals import config_signal @@ -67,6 +67,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', @@ -148,7 +167,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)) @receiver(post_database_setup, dispatch_uid='motion_create_builtin_workflows') diff --git a/openslides/motion/templates/motion/motion_detail.html b/openslides/motion/templates/motion/motion_detail.html index 9eaa029fd..99929a5fa 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 %} @@ -280,6 +283,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_list.html b/openslides/motion/templates/motion/motion_list.html index 53be1324c..0b5313387 100644 --- a/openslides/motion/templates/motion/motion_list.html +++ b/openslides/motion/templates/motion/motion_list.html @@ -33,7 +33,7 @@ {% 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.motion.can_manage_motion %} @@ -64,7 +64,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 af8085b03..c9d73e2ce 100644 --- a/openslides/motion/urls.py +++ b/openslides/motion/urls.py @@ -2,107 +2,112 @@ 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 cbd1cbbdf..168929064 100644 --- a/openslides/motion/views.py +++ b/openslides/motion/views.py @@ -57,8 +57,6 @@ class MotionListView(ListView): motions.append(motion) return motions -motion_list = MotionListView.as_view() - class MotionDetailView(DetailView): """ @@ -96,8 +94,6 @@ class MotionDetailView(DetailView): 'reason': version.reason}) return super(MotionDetailView, self).get_context_data(**kwargs) -motion_detail = MotionDetailView.as_view() - class MotionEditMixin(object): """ @@ -205,17 +201,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. """ + # First, validate and process the form and create the motion response = super(MotionCreateView, self).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): + """ + Sets the initial data for the MotionCreateForm. + """ initial = super(MotionCreateView, self).get_initial() + initial['text'] = config['motion_preamble'] if self.request.user.has_perm('motion.can_manage_motion'): initial['workflow'] = config['motion_workflow'] return initial @@ -229,7 +234,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(MotionCreateAmendmentView, self).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(MotionCreateAmendmentView, self).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(MotionCreateAmendmentView, self).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): @@ -297,8 +348,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): """ @@ -316,8 +365,6 @@ class MotionDeleteView(DeleteView): def get_final_message(self): return _('%s was successfully deleted.') % _('Motion') -motion_delete = MotionDeleteView.as_view() - class VersionDeleteView(DeleteView): """ @@ -349,8 +396,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): """ @@ -396,8 +441,6 @@ class VersionPermitView(SingleObjectMixin, QuestionView): ugettext_noop('permitted')], person=self.request.user) -version_permit = VersionPermitView.as_view() - class VersionDiffView(DetailView): """ @@ -438,8 +481,6 @@ class VersionDiffView(DetailView): }) return context -version_diff = VersionDiffView.as_view() - class SupportView(SingleObjectMixin, QuestionView): """ @@ -502,9 +543,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): """ @@ -528,8 +566,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): """ @@ -595,8 +631,6 @@ class PollUpdateView(PollMixin, PollFormView): self.get_object().write_log([ugettext_noop('Poll updated')], self.request.user) return value -poll_update = PollUpdateView.as_view() - class PollDeleteView(PollMixin, DeleteView): """ @@ -618,8 +652,6 @@ class PollDeleteView(PollMixin, DeleteView): """ return reverse('motion_detail', args=[self.get_object().motion.pk]) -poll_delete = PollDeleteView.as_view() - class PollPDFView(PollMixin, PDFView): """ @@ -649,8 +681,6 @@ class PollPDFView(PollMixin, PDFView): """ motion_poll_to_pdf(pdf, self.get_object()) -poll_pdf = PollPDFView.as_view() - class MotionSetStateView(SingleObjectMixin, RedirectView): """ @@ -688,9 +718,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): """ @@ -705,8 +732,6 @@ class CreateRelatedAgendaItemView(_CreateRelatedAgendaItemView): super(CreateRelatedAgendaItemView, self).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): """ @@ -767,16 +792,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' @@ -784,8 +804,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' @@ -793,8 +811,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' @@ -803,8 +819,6 @@ class CategoryDeleteView(DeleteView): url_name_args = [] success_url_name = 'motion_category_list' -category_delete = CategoryDeleteView.as_view() - class MotionCSVImportView(CSVImportView): """ @@ -830,5 +844,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/tests/motion/test_models.py b/tests/motion/test_models.py index 1e6d30ec1..65dba507a 100644 --- a/tests/motion/test_models.py +++ b/tests/motion/test_models.py @@ -147,6 +147,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 273215c4b..c68c48735 100644 --- a/tests/motion/test_views.py +++ b/tests/motion/test_views.py @@ -2,12 +2,15 @@ import os import tempfile +from 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.participant.models import Group, User from openslides.utils.test import TestCase @@ -244,6 +247,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/' From bcb1ee12130f3433efaf5241ee04aec50de82baf Mon Sep 17 00:00:00 2001 From: Oskar Hahn Date: Fri, 26 Dec 2014 13:45:13 +0100 Subject: [PATCH 2/2] 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')