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(