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/motion-services.js b/openslides/motions/static/js/motions/motion-services.js index cb9fd768e..3446a5e54 100644 --- a/openslides/motions/static/js/motions/motion-services.js +++ b/openslides/motions/static/js/motions/motion-services.js @@ -4,6 +4,13 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions', 'OpenSlidesApp.motions.lineNumbering']) +/* Generic inline editing factory. + * + * getOriginalData: Function that should return the editor data. The editor object is passed. + * saveData: Function that is called whith the editor object as argument. This function + * should prepare the save. If the function returns true, the save process won't be + * continued. Else a patch request is send. + */ .factory('MotionInlineEditing', [ 'Editor', 'Motion', @@ -85,30 +92,31 @@ angular.module('OpenSlidesApp.motions.motionservices', ['OpenSlidesApp.motions', }; obj.save = function () { - saveData(obj); - obj.disable(); + if (!saveData(obj)) { + obj.disable(); - Motion.inject(motion); - // save change motion object on server - Motion.save(motion, {method: 'PATCH'}).then( - function (success) { - if (versioning) { - $scope.showVersion(motion.getVersion(-1)); + Motion.inject(motion); + // save change motion object on server + Motion.save(motion, {method: 'PATCH'}).then( + function (success) { + if (versioning) { + $scope.showVersion(motion.getVersion(-1)); + } + obj.revert(); + }, + function (error) { + // save error: revert all changes by restore + // (refresh) original motion object from server + Motion.refresh(motion); + obj.revert(); + var message = ''; + for (var e in error.data) { + message += e + ': ' + error.data[e] + ' '; + } + $scope.alert = {type: 'danger', msg: message, show: true}; } - obj.revert(); - }, - function (error) { - // save error: revert all changes by restore - // (refresh) original motion object from server - Motion.refresh(motion); - obj.revert(); - var message = ''; - for (var e in error.data) { - message += e + ': ' + error.data[e] + ' '; - } - $scope.alert = {type: 'danger', msg: message, show: true}; - } - ); + ); + } }; return obj; diff --git a/openslides/motions/static/js/motions/site.js b/openslides/motions/static/js/motions/site.js index d06d080f8..8f6baa1bd 100644 --- a/openslides/motions/static/js/motions/site.js +++ b/openslides/motions/static/js/motions/site.js @@ -854,6 +854,7 @@ angular.module('OpenSlidesApp.motions.site', [ '$http', 'gettext', 'gettextCatalog', + 'operator', 'ngDialog', 'MotionForm', 'Motion', @@ -871,10 +872,9 @@ angular.module('OpenSlidesApp.motions.site', [ 'osTableSort', 'MotionExportForm', 'MotionPdfExport', - function($scope, $state, $http, gettext, gettextCatalog, ngDialog, MotionForm, Motion, MotionComment, - Category, Config, Tag, Workflow, User, Agenda, MotionBlock, Projector, + function($scope, $state, $http, gettext, gettextCatalog, operator, ngDialog, MotionForm, Motion, + MotionComment, Category, Config, Tag, Workflow, User, Agenda, MotionBlock, Projector, ProjectionDefault, osTableFilter, osTableSort, MotionExportForm, MotionPdfExport) { - Motion.bindAll({}, $scope, 'motions'); Category.bindAll({}, $scope, 'categories'); MotionBlock.bindAll({}, $scope, 'motionBlocks'); Tag.bindAll({}, $scope, 'tags'); @@ -889,6 +889,18 @@ angular.module('OpenSlidesApp.motions.site', [ $scope.defaultProjectorId = projectiondefault.projector_id; } }); + $scope.$watch(function () { + return Motion.lastModified(); + }, function () { + $scope.motions = Motion.getAll(); + _.forEach($scope.motions, function (motion) { + motion.personalNote = _.find(motion.personal_notes, function (note) { + return note.user_id === operator.user.id; + }); + // For filtering, we cannot filter for .personalNote.star + motion.star = motion.personalNote ? motion.personalNote.star : false; + }); + }); $scope.alert = {}; // collect all states and all recommendations of all workflows @@ -939,6 +951,14 @@ angular.module('OpenSlidesApp.motions.site', [ tag: [], recommendation: [], }; + $scope.filter.booleanFilters = { + isFavorite: { + value: undefined, + displayName: gettext('Favorite'), + choiceYes: gettext('Is favorite'), + choiceNo: gettext('Is not favorite'), + }, + }; } updateStateFilter(); $scope.filter.propertyList = ['identifier', 'origin']; @@ -1062,6 +1082,16 @@ angular.module('OpenSlidesApp.motions.site', [ } $scope.save(motion); }; + $scope.toggleStar = function (motion) { + if (motion.personalNote) { + motion.personalNote.star = !motion.personalNote.star; + } else { + motion.personalNote = {star: true}; + } + $http.put('/rest/motions/motion/' + motion.id + '/set_personal_note/', + motion.personalNote + ); + }; // open new/edit dialog $scope.openDialog = function (motion) { @@ -1198,6 +1228,9 @@ angular.module('OpenSlidesApp.motions.site', [ }, function () { $scope.motion = Motion.get(motionId); MotionComment.populateFields($scope.motion); + $scope.motion.personalNote = _.find($scope.motion.personal_notes, function (note) { + return note.user_id === operator.user.id; + }); }); $scope.projectionModes = [ {mode: 'original', @@ -1419,6 +1452,31 @@ 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 + ); + }; + $scope.changePN = function () { + }; + + // 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, @@ -1434,6 +1492,25 @@ angular.module('OpenSlidesApp.motions.site', [ } ); $scope.commentsInlineEditing = MotionCommentsInlineEditing.createInstances($scope, motion); + $scope.personalNoteInlineEditing = MotionInlineEditing.createInstance($scope, motion, + 'personal-note-inline-editor', false, + function (obj) { + return motion.personalNote ? motion.personalNote.note : ''; + }, + function (obj) { + if (motion.personalNote) { + motion.personalNote.note = obj.editor.getData(); + } else { + motion.personalNote = {note: obj.editor.getData()}; + } + $http.put('/rest/motions/motion/' + $scope.motion.id + '/set_personal_note/', + motion.personalNote + ); + obj.revert(); + obj.disable(); + return true; // Do not update the motion via patch request. + } + ); // Change recommendation creation functions $scope.createChangeRecommendation = ChangeRecommmendationCreate; diff --git a/openslides/motions/static/templates/motions/motion-detail.html b/openslides/motions/static/templates/motions/motion-detail.html index 3016bbe77..a04d3d7c2 100644 --- a/openslides/motions/static/templates/motions/motion-detail.html +++ b/openslides/motions/static/templates/motions/motion-detail.html @@ -59,7 +59,12 @@ PDF -

{{ motion.agenda_item.getTitle() || motion.getTitle() }}

+

+ {{ motion.agenda_item.getTitle() || motion.getTitle() }} + +

Motion {{ motion.identifier }} @@ -542,3 +547,6 @@ + + + diff --git a/openslides/motions/static/templates/motions/motion-detail/personal-note.html b/openslides/motions/static/templates/motions/motion-detail/personal-note.html new file mode 100644 index 000000000..2033021d7 --- /dev/null +++ b/openslides/motions/static/templates/motions/motion-detail/personal-note.html @@ -0,0 +1,36 @@ +
+
+ +
+
+ + +
+

Personal note

+
+ + +
+
+ +
+
The personal note has been changed.
+ + +
+
+
+
diff --git a/openslides/motions/static/templates/motions/motion-list.html b/openslides/motions/static/templates/motions/motion-list.html index c6039b1c0..d9e3a8947 100644 --- a/openslides/motions/static/templates/motions/motion-list.html +++ b/openslides/motions/static/templates/motions/motion-list.html @@ -268,6 +268,30 @@
+ + + + {{ booleanFilter.displayName | translate }} + + + + No tag set + + + + + {{ booleanFilter.value ? booleanFilter.choiceYes : booleanFilter.choiceNo | translate }} + + @@ -402,6 +437,7 @@ | MultiselectFilter: filter.multiselectFilters.motionBlock : getItemId.motionBlock | MultiselectFilter: filter.multiselectFilters.recommendation : getItemId.recommendation | MultiselectFilter: filter.multiselectFilters.tag : getItemId.tag + | filter: {star: filter.booleanFilters.isFavorite.value} | toArray | orderBy: sort.column : sort.reverse) | limitTo : itemsPerPage : limitBegin"> @@ -427,7 +463,11 @@
- {{ motion.getTitle() }} + {{ motion.getTitle() }} + + +
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..47b363dc3 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,29 @@ 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, + user_id=self.id, + defaults=changes, + ) + else: + personal_note = None + return personal_note + class GroupManager(GroupManager): """ diff --git a/openslides/users/static/js/users/site.js b/openslides/users/static/js/users/site.js index fe4289a4f..de84e2bbc 100644 --- a/openslides/users/static/js/users/site.js +++ b/openslides/users/static/js/users/site.js @@ -538,7 +538,6 @@ angular.module('OpenSlidesApp.users.site', [ choiceYes: gettext('Is a committee'), choiceNo: gettext('Is not a committee'), }, - }; } $scope.filter.propertyList = ['first_name', 'last_name', 'title', 'number', 'comment', 'structure_level']; 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): """