Merge pull request #1254 from normanjaeckel/AgendaNumbering

Agenda numbering
This commit is contained in:
Norman Jäckel 2014-05-05 00:42:39 +02:00
commit 4620b0d22a
11 changed files with 167 additions and 6 deletions

View File

@ -11,6 +11,8 @@ Version 1.6 (unreleased)
Agenda: Agenda:
- New projector view with the current list of speakers. - New projector view with the current list of speakers.
- Added CSV import. - Added CSV import.
- Added automatic numbering of agenda items.
- Fixed organizational item structuring.
Assignment: Assignment:
- Coupled assignment candidates with list of speakers. - Coupled assignment candidates with list of speakers.
Dashboard: Dashboard:

View File

@ -272,6 +272,8 @@ OpenSlides uses the following projects or parts of them:
* `ReportLab <http://www.reportlab.com/software/opensource/rl-toolkit/>`_, * `ReportLab <http://www.reportlab.com/software/opensource/rl-toolkit/>`_,
License: BSD License: BSD
* `roman <https://pypi.python.org/pypi/roman>`_, License: Python 2.1.1
* `sockjs-client <https://github.com/sockjs/sockjs-client>`_, * `sockjs-client <https://github.com/sockjs/sockjs-client>`_,
License: MIT License: MIT

View File

@ -16,7 +16,6 @@ class ItemForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm):
""" """
Form to create of update an item. Form to create of update an item.
""" """
clean_html_fields = ('text', ) clean_html_fields = ('text', )
parent = TreeNodeChoiceField( parent = TreeNodeChoiceField(
@ -32,7 +31,7 @@ class ItemForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm):
class Meta: class Meta:
model = Item model = Item
exclude = ('closed', 'weight', 'content_type', 'object_id') fields = ('item_number', 'title', 'text', 'comment', 'type', 'duration', 'parent', 'speaker_list_closed')
class RelatedItemForm(ItemForm): class RelatedItemForm(ItemForm):
@ -41,7 +40,7 @@ class RelatedItemForm(ItemForm):
""" """
class Meta: class Meta:
model = Item model = Item
exclude = ('closed', 'type', 'weight', 'content_type', 'object_id', 'title', 'text') fields = ('comment', 'duration', 'parent', 'speaker_list_closed')
class ItemOrderForm(CssClassMixin, forms.Form): class ItemOrderForm(CssClassMixin, forms.Form):

View File

@ -5,6 +5,7 @@ from datetime import datetime
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes import generic from django.contrib.contenttypes import generic
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import models from django.db import models
from django.utils.translation import ugettext as _ 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.exceptions import OpenSlidesError
from openslides.utils.models import AbsoluteUrlMixin from openslides.utils.models import AbsoluteUrlMixin
from openslides.utils.person.models import PersonField from openslides.utils.person.models import PersonField
from openslides.utils.utils import to_roman
class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel): class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel):
@ -36,6 +38,11 @@ class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel):
(AGENDA_ITEM, ugettext_lazy('Agenda item')), (AGENDA_ITEM, ugettext_lazy('Agenda item')),
(ORGANIZATIONAL_ITEM, ugettext_lazy('Organizational 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 = models.CharField(null=True, max_length=255, verbose_name=ugettext_lazy("Title"))
""" """
Title of the agenda item. Title of the agenda item.
@ -116,6 +123,16 @@ class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel):
if self.parent and self.parent.is_active_slide(): if self.parent and self.parent.is_active_slide():
update_projector() 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): def __unicode__(self):
return self.get_title() return self.get_title()
@ -147,7 +164,8 @@ class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel):
Return the title of this item. Return the title of this item.
""" """
if not self.content_object: 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: try:
return self.content_object.get_agenda_title() return self.content_object.get_agenda_title()
except AttributeError: except AttributeError:
@ -284,6 +302,41 @@ class Item(SlideMixin, AbsoluteUrlMixin, MPTTModel):
value = False value = False
return value 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): class SpeakerManager(models.Manager):
def add(self, person, item): def add(self, person, item):

View File

@ -60,6 +60,25 @@ def setup_agenda_config(sender, **kwargs):
help_text=ugettext_lazy('[Begin speach] starts the countdown, [End speach] stops the countdown.'), help_text=ugettext_lazy('[Begin speach] starts the countdown, [End speach] stops the countdown.'),
required=False)) 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_stylefiles = ['css/jquery-ui-timepicker.css']
extra_javascript = ['js/jquery/jquery-ui-timepicker-addon.min.js', extra_javascript = ['js/jquery/jquery-ui-timepicker-addon.min.js',
'js/jquery/jquery-ui-sliderAccess.min.js', 'js/jquery/jquery-ui-sliderAccess.min.js',
@ -71,7 +90,9 @@ def setup_agenda_config(sender, **kwargs):
weight=20, weight=20,
variables=(agenda_start_event_date_time, variables=(agenda_start_event_date_time,
agenda_show_last_speakers, 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_context={'extra_stylefiles': extra_stylefiles,
'extra_javascript': extra_javascript}) 'extra_javascript': extra_javascript})

View File

@ -70,6 +70,10 @@
{% else %} {% else %}
<a href="{% url 'config_agenda' %}" class="btn btn-mini pull-right">{% trans 'Set start time of event' %}</a> <a href="{% url 'config_agenda' %}" class="btn btn-mini pull-right">{% trans 'Set start time of event' %}</a>
{% endif %} {% endif %}
{% if perms.agenda.can_manage_agenda %}
<a href="{% url 'agenda_numbering' %}"
class="btn btn-mini pull-left">{% trans 'Number agenda items' %}</a>
{% endif %}
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@ -40,6 +40,10 @@ urlpatterns = patterns(
views.AgendaPDF.as_view(), views.AgendaPDF.as_view(),
name='print_agenda'), name='print_agenda'),
url(r'^numbering/$',
views.AgendaNumberingView.as_view(),
name='agenda_numbering'),
# List of speakers # List of speakers
url(r'^(?P<pk>\d+)/speaker/$', url(r'^(?P<pk>\d+)/speaker/$',
views.SpeakerAppendView.as_view(), views.SpeakerAppendView.as_view(),

View File

@ -34,6 +34,7 @@ from openslides.utils.views import (
DeleteView, DeleteView,
FormView, FormView,
PDFView, PDFView,
QuestionView,
RedirectView, RedirectView,
SingleObjectMixin, SingleObjectMixin,
TemplateView, TemplateView,
@ -134,8 +135,14 @@ class Overview(TemplateView):
parent = Item.objects.get(id=form.cleaned_data['parent']) parent = Item.objects.get(id=form.cleaned_data['parent'])
except Item.DoesNotExist: except Item.DoesNotExist:
parent = None 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.parent = parent
item.weight = form.cleaned_data['weight']
Model.save(item) Model.save(item)
else: else:
transaction.rollback() transaction.rollback()
@ -336,6 +343,22 @@ class CreateRelatedAgendaItemView(SingleObjectMixin, RedirectView):
self.item = Item.objects.create(content_object=self.object) 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): class AgendaPDF(PDFView):
""" """
Create a full agenda-PDF. Create a full agenda-PDF.

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import difflib import difflib
import roman
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.shortcuts import render_to_response from django.shortcuts import render_to_response
@ -71,3 +72,14 @@ def int_or_none(var):
return int(var) return int(var)
except (TypeError, ValueError): except (TypeError, ValueError):
return None 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

View File

@ -7,6 +7,7 @@ django-mptt>=0.6,<0.7
jsonfield>=0.9,<0.10 jsonfield>=0.9,<0.10
natsort>=3.1,<3.2 natsort>=3.1,<3.2
reportlab>=2.7,<2.8 reportlab>=2.7,<2.8
roman>=2.0,<2.1
setuptools>=2.1,<3.5 setuptools>=2.1,<3.5
sockjs-tornado>=1.0,<1.1 sockjs-tornado>=1.0,<1.1
tornado>=3.1,<3.3 tornado>=3.1,<3.3

View File

@ -229,6 +229,20 @@ class ViewTest(TestCase):
self.assertIsNone(Item.objects.get(pk=1).parent) self.assertIsNone(Item.objects.get(pk=1).parent)
self.assertEqual(Item.objects.get(pk=2).parent_id, 1) 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): def test_delete(self):
response = self.adminClient.get('/agenda/%s/del/' % self.item1.pk) response = self.adminClient.get('/agenda/%s/del/' % self.item1.pk)
self.assertRedirects(response, '/agenda/') self.assertRedirects(response, '/agenda/')
@ -276,6 +290,32 @@ class ViewTest(TestCase):
response = client.get('/agenda/2/') response = client.get('/agenda/2/')
self.assertEqual(response.status_code, 200) 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): def test_csv_import(self):
item_number = Item.objects.all().count() item_number = Item.objects.all().count()
new_csv_file = SimpleUploadedFile( new_csv_file = SimpleUploadedFile(