diff --git a/.travis.yml b/.travis.yml index d9c90f81c..472e31682 100644 --- a/.travis.yml +++ b/.travis.yml @@ -28,6 +28,6 @@ script: - coverage report --fail-under=44 - DJANGO_SETTINGS_MODULE='tests.settings' coverage run ./manage.py test tests.integration - - coverage report --fail-under=73 + - coverage report --fail-under=74 - DJANGO_SETTINGS_MODULE='tests.old.settings' ./manage.py test tests.old diff --git a/CHANGELOG b/CHANGELOG index 6d1607f67..d18e67e07 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -87,6 +87,7 @@ Elections: Users: - Added new matrix-interface for managing groups and their permissions. +- Added autoupdate on permission change (permission added). - Improved password reset view for administrators. - Changed field for initial password to an unchangeable field. - Added new field for participant number. diff --git a/openslides/agenda/apps.py b/openslides/agenda/apps.py index 8c94160b0..2a7087bbd 100644 --- a/openslides/agenda/apps.py +++ b/openslides/agenda/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig +from ..utils.collection import Collection + class AgendaAppConfig(AppConfig): name = 'openslides.agenda' @@ -15,9 +17,11 @@ class AgendaAppConfig(AppConfig): # Import all required stuff. from django.db.models.signals import pre_delete, post_save from openslides.core.config import config + from openslides.core.signals import permission_change from openslides.utils.rest_api import router from .config_variables import get_config_variables from .signals import ( + get_permission_change_data, listen_to_related_object_post_delete, listen_to_related_object_post_save) from .views import ItemViewSet @@ -32,10 +36,16 @@ class AgendaAppConfig(AppConfig): pre_delete.connect( listen_to_related_object_post_delete, dispatch_uid='listen_to_related_object_post_delete') + permission_change.connect( + get_permission_change_data, + dispatch_uid='agenda_get_permission_change_data') # Register viewsets. router.register(self.get_model('Item').get_collection_string(), ItemViewSet) def get_startup_elements(self): - from ..utils.collection import Collection - return [Collection(self.get_model('Item').get_collection_string())] + """ + Yields all collections required on startup i. e. opening the websocket + connection. + """ + yield Collection(self.get_model('Item').get_collection_string()) diff --git a/openslides/agenda/signals.py b/openslides/agenda/signals.py index 83679a5d0..2e2064942 100644 --- a/openslides/agenda/signals.py +++ b/openslides/agenda/signals.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.contrib.contenttypes.models import ContentType from openslides.utils.autoupdate import inform_changed_data @@ -36,3 +37,17 @@ def listen_to_related_object_post_delete(sender, instance, **kwargs): except Item.DoesNotExist: # Item does not exist so we do not have to delete it. pass + + +def get_permission_change_data(sender, permissions, **kwargs): + """ + Yields all necessary collections if 'agenda.can_see' or + 'agenda.can_see_hidden_items' permissions changes. + """ + agenda_app = apps.get_app_config(app_label='agenda') + for permission in permissions: + # There could be only one 'agenda.can_see' and then we want to return data. + if (permission.content_type.app_label == agenda_app.label + and permission.codename in ('can_see', 'can_see_hidden_items')): + yield from agenda_app.get_startup_elements() + break diff --git a/openslides/assignments/apps.py b/openslides/assignments/apps.py index d2ca4d030..16634c46e 100644 --- a/openslides/assignments/apps.py +++ b/openslides/assignments/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig +from ..utils.collection import Collection + class AssignmentsAppConfig(AppConfig): name = 'openslides.assignments' @@ -14,20 +16,30 @@ class AssignmentsAppConfig(AppConfig): # Import all required stuff. from openslides.core.config import config + from openslides.core.signals import permission_change from openslides.utils.rest_api import router from .config_variables import get_config_variables + from .signals import get_permission_change_data from .views import AssignmentViewSet, AssignmentPollViewSet # Define config variables config.update_config_variables(get_config_variables()) + # Connect signals. + permission_change.connect( + get_permission_change_data, + dispatch_uid='assignments_get_permission_change_data') + # Register viewsets. router.register(self.get_model('Assignment').get_collection_string(), AssignmentViewSet) router.register('assignments/poll', AssignmentPollViewSet) def get_startup_elements(self): - from ..utils.collection import Collection - return [Collection(self.get_model('Assignment').get_collection_string())] + """ + Yields all collections required on startup i. e. opening the websocket + connection. + """ + yield Collection(self.get_model('Assignment').get_collection_string()) def get_angular_constants(self): assignment = self.get_model('Assignment') diff --git a/openslides/assignments/signals.py b/openslides/assignments/signals.py new file mode 100644 index 000000000..22182ba51 --- /dev/null +++ b/openslides/assignments/signals.py @@ -0,0 +1,12 @@ +from django.apps import apps + + +def get_permission_change_data(sender, permissions=None, **kwargs): + """ + Yields all necessary collections if 'assignments.can_see' permission changes. + """ + assignments_app = apps.get_app_config(app_label='assignments') + for permission in permissions: + # There could be only one 'assignment.can_see' and then we want to return data. + if permission.content_type.app_label == assignments_app.label and permission.codename == 'can_see': + yield from assignments_app.get_startup_elements() diff --git a/openslides/core/apps.py b/openslides/core/apps.py index 1758b94b6..389316682 100644 --- a/openslides/core/apps.py +++ b/openslides/core/apps.py @@ -1,6 +1,8 @@ from django.apps import AppConfig from django.conf import settings +from ..utils.collection import Collection + class CoreAppConfig(AppConfig): name = 'openslides.core' @@ -18,7 +20,10 @@ class CoreAppConfig(AppConfig): from .signals import post_permission_creation from ..utils.rest_api import router from .config_variables import get_config_variables - from .signals import delete_django_app_permissions + from .signals import ( + delete_django_app_permissions, + get_permission_change_data, + permission_change) from .views import ( ChatMessageViewSet, ConfigViewSet, @@ -35,6 +40,9 @@ class CoreAppConfig(AppConfig): post_permission_creation.connect( delete_django_app_permissions, dispatch_uid='delete_django_app_permissions') + permission_change.connect( + get_permission_change_data, + dispatch_uid='core_get_permission_change_data') # Register viewsets. router.register(self.get_model('Projector').get_collection_string(), ProjectorViewSet) @@ -45,8 +53,11 @@ class CoreAppConfig(AppConfig): router.register(self.get_model('Countdown').get_collection_string(), CountdownViewSet) def get_startup_elements(self): + """ + Yields all collections required on startup i. e. opening the websocket + connection. + """ from .config import config - from ..utils.collection import Collection for model in ('Projector', 'ChatMessage', 'Tag', 'ProjectorMessage', 'Countdown'): yield Collection(self.get_model(model).get_collection_string()) yield Collection(config.get_collection_string()) diff --git a/openslides/core/signals.py b/openslides/core/signals.py index 033626341..2d3f2f8e7 100644 --- a/openslides/core/signals.py +++ b/openslides/core/signals.py @@ -1,13 +1,20 @@ +from django.apps import apps from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.dispatch import Signal -# This signal is sent when the migrate command is done. That means it is sent +from ..utils.collection import Collection + +# This signal is send when the migrate command is done. That means it is sent # after post_migrate sending and creating all Permission objects. Don't use it # for other things than dealing with Permission objects. post_permission_creation = Signal() +# This signal is sent if a permission is changed (e. g. a group gets a new +# permission). Connected receivers may yield Collections. +permission_change = Signal() + def delete_django_app_permissions(sender, **kwargs): """ @@ -19,3 +26,19 @@ def delete_django_app_permissions(sender, **kwargs): Q(app_label='contenttypes') | Q(app_label='sessions')) Permission.objects.filter(content_type__in=contenttypes).delete() + + +def get_permission_change_data(sender, permissions, **kwargs): + """ + Yields all necessary collections if the respective permissions change. + """ + core_app = apps.get_app_config(app_label='core') + for permission in permissions: + if permission.content_type.app_label == core_app.label: + if permission.codename == 'can_see_projector': + yield Collection(core_app.get_model('Projector').get_collection_string()) + elif permission.codename == 'can_manage_projector': + yield Collection(core_app.get_model('ProjectorMessage').get_collection_string()) + yield Collection(core_app.get_model('Countdown').get_collection_string()) + elif permission.codename == 'can_use_chat': + yield Collection(core_app.get_model('ChatMessage').get_collection_string()) diff --git a/openslides/mediafiles/apps.py b/openslides/mediafiles/apps.py index 31d18c7e0..76ef8b5b6 100644 --- a/openslides/mediafiles/apps.py +++ b/openslides/mediafiles/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig +from ..utils.collection import Collection + class MediafilesAppConfig(AppConfig): name = 'openslides.mediafiles' @@ -13,12 +15,22 @@ class MediafilesAppConfig(AppConfig): from . import projector # noqa # Import all required stuff. + from openslides.core.signals import permission_change from openslides.utils.rest_api import router + from .signals import get_permission_change_data from .views import MediafileViewSet + # Connect signals. + permission_change.connect( + get_permission_change_data, + dispatch_uid='mediafiles_get_permission_change_data') + # Register viewsets. router.register(self.get_model('Mediafile').get_collection_string(), MediafileViewSet) def get_startup_elements(self): - from ..utils.collection import Collection - return [Collection(self.get_model('Mediafile').get_collection_string())] + """ + Yields all collections required on startup i. e. opening the websocket + connection. + """ + yield Collection(self.get_model('Mediafile').get_collection_string()) diff --git a/openslides/mediafiles/signals.py b/openslides/mediafiles/signals.py new file mode 100644 index 000000000..ad297ac19 --- /dev/null +++ b/openslides/mediafiles/signals.py @@ -0,0 +1,12 @@ +from django.apps import apps + + +def get_permission_change_data(sender, permissions=None, **kwargs): + """ + Yields all necessary collections if 'mediafiles.can_see' permission changes. + """ + mediafiles_app = apps.get_app_config(app_label='mediafiles') + for permission in permissions: + # There could be only one 'mediafiles.can_see' and then we want to return data. + if permission.content_type.app_label == mediafiles_app.label and permission.codename == 'can_see': + yield from mediafiles_app.get_startup_elements() diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py index da7487943..5a037e16b 100644 --- a/openslides/motions/apps.py +++ b/openslides/motions/apps.py @@ -1,6 +1,8 @@ from django.apps import AppConfig from django.db.models.signals import post_migrate +from ..utils.collection import Collection + class MotionsAppConfig(AppConfig): name = 'openslides.motions' @@ -15,9 +17,10 @@ class MotionsAppConfig(AppConfig): # Import all required stuff. from openslides.core.config import config + from openslides.core.signals import permission_change from openslides.utils.rest_api import router from .config_variables import get_config_variables - from .signals import create_builtin_workflows + from .signals import create_builtin_workflows, get_permission_change_data from .views import CategoryViewSet, MotionViewSet, MotionBlockViewSet, MotionPollViewSet, MotionChangeRecommendationViewSet, WorkflowViewSet # Define config variables @@ -25,6 +28,9 @@ class MotionsAppConfig(AppConfig): # Connect signals. post_migrate.connect(create_builtin_workflows, dispatch_uid='motion_create_builtin_workflows') + permission_change.connect( + get_permission_change_data, + dispatch_uid='motions_get_permission_change_data') # Register viewsets. router.register(self.get_model('Category').get_collection_string(), CategoryViewSet) @@ -36,6 +42,9 @@ class MotionsAppConfig(AppConfig): router.register('motions/motionpoll', MotionPollViewSet) def get_startup_elements(self): - from ..utils.collection import Collection + """ + Yields all collections required on startup i. e. opening the websocket + connection. + """ for model in ('Category', 'Motion', 'MotionBlock', 'Workflow', 'MotionChangeRecommendation'): yield Collection(self.get_model(model).get_collection_string()) diff --git a/openslides/motions/signals.py b/openslides/motions/signals.py index 360d113ed..64a0cdb9e 100644 --- a/openslides/motions/signals.py +++ b/openslides/motions/signals.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.utils.translation import ugettext_noop from .models import State, Workflow @@ -102,3 +103,14 @@ def create_builtin_workflows(sender, **kwargs): state_2_2.next_states.add(state_2_3, state_2_4, state_2_5, state_2_6, state_2_7, state_2_8, state_2_9) workflow_2.first_state = state_2_1 workflow_2.save() + + +def get_permission_change_data(sender, permissions, **kwargs): + """ + Yields all necessary collections if 'motions.can_see' permission changes. + """ + motions_app = apps.get_app_config(app_label='motions') + for permission in permissions: + # There could be only one 'motions.can_see' and then we want to return data. + if permission.content_type.app_label == motions_app.label and permission.codename == 'can_see': + yield from motions_app.get_startup_elements() diff --git a/openslides/topics/apps.py b/openslides/topics/apps.py index 49eba7af8..a48165464 100644 --- a/openslides/topics/apps.py +++ b/openslides/topics/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig +from ..utils.collection import Collection + class TopicsAppConfig(AppConfig): name = 'openslides.topics' @@ -13,12 +15,22 @@ class TopicsAppConfig(AppConfig): from . import projector # noqa # Import all required stuff. + from openslides.core.signals import permission_change from ..utils.rest_api import router + from .signals import get_permission_change_data from .views import TopicViewSet + # Connect signals. + permission_change.connect( + get_permission_change_data, + dispatch_uid='topics_get_permission_change_data') + # Register viewsets. router.register(self.get_model('Topic').get_collection_string(), TopicViewSet) def get_startup_elements(self): - from ..utils.collection import Collection - return [Collection(self.get_model('Topic').get_collection_string())] + """ + Yields all collections required on startup i. e. opening the websocket + connection. + """ + yield Collection(self.get_model('Topic').get_collection_string()) diff --git a/openslides/topics/signals.py b/openslides/topics/signals.py new file mode 100644 index 000000000..61b313021 --- /dev/null +++ b/openslides/topics/signals.py @@ -0,0 +1,14 @@ +from django.apps import apps + + +def get_permission_change_data(sender, permissions, **kwargs): + """ + Yields all necessary collections from the topics app if + 'agenda.can_see' permission changes, because topics are strongly + connected to the agenda items. + """ + topics_app = apps.get_app_config(app_label='topics') + for permission in permissions: + # There could be only one 'agenda.can_see' and then we want to return data. + if permission.content_type.app_label == 'agenda' and permission.codename == 'can_see': + yield from topics_app.get_startup_elements() diff --git a/openslides/users/apps.py b/openslides/users/apps.py index 532a4803e..7bbd60678 100644 --- a/openslides/users/apps.py +++ b/openslides/users/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig +from ..utils.collection import Collection + class UsersAppConfig(AppConfig): name = 'openslides.users' @@ -14,10 +16,10 @@ class UsersAppConfig(AppConfig): # Import all required stuff. from ..core.config import config - from ..core.signals import post_permission_creation + from ..core.signals import post_permission_creation, permission_change from ..utils.rest_api import router from .config_variables import get_config_variables - from .signals import create_builtin_groups_and_admin + from .signals import create_builtin_groups_and_admin, get_permission_change_data from .views import GroupViewSet, UserViewSet # Define config variables @@ -27,12 +29,18 @@ class UsersAppConfig(AppConfig): post_permission_creation.connect( create_builtin_groups_and_admin, dispatch_uid='create_builtin_groups_and_admin') + permission_change.connect( + get_permission_change_data, + dispatch_uid='users_get_permission_change_data') # Register viewsets. router.register(self.get_model('User').get_collection_string(), UserViewSet) router.register(self.get_model('Group').get_collection_string(), GroupViewSet) def get_startup_elements(self): - from ..utils.collection import Collection + """ + Yields all collections required on startup i. e. opening the websocket + connection. + """ for model in ('User', 'Group'): yield Collection(self.get_model(model).get_collection_string()) diff --git a/openslides/users/signals.py b/openslides/users/signals.py index 56799d6c1..155ca6197 100644 --- a/openslides/users/signals.py +++ b/openslides/users/signals.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.contrib.auth.models import Permission from django.db.models import Q @@ -5,6 +6,17 @@ from ..utils.autoupdate import inform_changed_data from .models import Group, User +def get_permission_change_data(sender, permissions=None, **kwargs): + """ + Yields all necessary collections if 'users.can_see_name' permission changes. + """ + users_app = apps.get_app_config(app_label='users') + for permission in permissions: + # There could be only one 'users.can_see_name' and then we want to return data. + if permission.content_type.app_label == users_app.label and permission.codename == 'can_see_name': + yield from users_app.get_startup_elements() + + def create_builtin_groups_and_admin(**kwargs): """ Creates the builtin groups: Default, Delegates, Staff and Committees. diff --git a/openslides/users/views.py b/openslides/users/views.py index 385d3eb98..191d4e413 100644 --- a/openslides/users/views.py +++ b/openslides/users/views.py @@ -6,8 +6,10 @@ from django.utils.encoding import force_text from django.utils.translation import ugettext as _ from ..core.config import config +from ..core.signals import permission_change from ..utils.auth import anonymous_is_enabled, has_perm -from ..utils.collection import CollectionElement +from ..utils.autoupdate import inform_data_collection_element_list +from ..utils.collection import CollectionElement, CollectionElementList from ..utils.rest_api import ( ModelViewSet, Response, @@ -19,7 +21,7 @@ from ..utils.rest_api import ( from ..utils.views import APIView from .access_permissions import GroupAccessPermissions, UserAccessPermissions from .models import Group, User -from .serializers import GroupSerializer +from .serializers import GroupSerializer, PermissionRelatedField # Viewsets for the REST API @@ -159,6 +161,51 @@ class GroupViewSet(ModelViewSet): result = False return result + def update(self, request, *args, **kwargs): + """ + Customized endpoint to update a group. Send the signal + 'permission_change' if group permissions change. + """ + group = self.get_object() + + # Collect old and new (given) permissions to get the difference. + old_permissions = list(group.permissions.all()) # Force evaluation so the perms don't change anymore. + permission_names = request.data['permissions'] + if isinstance(permission_names, str): + permission_names = [permission_names] + given_permissions = [ + PermissionRelatedField(read_only=True).to_internal_value(data=perm) for perm in permission_names] + + # Run super to update the group. + response = super().update(request, *args, **kwargs) + + # Check status code and send 'permission_change' signal. + if response.status_code == 200: + + def diff(full, part): + """ + This helper function calculates the difference of two lists: + The result is a list of all elements of 'full' that are + not in 'part'. + """ + part = set(part) + return [item for item in full if item not in part] + + new_permissions = diff(given_permissions, old_permissions) + + # Some permissions are added. + if len(new_permissions) > 0: + collection_elements = CollectionElementList() + signal_results = permission_change.send(None, permissions=new_permissions, action='added') + for receiver, signal_collections in signal_results: + for collection in signal_collections: + collection_elements.extend(collection.element_generator()) + inform_data_collection_element_list(collection_elements) + + # TODO: Some permissions are deleted. + + return response + def destroy(self, request, *args, **kwargs): """ Protects builtin groups 'Default' (pk=1) from being deleted. diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index dff469dab..4218fb2ce 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -146,13 +146,15 @@ def send_data(message): Informs all site users and projector clients about changed data. """ collection_elements = CollectionElementList.from_channels_message(message) + + # Send data to site users. for user_id, channel_names in websocket_user_cache.get_all().items(): if not user_id: # Anonymous user user = None else: try: - user = CollectionElement.from_values('users/user', user_id) + user = user_to_collection_user(user_id) except ObjectDoesNotExist: # The user does not exist. Skip him/her. continue @@ -253,6 +255,18 @@ def inform_deleted_data(*args, information=None): transaction.on_commit(lambda: send_autoupdate(collection_elements)) +def inform_data_collection_element_list(collection_elements, information=None): + """ + Informs the autoupdate system about some collection elements. This is + used just to send some data to all users. + """ + # If currently there is an open database transaction, then the + # send_autoupdate function is only called, when the transaction is + # commited. If there is currently no transaction, then the function + # is called immediately. + transaction.on_commit(lambda: send_autoupdate(collection_elements)) + + def send_autoupdate(collection_elements): """ Helper function, that sends collection_elements through a channel to the diff --git a/setup.cfg b/setup.cfg index 39c01bcdc..8ad6eaacc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,8 @@ [coverage:run] source = openslides -omit = openslides/core/management/commands/getgeiss.py +omit = + openslides/core/management/commands/*.py + openslides/users/management/commands/*.py [coverage:html] directory = personal_data/tmp/htmlcov diff --git a/tests/integration/users/test_viewset.py b/tests/integration/users/test_viewset.py index 06a2e94ea..859ab0b1a 100644 --- a/tests/integration/users/test_viewset.py +++ b/tests/integration/users/test_viewset.py @@ -430,6 +430,53 @@ class GroupUpdate(TestCase): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.data, {'name': ['This field is required.']}) + def test_update_via_put_with_new_permissions(self): + admin_client = APIClient() + admin_client.login(username='admin', password='admin') + group = Group.objects.create(name='group_name_inooThe3dii4mahWeeSe') + # This contains all permissions. + permissions = [ + 'agenda.can_be_speaker', + 'agenda.can_manage', + 'agenda.can_see', + 'agenda.can_see_hidden_items', + 'assignments.can_manage', + 'assignments.can_nominate_other', + 'assignments.can_nominate_self', + 'assignments.can_see', + 'core.can_manage_config', + 'core.can_manage_projector', + 'core.can_manage_tags', + 'core.can_manage_chat', + 'core.can_see_frontpage', + 'core.can_see_projector', + 'core.can_use_chat', + 'mediafiles.can_manage', + 'mediafiles.can_see', + 'mediafiles.can_see_hidden', + 'mediafiles.can_upload', + 'motions.can_create', + 'motions.can_manage', + 'motions.can_see', + 'motions.can_see_and_manage_comments', + 'motions.can_support', + 'users.can_manage', + 'users.can_see_extra_data', + 'users.can_see_name', + ] + + response = admin_client.put( + reverse('group-detail', args=[group.pk]), + {'name': 'new_group_name_Chie6duwaepoo8aech7r', + 'permissions': permissions}, + format='json') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + group = Group.objects.get(pk=group.pk) + for permission in permissions: + app_label, codename = permission.split('.') + self.assertTrue(group.permissions.get(content_type__app_label=app_label, codename=codename)) + class GroupDelete(TestCase): """