diff --git a/CHANGELOG b/CHANGELOG index e387a527a..11b2ed524 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -11,6 +11,8 @@ Version 1.6 (unreleased) Agenda: - New projector view with the current list of speakers. - Added CSV import. +- Added automatic numbering of agenda items. +- Fixed organizational item structuring. Assignment: - Coupled assignment candidates with list of speakers. Dashboard: diff --git a/README.rst b/README.rst index b54c3446d..105cfc79a 100644 --- a/README.rst +++ b/README.rst @@ -272,6 +272,8 @@ OpenSlides uses the following projects or parts of them: * `ReportLab `_, License: BSD +* `roman `_, License: Python 2.1.1 + * `sockjs-client `_, License: MIT diff --git a/openslides/agenda/forms.py b/openslides/agenda/forms.py index 035e0ad3f..839cfeed6 100644 --- a/openslides/agenda/forms.py +++ b/openslides/agenda/forms.py @@ -16,7 +16,6 @@ class ItemForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm): """ Form to create of update an item. """ - clean_html_fields = ('text', ) parent = TreeNodeChoiceField( @@ -32,7 +31,7 @@ class ItemForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm): class Meta: model = Item - exclude = ('closed', 'weight', 'content_type', 'object_id') + fields = ('item_number', 'title', 'text', 'comment', 'type', 'duration', 'parent', 'speaker_list_closed') class RelatedItemForm(ItemForm): @@ -41,7 +40,7 @@ class RelatedItemForm(ItemForm): """ class Meta: model = Item - exclude = ('closed', 'type', 'weight', 'content_type', 'object_id', 'title', 'text') + fields = ('comment', 'duration', 'parent', 'speaker_list_closed') class ItemOrderForm(CssClassMixin, forms.Form): diff --git a/openslides/agenda/models.py b/openslides/agenda/models.py index ffc90430f..a8bfb6b94 100644 --- a/openslides/agenda/models.py +++ b/openslides/agenda/models.py @@ -5,6 +5,7 @@ from datetime import datetime from django.contrib.auth.models import AnonymousUser from django.contrib.contenttypes import generic from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.db import models from django.utils.translation import ugettext as _ @@ -19,6 +20,7 @@ from openslides.projector.models import SlideMixin from openslides.utils.exceptions import OpenSlidesError from openslides.utils.models import AbsoluteUrlMixin from openslides.utils.person.models import PersonField +from openslides.utils.utils import to_roman class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel): @@ -36,6 +38,11 @@ class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel): (AGENDA_ITEM, ugettext_lazy('Agenda item')), (ORGANIZATIONAL_ITEM, ugettext_lazy('Organizational item'))) + item_number = models.CharField(blank=True, max_length=255, verbose_name=ugettext_lazy("Number")) + """ + Number of agenda item. + """ + title = models.CharField(null=True, max_length=255, verbose_name=ugettext_lazy("Title")) """ Title of the agenda item. @@ -116,6 +123,16 @@ class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel): if self.parent and self.parent.is_active_slide(): update_projector() + def clean(self): + """ + Ensures that the children of orga items are only orga items. + """ + if self.type == self.AGENDA_ITEM and self.parent is not None and self.parent.type == self.ORGANIZATIONAL_ITEM: + raise ValidationError(_('Agenda items can not be descendents of an organizational item.')) + if self.type == self.ORGANIZATIONAL_ITEM and self.get_descendants().filter(type=self.AGENDA_ITEM).exists(): + raise ValidationError(_('Organizational items can not have agenda items as descendents.')) + return super(Item, self).clean() + def __unicode__(self): return self.get_title() @@ -147,7 +164,8 @@ class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel): Return the title of this item. """ if not self.content_object: - return self.title + item_no = self.item_no + return '%s %s' % (item_no, self.title) if item_no else self.title try: return self.content_object.get_agenda_title() except AttributeError: @@ -284,6 +302,41 @@ class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel): value = False return value + @property + def item_no(self): + if self.item_number: + item_no = '%s %s' % (config['agenda_number_prefix'], self.item_number) + else: + item_no = None + return item_no + + def calc_item_no(self): + """ + Returns the number of this agenda item. + """ + if self.type == self.AGENDA_ITEM: + if self.is_root_node(): + if config['agenda_numeral_system'] == 'arabic': + return str(self._calc_sibling_no()) + else: # config['agenda_numeral_system'] == 'roman' + return to_roman(self._calc_sibling_no()) + else: + return '%s.%s' % (self.parent.calc_item_no(), self._calc_sibling_no()) + else: + return '' + + def _calc_sibling_no(self): + """ + Counts all siblings on the same level which are AGENDA_ITEMs. + """ + sibling_no = 0 + prev_sibling = self.get_previous_sibling() + while prev_sibling is not None: + if prev_sibling.type == self.AGENDA_ITEM: + sibling_no += 1 + prev_sibling = prev_sibling.get_previous_sibling() + return sibling_no + 1 + class SpeakerManager(models.Manager): def add(self, person, item): diff --git a/openslides/agenda/signals.py b/openslides/agenda/signals.py index 854a6cfee..aa818ccf1 100644 --- a/openslides/agenda/signals.py +++ b/openslides/agenda/signals.py @@ -60,6 +60,25 @@ def setup_agenda_config(sender, **kwargs): help_text=ugettext_lazy('[Begin speach] starts the countdown, [End speach] stops the countdown.'), required=False)) + agenda_number_prefix = ConfigVariable( + name='agenda_number_prefix', + default_value='', + form_field=forms.CharField( + label=ugettext_lazy('Numbering prefix for agenda items'), + max_length=20, + required=False)) + + agenda_numeral_system = ConfigVariable( + name='agenda_numeral_system', + default_value='arabic', + form_field=forms.ChoiceField( + label=ugettext_lazy('Numeral system for agenda items'), + widget=forms.Select(), + choices=( + ('arabic', ugettext_lazy('Arabic')), + ('roman', ugettext_lazy('Roman'))), + required=False)) + extra_stylefiles = ['css/jquery-ui-timepicker.css'] extra_javascript = ['js/jquery/jquery-ui-timepicker-addon.min.js', 'js/jquery/jquery-ui-sliderAccess.min.js', @@ -71,7 +90,9 @@ def setup_agenda_config(sender, **kwargs): weight=20, variables=(agenda_start_event_date_time, agenda_show_last_speakers, - agenda_couple_countdown_and_speakers), + agenda_couple_countdown_and_speakers, + agenda_number_prefix, + agenda_numeral_system), extra_context={'extra_stylefiles': extra_stylefiles, 'extra_javascript': extra_javascript}) diff --git a/openslides/agenda/templates/agenda/overview.html b/openslides/agenda/templates/agenda/overview.html index c67fb0309..6b96db6db 100644 --- a/openslides/agenda/templates/agenda/overview.html +++ b/openslides/agenda/templates/agenda/overview.html @@ -70,6 +70,10 @@ {% else %} {% trans 'Set start time of event' %} {% endif %} + {% if perms.agenda.can_manage_agenda %} + {% trans 'Number agenda items' %} + {% endif %} {% endif %} diff --git a/openslides/agenda/urls.py b/openslides/agenda/urls.py index 0e854f1c6..218e3148f 100644 --- a/openslides/agenda/urls.py +++ b/openslides/agenda/urls.py @@ -40,6 +40,10 @@ urlpatterns = patterns( views.AgendaPDF.as_view(), name='print_agenda'), + url(r'^numbering/$', + views.AgendaNumberingView.as_view(), + name='agenda_numbering'), + # List of speakers url(r'^(?P\d+)/speaker/$', views.SpeakerAppendView.as_view(), diff --git a/openslides/agenda/views.py b/openslides/agenda/views.py index 6f41917e6..140838981 100644 --- a/openslides/agenda/views.py +++ b/openslides/agenda/views.py @@ -34,6 +34,7 @@ from openslides.utils.views import ( DeleteView, FormView, PDFView, + QuestionView, RedirectView, SingleObjectMixin, TemplateView, @@ -134,8 +135,14 @@ class Overview(TemplateView): parent = Item.objects.get(id=form.cleaned_data['parent']) except Item.DoesNotExist: parent = None - item.weight = form.cleaned_data['weight'] + else: + if item.type == item.AGENDA_ITEM and parent.type == item.ORGANIZATIONAL_ITEM: + transaction.rollback() + messages.error( + request, _('Agenda items can not be descendents of an organizational item.')) + break item.parent = parent + item.weight = form.cleaned_data['weight'] Model.save(item) else: transaction.rollback() @@ -336,6 +343,22 @@ class CreateRelatedAgendaItemView(SingleObjectMixin, RedirectView): self.item = Item.objects.create(content_object=self.object) +class AgendaNumberingView(QuestionView): + permission_required = 'agenda.can_manage_agenda' + question_url_name = 'item_overview' + url_name = 'item_overview' + question_message = ugettext_lazy('Do you really want to generate agenda numbering? Manually added item numbers will be overwritten!') + url_name_args = [] + + def on_clicked_yes(self): + for item in Item.objects.all(): + item.item_number = item.calc_item_no() + item.save() + + def get_final_message(self): + return ugettext_lazy('The agenda has been numbered.') + + class AgendaPDF(PDFView): """ Create a full agenda-PDF. diff --git a/openslides/utils/utils.py b/openslides/utils/utils.py index 832dbc0d7..d509c7a41 100644 --- a/openslides/utils/utils.py +++ b/openslides/utils/utils.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import difflib +import roman from django.contrib.auth.models import Permission from django.shortcuts import render_to_response @@ -71,3 +72,14 @@ def int_or_none(var): return int(var) except (TypeError, ValueError): return None + + +def to_roman(number): + """ + Converts an arabic number within range from 1 to 4999 to the corresponding roman number. + Returns None on error conditions. + """ + try: + return roman.toRoman(number) + except (roman.NotIntegerError, roman.OutOfRangeError): + return None diff --git a/requirements_production.txt b/requirements_production.txt index 2745c22dd..3e2480a56 100644 --- a/requirements_production.txt +++ b/requirements_production.txt @@ -7,6 +7,7 @@ django-mptt>=0.6,<0.7 jsonfield>=0.9,<0.10 natsort>=3.1,<3.2 reportlab>=2.7,<2.8 +roman>=2.0,<2.1 setuptools>=2.1,<3.5 sockjs-tornado>=1.0,<1.1 tornado>=3.1,<3.3 diff --git a/tests/agenda/tests.py b/tests/agenda/tests.py index 48c868a28..cec6a6351 100644 --- a/tests/agenda/tests.py +++ b/tests/agenda/tests.py @@ -229,6 +229,20 @@ class ViewTest(TestCase): self.assertIsNone(Item.objects.get(pk=1).parent) self.assertEqual(Item.objects.get(pk=2).parent_id, 1) + def test_change_item_order_with_orga_item(self): + self.item1.type = 2 + self.item1.save() + data = { + 'i1-self': 1, + 'i1-weight': 50, + 'i1-parent': 0, + 'i2-self': 2, + 'i2-weight': 50, + 'i2-parent': 1} + response = self.adminClient.post('/agenda/', data) + self.assertNotEqual(Item.objects.get(pk=2).parent_id, 1) + self.assertContains(response, 'Agenda items can not be descendents of an organizational item.') + def test_delete(self): response = self.adminClient.get('/agenda/%s/del/' % self.item1.pk) self.assertRedirects(response, '/agenda/') @@ -276,6 +290,32 @@ class ViewTest(TestCase): response = client.get('/agenda/2/') self.assertEqual(response.status_code, 200) + def test_orga_item_with_orga_parent_one(self): + item1 = Item.objects.create(title='item1_Taeboog1de1sahSeiM8y', type=2) + response = self.adminClient.post( + '/agenda/new/', + {'title': 'item2_faelohD2uK7ohNgeepi2', + 'type': '1', + 'parent': item1.pk}) + self.assertFormError( + response, + 'form', + None, + 'Agenda items can not be descendents of an organizational item.') + + def test_orga_item_with_orga_parent_two(self): + item1 = Item.objects.create(title='item1_aeNg4Heibee8ULooneep') + Item.objects.create(title='item2_fooshaeroo7Ohvoow0hoo', parent=item1) + response = self.adminClient.post( + '/agenda/%s/edit/' % item1.pk, + {'title': 'item1_aeNg4Heibee8ULooneep_changed', + 'type': '2'}) + self.assertFormError( + response, + 'form', + None, + 'Organizational items can not have agenda items as descendents.') + def test_csv_import(self): item_number = Item.objects.all().count() new_csv_file = SimpleUploadedFile(