Merge pull request #1254 from normanjaeckel/AgendaNumbering
Agenda numbering
This commit is contained in:
commit
4620b0d22a
@ -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:
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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):
|
||||||
|
@ -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):
|
||||||
|
@ -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})
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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(),
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user