Added personal notes for motions (server side part).
This commit is contained in:
parent
e1a95588e7
commit
6aee27e49f
@ -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].
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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': <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):
|
||||
"""
|
||||
|
30
openslides/users/migrations/0004_personalnote.py
Normal file
30
openslides/users/migrations/0004_personalnote.py
Normal file
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
@ -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):
|
||||
"""
|
||||
|
@ -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):
|
||||
"""
|
||||
|
Loading…
Reference in New Issue
Block a user