From b30afbd635172e50dfddc72d03e05cd6d7da4214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Norman=20J=C3=A4ckel?= Date: Thu, 30 Apr 2015 19:13:28 +0200 Subject: [PATCH] Added several motion REST API views. Added motion creation view, motion update view, version permit and delete view, view to support motions, view to set and reset state. Refactored motion submitters and supporters. --- openslides/motions/forms.py | 4 +- openslides/motions/models.py | 109 ++----- openslides/motions/pdf.py | 4 +- openslides/motions/personal_info.py | 6 +- openslides/motions/serializers.py | 127 +++++--- .../templates/motions/motion_detail.html | 8 +- .../templates/motions/motion_list.html | 4 +- .../motions/templates/motions/slide.html | 4 +- .../search/indexes/motions/motion_text.txt | 4 +- openslides/motions/views.py | 221 ++++++++++++- openslides/utils/rest_api.py | 1 + tests/integration/motions/test_viewset.py | 294 ++++++++++++++++++ tests/old/motions/test_csv_import.py | 8 +- tests/old/motions/test_models.py | 11 +- tests/old/motions/test_views.py | 12 +- tests/unit/motions/__init__.py | 0 tests/unit/motions/test_views.py | 81 +++++ 17 files changed, 734 insertions(+), 164 deletions(-) create mode 100644 tests/integration/motions/test_viewset.py create mode 100644 tests/unit/motions/__init__.py create mode 100644 tests/unit/motions/test_views.py diff --git a/openslides/motions/forms.py b/openslides/motions/forms.py index 432020a74..048480e79 100644 --- a/openslides/motions/forms.py +++ b/openslides/motions/forms.py @@ -104,7 +104,7 @@ class MotionSubmitterMixin(forms.ModelForm): def __init__(self, *args, **kwargs): """Fill in the submitter of the motion as default value.""" if self.motion is not None: - submitter = [submitter.person.id for submitter in self.motion.submitter.all()] + submitter = self.motion.submitters.all() self.initial['submitter'] = submitter super(MotionSubmitterMixin, self).__init__(*args, **kwargs) @@ -119,7 +119,7 @@ class MotionSupporterMixin(forms.ModelForm): def __init__(self, *args, **kwargs): """Fill in the supporter of the motions as default value.""" if self.motion is not None: - supporter = [supporter.person.id for supporter in self.motion.supporter.all()] + supporter = self.motion.supporters.all() self.initial['supporter'] = supporter super(MotionSupporterMixin, self).__init__(*args, **kwargs) diff --git a/openslides/motions/models.py b/openslides/motions/models.py index bdc807308..80b109128 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.urlresolvers import reverse from django.db import models from django.db.models import Max @@ -83,6 +84,16 @@ class Motion(RESTModelMixin, SlideMixin, AbsoluteUrlMixin, models.Model): Tags to categorise motions. """ + submitters = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='motion_submitters') + """ + Users who submit this motion. + """ + + supporters = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='motion_supporters') + """ + Users who support this motion. + """ + class Meta: permissions = ( ('can_see', ugettext_noop('Can see motions')), @@ -378,55 +389,17 @@ class Motion(RESTModelMixin, SlideMixin, AbsoluteUrlMixin, models.Model): except IndexError: return self.get_new_version() - @property - def submitters(self): - return sorted([object.person for object in self.submitter.all()], - key=lambda person: person.sort_name) - - def is_submitter(self, person): - """Return True, if person is a submitter of this motion. Else: False.""" - return self.submitter.filter(person=person.pk).exists() - - @property - def supporters(self): - return [supporter.person for supporter in self.supporter.all()] - - def add_submitter(self, person): - MotionSubmitter.objects.create(motion=self, person=person) - - def clear_submitters(self): - MotionSubmitter.objects.filter(motion=self).delete() - - def is_supporter(self, person): + def is_submitter(self, user): """ - Return True, if person is a supporter of this motion. Else: False. + Returns True if user is a submitter of this motion, else False. """ - return self.supporter.filter(person=person.pk).exists() + return user in self.submitters.all() - def support(self, person): + def is_supporter(self, user): """ - Add 'person' as a supporter of this motion. + Returns True if user is a supporter of this motion, else False. """ - if self.state.allow_support: - if not self.is_supporter(person): - MotionSupporter(motion=self, person=person).save() - else: - raise WorkflowError('You can not support a motion in state %s.' % self.state.name) - - def unsupport(self, person): - """ - Remove 'person' as supporter from this motion. - """ - if self.state.allow_support: - self.supporter.filter(person=person).delete() - else: - raise WorkflowError('You can not unsupport a motion in state %s.' % self.state.name) - - def clear_supporters(self): - """ - Deletes all supporters of this motion. - """ - MotionSupporter.objects.filter(motion=self).delete() + return user in self.supporters.all() def create_poll(self): """ @@ -443,6 +416,13 @@ class Motion(RESTModelMixin, SlideMixin, AbsoluteUrlMixin, models.Model): else: raise WorkflowError('You can not create a poll in state %s.' % self.state.name) + @property + def workflow(self): + """ + Returns the id of the workflow of the motion. + """ + return self.state.workflow.pk + def set_state(self, state): """ Set the state of the motion. @@ -504,6 +484,7 @@ class Motion(RESTModelMixin, SlideMixin, AbsoluteUrlMixin, models.Model): * change_state * reset_state """ + # TODO: Remove this method and implement these things in the views. actions = { 'see': (person.has_perm('motions.can_see') and (not self.state.required_permission_to_see or @@ -619,46 +600,6 @@ class MotionVersion(RESTModelMixin, AbsoluteUrlMixin, models.Model): return self.motion -class MotionSubmitter(RESTModelMixin, models.Model): - """Save the submitter of a Motion.""" - - motion = models.ForeignKey('Motion', related_name="submitter") - """The motion to witch the object belongs.""" - - person = models.ForeignKey(User) - """The user, who is the submitter.""" - - def __str__(self): - """Return the name of the submitter as string.""" - return str(self.person) - - def get_root_rest_element(self): - """ - Returns the motion to this instance which is the root REST element. - """ - return self.motion - - -class MotionSupporter(RESTModelMixin, models.Model): - """Save the submitter of a Motion.""" - - motion = models.ForeignKey('Motion', related_name="supporter") - """The motion to witch the object belongs.""" - - person = models.ForeignKey(User) - """The person, who is the supporter.""" - - def __str__(self): - """Return the name of the supporter as string.""" - return str(self.person) - - def get_root_rest_element(self): - """ - Returns the motion to this instance which is the root REST element. - """ - return self.motion - - class Category(RESTModelMixin, AbsoluteUrlMixin, models.Model): name = models.CharField(max_length=255, verbose_name=ugettext_lazy("Category name")) """Name of the category.""" diff --git a/openslides/motions/pdf.py b/openslides/motions/pdf.py index d7c930a6b..7b70bb250 100644 --- a/openslides/motions/pdf.py +++ b/openslides/motions/pdf.py @@ -49,7 +49,7 @@ def motion_to_pdf(pdf, motion): stylesheet['Heading4'])) cell1b = [] cell1b.append(Spacer(0, 0.2 * cm)) - for submitter in motion.submitter.all(): + for submitter in motion.submitters.all(): cell1b.append(Paragraph(str(submitter), stylesheet['Normal'])) motion_data.append([cell1a, cell1b]) @@ -71,7 +71,7 @@ def motion_to_pdf(pdf, motion): cell3b = [] cell3a.append(Paragraph("%s:" % _("Supporters"), stylesheet['Heading4'])) - supporters = motion.supporter.all() + supporters = motion.supporters.all() for supporter in supporters: cell3b.append(Paragraph(".  %s" % str(supporter), stylesheet['Normal'])) diff --git a/openslides/motions/personal_info.py b/openslides/motions/personal_info.py index 147acbfcf..8d7df7298 100644 --- a/openslides/motions/personal_info.py +++ b/openslides/motions/personal_info.py @@ -3,8 +3,6 @@ from django.utils.translation import ugettext_lazy from openslides.config.api import config from openslides.utils.personal_info import PersonalInfo -from .models import Motion - class MotionSubmitterPersonalInfo(PersonalInfo): """ @@ -14,7 +12,7 @@ class MotionSubmitterPersonalInfo(PersonalInfo): default_weight = 20 def get_queryset(self): - return Motion.objects.filter(submitter__person=self.request.user) + return None # TODO: Fix this after transforming everything using AngularJS. class MotionSupporterPersonalInfo(PersonalInfo): @@ -26,7 +24,7 @@ class MotionSupporterPersonalInfo(PersonalInfo): def get_queryset(self): if config['motion_min_supporters']: - return_value = Motion.objects.filter(supporter__person=self.request.user) + return_value = None # TODO: Fix this after transforming everything using AngularJS. else: return_value = None return return_value diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index d8566aedd..8802d1455 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -1,4 +1,13 @@ -from openslides.utils.rest_api import ModelSerializer, PrimaryKeyRelatedField, SerializerMethodField +from django.db import transaction +from django.utils.translation import ugettext as _ + +from openslides.config.api import config +from openslides.utils.rest_api import ( + CharField, + IntegerField, + ModelSerializer, + PrimaryKeyRelatedField, + ValidationError,) from .models import ( Category, @@ -6,14 +15,20 @@ from .models import ( MotionLog, MotionOption, MotionPoll, - MotionSubmitter, - MotionSupporter, MotionVersion, MotionVote, State, Workflow,) +def validate_workflow_field(value): + """ + Validator to ensure that the workflow with the given id exists. + """ + if not Workflow.objects.filter(pk=value).exists(): + raise ValidationError(_('Workflow %(pk)d does not exist.') % {'pk': value}) + + class CategorySerializer(ModelSerializer): """ Serializer for motion.models.Category objects. @@ -56,24 +71,6 @@ class WorkflowSerializer(ModelSerializer): fields = ('id', 'name', 'state_set', 'first_state',) -class MotionSubmitterSerializer(ModelSerializer): - """ - Serializer for motion.models.MotionSubmitter objects. - """ - class Meta: - model = MotionSubmitter - fields = ('person',) # TODO: Rename this to 'user', see #1348 - - -class MotionSupporterSerializer(ModelSerializer): - """ - Serializer for motion.models.MotionSupporter objects. - """ - class Meta: - model = MotionSupporter - fields = ('person',) # TODO: Rename this to 'user', see #1348 - - class MotionLogSerializer(ModelSerializer): """ Serializer for motion.models.MotionLog objects. @@ -138,36 +135,94 @@ class MotionSerializer(ModelSerializer): """ Serializer for motion.models.Motion objects. """ - versions = MotionVersionSerializer(many=True, read_only=True) active_version = PrimaryKeyRelatedField(read_only=True) - submitter = MotionSubmitterSerializer(many=True, read_only=True) - supporter = MotionSupporterSerializer(many=True, read_only=True) - state = StateSerializer(read_only=True) - workflow = SerializerMethodField() - polls = MotionPollSerializer(many=True, read_only=True) log_messages = MotionLogSerializer(many=True, read_only=True) + polls = MotionPollSerializer(many=True, read_only=True) + reason = CharField(allow_blank=True, required=False, write_only=True) + state = StateSerializer(read_only=True) + text = CharField(write_only=True) + title = CharField(max_length=255, write_only=True) + versions = MotionVersionSerializer(many=True, read_only=True) + workflow = IntegerField(min_value=1, required=False, validators=[validate_workflow_field]) class Meta: model = Motion fields = ( 'id', 'identifier', - 'identifier_number', - 'parent', - 'category', - 'tags', + 'title', + 'text', + 'reason', 'versions', 'active_version', - 'submitter', - 'supporter', + 'parent', + 'category', + 'submitters', + 'supporters', 'state', 'workflow', + 'tags', 'attachments', 'polls', 'log_messages',) + read_only_fields = ('parent',) # Some other fields are also read_only. See definitions above. - def get_workflow(self, motion): + @transaction.atomic + def create(self, validated_data): """ - Returns the id of the workflow of the motion. + Customized method to create a new motion from some data. """ - return motion.state.workflow.pk + motion = Motion() + motion.title = validated_data['title'] + motion.text = validated_data['text'] + motion.reason = validated_data.get('reason', '') + motion.identifier = validated_data.get('identifier') + motion.category = validated_data.get('category') + motion.reset_state(validated_data.get('workflow', int(config['motion_workflow']))) + motion.save() + if validated_data['submitters']: + motion.submitters.add(*validated_data['submitters']) + else: + motion.submitters.add(validated_data['request_user']) + motion.supporters.add(*validated_data['supporters']) + motion.attachments.add(*validated_data['attachments']) + motion.tags.add(*validated_data['tags']) + return motion + + @transaction.atomic + def update(self, motion, validated_data): + """ + Customized method to update a motion. + """ + # Identifier and category. + for key in ('identifier', 'category'): + if key in validated_data.keys(): + setattr(motion, key, validated_data[key]) + + # Workflow. + workflow = validated_data.get('workflow') + if workflow is not None and workflow != motion.workflow: + motion.reset_state(workflow) + + # Decide if a new version is saved to the database. + if (motion.state.versioning and + not validated_data.get('disable_versioning', False)): # TODO + version = motion.get_new_version() + else: + version = motion.get_last_version() + + # Title, text, reason. + for key in ('title', 'text', 'reason'): + if key in validated_data.keys(): + setattr(version, key, validated_data[key]) + + motion.save(use_version=version) + + # Submitters, supporters, attachments and tags + for key in ('submitters', 'supporters', 'attachments', 'tags'): + if key in validated_data.keys(): + attr = getattr(motion, key) + attr.clear() + attr.add(*validated_data[key]) + + return motion diff --git a/openslides/motions/templates/motions/motion_detail.html b/openslides/motions/templates/motions/motion_detail.html index 52c0102fd..d71e01ecb 100644 --- a/openslides/motions/templates/motions/motion_detail.html +++ b/openslides/motions/templates/motions/motion_detail.html @@ -196,8 +196,8 @@
{% trans "Submitter" %}:
- {% for submitter in motion.submitter.all %} - {{ submitter }}{% if not forloop.last %}, {% endif %} + {% for submitter in motion.submitters.all %} + {{ submitter }}{% if not forloop.last %}, {% endif %} {% endfor %} @@ -207,8 +207,8 @@ - {% else %}
    - {% for supporter in motion.supporter.all %} -
  1. {{ supporter }}
  2. + {% for supporter in motion.supporters.all %} +
  3. {{ supporter }}
  4. {% endfor %}
{% endif %} diff --git a/openslides/motions/templates/motions/motion_list.html b/openslides/motions/templates/motions/motion_list.html index 7135690b2..4eb6e0060 100644 --- a/openslides/motions/templates/motions/motion_list.html +++ b/openslides/motions/templates/motions/motion_list.html @@ -98,12 +98,12 @@ {% if motion.category %}{{ motion.category }}{% else %}–{% endif %} {% trans motion.state.name %} - {% for submitter in motion.submitter.all %} + {% for submitter in motion.submitters.all %} {{ submitter.person }}{% if not forloop.last %}, {% endif %} {% endfor %} {% if 'motion_min_supporters'|get_config > 0 %} - {% with supporters=motion.supporters|length %} + {% with supporters=motion.supporters.all|length %} {% if supporters >= 'motion_min_supporters'|get_config %} {{ supporters }} diff --git a/openslides/motions/templates/motions/slide.html b/openslides/motions/templates/motions/slide.html index afbbfa63e..ea3f9fe94 100644 --- a/openslides/motions/templates/motions/slide.html +++ b/openslides/motions/templates/motions/slide.html @@ -49,14 +49,14 @@

{% trans "Submitter" %}:

- {% for submitter in motion.submitter.all %} + {% for submitter in motion.submitters.all %} {{ submitter.person }}{% if not forloop.last %},
{% endif %} {% empty %} - {% endfor %} - {% with motion.supporter.all as supporters %} + {% with motion.supporters.all as supporters %} {% if supporters|length > 0 %}

{% trans "Supporters" %}:

{% for supporter in supporters %} diff --git a/openslides/motions/templates/search/indexes/motions/motion_text.txt b/openslides/motions/templates/search/indexes/motions/motion_text.txt index d6261e605..b27bd5d81 100644 --- a/openslides/motions/templates/search/indexes/motions/motion_text.txt +++ b/openslides/motions/templates/search/indexes/motions/motion_text.txt @@ -2,7 +2,7 @@ {{ object.title }} {{ object.text }} {{ object.reason }} -{{ object.submitters }} -{{ object.supporters }} +{{ object.submitters.all }} +{{ object.supporters.all }} {{ object.category }} {{ object.tags.all }} diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 003c830b6..915b92f96 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -1,12 +1,16 @@ +from django.http import Http404 from django.utils.text import slugify from django.utils.translation import ugettext as _ +from django.utils.translation import ugettext_noop from django.shortcuts import get_object_or_404 from reportlab.platypus import SimpleDocTemplate +from rest_framework import status -from openslides.utils.rest_api import ModelViewSet +from openslides.config.api import config +from openslides.utils.rest_api import ModelViewSet, Response, ValidationError, detail_route from openslides.utils.views import (PDFView, SingleObjectMixin) -from .models import (Category, Motion, MotionPoll, Workflow) +from .models import (Category, Motion, MotionPoll, MotionVersion, Workflow) from .pdf import motion_poll_to_pdf, motion_to_pdf, motions_to_pdf from .serializers import CategorySerializer, MotionSerializer, WorkflowSerializer @@ -21,17 +25,216 @@ class MotionViewSet(ModelViewSet): def check_permissions(self, request): """ Calls self.permission_denied() if the requesting user has not the - permission to see motions and in case of create, update or - destroy requests the permission to manage motions. + permission to see motions and in case of destroy requests the + permission to manage motions. """ - # TODO: Use motions.can_create permission and - # motions.can_support permission to create and update some - # objects but restricted concerning the requesting user. if (not request.user.has_perm('motions.can_see') or - (self.action in ('create', 'update', 'destroy') and not - request.user.has_perm('motions.can_manage'))): + (self.action == 'destroy' and not request.user.has_perm('motions.can_manage'))): self.permission_denied(request) + def create(self, request, *args, **kwargs): + """ + Customized view endpoint to create a new motion. + + Checks also whether the requesting user can submit a new motion. He + needs at least the permissions 'motions.can_see' (see + self.check_permission()) and 'motions.can_create'. If the + submitting of new motions by non-staff users is stopped via config + variable 'motion_stop_submitting', the requesting user needs also + to have the permission 'motions.can_manage'. + """ + # Check permissions. + if (not request.user.has_perm('motions.can_create') or + (not config['motion_stop_submitting'] and + not request.user.has_perm('motions.can_manage'))): + self.permission_denied(request) + + # Check permission to send submitter and supporter data. + if (not request.user.has_perm('motions.can_manage') and + (request.data.getlist('submitters') or request.data.getlist('supporters'))): + # Non-staff users are not allowed to send submitter or supporter data. + self.permission_denied(request) + + # Validate data and create motion. + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + motion = serializer.save(request_user=request.user) + + # Write the log message and initiate response. + motion.write_log([ugettext_noop('Motion created')], request.user) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def update(self, request, *args, **kwargs): + """ + Customized view endpoint to update a motion. + + Checks also whether the requesting user can update the motion. He + needs at least the permissions 'motions.can_see' (see + self.check_permission()). Also the instance method + get_allowed_actions() is evaluated. + """ + # Get motion. + motion = self.get_object() + + # Check permissions. + if not motion.get_allowed_actions(request.user)['update']: + self.permission_denied(request) + + # Check permission to send submitter and supporter data. + if (not request.user.has_perm('motions.can_manage') and + (request.data.getlist('submitters') or request.data.getlist('supporters'))): + # Non-staff users are not allowed to send submitter or supporter data. + self.permission_denied(request) + + # Validate data and update motion. + serializer = self.get_serializer( + motion, + data=request.data, + partial=kwargs.get('partial', False)) + serializer.is_valid(raise_exception=True) + updated_motion = serializer.save() + + # Write the log message, check removal of supporters and initiate response. + # TODO: Log if a version was updated. + updated_motion.write_log([ugettext_noop('Motion updated')], request.user) + if (config['motion_remove_supporters'] and updated_motion.state.allow_support and + not request.user.has_perm('motions.can_manage')): + updated_motion.supporters.clear() + updated_motion.write_log([ugettext_noop('All supporters removed')], request.user) + return Response(serializer.data) + + @detail_route(methods=['put', 'delete']) + def manage_version(self, request, pk=None): + """ + Special view endpoint to permit and delete a version of a motion. + + Send PUT {'version_number': } to permit and DELETE + {'version_number': } to delete a version. Deleting the + active version is not allowed. Only managers can use this view. + """ + # Check permission. + if not request.user.has_perm('motions.can_manage'): + self.permission_denied(request) + + # Retrieve motion and version. + motion = self.get_object() + version_number = request.data.get('version_number') + try: + version = motion.versions.get(version_number=version_number) + except MotionVersion.DoesNotExist: + raise Http404('Version %s not found.' % version_number) + + # Permit or delete version. + if request.method == 'PUT': + # Permit version. + motion.active_version = version + motion.save(update_fields=['active_version']) + motion.write_log( + message_list=[ugettext_noop('Version'), + ' %d ' % version.version_number, + ugettext_noop('permitted')], + person=self.request.user) + message = _('Version %d permitted successfully.') % version.version_number + else: + # Delete version. + # request.method == 'DELETE' + if version == motion.active_version: + raise ValidationError({'detail': _('You can not delete the active version of a motion.')}) + version.delete() + motion.write_log( + message_list=[ugettext_noop('Version'), + ' %d ' % version.version_number, + ugettext_noop('deleted')], + person=self.request.user) + message = _('Version %d deleted successfully.') % version.version_number + + # Initiate response. + return Response({'detail': message}) + + @detail_route(methods=['post', 'delete']) + def support(self, request, pk=None): + """ + Special view endpoint to support a motion or withdraw support + (unsupport). + + Send POST to support and DELETE to unsupport. + + Checks also whether the requesting user can do this. He needs at + least the permissions 'motions.can_see' (see + self.check_permission()). Also the the permission + 'motions.can_support' is required and the instance method + get_allowed_actions() is evaluated. + """ + # Check permission. + if not request.user.has_perm('motions.can_support'): + self.permission_denied(request) + + # Retrieve motion and allowed actions. + motion = self.get_object() + allowed_actions = motion.get_allowed_actions(request.user) + + # Support or unsupport motion. + if request.method == 'POST': + # Support motion. + if not allowed_actions['support']: + raise ValidationError({'detail': _('You can not support this motion.')}) + motion.supporters.add(request.user) + motion.write_log([ugettext_noop('Motion supported')], request.user) + message = _('You have supported this motion successfully.') + else: + # Unsupport motion. + # request.method == 'DELETE' + if not allowed_actions['unsupport']: + raise ValidationError({'detail': _('You can not unsupport this motion.')}) + motion.supporters.remove(request.user) + motion.write_log([ugettext_noop('Motion unsupported')], request.user) + message = _('You have unsupported this motion successfully.') + + # Initiate response. + return Response({'detail': message}) + + @detail_route(methods=['put']) + def set_state(self, request, pk=None): + """ + Special view endpoint to set and reset a state of a motion. + + Send PUT {'state': } to set and just PUT {} to reset the + state. Only managers can use this view. + """ + # Check permission. + if not request.user.has_perm('motions.can_manage'): + self.permission_denied(request) + + # Retrieve motion and state. + motion = self.get_object() + state = request.data.get('state') + + # Set or reset state. + if state is not None: + # Check data and set state. + try: + state_id = int(state) + except ValueError: + raise ValidationError({'detail': _('Invalid data. State must be an integer.')}) + if state_id not in [item.id for item in motion.state.next_states.all()]: + raise ValidationError( + {'detail': _('You can not set the state to %(state_id)d.') % {'state_id': state_id}}) + motion.set_state(state_id) + else: + # Reset state. + motion.reset_state() + + # Save motion. + motion.save(update_fields=['state', 'identifier']) + message = _('The state of the motion was set to %s.') % motion.state.name + + # Write the log message and initiate response. + motion.write_log( + message_list=[ugettext_noop('State set to'), ' ', motion.state.name], + person=request.user) + return Response({'detail': message}) + class PollMixin(object): """ diff --git a/openslides/utils/rest_api.py b/openslides/utils/rest_api.py index 53aad5b4a..cb24813c0 100644 --- a/openslides/utils/rest_api.py +++ b/openslides/utils/rest_api.py @@ -6,6 +6,7 @@ from django.core.urlresolvers import reverse from rest_framework.decorators import detail_route # noqa from rest_framework.serializers import ( # noqa CharField, + IntegerField, ListSerializer, ModelSerializer, PrimaryKeyRelatedField, diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py new file mode 100644 index 000000000..78b515439 --- /dev/null +++ b/tests/integration/motions/test_viewset.py @@ -0,0 +1,294 @@ +from django.contrib.auth import get_user_model +from django.core.urlresolvers import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from openslides.config.api import config +from openslides.core.models import Tag +from openslides.motions.models import Category, Motion +from openslides.utils.test import TestCase + + +class CreateMotion(TestCase): + """ + Tests motion creation. + """ + def setUp(self): + self.client.login(username='admin', password='admin') + + def test_simple(self): + response = self.client.post( + reverse('motion-list'), + {'title': 'test_title_OoCoo3MeiT9li5Iengu9', + 'text': 'test_text_thuoz0iecheiheereiCi'}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + self.assertEqual(motion.title, 'test_title_OoCoo3MeiT9li5Iengu9') + self.assertEqual(motion.identifier, '1') + self.assertTrue(motion.submitters.exists()) + self.assertEqual(motion.submitters.get().username, 'admin') + + def test_with_reason(self): + response = self.client.post( + reverse('motion-list'), + {'title': 'test_title_saib4hiHaifo9ohp9yie', + 'text': 'test_text_shahhie8Ej4mohvoorie', + 'reason': 'test_reason_Ou8GivahYivoh3phoh9c'}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Motion.objects.get().reason, 'test_reason_Ou8GivahYivoh3phoh9c') + + def test_without_data(self): + response = self.client.post( + reverse('motion-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_with_category(self): + category = Category.objects.create( + name='test_category_name_CiengahzooH4ohxietha', + prefix='TEST_PREFIX_la0eadaewuec3seoxeiN') + response = self.client.post( + reverse('motion-list'), + {'title': 'test_title_Air0bahchaiph1ietoo2', + 'text': 'test_text_chaeF9wosh8OowazaiVu', + 'category': category.pk}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + self.assertEqual(motion.category, category) + self.assertEqual(motion.identifier, 'TEST_PREFIX_la0eadaewuec3seoxeiN 1') + + def test_with_submitters(self): + submitter_1 = get_user_model().objects.create_user( + username='test_username_ooFe6aebei9ieQui2poo', + password='test_password_vie9saiQu5Aengoo9ku0') + submitter_2 = get_user_model().objects.create_user( + username='test_username_eeciengoc4aihie5eeSh', + password='test_password_peik2Eihu5oTh7siequi') + response = self.client.post( + reverse('motion-list'), + {'title': 'test_title_pha7moPh7quoth4paina', + 'text': 'test_text_YooGhae6tiangung5Rie', + 'submitters': [submitter_1.pk, submitter_2.pk]}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + self.assertEqual(motion.submitters.count(), 2) + + def test_with_one_supporter(self): + supporter = get_user_model().objects.create_user( + username='test_username_ahGhi4Quohyee7ohngie', + password='test_password_Nei6aeh8OhY8Aegh1ohX') + response = self.client.post( + reverse('motion-list'), + {'title': 'test_title_Oecee4Da2Mu9EY6Ui4mu', + 'text': 'test_text_FbhgnTFgkbjdmvcjbffg', + 'supporters': [supporter.pk]}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + self.assertEqual(motion.supporters.get().username, 'test_username_ahGhi4Quohyee7ohngie') + + def test_with_tag(self): + tag = Tag.objects.create(name='test_tag_iRee3kiecoos4rorohth') + response = self.client.post( + reverse('motion-list'), + {'title': 'test_title_Hahke4loos4eiduNiid9', + 'text': 'test_text_johcho0Ucaibiehieghe', + 'tags': [tag.pk]}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + self.assertEqual(motion.tags.get().name, 'test_tag_iRee3kiecoos4rorohth') + + def test_with_workflow(self): + self.assertEqual(config['motion_workflow'], '1') + response = self.client.post( + reverse('motion-list'), + {'title': 'test_title_eemuR5hoo4ru2ahgh5EJ', + 'text': 'test_text_ohviePopahPhoili7yee', + 'workflow': '2'}) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + motion = Motion.objects.get() + self.assertEqual(motion.state.workflow.pk, 2) + + +class UpdateMotion(TestCase): + """ + Tests updating motions. + """ + def setUp(self): + self.client = APIClient() + self.client.login(username='admin', password='admin') + self.motion = Motion( + title='test_title_aeng7ahChie3waiR8xoh', + text='test_text_xeigheeha7thopubeu4U') + self.motion.save() + + def test_simple_patch(self): + response = self.client.patch( + reverse('motion-detail', args=[self.motion.pk]), + {'identifier': 'test_identifier_jieseghohj7OoSah1Ko9'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + motion = Motion.objects.get() + self.assertEqual(motion.title, 'test_title_aeng7ahChie3waiR8xoh') + self.assertEqual(motion.identifier, 'test_identifier_jieseghohj7OoSah1Ko9') + + def test_patch_workflow(self): + self.assertEqual(config['motion_workflow'], '1') + response = self.client.patch( + reverse('motion-detail', args=[self.motion.pk]), + {'workflow': '2'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + motion = Motion.objects.get() + self.assertEqual(motion.title, 'test_title_aeng7ahChie3waiR8xoh') + self.assertEqual(motion.workflow, 2) + + def test_patch_supporters(self): + supporter = get_user_model().objects.create_user( + username='test_username_ieB9eicah0uqu6Phoovo', + password='test_password_XaeTe3aesh8ohg6Cohwo') + response = self.client.patch( + reverse('motion-detail', args=[self.motion.pk]), + {'supporters': [supporter.pk]}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + motion = Motion.objects.get() + self.assertEqual(motion.title, 'test_title_aeng7ahChie3waiR8xoh') + self.assertEqual(motion.supporters.get().username, 'test_username_ieB9eicah0uqu6Phoovo') + + def test_removal_of_supporters(self): + admin = get_user_model().objects.get(username='admin') + group_staff = admin.groups.get(name='Staff') + admin.groups.remove(group_staff) + self.motion.submitters.add(admin) + supporter = get_user_model().objects.create_user( + username='test_username_ahshi4oZin0OoSh9chee', + password='test_password_Sia8ahgeenixu5cei2Ib') + self.motion.supporters.add(supporter) + config['motion_remove_supporters'] = True + self.assertEqual(self.motion.supporters.count(), 1) + + response = self.client.patch( + reverse('motion-detail', args=[self.motion.pk]), + {'title': 'new_title_ohph1aedie5Du8sai2ye'}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + motion = Motion.objects.get() + self.assertEqual(motion.title, 'new_title_ohph1aedie5Du8sai2ye') + self.assertEqual(motion.supporters.count(), 0) + + +class ManageVersion(TestCase): + """ + Tests permitting and deleting versions. + """ + def setUp(self): + self.client = APIClient() + self.client.login(username='admin', password='admin') + self.motion = Motion( + title='test_title_InieJ5HieZieg1Meid7K', + text='test_text_daePhougho7EenguWe4g') + self.motion.save() + self.version_2 = self.motion.get_new_version(title='new_title_fee7tef0seechazeefiW') + self.motion.save(use_version=self.version_2) + + def test_permit(self): + self.assertEqual(Motion.objects.get(pk=self.motion.pk).active_version.version_number, 2) + response = self.client.put( + reverse('motion-manage-version', args=[self.motion.pk]), + {'version_number': '1'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {'detail': 'Version 1 permitted successfully.'}) + self.assertEqual(Motion.objects.get(pk=self.motion.pk).active_version.version_number, 1) + + def test_permit_invalid_version(self): + response = self.client.put( + reverse('motion-manage-version', args=[self.motion.pk]), + {'version_number': '3'}) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete(self): + response = self.client.delete( + reverse('motion-manage-version', args=[self.motion.pk]), + {'version_number': '1'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {'detail': 'Version 1 deleted successfully.'}) + self.assertEqual(Motion.objects.get(pk=self.motion.pk).versions.count(), 1) + + def test_delete_active_version(self): + response = self.client.delete( + reverse('motion-manage-version', args=[self.motion.pk]), + {'version_number': '2'}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {'detail': 'You can not delete the active version of a motion.'}) + + +class SupportMotion(TestCase): + """ + Tests supporting a motion. + """ + def setUp(self): + self.admin = get_user_model().objects.get(username='admin') + self.admin.groups.add(3) + self.client.login(username='admin', password='admin') + self.motion = Motion( + title='test_title_chee7ahCha6bingaew4e', + text='test_text_birah1theL9ooseeFaip') + self.motion.save() + + def test_support(self): + config['motion_min_supporters'] = 1 + response = self.client.post(reverse('motion-support', args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {'detail': 'You have supported this motion successfully.'}) + + def test_unsupport(self): + config['motion_min_supporters'] = 1 + self.motion.supporters.add(self.admin) + response = self.client.delete(reverse('motion-support', args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {'detail': 'You have unsupported this motion successfully.'}) + + +class SetState(TestCase): + """ + Tests setting a state. + """ + def setUp(self): + self.client = APIClient() + self.client.login(username='admin', password='admin') + self.motion = Motion( + title='test_title_iac4ohquie9Ku6othieC', + text='test_text_Xohphei6Oobee0Evooyu') + self.motion.save() + self.state_id_accepted = 2 # This should be the id of the state 'accepted'. + + def test_set_state(self): + response = self.client.put( + reverse('motion-set-state', args=[self.motion.pk]), + {'state': self.state_id_accepted}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {'detail': 'The state of the motion was set to accepted.'}) + self.assertEqual(Motion.objects.get(pk=self.motion.pk).state.name, 'accepted') + + def test_set_state_with_string(self): + # Using a string is not allowed even if it is the correct name of the state. + response = self.client.put( + reverse('motion-set-state', args=[self.motion.pk]), + {'state': 'accepted'}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {'detail': 'Invalid data. State must be an integer.'}) + + def test_set_unknown_state(self): + invalid_state_id = 0 + response = self.client.put( + reverse('motion-set-state', args=[self.motion.pk]), + {'state': invalid_state_id}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data, {'detail': 'You can not set the state to %d.' % invalid_state_id}) + + def test_reset(self): + self.motion.set_state(self.state_id_accepted) + self.motion.save() + response = self.client.put(reverse('motion-set-state', args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, {'detail': 'The state of the motion was set to submitted.'}) + self.assertEqual(Motion.objects.get(pk=self.motion.pk).state.name, 'submitted') diff --git a/tests/old/motions/test_csv_import.py b/tests/old/motions/test_csv_import.py index 4f58e5be3..2fc0c58d9 100644 --- a/tests/old/motions/test_csv_import.py +++ b/tests/old/motions/test_csv_import.py @@ -49,8 +49,8 @@ class CSVImport(TestCase): self.assertEqual(motion1.title, u'Entlastung des Vorstandes') self.assertEqual(motion1.text, u'Die Versammlung möge beschließen, den Vorstand für seine letzte Legislaturperiode zu entlasten.') self.assertEqual(motion1.reason, u'Bericht erfolgt mündlich.') - self.assertEqual(len(motion1.submitter.all()), 1) - self.assertEqual(motion1.submitter.all()[0].person, self.normal_user) + self.assertEqual(len(motion1.submitters.all()), 1) + self.assertEqual(motion1.submitters.all()[0], self.normal_user) self.assertTrue(motion1.category is None) self.assertTrue('Submitter unknown.' in warning_message) self.assertTrue('Category unknown.' in warning_message) @@ -61,8 +61,8 @@ class CSVImport(TestCase): self.assertHTMLEqual(motion2.text, u'''

Die Versammlung möge beschließen, die Satzung in § 2 Abs. 3 wie folgt zu ändern:

Es wird vor dem Wort "Zweck" das Wort "gemeinnütziger" eingefügt.

''') self.assertEqual(motion2.reason, u'Die Änderung der Satzung ist aufgrund der letzten Erfahrungen eine sinnvolle Maßnahme, weil ...') - self.assertEqual(len(motion2.submitter.all()), 1) - self.assertEqual(motion2.submitter.all()[0].person, special_user) + self.assertEqual(len(motion2.submitters.all()), 1) + self.assertEqual(motion2.submitters.all()[0], special_user) self.assertEqual(motion2.category.name, u"Satzungsanträge") # category is created automatically # check user 'John Doe' diff --git a/tests/old/motions/test_models.py b/tests/old/motions/test_models.py index 50bf3f177..85b5dcb3d 100644 --- a/tests/old/motions/test_models.py +++ b/tests/old/motions/test_models.py @@ -66,11 +66,10 @@ class ModelTest(TestCase): def test_supporter(self): self.assertFalse(self.motion.is_supporter(self.test_user)) - self.motion.support(self.test_user) + self.motion.supporters.add(self.test_user) self.assertTrue(self.motion.is_supporter(self.test_user)) - self.motion.unsupport(self.test_user) + self.motion.supporters.remove(self.test_user) self.assertFalse(self.motion.is_supporter(self.test_user)) - self.motion.unsupport(self.test_user) def test_poll(self): self.motion.state = State.objects.get(pk=1) @@ -89,10 +88,8 @@ class ModelTest(TestCase): self.motion.state = State.objects.get(pk=6) self.assertEqual(self.motion.state.name, 'permitted') self.assertEqual(self.motion.state.get_action_word(), 'Permit') - with self.assertRaises(WorkflowError): - self.motion.support(self.test_user) - with self.assertRaises(WorkflowError): - self.motion.unsupport(self.test_user) + self.assertFalse(self.motion.get_allowed_actions(self.test_user)['support']) + self.assertFalse(self.motion.get_allowed_actions(self.test_user)['unsupport']) def test_new_states_or_workflows(self): workflow_1 = Workflow.objects.create(name='W1') diff --git a/tests/old/motions/test_views.py b/tests/old/motions/test_views.py index d693d5f53..fc3d0cc02 100644 --- a/tests/old/motions/test_views.py +++ b/tests/old/motions/test_views.py @@ -135,7 +135,7 @@ class TestMotionDetailView(MotionViewTestCase): def test_get_without_required_permission_from_state_but_by_submitter(self): self.motion1.state.required_permission_to_see = 'motions.can_manage' self.motion1.state.save() - self.motion1.add_submitter(self.registered) + self.motion1.submitters.add(self.registered) self.check_url('/motions/1/', self.registered_client, 200) @@ -360,7 +360,7 @@ class TestMotionUpdateView(MotionViewTestCase): 'reason': 'motion reason'}) self.assertEqual(response.status_code, 403) motion = Motion.objects.get(pk=1) - motion.add_submitter(self.delegate) + motion.submitters.add(self.delegate) response = self.delegate_client.post(self.url, {'title': 'my title', 'text': 'motion text', 'reason': 'motion reason'}) @@ -468,7 +468,7 @@ class TestMotionUpdateView(MotionViewTestCase): 'text': 'eequei1Tee1aegeNgee0', 'submitter': self.delegate.id}) self.assertEqual(response.status_code, 403) - motion.add_submitter(self.delegate) + motion.submitters.add(self.delegate) # Edit three times, without removal of supporters, with removal and in another state for i in range(3): @@ -480,9 +480,9 @@ class TestMotionUpdateView(MotionViewTestCase): 'text': 'Lohjuu1aebewiu2or3oh'}) self.assertRedirects(response, '/motions/%s/' % motion.id) if i == 0 or i == 2: - self.assertTrue(self.registered in Motion.objects.get(pk=motion.pk).supporters) + self.assertTrue(self.registered in Motion.objects.get(pk=motion.pk).supporters.all()) else: - self.assertFalse(self.registered in Motion.objects.get(pk=motion.pk).supporters) + self.assertFalse(self.registered in Motion.objects.get(pk=motion.pk).supporters.all()) # Preparing the comming (third) run motion = Motion.objects.get(pk=motion.pk) motion.support(self.registered) @@ -577,7 +577,7 @@ class TestMotionDeleteView(MotionViewTestCase): def test_delegate(self): response = self.delegate_client.post('/motions/2/del/', {'yes': 'yes'}) self.assertEqual(response.status_code, 403) - Motion.objects.get(pk=2).add_submitter(self.delegate) + Motion.objects.get(pk=2).submitters.add(self.delegate) response = self.delegate_client.post('/motions/2/del/', {'yes': 'yes'}) self.assertEqual(response.status_code, 403) diff --git a/tests/unit/motions/__init__.py b/tests/unit/motions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/motions/test_views.py b/tests/unit/motions/test_views.py new file mode 100644 index 000000000..f3cbe49be --- /dev/null +++ b/tests/unit/motions/test_views.py @@ -0,0 +1,81 @@ +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from rest_framework.exceptions import PermissionDenied + +from openslides.motions.views import MotionViewSet + + +class MotionViewSetCreate(TestCase): + """ + Tests create view of MotionViewSet. + """ + def setUp(self): + self.request = MagicMock() + self.view_instance = MotionViewSet() + self.view_instance.request = self.request + self.view_instance.format_kwarg = MagicMock() + self.view_instance.get_serializer = get_serializer_mock = MagicMock() + get_serializer_mock.return_value = self.mock_serializer = MagicMock() + + @patch('openslides.motions.views.config') + def test_simple_create(self, mock_config): + self.request.user.has_perm.return_value = True + self.view_instance.create(self.request) + self.mock_serializer.save.assert_called_with(request_user=self.request.user) + + @patch('openslides.motions.views.config') + def test_user_without_can_create_perm(self, mock_config): + self.request.user.has_perm.return_value = False + with self.assertRaises(PermissionDenied): + self.view_instance.create(self.request) + + +class MotionViewSetUpdate(TestCase): + """ + Tests update view of MotionViewSet. + """ + def setUp(self): + self.request = MagicMock() + self.view_instance = MotionViewSet() + self.view_instance.request = self.request + self.view_instance.kwargs = MagicMock() + self.view_instance.get_object = MagicMock() + self.view_instance.get_serializer = get_serializer_mock = MagicMock() + get_serializer_mock.return_value = self.mock_serializer = MagicMock() + + @patch('openslides.motions.views.config') + def test_simple_update(self, mock_config): + self.request.user.has_perm.return_value = True + self.view_instance.update(self.request) + self.mock_serializer.save.assert_called_with() + + @patch('openslides.motions.views.config') + def test_user_without_perms(self, mock_config): + self.request.user.has_perm.return_value = False + with self.assertRaises(PermissionDenied): + self.view_instance.update(self.request) + + +class MotionViewSetManageVersion(TestCase): + """ + Tests views of MotionViewSet to manage versions. + """ + def setUp(self): + self.request = MagicMock() + self.view_instance = MotionViewSet() + self.view_instance.request = self.request + self.view_instance.get_object = get_object_mock = MagicMock() + get_object_mock.return_value = self.mock_motion = MagicMock() + + def test_activate_version(self): + self.request.method = 'PUT' + self.request.user.has_perm.return_value = True + self.view_instance.manage_version(self.request) + self.mock_motion.save.assert_called_with(update_fields=['active_version']) + + def test_delete_version(self): + self.request.method = 'DELETE' + self.request.user.has_perm.return_value = True + self.view_instance.manage_version(self.request) + self.mock_motion.versions.get.return_value.delete.assert_called_with()