From 6aee27e49f3f77023f7b535fb99ca23a62e50340 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Norman=20J=C3=A4ckel?= Date: Fri, 7 Apr 2017 16:15:53 +0200 Subject: [PATCH] Added personal notes for motions (server side part). --- CHANGELOG | 2 + openslides/global_settings.py | 2 + openslides/motions/access_permissions.py | 8 +++ openslides/motions/models.py | 13 ++++- openslides/motions/serializers.py | 8 +++ openslides/motions/static/js/motions/site.js | 12 +++++ openslides/motions/views.py | 16 +++++- .../users/migrations/0004_personalnote.py | 30 +++++++++++ openslides/users/models.py | 54 +++++++++++++++++++ tests/integration/motions/test_viewset.py | 42 +++++++++++++-- 10 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 openslides/users/migrations/0004_personalnote.py diff --git a/CHANGELOG b/CHANGELOG index 71c6fba32..4fd085a01 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -6,6 +6,7 @@ https://openslides.org/ Version 2.2 (unreleased) ========================== +[https://github.com/OpenSlides/OpenSlides/milestones/2.2] Agenda: - Fixed wrong sorting of last speakers [#3193]. @@ -13,6 +14,7 @@ Agenda: Motions: - New export dialog [#3185]. +- New feature: Personal notes for motions [#3190]. - Fixed issue when creating/deleting motion comment fields in the settings [#3187]. - Fixed empty motion comment field in motion update form [#3194]. diff --git a/openslides/global_settings.py b/openslides/global_settings.py index e47f8fe8a..f5a7124cf 100644 --- a/openslides/global_settings.py +++ b/openslides/global_settings.py @@ -92,6 +92,8 @@ STATICFILES_DIRS = [ AUTH_USER_MODEL = 'users.User' +AUTH_PERSONAL_NOTE_MODEL = 'users.PersonalNote' + SESSION_COOKIE_NAME = 'OpenSlidesSessionID' SESSION_EXPIRE_AT_BROWSER_CLOSE = True diff --git a/openslides/motions/access_permissions.py b/openslides/motions/access_permissions.py index 0471bc52d..0fc996d24 100644 --- a/openslides/motions/access_permissions.py +++ b/openslides/motions/access_permissions.py @@ -64,6 +64,14 @@ class MotionAccessPermissions(BaseAccessPermissions): except IndexError: # No data in range. Just do nothing. pass + # Now filter personal notes. + data = data.copy() + data['personal_notes'] = [] + if user is not None: + for personal_note in full_data.get('personal_notes', []): + if personal_note.get('user_id') == user.id: + data['personal_notes'].append(personal_note) + break return data def get_projector_data(self, full_data): diff --git a/openslides/motions/models.py b/openslides/motions/models.py index 4c72872f3..06f1b29a3 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -50,7 +50,8 @@ class MotionManager(models.Manager): 'attachments', 'tags', 'submitters', - 'supporters')) + 'supporters', + 'personal_notes')) class Motion(RESTModelMixin, models.Model): @@ -170,6 +171,8 @@ class Motion(RESTModelMixin, models.Model): Configurable fields for comments. Contains a list of strings. """ + personal_notes = GenericRelation(settings.AUTH_PERSONAL_NOTE_MODEL, related_name='motions') + # In theory there could be one then more agenda_item. But we support only # one. See the property agenda_item. agenda_items = GenericRelation(Item, related_name='motions') @@ -639,6 +642,14 @@ class Motion(RESTModelMixin, models.Model): """ return self.agenda_item.pk + def set_personal_note(self, user, note=None, star=None, skip_autoupdate=False): + """ + Saves or overrides a personal note to this motion for a given user. + """ + user.set_personal_note(self, note, star) + if not skip_autoupdate: + inform_changed_data(self) + def write_log(self, message_list, person=None, skip_autoupdate=False): """ Write a log message. diff --git a/openslides/motions/serializers.py b/openslides/motions/serializers.py index 1dabd9a05..79997fa30 100644 --- a/openslides/motions/serializers.py +++ b/openslides/motions/serializers.py @@ -270,6 +270,7 @@ class MotionSerializer(ModelSerializer): """ active_version = PrimaryKeyRelatedField(read_only=True) comments = MotionCommentsJSONSerializerField(required=False) + personal_notes = SerializerMethodField() 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) @@ -300,6 +301,7 @@ class MotionSerializer(ModelSerializer): 'submitters', 'supporters', 'comments', + 'personal_notes', 'state', 'state_required_permission_to_see', 'workflow_id', @@ -386,6 +388,12 @@ class MotionSerializer(ModelSerializer): return motion + def get_personal_notes(self, motion): + """ + Returns the personal notes of all users. + """ + return [personal_note.get_data() for personal_note in motion.personal_notes.all()] + def get_state_required_permission_to_see(self, motion): """ Returns the permission (as string) that is required for non diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index d06d080f8..57d672a5a 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -1420,6 +1420,18 @@ angular.module('OpenSlidesApp.motions.site', [ return Boolean(isAllowed); }; + // personal note + $scope.toggleStar = function () { + if ($scope.motion.personalNote) { + $scope.motion.personalNote.star = !$scope.motion.personalNote.star; + } else { + $scope.motion.personalNote = {star: true}; + } + $http.put('/rest/motions/motion/' + $scope.motion.id + '/set_personal_note/', + $scope.motion.personalNote + ); + }; + // Inline editing functions $scope.inlineEditing = MotionInlineEditing.createInstance($scope, motion, 'view-original-text-inline-editor', true, diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 34bcac0ec..87b777d72 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -63,7 +63,7 @@ class MotionViewSet(ModelViewSet): """ if self.action in ('list', 'retrieve'): result = self.get_access_permissions().check_permissions(self.request.user) - elif self.action in ('metadata', 'partial_update', 'update'): + elif self.action in ('metadata', 'partial_update', 'update', 'set_personal_note'): result = has_perm(self.request.user, 'motions.can_see') # For partial_update and update requests the rest of the check is # done in the update method. See below. @@ -355,6 +355,20 @@ class MotionViewSet(ModelViewSet): person=request.user) return Response({'detail': message}) + @detail_route(methods=['put']) + def set_personal_note(self, request, pk=None): + """ + Special view endpoint to save a personal note to a motion. + + Send PUT with {'note': , 'star': True|False}. + """ + motion = self.get_object() + if not request.user.is_authenticated(): + raise ValidationError({'detail': _('Anonymous users are not able to set personal notes.')}) + motion.set_personal_note(request.user, request.data.get('note'), bool(request.data.get('star'))) + message = _('You set yopur personal notes successfully.') + return Response({'detail': message}) + @detail_route(methods=['post']) def create_poll(self, request, pk=None): """ diff --git a/openslides/users/migrations/0004_personalnote.py b/openslides/users/migrations/0004_personalnote.py new file mode 100644 index 000000000..be3c9ce51 --- /dev/null +++ b/openslides/users/migrations/0004_personalnote.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.6 on 2017-04-07 14:15 +from __future__ import unicode_literals + +import django.db.models.deletion + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('users', '0003_group'), + ] + + operations = [ + migrations.CreateModel( + name='PersonalNote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('note', models.TextField(blank=True)), + ('star', models.BooleanField(default=False)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='personal_notes', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/openslides/users/models.py b/openslides/users/models.py index 3ec29eed2..4b0b022a8 100644 --- a/openslides/users/models.py +++ b/openslides/users/models.py @@ -9,6 +9,8 @@ from django.contrib.auth.models import ( Permission, PermissionsMixin, ) +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models import Prefetch, Q @@ -17,6 +19,36 @@ from ..utils.models import RESTModelMixin from .access_permissions import GroupAccessPermissions, UserAccessPermissions +class PersonalNote(models.Model): + """ + Model for personal notes and likes (stars) of a user concerning different + openslides models like motions. + + To use this in your app simply run e. g. + + user.set_personal_note(motion, note, star) + + in a setter view and add a SerializerMethodField to your serializer that + calls get_data for all users. + """ + user = models.ForeignKey('User', on_delete=models.CASCADE, related_name='personal_notes') + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey() + note = models.TextField(blank=True) + star = models.BooleanField(default=False, blank=True) + + def get_data(self): + """ + Returns note and star to be serialized in content object serializers. + """ + return { + 'user_id': self.user_id, + 'note': self.note, + 'star': self.star, + } + + class UserManager(BaseUserManager): """ Customized manager that creates new users only with a password and a @@ -211,6 +243,28 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser): """ raise RuntimeError('Do not use user.has_perm() but use openslides.utils.auth.has_perm') + def set_personal_note(self, content_object, note=None, star=None): + """ + Saves or overrides a personal note for this user for a given object + like motion. + """ + changes = {} + if note is not None: + changes['note'] = note + if star is not None: + changes['star'] = star + if changes: + # TODO: This is prone to race-conditions in rare cases. Fix it. + personal_note, created = PersonalNote.objects.update_or_create( + user=self, + content_type=ContentType.objects.get_for_model(content_object), + object_id=content_object.id, + defaults=changes, + ) + else: + personal_note = None + return personal_note + class GroupManager(GroupManager): """ diff --git a/tests/integration/motions/test_viewset.py b/tests/integration/motions/test_viewset.py index 0807cfbd5..6c75123ac 100644 --- a/tests/integration/motions/test_viewset.py +++ b/tests/integration/motions/test_viewset.py @@ -25,6 +25,9 @@ class TestMotionDBQueries(TestCase): config['general_system_enable_anonymous'] = True for index in range(10): Motion.objects.create(title='motion{}'.format(index)) + get_user_model().objects.create_user( + username='user_{}'.format(index), + password='password') # TODO: Create some polls etc. @use_cache() @@ -39,10 +42,11 @@ class TestMotionDBQueries(TestCase): * 1 request to get the polls, * 1 request to get the attachments, * 1 request to get the tags, - * 2 requests to get the submitters and supporters and + * 2 requests to get the submitters and supporters, + * 1 requests to get the personal notes. """ self.client.force_login(get_user_model().objects.get(pk=1)) - with self.assertNumQueries(14): + with self.assertNumQueries(15): self.client.get(reverse('motion-list')) @use_cache() @@ -57,9 +61,10 @@ class TestMotionDBQueries(TestCase): * 1 request to get the polls, * 1 request to get the attachments, * 1 request to get the tags, - * 2 requests to get the submitters and supporters + * 2 requests to get the submitters and supporters, + * 1 request to get the personal notes. """ - with self.assertNumQueries(13): + with self.assertNumQueries(14): self.client.get(reverse('motion-list')) @@ -378,10 +383,29 @@ class RetrieveMotion(TestCase): text='test_text_ithohchaeThohmae5aug') self.motion.save() self.motion.create_poll() + for index in range(10): + get_user_model().objects.create_user( + username='user_{}'.format(index), + password='password') @use_cache() def test_number_of_queries(self): - with self.assertNumQueries(18): + """ + Tests that only the following db queries are done: + * 7 requests to get the session and the request user with its permissions (3 of them are possibly a bug) + * 1 request to get the motion, + * 1 request to get the version, + * 1 request to get the agenda item, + * 1 request to get the log, + * 3 request to get the polls (1 of them is possibly a bug), + * 1 request to get the attachments, + * 1 request to get the tags, + * 2 requests to get the submitters and supporters, + * 1 request to get personal notes. + + TODO: Fix all bugs. + """ + with self.assertNumQueries(19): self.client.get(reverse('motion-detail', args=[self.motion.pk])) def test_guest_state_with_required_permission_to_see(self): @@ -434,6 +458,14 @@ class RetrieveMotion(TestCase): response_3 = guest_client.get(reverse('user-detail', args=[extra_user.pk])) self.assertEqual(response_3.status_code, status.HTTP_403_FORBIDDEN) + def test_anonymous_without_personal_notes(self): + self.motion.set_personal_note(get_user_model().objects.get(pk=1), note='admin_personal_note_OoGh8choro0oosh0roob') + config['general_system_enable_anonymous'] = True + guest_client = APIClient() + response = guest_client.get(reverse('motion-detail', args=[self.motion.pk])) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertNotContains(response, 'admin_personal_note_OoGh8choro0oosh0roob') + class UpdateMotion(TestCase): """