New motion features

- Added weight and sort_parent fields to the motion model
- Added motion sort view (adapted from agenda)
- Added statute-paragraph model and tests for it
- Added statute_paragraph foreign key to the motion model
- Created migrations for sorting and statute-paragraph
This commit is contained in:
FinnStutzenstein 2018-09-24 10:28:31 +02:00
parent db29d1cab1
commit f7d392f1fc
10 changed files with 352 additions and 4 deletions

View File

@ -17,6 +17,9 @@ Core:
- Enabled docs for using OpenSlides with Gunicorn and Uvicorn in big - Enabled docs for using OpenSlides with Gunicorn and Uvicorn in big
mode [#3799, #3817]. mode [#3799, #3817].
Motions:
- Option to customly sort motions [#3894].
- Added support for adding a statute [#3894].
Version 2.3 (unreleased) Version 2.3 (unreleased)
======================== ========================

View File

@ -37,6 +37,8 @@ export class Motion extends AgendaBaseModel {
public polls: Object[]; public polls: Object[];
public agenda_item_id: number; public agenda_item_id: number;
public log_messages: MotionLog[]; public log_messages: MotionLog[];
public weight: number;
public sort_parent_id: number;
public constructor(input?: any) { public constructor(input?: any) {
super('motions/motion', 'Motion', input); super('motions/motion', 'Motion', input);

View File

@ -0,0 +1,22 @@
import { BaseModel } from '../base/base-model';
/**
* Representation of a statute paragraph.
* @ignore
*/
export class StatuteParagraph extends BaseModel<StatuteParagraph> {
public id: number;
public title: string;
public text: string;
public weight: number;
public constructor(input?: any) {
super('motions/statute-paragraph', input);
}
public getTitle(): string {
return this.title;
}
}
BaseModel.registerCollectionElement('motions/statute-paragraph', StatuteParagraph);

View File

@ -138,6 +138,25 @@ class MotionCommentSectionAccessPermissions(BaseAccessPermissions):
return data return data
class StatuteParagraphAccessPermissions(BaseAccessPermissions):
"""
Access permissions container for StatuteParagraph and StatuteParagraphViewSet.
"""
def check_permissions(self, user):
"""
Returns True if the user has read access model instances.
"""
return has_perm(user, 'motions.can_see')
def get_serializer_class(self, user=None):
"""
Returns serializer class.
"""
from .serializers import StatuteParagraphSerializer
return StatuteParagraphSerializer
class CategoryAccessPermissions(BaseAccessPermissions): class CategoryAccessPermissions(BaseAccessPermissions):
""" """
Access permissions container for Category and CategoryViewSet. Access permissions container for Category and CategoryViewSet.

View File

@ -22,6 +22,7 @@ class MotionsAppConfig(AppConfig):
) )
from .views import ( from .views import (
CategoryViewSet, CategoryViewSet,
StatuteParagraphViewSet,
MotionViewSet, MotionViewSet,
MotionCommentSectionViewSet, MotionCommentSectionViewSet,
MotionBlockViewSet, MotionBlockViewSet,
@ -47,6 +48,7 @@ class MotionsAppConfig(AppConfig):
# Register viewsets. # Register viewsets.
router.register(self.get_model('Category').get_collection_string(), CategoryViewSet) router.register(self.get_model('Category').get_collection_string(), CategoryViewSet)
router.register(self.get_model('StatuteParagraph').get_collection_string(), StatuteParagraphViewSet)
router.register(self.get_model('Motion').get_collection_string(), MotionViewSet) router.register(self.get_model('Motion').get_collection_string(), MotionViewSet)
router.register(self.get_model('MotionBlock').get_collection_string(), MotionBlockViewSet) router.register(self.get_model('MotionBlock').get_collection_string(), MotionBlockViewSet)
router.register(self.get_model('MotionCommentSection').get_collection_string(), MotionCommentSectionViewSet) router.register(self.get_model('MotionCommentSection').get_collection_string(), MotionCommentSectionViewSet)
@ -65,6 +67,6 @@ class MotionsAppConfig(AppConfig):
Yields all Cachables required on startup i. e. opening the websocket Yields all Cachables required on startup i. e. opening the websocket
connection. connection.
""" """
for model_name in ('Category', 'Motion', 'MotionBlock', 'Workflow', for model_name in ('Category', 'StatuteParagraph', 'Motion', 'MotionBlock',
'MotionChangeRecommendation', 'MotionCommentSection'): 'Workflow', 'MotionChangeRecommendation', 'MotionCommentSection'):
yield self.get_model(model_name) yield self.get_model(model_name)

View File

@ -0,0 +1,65 @@
# Generated by Django 2.1.1 on 2018-09-24 08:26
import django.db.models.deletion
from django.db import migrations, models
import openslides.utils.models
class Migration(migrations.Migration):
dependencies = [
('motions', '0012_motion_comments'),
]
operations = [
migrations.AlterModelOptions(
name='motionblock',
options={
'default_permissions': (),
'verbose_name': 'Motion block'},
),
migrations.AddField(
model_name='motion',
name='sort_parent',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='children',
to='motions.Motion'),
),
migrations.AddField(
model_name='motion',
name='weight',
field=models.IntegerField(default=10000),
),
migrations.CreateModel(
name='StatuteParagraph',
fields=[
('id', models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID')),
('title', models.CharField(max_length=255)),
('text', models.TextField()),
('weight', models.IntegerField(default=10000)),
],
options={
'ordering': ['weight', 'title'],
'default_permissions': (),
},
bases=(openslides.utils.models.RESTModelMixin, models.Model),
),
migrations.AddField(
model_name='motion',
name='statute_paragraph',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='motions',
to='motions.StatuteParagraph'),
),
]

View File

@ -30,11 +30,37 @@ from .access_permissions import (
MotionBlockAccessPermissions, MotionBlockAccessPermissions,
MotionChangeRecommendationAccessPermissions, MotionChangeRecommendationAccessPermissions,
MotionCommentSectionAccessPermissions, MotionCommentSectionAccessPermissions,
StatuteParagraphAccessPermissions,
WorkflowAccessPermissions, WorkflowAccessPermissions,
) )
from .exceptions import WorkflowError from .exceptions import WorkflowError
class StatuteParagraph(RESTModelMixin, models.Model):
"""
Model for parts of the statute
"""
access_permissions = StatuteParagraphAccessPermissions()
title = models.CharField(max_length=255)
"""Title of the statute paragraph."""
text = models.TextField()
"""Content of the statute paragraph."""
weight = models.IntegerField(default=10000)
"""
A weight field to sort statute paragraphs.
"""
class Meta:
default_permissions = ()
ordering = ['weight', 'title']
def __str__(self):
return self.title
class MotionManager(models.Manager): class MotionManager(models.Manager):
""" """
Customized model manager to support our get_full_queryset method. Customized model manager to support our get_full_queryset method.
@ -134,6 +160,21 @@ class Motion(RESTModelMixin, models.Model):
Needed to find the next free motion identifier. Needed to find the next free motion identifier.
""" """
weight = models.IntegerField(default=10000)
"""
A weight field to sort motions.
"""
sort_parent = models.ForeignKey(
'self',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='children')
"""
A parent field for multi-depth sorting of motions.
"""
category = models.ForeignKey( category = models.ForeignKey(
'Category', 'Category',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
@ -175,6 +216,19 @@ class Motion(RESTModelMixin, models.Model):
Null if the motion is not an amendment. Null if the motion is not an amendment.
""" """
statute_paragraph = models.ForeignKey(
StatuteParagraph,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='motions')
"""
Field to reference to a statute paragraph if this motion is a
statute-amendment.
Null if the motion is not a statute-amendment.
"""
tags = models.ManyToManyField(Tag, blank=True) tags = models.ManyToManyField(Tag, blank=True)
""" """
Tags to categorise motions. Tags to categorise motions.

View File

@ -28,6 +28,7 @@ from .models import (
MotionLog, MotionLog,
MotionPoll, MotionPoll,
State, State,
StatuteParagraph,
Submitter, Submitter,
Workflow, Workflow,
) )
@ -41,6 +42,15 @@ def validate_workflow_field(value):
raise ValidationError({'detail': _('Workflow %(pk)d does not exist.') % {'pk': value}}) raise ValidationError({'detail': _('Workflow %(pk)d does not exist.') % {'pk': value}})
class StatuteParagraphSerializer(ModelSerializer):
"""
Serializer for motion.models.StatuteParagraph objects.
"""
class Meta:
model = StatuteParagraph
fields = ('id', 'title', 'text', 'weight')
class CategorySerializer(ModelSerializer): class CategorySerializer(ModelSerializer):
""" """
Serializer for motion.models.Category objects. Serializer for motion.models.Category objects.
@ -404,7 +414,9 @@ class MotionSerializer(ModelSerializer):
'agenda_item_id', 'agenda_item_id',
'agenda_type', 'agenda_type',
'agenda_parent_id', 'agenda_parent_id',
'log_messages',) 'log_messages',
'sort_parent',
'weight',)
read_only_fields = ('state', 'recommendation',) # Some other fields are also read_only. See definitions above. read_only_fields = ('state', 'recommendation',) # Some other fields are also read_only. See definitions above.
def validate(self, data): def validate(self, data):

View File

@ -24,6 +24,7 @@ from ..utils.rest_api import (
UpdateModelMixin, UpdateModelMixin,
ValidationError, ValidationError,
detail_route, detail_route,
list_route,
) )
from ..utils.views import BinaryTemplateView from ..utils.views import BinaryTemplateView
from .access_permissions import ( from .access_permissions import (
@ -32,6 +33,7 @@ from .access_permissions import (
MotionBlockAccessPermissions, MotionBlockAccessPermissions,
MotionChangeRecommendationAccessPermissions, MotionChangeRecommendationAccessPermissions,
MotionCommentSectionAccessPermissions, MotionCommentSectionAccessPermissions,
StatuteParagraphAccessPermissions,
WorkflowAccessPermissions, WorkflowAccessPermissions,
) )
from .exceptions import WorkflowError from .exceptions import WorkflowError
@ -44,6 +46,7 @@ from .models import (
MotionCommentSection, MotionCommentSection,
MotionPoll, MotionPoll,
State, State,
StatuteParagraph,
Submitter, Submitter,
Workflow, Workflow,
) )
@ -78,7 +81,7 @@ class MotionViewSet(ModelViewSet):
has_perm(self.request.user, 'motions.can_create') and has_perm(self.request.user, 'motions.can_create') and
(not config['motions_stop_submitting'] or (not config['motions_stop_submitting'] or
has_perm(self.request.user, 'motions.can_manage'))) has_perm(self.request.user, 'motions.can_manage')))
elif self.action in ('set_state', 'manage_comments', 'set_recommendation', elif self.action in ('set_state', 'sort', 'manage_comments', 'set_recommendation',
'follow_recommendation', 'create_poll', 'manage_submitters', 'follow_recommendation', 'create_poll', 'manage_submitters',
'sort_submitters'): 'sort_submitters'):
result = (has_perm(self.request.user, 'motions.can_see') and result = (has_perm(self.request.user, 'motions.can_see') and
@ -256,6 +259,38 @@ class MotionViewSet(ModelViewSet):
return Response(serializer.data) return Response(serializer.data)
@list_route(methods=['post'])
def sort(self, request):
"""
Sort motions. Also checks sort_parent field to prevent hierarchical loops.
Note: This view is not tested! Maybe needs to be refactored. Add documentation
abou the data to be send.
"""
raise ValidationError({'detail': _('This view needs testing and refactoring!')})
nodes = request.data.get('nodes', [])
sort_parent_id = request.data.get('sort_parent_id')
motions = []
with transaction.atomic():
for index, node in enumerate(nodes):
motion = Motion.objects.get(pk=node['id'])
motion.sort_parent_id = sort_parent_id
motion.weight = index
motion.save(skip_autoupdate=True)
motions.append(motion)
# Now check consistency. TODO: Try to use less DB queries.
motion = Motion.objects.get(pk=node['id'])
ancestor = motion.sort_parent
while ancestor is not None:
if ancestor == motion:
raise ValidationError({'detail': _(
'There must not be a hierarchical loop.')})
ancestor = ancestor.sort_parent
inform_changed_data(motions)
return Response({'detail': _('The motions has been sorted.')})
@detail_route(methods=['POST', 'DELETE']) @detail_route(methods=['POST', 'DELETE'])
def manage_comments(self, request, pk=None): def manage_comments(self, request, pk=None):
""" """
@ -697,6 +732,30 @@ class MotionCommentSectionViewSet(ModelViewSet):
return result return result
class StatuteParagraphViewSet(ModelViewSet):
"""
API endpoint for statute paragraphs.
There are the following views: list, retrieve, create,
partial_update, update and destroy.
"""
access_permissions = StatuteParagraphAccessPermissions()
queryset = StatuteParagraph.objects.all()
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
if self.action in ('list', 'retrieve'):
result = self.get_access_permissions().check_permissions(self.request.user)
elif self.action in ('create', 'partial_update', 'update', 'destroy'):
result = (has_perm(self.request.user, 'motions.can_see') and
has_perm(self.request.user, 'motions.can_manage'))
else:
result = False
return result
class CategoryViewSet(ModelViewSet): class CategoryViewSet(ModelViewSet):
""" """
API endpoint for categories. API endpoint for categories.

View File

@ -16,6 +16,7 @@ from openslides.motions.models import (
MotionCommentSection, MotionCommentSection,
MotionLog, MotionLog,
State, State,
StatuteParagraph,
Submitter, Submitter,
Workflow, Workflow,
) )
@ -79,6 +80,20 @@ def test_category_db_queries():
assert count_queries(Category.get_elements) == 1 assert count_queries(Category.get_elements) == 1
@pytest.mark.django_db(transaction=False)
def test_statute_paragraph_db_queries():
"""
Tests that only the following db queries are done:
* 1 requests to get the list of all statute paragraphs.
"""
for index in range(10):
StatuteParagraph.objects.create(
title='statute_paragraph{}'.format(index),
text='text{}'.format(index))
assert count_queries(StatuteParagraph.get_elements) == 1
@pytest.mark.django_db(transaction=False) @pytest.mark.django_db(transaction=False)
def test_workflow_db_queries(): def test_workflow_db_queries():
""" """
@ -91,6 +106,101 @@ def test_workflow_db_queries():
assert count_queries(Workflow.get_elements) == 3 assert count_queries(Workflow.get_elements) == 3
class TestStatuteParagraphs(TestCase):
"""
Tests all CRUD operations of statute paragraphs.
"""
def setUp(self):
self.client = APIClient()
self.client.login(username='admin', password='admin')
def create_statute_paragraph(self):
self.title = 'test_title_fiWs82D0D)2kje3KDm2s'
self.text = 'test_text_3jfjoDqm,S;cmor3DJwk'
self.cp = StatuteParagraph.objects.create(
title=self.title,
text=self.text)
def test_create_simple(self):
response = self.client.post(
reverse('statuteparagraph-list'),
{'title': 'test_title_f3FM328cq)tzdU238df2',
'text': 'test_text_2fb)BEjwdI38=kfemiRkcOW'})
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
cp = StatuteParagraph.objects.get()
self.assertEqual(cp.title, 'test_title_f3FM328cq)tzdU238df2')
self.assertEqual(cp.text, 'test_text_2fb)BEjwdI38=kfemiRkcOW')
def test_create_without_data(self):
response = self.client.post(reverse('statuteparagraph-list'), {})
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data, {'title': ['This field is required.'], 'text': ['This field is required.']})
def test_create_non_admin(self):
self.admin = get_user_model().objects.get(username='admin')
self.admin.groups.add(2)
self.admin.groups.remove(4)
inform_changed_data(self.admin)
response = self.client.post(
reverse('statuteparagraph-list'),
{'title': 'test_title_f3(Dj2jdP39fjW2kdcwe',
'text': 'test_text_vlC)=fwWmcwcpWMvnuw('})
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_retrieve_simple(self):
self.create_statute_paragraph()
response = self.client.get(reverse('statuteparagraph-detail', args=[self.cp.pk]))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(sorted(response.data.keys()), sorted((
'id',
'title',
'text',
'weight',)))
def test_update_simple(self):
self.create_statute_paragraph()
response = self.client.patch(
reverse('statuteparagraph-detail', args=[self.cp.pk]),
{'text': 'test_text_ke(czr/cwk1Sl2seeFwE'})
self.assertEqual(response.status_code, status.HTTP_200_OK)
cp = StatuteParagraph.objects.get()
self.assertEqual(cp.title, self.title)
self.assertEqual(cp.text, 'test_text_ke(czr/cwk1Sl2seeFwE')
def test_update_non_admin(self):
self.admin = get_user_model().objects.get(username='admin')
self.admin.groups.add(2)
self.admin.groups.remove(4)
inform_changed_data(self.admin)
self.create_statute_paragraph()
response = self.client.patch(
reverse('statuteparagraph-detail', args=[self.cp.pk]),
{'text': 'test_text_ke(czr/cwk1Sl2seeFwE'})
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
cp = StatuteParagraph.objects.get()
self.assertEqual(cp.text, self.text)
def test_delete_simple(self):
self.create_statute_paragraph()
response = self.client.delete(reverse('statuteparagraph-detail', args=[self.cp.pk]))
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(StatuteParagraph.objects.count(), 0)
def test_delete_non_admin(self):
self.admin = get_user_model().objects.get(username='admin')
self.admin.groups.add(2)
self.admin.groups.remove(4)
inform_changed_data(self.admin)
self.create_statute_paragraph()
response = self.client.delete(reverse('statuteparagraph-detail', args=[self.cp.pk]))
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(StatuteParagraph.objects.count(), 1)
class CreateMotion(TestCase): class CreateMotion(TestCase):
""" """
Tests motion creation. Tests motion creation.