New Agenda Item

Changed agenda item, so it can not be manualy created, but is always created
when a custom slide, motion or assignment is created.
This commit is contained in:
Oskar Hahn 2015-10-24 19:02:43 +02:00
parent d3a6c05a68
commit 12a08b9732
44 changed files with 426 additions and 380 deletions

View File

@ -8,6 +8,7 @@ cache:
python:
- "3.3"
- "3.4"
- "3.5"
install:
- "pip install --upgrade --requirement requirements.txt"
- "npm install"
@ -18,7 +19,7 @@ script:
- "isort --check-only --recursive openslides tests"
- "DJANGO_SETTINGS_MODULE='tests.settings' coverage run ./manage.py test tests.unit"
- "coverage report --fail-under=43"
- "coverage report --fail-under=40"
- "DJANGO_SETTINGS_MODULE='tests.settings' coverage run ./manage.py test tests.integration"
- "coverage report --fail-under=50"

View File

@ -14,15 +14,23 @@ class AgendaAppConfig(AppConfig):
from . import projector # noqa
# Import all required stuff.
from django.db.models.signals import pre_delete
from django.db.models.signals import pre_delete, post_save
from openslides.core.signals import config_signal
from openslides.utils.rest_api import router
from .signals import setup_agenda_config, listen_to_related_object_delete_signal
from .signals import (
setup_agenda_config,
listen_to_related_object_post_delete,
listen_to_related_object_post_save)
from .views import ItemViewSet
# Connect signals.
config_signal.connect(setup_agenda_config, dispatch_uid='setup_agenda_config')
pre_delete.connect(listen_to_related_object_delete_signal, dispatch_uid='agenda_listen_to_related_object_delete_signal')
post_save.connect(
listen_to_related_object_post_save,
dispatch_uid='listen_to_related_object_post_save')
pre_delete.connect(
listen_to_related_object_post_delete,
dispatch_uid='listen_to_related_object_post_delete')
# Register viewsets.
router.register('agenda/item', ItemViewSet)

View File

@ -0,0 +1,46 @@
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agenda', '0003_auto_20150904_1732'),
]
operations = [
migrations.AlterModelOptions(
name='item',
options={
'permissions': (
('can_see', 'Can see agenda'),
('can_manage', 'Can manage agenda'),
('can_see_hidden_items',
'Can see hidden items and time scheduling of agenda'))},
),
migrations.AlterField(
model_name='item',
name='type',
field=models.IntegerField(
choices=[(1, 'Agenda item'), (2, 'Hidden item')],
verbose_name='Type',
default=1),
),
migrations.AlterUniqueTogether(
name='item',
unique_together=set([('content_type', 'object_id')]),
),
migrations.RemoveField(
model_name='item',
name='tags',
),
migrations.RemoveField(
model_name='item',
name='text',
),
migrations.RemoveField(
model_name='item',
name='title',
),
]

View File

@ -5,13 +5,11 @@ from django.conf import settings
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy, ugettext_noop
from openslides.core.config import config
from openslides.core.models import Tag
from openslides.core.projector import Countdown
from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.models import RESTModelMixin
@ -19,6 +17,45 @@ from openslides.utils.utils import to_roman
class ItemManager(models.Manager):
def get_only_agenda_items(self, queryset=None):
"""
Generator, which yields only agenda items. Skips hidden items.
"""
if queryset is None:
queryset = self.all()
# Do not execute item.is_hidden() because this would create a lot of db queries
root_items, item_children = self.get_root_and_children(only_agenda_items=True)
def yield_items(items):
"""
Generator that yields a list of items and their children.
"""
for item in items:
yield item
yield from yield_items(item_children[item.pk])
yield from yield_items(root_items)
def get_root_and_children(self, queryset=None, only_agenda_items=False):
"""
Returns a list with all root items and a dictonary where the key is an
item pk and the value is a list with all children of the item.
"""
if queryset is None:
queryset = self.order_by('weight')
item_children = defaultdict(list)
root_items = []
for item in queryset:
if only_agenda_items and item.type == item.HIDDEN_ITEM:
continue
if item.parent_id is not None:
item_children[item.parent_id].append(item)
else:
root_items.append(item)
return root_items, item_children
def get_tree(self, only_agenda_items=False, include_content=False):
"""
Generator that yields dictonaries. Each dictonary has two keys, id
@ -30,15 +67,7 @@ class ItemManager(models.Manager):
If include_content is True, the yielded dictonaries have no key 'id'
but a key 'item' with the entire object.
"""
item_queryset = self.order_by('weight')
if only_agenda_items:
item_queryset = item_queryset.filter(type__exact=Item.AGENDA_ITEM)
# Index the items to get the children for each item
item_children = defaultdict(list)
for item in item_queryset:
if item.parent:
item_children[item.parent_id].append(item)
root_items, item_children = self.get_root_and_children(only_agenda_items=only_agenda_items)
def get_children(items):
"""
@ -50,7 +79,7 @@ class ItemManager(models.Manager):
else:
yield dict(id=item.pk, children=get_children(item_children[item.pk]))
yield from get_children(filter(lambda item: item.parent is None, item_queryset))
yield from get_children(root_items)
@transaction.atomic
def set_tree(self, tree):
@ -101,27 +130,17 @@ class Item(RESTModelMixin, models.Model):
slide_callback_name = 'agenda'
AGENDA_ITEM = 1
ORGANIZATIONAL_ITEM = 2
HIDDEN_ITEM = 2
ITEM_TYPE = (
(AGENDA_ITEM, ugettext_lazy('Agenda item')),
(ORGANIZATIONAL_ITEM, ugettext_lazy('Organizational item')))
(HIDDEN_ITEM, ugettext_lazy('Hidden 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.
"""
text = models.TextField(null=True, blank=True, verbose_name=ugettext_lazy("Text"))
"""
The optional text of the agenda item.
"""
comment = models.TextField(null=True, blank=True, verbose_name=ugettext_lazy("Comment"))
"""
Optional comment to the agenda item. Will not be shoun to normal users.
@ -132,7 +151,10 @@ class Item(RESTModelMixin, models.Model):
Flag, if the item is finished.
"""
type = models.IntegerField(choices=ITEM_TYPE, default=AGENDA_ITEM, verbose_name=ugettext_lazy("Type"))
type = models.IntegerField(
choices=ITEM_TYPE,
default=AGENDA_ITEM,
verbose_name=ugettext_lazy("Type"))
"""
Type of the agenda item.
@ -175,32 +197,15 @@ class Item(RESTModelMixin, models.Model):
True, if the list of speakers is closed.
"""
tags = models.ManyToManyField(Tag, blank=True)
"""
Tags to categorise agenda items.
"""
class Meta:
permissions = (
('can_see', ugettext_noop("Can see agenda")),
('can_manage', ugettext_noop("Can manage agenda")),
('can_see_orga_items', ugettext_noop("Can see orga items and time scheduling of agenda")))
('can_see_hidden_items', ugettext_noop("Can see hidden items and time scheduling of agenda")))
unique_together = ('content_type', 'object_id')
def __str__(self):
return self.get_title()
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 child elements of an organizational item.'))
if (self.type == self.ORGANIZATIONAL_ITEM and
self.children.filter(type=self.AGENDA_ITEM).exists()):
raise ValidationError(_('Organizational items can not have agenda items as child elements.'))
return super().clean()
return self.title
def delete(self, with_children=False):
"""
@ -216,91 +221,27 @@ class Item(RESTModelMixin, models.Model):
child.save()
super().delete()
def get_title(self):
@property
def title(self):
"""
Return the title of this item.
Return get_agenda_title() from the content_object.
"""
if not self.content_object:
agenda_title = self.title or ""
else:
try:
agenda_title = self.content_object.get_agenda_title()
except AttributeError:
raise NotImplementedError('You have to provide a get_agenda_title '
'method on your related model.')
return '%s %s' % (self.item_no, agenda_title) if self.item_no else agenda_title
def get_title_supplement(self):
"""
Return a supplement for the title.
"""
if not self.content_object:
return ''
try:
return self.content_object.get_agenda_title_supplement()
title = self.content_object.get_agenda_title()
except AttributeError:
raise NotImplementedError('You have to provide a get_agenda_title_supplement method on your related model.')
raise NotImplementedError('You have to provide a get_agenda_title '
'method on your related model.')
return '%s %s' % (self.item_no, title) if self.item_no else title
def get_list_of_speakers(self, old_speakers_count=None, coming_speakers_count=None):
def is_hidden(self):
"""
Returns the list of speakers as a list of dictionaries. Each
dictionary contains a prefix, the speaker and its type. Types
are old_speaker, actual_speaker and coming_speaker.
Returns True if the type of this object itself is a hidden item or any
of its ancestors has such a type.
Attention! This executes one query for each ancestor of the item.
"""
list_of_speakers = []
# Parse old speakers
old_speakers = self.speakers.exclude(begin_time=None).exclude(end_time=None).order_by('end_time')
if old_speakers_count is None:
old_speakers_count = old_speakers.count()
last_old_speakers_count = max(0, old_speakers.count() - old_speakers_count)
old_speakers = old_speakers[last_old_speakers_count:]
for number, speaker in enumerate(old_speakers):
prefix = old_speakers_count - number
speaker_dict = {
'prefix': '-%d' % prefix,
'speaker': speaker,
'type': 'old_speaker',
'first_in_group': False,
'last_in_group': False}
if number == 0:
speaker_dict['first_in_group'] = True
if number == old_speakers_count - 1:
speaker_dict['last_in_group'] = True
list_of_speakers.append(speaker_dict)
# Parse actual speaker
try:
actual_speaker = self.speakers.filter(end_time=None).exclude(begin_time=None).get()
except Speaker.DoesNotExist:
pass
else:
list_of_speakers.append({
'prefix': '0',
'speaker': actual_speaker,
'type': 'actual_speaker',
'first_in_group': True,
'last_in_group': True})
# Parse coming speakers
coming_speakers = self.speakers.filter(begin_time=None).order_by('weight')
if coming_speakers_count is None:
coming_speakers_count = coming_speakers.count()
coming_speakers = coming_speakers[:max(0, coming_speakers_count)]
for number, speaker in enumerate(coming_speakers):
speaker_dict = {
'prefix': number + 1,
'speaker': speaker,
'type': 'coming_speaker',
'first_in_group': False,
'last_in_group': False}
if number == 0:
speaker_dict['first_in_group'] = True
if number == coming_speakers_count - 1:
speaker_dict['last_in_group'] = True
list_of_speakers.append(speaker_dict)
return list_of_speakers
return (self.type == self.HIDDEN_ITEM or
(self.parent is not None and self.parent.is_hidden()))
def get_next_speaker(self):
"""
@ -421,11 +362,12 @@ class Speaker(RESTModelMixin, models.Model):
speaking, end his speech.
"""
try:
actual_speaker = Speaker.objects.filter(item=self.item, end_time=None).exclude(begin_time=None).get()
current_speaker = (Speaker.objects.filter(item=self.item, end_time=None)
.exclude(begin_time=None).get())
except Speaker.DoesNotExist:
pass
else:
actual_speaker.end_speech()
current_speaker.end_speech()
self.weight = None
self.begin_time = datetime.now()
self.save()

View File

@ -1,7 +1,6 @@
from django.utils.translation import ugettext as _
from openslides.core.exceptions import ProjectorException
from openslides.core.views import TagViewSet
from openslides.utils.projector import ProjectorElement, ProjectorRequirement
from .models import Item
@ -80,8 +79,3 @@ class ItemDetailSlide(ProjectorElement):
view_class=speaker.user.get_view_class(),
view_action='retrieve',
pk=str(speaker.user_id))
for tag in item.tags.all():
yield ProjectorRequirement(
view_class=TagViewSet,
view_action='retrieve',
pk=str(tag.pk))

View File

@ -1,7 +1,6 @@
from django.core.urlresolvers import reverse
from openslides.utils.rest_api import (
CharField,
ModelSerializer,
RelatedField,
get_collection_and_id_from_url,
@ -45,10 +44,7 @@ class ItemSerializer(ModelSerializer):
"""
Serializer for agenda.models.Item objects.
"""
get_title = CharField(read_only=True)
get_title_supplement = CharField(read_only=True)
content_object = RelatedItemRelatedField(read_only=True)
item_no = CharField(read_only=True)
speakers = SpeakerSerializer(many=True, read_only=True)
class Meta:
@ -56,11 +52,7 @@ class ItemSerializer(ModelSerializer):
fields = (
'id',
'item_number',
'item_no',
'title',
'get_title',
'get_title_supplement',
'text',
'comment',
'closed',
'type',
@ -68,6 +60,5 @@ class ItemSerializer(ModelSerializer):
'speakers',
'speaker_list_closed',
'content_object',
'tags',
'weight',
'parent',)

View File

@ -72,15 +72,24 @@ def setup_agenda_config(sender, **kwargs):
group=ugettext_lazy('Agenda'))
def listen_to_related_object_delete_signal(sender, instance, **kwargs):
def listen_to_related_object_post_save(sender, instance, created, **kwargs):
"""
Receiver function to change agenda items of a related item that is to
be deleted. It is connected to the signal
django.db.models.signals.pre_delete during app loading.
Receiver function to create agenda items. It is connected to the signal
django.db.models.signals.post_save during app loading.
"""
if created and hasattr(instance, 'get_agenda_title'):
Item.objects.create(content_object=instance)
def listen_to_related_object_post_delete(sender, instance, **kwargs):
"""
Receiver function to delete agenda items. It is connected to the signal
django.db.models.signals.post_delete during app loading.
"""
if hasattr(instance, 'get_agenda_title'):
for item in Item.objects.filter(content_type=ContentType.objects.get_for_model(sender), object_id=instance.pk):
item.title = '< Item for deleted (%s) >' % instance.get_agenda_title()
item.content_type = None
item.object_id = None
item.save()
content_type = ContentType.objects.get_for_model(instance)
try:
Item.objects.get(object_id=instance.pk, content_type=content_type).delete()
except Item.DoesNotExist:
# Item does not exist so we do not have to delete it.
pass

View File

@ -33,6 +33,27 @@ angular.module('OpenSlidesApp.agenda', ['OpenSlidesApp.users'])
methods: {
getResourceName: function () {
return name;
},
getContentObject: function () {
return DS.get(this.content_object.collection, this.content_object.id);
},
getContentResource: function () {
return DS.definitions[this.content_object.collection];
},
getTitle: function () {
var title;
try {
title = this.getContentObject().getAgendaTitle();
} catch (e) {
// Only use this.title when the content object is not
// in the DS store.
title = this.title;
}
return _.trim(
title + ' ' + (
this.getContentResource().agendaSupplement || ''
)
);
}
},
relations: {
@ -97,6 +118,7 @@ angular.module('OpenSlidesApp.agenda', ['OpenSlidesApp.users'])
});
return getChildren(parentItems);
},
// Returns a list of all items as a flat tree the attribute parentCount
getFlatTree: function(items) {
var tree = this.getTree(items);

View File

@ -99,6 +99,7 @@ angular.module('OpenSlidesApp.agenda.site', ['OpenSlidesApp.agenda'])
// Bind agenda tree to the scope
$scope.$watch(function () {
return Agenda.lastModified();
}, function () {
$scope.items = AgendaTree.getFlatTree(Agenda.getAll());
});

View File

@ -103,7 +103,7 @@
<!-- agenda data columns -->
<td>
<span ng-repeat="n in [].constructor(item.parentCount) track by $index">&ndash;</span>
{{ item.item_number }} {{ item.title }}
{{ item.item_number }} {{ item.getTitle() }}
<div ng-if="item.comment">
<small><i class="fa fa-info-circle"></i> {{ item.comment }}</small>
</div>

View File

@ -1,16 +0,0 @@
{% load i18n %}
{% load highlight %}
{% if perms.agenda.can_see and result.object.type == result.object.AGENDA_ITEM %}
<li>
<a href="{{ result.object.get_absolute_url }}">{{ result.object }}</a><br>
<span class="app">{% trans "Agenda" %}</a></span><br>
{% highlight result.text with request.GET.q %}
</li>
{% elif perms.agenda.can_see_orga_items and result.object.type == result.object.ORGANIZATIONAL_ITEM %}
<li>
<a href="{{ result.object.get_absolute_url }}"><i>[{{ result.object }}]</i></a><br>
<span class="app">{% trans "Agenda" %} ({% trans "Organizational item" %})</a></span><br>
{% highlight result.text with request.GET.q %}
</li>
{% endif %}

View File

@ -1,3 +0,0 @@
{{ object.title }}
{{ object.text }}
{{ object.tags.all }}

View File

@ -8,8 +8,11 @@ from reportlab.platypus import Paragraph
from openslides.utils.exceptions import OpenSlidesError
from openslides.utils.pdf import stylesheet
from openslides.utils.rest_api import (
ModelViewSet,
GenericViewSet,
ListModelMixin,
Response,
RetrieveModelMixin,
UpdateModelMixin,
ValidationError,
detail_route,
list_route,
@ -22,7 +25,7 @@ from .serializers import ItemSerializer
# Viewsets for the REST API
class ItemViewSet(ModelViewSet):
class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericViewSet):
"""
API endpoint for agenda items.
@ -40,9 +43,9 @@ class ItemViewSet(ModelViewSet):
result = self.request.user.has_perm('agenda.can_see')
# For manage_speaker and tree requests the rest of the check is
# done in the specific method. See below.
elif self.action in ('create', 'partial_update', 'update', 'destroy'):
elif self.action in ('partial_update', 'update'):
result = (self.request.user.has_perm('agenda.can_see') and
self.request.user.has_perm('agenda.can_see_orga_items') and
self.request.user.has_perm('agenda.can_see_hidden_items') and
self.request.user.has_perm('agenda.can_manage'))
elif self.action == 'speak':
result = (self.request.user.has_perm('agenda.can_see') and
@ -56,7 +59,7 @@ class ItemViewSet(ModelViewSet):
Checks if the requesting user has permission to see also an
organizational item if it is one.
"""
if obj.type == obj.ORGANIZATIONAL_ITEM and not request.user.has_perm('agenda.can_see_orga_items'):
if obj.is_hidden() and not request.user.has_perm('agenda.can_see_hidden_items'):
self.permission_denied(request)
def get_queryset(self):
@ -64,9 +67,10 @@ class ItemViewSet(ModelViewSet):
Filters organizational items if the user has no permission to see them.
"""
queryset = super().get_queryset()
if not self.request.user.has_perm('agenda.can_see_orga_items'):
queryset = queryset.exclude(type__exact=Item.ORGANIZATIONAL_ITEM)
return queryset
if self.request.user.has_perm('agenda.can_see_hidden_items'):
return queryset
else:
return Item.objects.get_only_agenda_items(queryset)
@detail_route(methods=['POST', 'DELETE'])
def manage_speaker(self, request, pk=None):
@ -197,7 +201,7 @@ class ItemViewSet(ModelViewSet):
"""
if request.method == 'PUT':
if not (request.user.has_perm('agenda.can_manage') and
request.user.has_perm('agenda.can_see_orga_items')):
request.user.has_perm('agenda.can_see_hidden_items')):
self.permission_denied(request)
try:
tree = request.data['tree']
@ -242,7 +246,7 @@ class AgendaPDF(PDFView):
if ancestors:
space = "&nbsp;" * 6 * ancestors
story.append(Paragraph(
"%s%s" % (space, escape(item.get_title())),
"%s%s" % (space, escape(item.title)),
stylesheet['Subitem']))
else:
story.append(Paragraph(escape(item.get_title()), stylesheet['Item']))
story.append(Paragraph(escape(item.title), stylesheet['Item']))

View File

@ -1,5 +1,5 @@
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.datastructures import SortedDict
from django.utils.translation import ugettext as _
@ -116,11 +116,6 @@ class Assignment(RESTModelMixin, models.Model):
Tags for the assignment.
"""
items = GenericRelation(Item)
"""
Agenda items for this assignment.
"""
class Meta:
permissions = (
('can_see', ugettext_noop('Can see elections')),
@ -308,8 +303,20 @@ class Assignment(RESTModelMixin, models.Model):
def get_agenda_title(self):
return str(self)
def get_agenda_title_supplement(self):
return '(%s)' % _('Assignment')
@property
def agenda_item(self):
"""
Returns the related agenda item.
"""
content_type = ContentType.objects.get_for_model(self)
return Item.objects.get(object_id=self.pk, content_type=content_type)
@property
def agenda_item_id(self):
"""
Returns the id of the agenda item object related to this object.
"""
return self.agenda_item.pk
class AssignmentVote(RESTModelMixin, BaseVote):

View File

@ -170,6 +170,7 @@ class AssignmentFullSerializer(ModelSerializer):
'assignment_related_users',
'poll_description_default',
'polls',
'agenda_item_id',
'tags',)
@ -191,4 +192,5 @@ class AssignmentShortSerializer(AssignmentFullSerializer):
'assignment_related_users',
'poll_description_default',
'polls',
'agenda_item_id',
'tags',)

View File

@ -4,18 +4,34 @@
angular.module('OpenSlidesApp.assignments', [])
.factory('Assignment', ['DS', 'jsDataModel', function(DS, jsDataModel) {
var name = 'assignments/assignment';
return DS.defineResource({
name: name,
useClass: jsDataModel,
methods: {
getResourceName: function () {
return name;
.factory('Assignment', [
'DS',
'jsDataModel',
function(DS, jsDataModel) {
var name = 'assignments/assignment';
return DS.defineResource({
name: name,
useClass: jsDataModel,
agendaSupplement: '(Assignment)',
methods: {
getResourceName: function () {
return name;
},
getAgendaTitle: function () {
return this.title;
}
},
relations: {
belongsTo: {
'agenda/item': {
localKey: 'agenda_item_id',
localField: 'agenda_item',
}
}
}
}
});
}])
});
}
])
.run(['Assignment', function(Assignment) {}]);

View File

@ -79,6 +79,7 @@ angular.module('OpenSlidesApp.assignments.site', ['OpenSlidesApp.assignments'])
.controller('AssignmentDetailCtrl', function($scope, Assignment, assignment) {
Assignment.bindOne(assignment.id, $scope, 'assignment');
Assignment.loadRelations(assignment);
})
.controller('AssignmentCreateCtrl', function($scope, $state, Assignment) {

View File

@ -24,6 +24,8 @@
</a>
</div>
{{ assignment.agenda_item }}
<h3 translate>Description</h3>
<div class="white-space-pre-line">{{ assignment.description }}</div>

View File

@ -1,10 +0,0 @@
{% load i18n %}
{% load highlight %}
{% if perms.assignments.can_see %}
<li>
<a href="{{ result.object.get_absolute_url }}">{{ result.object }}</a><br>
<span class="app">{% trans "Election" %}</a></span><br>
{% highlight result.text with request.GET.q %}
</li>
{% endif %}

View File

@ -1,4 +0,0 @@
{{ object.title }}
{{ object.description }}
{{ object.candidates }}
{{ object.tags.all }}

View File

@ -1,4 +1,5 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy, ugettext_noop
@ -135,6 +136,26 @@ class CustomSlide(RESTModelMixin, models.Model):
def __str__(self):
return self.title
@property
def agenda_item(self):
"""
Returns the related agenda item.
"""
# TODO: Move the agenda app in the core app to fix circular dependencies
from openslides.agenda.models import Item
content_type = ContentType.objects.get_for_model(self)
return Item.objects.get(object_id=self.pk, content_type=content_type)
@property
def agenda_item_id(self):
"""
Returns the id of the agenda item object related to this object.
"""
return self.agenda_item.pk
def get_agenda_title(self):
return self.title
class Tag(RESTModelMixin, models.Model):
"""

View File

@ -39,7 +39,7 @@ class CustomSlideSerializer(ModelSerializer):
"""
class Meta:
model = CustomSlide
fields = ('id', 'title', 'text', 'weight', )
fields = ('id', 'title', 'text', 'weight', 'agenda_item_id')
class TagSerializer(ModelSerializer):

View File

@ -140,18 +140,33 @@ angular.module('OpenSlidesApp.core', [
return BaseModel;
}])
.factory('Customslide', ['DS', 'jsDataModel', function(DS, jsDataModel) {
var name = 'core/customslide';
return DS.defineResource({
name: name,
useClass: jsDataModel,
methods: {
getResourceName: function () {
return name;
.factory('Customslide', [
'DS',
'jsDataModel',
function(DS, jsDataModel) {
var name = 'core/customslide';
return DS.defineResource({
name: name,
useClass: jsDataModel,
methods: {
getResourceName: function () {
return name;
},
getAgendaTitle: function () {
return this.title;
}
},
},
});
}])
relations: {
belongsTo: {
'agenda/item': {
localKey: 'agenda_item_id',
localField: 'agenda_item',
}
}
}
});
}
])
.factory('Tag', ['DS', function(DS) {
return DS.defineResource({

View File

@ -646,6 +646,7 @@ angular.module('OpenSlidesApp.core.site', [
.controller('CustomslideDetailCtrl', function($scope, Customslide, customslide) {
Customslide.bindOne(customslide.id, $scope, 'customslide');
Customslide.loadRelations(customslide);
})
.controller('CustomslideCreateCtrl', function($scope, $state, Customslide) {

View File

@ -18,5 +18,7 @@
</a>
</div>
{{ customslide.agenda_item }}
<div class="white-space-pre-line">{{ customslide.text }}</div>

View File

@ -82,7 +82,6 @@ INSTALLED_APPS = (
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'haystack', # full-text-search
'rest_framework',
'openslides.poll', # TODO: try to remove this line
'openslides.agenda',

View File

@ -1,4 +1,5 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Max
from django.utils import formats
@ -6,6 +7,7 @@ from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy, ugettext_noop
from jsonfield import JSONField
from openslides.agenda.models import Item
from openslides.core.config import config
from openslides.core.models import Tag
from openslides.mediafiles.models import Mediafile
@ -447,13 +449,23 @@ class Motion(RESTModelMixin, models.Model):
"""
Return a title for the agenda.
"""
# There has to be a function with the same return value in javascript.
return str(self)
def get_agenda_title_supplement(self):
@property
def agenda_item(self):
"""
Returns the supplement to the title for the agenda item.
Returns the related agenda item.
"""
return '(%s)' % _('Motion')
content_type = ContentType.objects.get_for_model(self)
return Item.objects.get(object_id=self.pk, content_type=content_type)
@property
def agenda_item_id(self):
"""
Returns the id of the agenda item object related to this object.
"""
return self.agenda_item.pk
def get_allowed_actions(self, person):
"""

View File

@ -152,7 +152,7 @@ def motion_to_pdf(pdf, motion):
def convert_html_to_reportlab(pdf, text):
# parsing and replacing not supported html tags for reportlab...
soup = BeautifulSoup(text)
soup = BeautifulSoup(text, "html5lib")
# read all list elements...
for element in soup.find_all('li'):
# ... and replace ul list elements with <para><bullet>&bull;</bullet>...<para>

View File

@ -204,6 +204,7 @@ class MotionSerializer(ModelSerializer):
'tags',
'attachments',
'polls',
'agenda_item_id',
'log_messages',)
read_only_fields = ('parent',) # Some other fields are also read_only. See definitions above.

View File

@ -2,63 +2,79 @@
angular.module('OpenSlidesApp.motions', [])
.factory('Motion', ['DS', 'jsDataModel', function(DS, jsDataModel) {
var name = 'motions/motion'
return DS.defineResource({
name: name,
useClass: jsDataModel,
methods: {
getResourceName: function () {
return name;
},
getVersion: function(versionId) {
versionId = versionId || this.active_version;
var index;
if (versionId == -1) {
index = this.versions.length - 1;
} else {
index = _.findIndex(this.versions, function(element) {
return element.id == versionId
});
}
return this.versions[index];
},
getTitle: function(versionId) {
return this.getVersion(versionId).title;
},
getText: function(versionId) {
return this.getVersion(versionId).text;
},
getReason: function(versionId) {
return this.getVersion(versionId).reason;
}
},
relations: {
belongsTo: {
'motions/category': {
localField: 'category',
localKey: 'category_id',
.factory('Motion', [
'DS',
'jsDataModel',
function(DS, jsDataModel) {
var name = 'motions/motion'
return DS.defineResource({
name: name,
useClass: jsDataModel,
agendaSupplement: '(Motion)',
methods: {
getResourceName: function () {
return name;
},
},
hasMany: {
'core/tag': {
localField: 'tags',
localKeys: 'tags_id',
},
'users/user': [
{
localField: 'submitters',
localKeys: 'submitters_id',
},
{
localField: 'supporters',
localKeys: 'supporters_id',
getVersion: function (versionId) {
versionId = versionId || this.active_version;
var index;
if (versionId == -1) {
index = this.versions.length - 1;
} else {
index = _.findIndex(this.versions, function (element) {
return element.id == versionId
});
}
],
return this.versions[index];
},
getTitle: function (versionId) {
return this.getVersion(versionId).title;
},
getText: function (versionId) {
return this.getVersion(versionId).text;
},
getReason: function (versionId) {
return this.getVersion(versionId).reason;
},
getAgendaTitle: function () {
var value = '';
if (this.identifier) {
value = this.identifier + ' | ';
}
return value + this.getTitle();
}
},
relations: {
belongsTo: {
'motions/category': {
localField: 'category',
localKey: 'category_id',
},
'agenda/item': {
localKey: 'agenda_item_id',
localField: 'agenda_item',
}
},
hasMany: {
'core/tag': {
localField: 'tags',
localKeys: 'tags_id',
},
'users/user': [
{
localField: 'submitters',
localKeys: 'submitters_id',
},
{
localField: 'supporters',
localKeys: 'supporters_id',
}
],
}
}
}
});
}])
});
}
])
.factory('Category', ['DS', function(DS) {
return DS.defineResource({
@ -240,6 +256,7 @@ angular.module('OpenSlidesApp.motions.site', ['OpenSlidesApp.motions'])
Motion.bindOne(motion.id, $scope, 'motion');
Category.bindAll({}, $scope, 'categories');
User.bindAll({}, $scope, 'users');
Motion.loadRelations(motion);
})
.controller('MotionCreateCtrl',

View File

@ -7,6 +7,8 @@
</h1>
{{ motion.tags }}
{{ motion.agenda_item }}
<div id="submenu">
<a ui-sref="motions.motion.list" class="btn btn-sm btn-default">
<i class="fa fa-angle-double-left fa-lg"></i>

View File

@ -1,8 +0,0 @@
{{ object.identifier }}
{{ object.title }}
{{ object.text }}
{{ object.reason }}
{{ object.submitters.all }}
{{ object.supporters.all }}
{{ object.category }}
{{ object.tags.all }}

View File

@ -1,10 +0,0 @@
{% load i18n %}
{% load highlight %}
{% if perms.motions.can_see %}
<li>
<a href="{{ result.object.get_absolute_url }}">{{ result.object }}</a><br>
<span class="app">{% trans "Motion" %}</a></span><br>
{% highlight result.text with request.GET.q %}
</li>
{% endif %}

View File

@ -105,7 +105,7 @@ def create_builtin_groups_and_admin(**kwargs):
'agenda.can_be_speaker',
'agenda.can_manage',
'agenda.can_see',
'agenda.can_see_orga_items',
'agenda.can_see_hidden_items',
'assignments.can_manage',
'assignments.can_nominate_other',
'assignments.can_nominate_self',
@ -141,7 +141,7 @@ def create_builtin_groups_and_admin(**kwargs):
# Anonymous (pk 1) and Registered (pk 2)
base_permissions = (
permission_dict['agenda.can_see'],
permission_dict['agenda.can_see_orga_items'],
permission_dict['agenda.can_see_hidden_items'],
permission_dict['assignments.can_see'],
permission_dict['core.can_see_dashboard'],
permission_dict['core.can_see_projector'],

View File

@ -1,4 +0,0 @@
{{ object.django_user }}
{{ object.structure_level }}
{{ object.committee }}
{{ object.about_me }}

View File

@ -1,10 +0,0 @@
{% load i18n %}
{% load highlight %}
{% if perms.users.can_see %}
<li>
<a href="{{ result.object.get_absolute_url }}">{{ result.object }}</a><br>
<span class="app">{% trans "User" %}</a></span><br>
{% highlight result.text with request.GET.q %}
</li>
{% endif %}

View File

@ -5,7 +5,12 @@ from urllib.parse import urlparse
from rest_framework import status # noqa
from rest_framework.decorators import detail_route, list_route # noqa
from rest_framework.metadata import SimpleMetadata # noqa
from rest_framework.mixins import DestroyModelMixin, UpdateModelMixin # noqa
from rest_framework.mixins import ( # noqa
DestroyModelMixin,
ListModelMixin,
RetrieveModelMixin,
UpdateModelMixin,
)
from rest_framework.response import Response # noqa
from rest_framework.routers import DefaultRouter
from rest_framework.serializers import ModelSerializer as _ModelSerializer

View File

@ -1,4 +1,3 @@
from django.core.management import call_command
from django.test import TestCase as _TestCase
from django.test.runner import DiscoverRunner
@ -40,6 +39,4 @@ class TestCase(_TestCase):
except AttributeError:
# The cache has only to be deleted if it exists.
pass
# Clear the whoosh search index
call_command('clear_index', interactive=False, verbosity=0)
return return_value

View File

@ -3,11 +3,11 @@ Django>=1.7.1,<1.9
beautifulsoup4>=4.1,<4.5
django-haystack>=2.1,<2.5
djangorestframework>=3.2.0,<3.3.0
html5lib>=0.9,<1.0
jsonfield>=0.9.19,<1.1
natsort>=3.2,<4.1
reportlab>=3.0,<3.3
roman>=2.0,<2.1
setuptools>=2.2,<19.0
sockjs-tornado>=1.0,<1.1
whoosh>=2.5.6,<2.8

View File

@ -0,0 +1,15 @@
from openslides.agenda.models import Item
from openslides.core.models import CustomSlide
from openslides.utils.test import TestCase
class TestItemManager(TestCase):
def test_get_root_and_children_db_queries(self):
"""
Test that get_root_and_children needs only one db query.
"""
for i in range(10):
CustomSlide.objects.create(title='item{}'.format(i))
with self.assertNumQueries(1):
Item.objects.get_root_and_children()

View File

@ -3,14 +3,17 @@ import json
from rest_framework.test import APIClient
from openslides.agenda.models import Item
from openslides.core.models import CustomSlide
from openslides.utils.test import TestCase
class AgendaTreeTest(TestCase):
def setUp(self):
Item.objects.create(title='item1')
item2 = Item.objects.create(title='item2')
Item.objects.create(title='item2a', parent=item2)
CustomSlide.objects.create(title='item1')
item2 = CustomSlide.objects.create(title='item2').agenda_item
item3 = CustomSlide.objects.create(title='item2a').agenda_item
item3.parent = item2
item3.save()
self.client = APIClient()
self.client.login(username='admin', password='admin')
@ -87,7 +90,7 @@ class TestAgendaPDF(TestCase):
"""
Tests that a requst on the pdf-page returns with statuscode 200.
"""
Item.objects.create(title='item1')
CustomSlide.objects.create(title='item1')
self.client.login(username='admin', password='admin')
response = self.client.get('/agenda/print/')

View File

@ -2,9 +2,9 @@ from django.contrib.auth import get_user_model
from django.core.urlresolvers import reverse
from rest_framework.test import APIClient
from openslides.agenda.models import Item, Speaker
from openslides.agenda.models import Speaker
from openslides.core.config import config
from openslides.core.models import Projector
from openslides.core.models import CustomSlide, Projector
from openslides.utils.test import TestCase
@ -15,7 +15,8 @@ class ManageSpeaker(TestCase):
def setUp(self):
self.client = APIClient()
self.client.login(username='admin', password='admin')
self.item = Item.objects.create(title='test_title_aZaedij4gohn5eeQu8fe')
self.item = CustomSlide.objects.create(title='test_title_aZaedij4gohn5eeQu8fe').agenda_item
self.user = get_user_model().objects.create_user(
username='test_user_jooSaex1bo5ooPhuphae',
password='test_password_e6paev4zeeh9n')
@ -132,7 +133,7 @@ class Speak(TestCase):
def setUp(self):
self.client = APIClient()
self.client.login(username='admin', password='admin')
self.item = Item.objects.create(title='test_title_KooDueco3zaiGhiraiho')
self.item = CustomSlide.objects.create(title='test_title_KooDueco3zaiGhiraiho').agenda_item
self.user = get_user_model().objects.create_user(
username='test_user_Aigh4vohb3seecha4aa4',
password='test_password_eneupeeVo5deilixoo8j')

View File

@ -4,57 +4,31 @@ from unittest.mock import patch
from openslides.agenda.models import Item
class ItemTitle(TestCase):
def test_get_title_without_item_no(self):
item = Item(title='test_title')
self.assertEqual(
item.get_title(),
'test_title')
@patch('openslides.agenda.models.Item.item_no', '5')
def test_get_title_with_item_no(self):
item = Item(title='test_title')
self.assertEqual(
item.get_title(),
'5 test_title')
class TestItemTitle(TestCase):
@patch('openslides.agenda.models.Item.content_object')
def test_get_title_from_related(self, content_object):
item = Item(title='test_title')
def test_title_from_content_object(self, content_object):
item = Item()
content_object.get_agenda_title.return_value = 'related_title'
self.assertEqual(
item.get_title(),
item.title,
'related_title')
@patch('openslides.agenda.models.Item.item_no', '5')
@patch('openslides.agenda.models.Item.content_object')
def test_get_title_invalid_related(self, content_object):
item = Item(title='test_title')
def test_title_with_item_no(self, content_object):
item = Item()
content_object.get_agenda_title.return_value = 'related_title'
self.assertEqual(
item.title,
'5 related_title')
@patch('openslides.agenda.models.Item.content_object')
def test_title_invalid_related(self, content_object):
item = Item()
content_object.get_agenda_title.return_value = 'related_title'
del content_object.get_agenda_title
with self.assertRaises(NotImplementedError):
item.get_title()
def test_title_supplement_without_related(self):
item = Item()
self.assertEqual(
item.get_title_supplement(),
'')
@patch('openslides.agenda.models.Item.content_object')
def test_title_supplement_with_related(self, content_object):
item = Item()
content_object.get_agenda_title_supplement.return_value = 'related_title_supplement'
self.assertEqual(
item.get_title_supplement(),
'related_title_supplement')
@patch('openslides.agenda.models.Item.content_object')
def test_title_supplement_invalid_related(self, content_object):
item = Item()
del content_object.get_agenda_title_supplement
with self.assertRaises(NotImplementedError):
item.get_title_supplement()
item.title

View File

@ -308,8 +308,8 @@ class UserManagerCreateOrResetAdminUser(TestCase):
manager.create_or_reset_admin_user()
mock_group.objects.get.assert_called_once(pk=2)
admin_user.groups.add.assert_called_once('mock_staff')
mock_group.objects.get.assert_called_once_with(pk=4)
admin_user.groups.add.assert_called_once_with('mock_staff')
def test_password_set_to_admin(self, mock_group):
"""