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:
- 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:

View File

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

View File

@ -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):

View File

@ -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):

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.'),
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})

View File

@ -70,6 +70,10 @@
{% else %}
<a href="{% url 'config_agenda' %}" class="btn btn-mini pull-right">{% trans 'Set start time of event' %}</a>
{% 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 %}
</div>
</div>

View File

@ -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<pk>\d+)/speaker/$',
views.SpeakerAppendView.as_view(),

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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(