From 060856628bac7cdf47e34dbd2675f941d1419462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Norman=20J=C3=A4ckel?= Date: Sun, 4 Nov 2018 14:02:30 +0100 Subject: [PATCH 1/2] OpenSlides history mode. Also containing auth check and viewpoint to clear history. --- CHANGELOG.rst | 1 + openslides/core/access_permissions.py | 22 +++ openslides/core/apps.py | 7 +- .../migrations/0009_auto_20181118_2126.py | 54 ++++++ openslides/core/models.py | 103 ++++++++++- openslides/core/serializers.py | 12 ++ openslides/core/urls.py | 4 + openslides/core/views.py | 93 +++++++++- openslides/motions/signals.py | 166 ++++++++++-------- openslides/motions/views.py | 20 ++- openslides/users/models.py | 16 +- openslides/users/signals.py | 29 ++- openslides/users/views.py | 8 +- openslides/utils/autoupdate.py | 81 +++++++-- openslides/utils/projector.py | 4 +- tests/integration/helpers.py | 13 -- tests/integration/utils/test_consumers.py | 72 +++++--- tests/unit/motions/test_views.py | 3 +- tests/unit/users/test_models.py | 13 +- 19 files changed, 557 insertions(+), 164 deletions(-) create mode 100644 openslides/core/migrations/0009_auto_20181118_2126.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 38443a582..c10d3e073 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,7 @@ Core: - Add a change-id system to get only new elements [#3938]. - Switch from Yarn back to npm [#3964]. - Added password reset link (password reset via email) [#3914]. + - Added global history mode [#3977]. Agenda: - Added viewpoint to assign multiple items to a new parent item [#4037]. diff --git a/openslides/core/access_permissions.py b/openslides/core/access_permissions.py index 2e2276465..1a8ce04f5 100644 --- a/openslides/core/access_permissions.py +++ b/openslides/core/access_permissions.py @@ -1,4 +1,5 @@ from ..utils.access_permissions import BaseAccessPermissions +from ..utils.auth import GROUP_ADMIN_PK, async_in_some_groups class ProjectorAccessPermissions(BaseAccessPermissions): @@ -88,3 +89,24 @@ class ConfigAccessPermissions(BaseAccessPermissions): from .serializers import ConfigSerializer return ConfigSerializer + + +class HistoryAccessPermissions(BaseAccessPermissions): + """ + Access permissions container for the Histroy. + """ + + async def async_check_permissions(self, user_id: int) -> bool: + """ + Returns True if the user is in admin group and has read access to + model instances. + """ + return await async_in_some_groups(user_id, [GROUP_ADMIN_PK]) + + def get_serializer_class(self, user=None): + """ + Returns serializer class. + """ + from .serializers import HistorySerializer + + return HistorySerializer diff --git a/openslides/core/apps.py b/openslides/core/apps.py index a4cf3c05e..5fc3ac3e3 100644 --- a/openslides/core/apps.py +++ b/openslides/core/apps.py @@ -32,6 +32,7 @@ class CoreAppConfig(AppConfig): ChatMessageViewSet, ConfigViewSet, CountdownViewSet, + HistoryViewSet, ProjectorMessageViewSet, ProjectorViewSet, TagViewSet, @@ -81,10 +82,12 @@ class CoreAppConfig(AppConfig): router.register(self.get_model('ConfigStore').get_collection_string(), ConfigViewSet, 'config') router.register(self.get_model('ProjectorMessage').get_collection_string(), ProjectorMessageViewSet) router.register(self.get_model('Countdown').get_collection_string(), CountdownViewSet) + router.register(self.get_model('History').get_collection_string(), HistoryViewSet) - # Sets the cache + # Sets the cache and builds the startup history if is_normal_server_start: element_cache.ensure_cache() + self.get_model('History').objects.build_history() # Register client messages register_client_message(NotifyWebsocketClientMessage()) @@ -104,7 +107,7 @@ class CoreAppConfig(AppConfig): Yields all Cachables required on startup i. e. opening the websocket connection. """ - for model_name in ('Projector', 'ChatMessage', 'Tag', 'ProjectorMessage', 'Countdown', 'ConfigStore'): + for model_name in ('Projector', 'ChatMessage', 'Tag', 'ProjectorMessage', 'Countdown', 'ConfigStore', 'History'): yield self.get_model(model_name) def get_angular_constants(self): diff --git a/openslides/core/migrations/0009_auto_20181118_2126.py b/openslides/core/migrations/0009_auto_20181118_2126.py new file mode 100644 index 000000000..3c7f2ff3f --- /dev/null +++ b/openslides/core/migrations/0009_auto_20181118_2126.py @@ -0,0 +1,54 @@ +# Generated by Django 2.1.3 on 2018-11-18 20:26 + +import django.db.models.deletion +import jsonfield.encoder +import jsonfield.fields +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0008_changed_logo_fields'), + ] + + operations = [ + migrations.CreateModel( + name='History', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('element_id', models.CharField(max_length=255)), + ('now', models.DateTimeField(auto_now_add=True)), + ('information', models.CharField(max_length=255)), + ], + options={ + 'default_permissions': (), + }, + bases=(openslides.utils.models.RESTModelMixin, models.Model), + ), + migrations.CreateModel( + name='HistoryData', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_data', jsonfield.fields.JSONField( + dump_kwargs={'cls': jsonfield.encoder.JSONEncoder, 'separators': (',', ':')}, load_kwargs={})), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.AddField( + model_name='history', + name='full_data', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='core.HistoryData'), + ), + migrations.AddField( + model_name='history', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/openslides/core/models.py b/openslides/core/models.py index 0c5af53f1..394dc241d 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -1,14 +1,18 @@ +from asgiref.sync import async_to_sync from django.conf import settings -from django.db import models +from django.db import models, transaction from django.utils.timezone import now from jsonfield import JSONField +from ..utils.autoupdate import Element +from ..utils.cache import element_cache, get_element_id from ..utils.models import RESTModelMixin from ..utils.projector import get_all_projector_elements from .access_permissions import ( ChatMessageAccessPermissions, ConfigAccessPermissions, CountdownAccessPermissions, + HistoryAccessPermissions, ProjectorAccessPermissions, ProjectorMessageAccessPermissions, TagAccessPermissions, @@ -324,3 +328,100 @@ class Countdown(RESTModelMixin, models.Model): self.running = False self.countdown_time = self.default_time self.save(skip_autoupdate=skip_autoupdate) + + +class HistoryData(models.Model): + """ + Django model to save the history of OpenSlides. + + This is not a RESTModel. It is not cachable and can only be reached by a + special viewset. + """ + full_data = JSONField() + + class Meta: + default_permissions = () + + +class HistoryManager(models.Manager): + """ + Customized model manager for the history model. + """ + def add_elements(self, elements): + """ + Method to add elements to the history. This does not trigger autoupdate. + """ + with transaction.atomic(): + instances = [] + for element in elements: + if element['disable_history'] or element['collection_string'] == self.model.get_collection_string(): + # Do not update history for history elements itself or if history is disabled. + continue + # HistoryData is not a root rest element so there is no autoupdate and not history saving here. + data = HistoryData.objects.create(full_data=element['full_data']) + instance = self.model( + element_id=get_element_id(element['collection_string'], element['id']), + information=element['information'], + user_id=element['user_id'], + full_data=data, + ) + instance.save(skip_autoupdate=True) # Skip autoupdate and of course history saving. + instances.append(instance) + return instances + + def build_history(self): + """ + Method to add all cachables to the history. + """ + # TODO: Add lock to prevent multiple history builds at once. See #4039. + instances = None + if self.all().count() == 0: + elements = [] + all_full_data = async_to_sync(element_cache.get_all_full_data)() + for collection_string, data in all_full_data.items(): + for full_data in data: + elements.append(Element( + id=full_data['id'], + collection_string=collection_string, + full_data=full_data, + information='', + user_id=None, + disable_history=False, + )) + instances = self.add_elements(elements) + return instances + + +class History(RESTModelMixin, models.Model): + """ + Django model to save the history of OpenSlides. + + This model itself is not part of the history. This means that if you + delete a user you may lose the information of the user field here. + """ + access_permissions = HistoryAccessPermissions() + + objects = HistoryManager() + + element_id = models.CharField( + max_length=255, + ) + + now = models.DateTimeField(auto_now_add=True) + + information = models.CharField( + max_length=255, + ) + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + on_delete=models.SET_NULL) + + full_data = models.OneToOneField( + HistoryData, + on_delete=models.CASCADE, + ) + + class Meta: + default_permissions = () diff --git a/openslides/core/serializers.py b/openslides/core/serializers.py index 6693a3cc4..555056fcf 100644 --- a/openslides/core/serializers.py +++ b/openslides/core/serializers.py @@ -5,6 +5,7 @@ from .models import ( ChatMessage, ConfigStore, Countdown, + History, ProjectionDefault, Projector, ProjectorMessage, @@ -110,3 +111,14 @@ class CountdownSerializer(ModelSerializer): class Meta: model = Countdown fields = ('id', 'description', 'default_time', 'countdown_time', 'running', ) + + +class HistorySerializer(ModelSerializer): + """ + Serializer for core.models.Countdown objects. + + Does not contain full data of history object. + """ + class Meta: + model = History + fields = ('id', 'element_id', 'now', 'information', 'user', ) diff --git a/openslides/core/urls.py b/openslides/core/urls.py index b087a5bbf..cc6d6e236 100644 --- a/openslides/core/urls.py +++ b/openslides/core/urls.py @@ -11,4 +11,8 @@ urlpatterns = [ url(r'^version/$', views.VersionView.as_view(), name='core_version'), + + url(r'^history/$', + views.HistoryView.as_view(), + name='core_history'), ] diff --git a/openslides/core/views.py b/openslides/core/views.py index 383962a55..f491ed230 100644 --- a/openslides/core/views.py +++ b/openslides/core/views.py @@ -1,3 +1,4 @@ +import datetime import os import uuid from typing import Any, Dict, List @@ -16,7 +17,12 @@ from mypy_extensions import TypedDict from .. import __license__ as license, __url__ as url, __version__ as version from ..utils import views as utils_views from ..utils.arguments import arguments -from ..utils.auth import anonymous_is_enabled, has_perm +from ..utils.auth import ( + GROUP_ADMIN_PK, + anonymous_is_enabled, + has_perm, + in_some_groups, +) from ..utils.autoupdate import inform_changed_data, inform_deleted_data from ..utils.plugins import ( get_plugin_description, @@ -26,8 +32,11 @@ from ..utils.plugins import ( get_plugin_version, ) from ..utils.rest_api import ( + GenericViewSet, + ListModelMixin, ModelViewSet, Response, + RetrieveModelMixin, ValidationError, detail_route, list_route, @@ -36,6 +45,7 @@ from .access_permissions import ( ChatMessageAccessPermissions, ConfigAccessPermissions, CountdownAccessPermissions, + HistoryAccessPermissions, ProjectorAccessPermissions, ProjectorMessageAccessPermissions, TagAccessPermissions, @@ -46,6 +56,8 @@ from .models import ( ChatMessage, ConfigStore, Countdown, + History, + HistoryData, ProjectionDefault, Projector, ProjectorMessage, @@ -716,6 +728,50 @@ class CountdownViewSet(ModelViewSet): return result +class HistoryViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + """ + API endpoint for History. + + There are the following views: list, retrieve, clear_history. + """ + access_permissions = HistoryAccessPermissions() + queryset = History.objects.all() + + def check_view_permissions(self): + """ + Returns True if the user has required permissions. + """ + if self.action in ('list', 'retrieve', 'clear_history'): + result = self.get_access_permissions().check_permissions(self.request.user) + else: + result = False + return result + + @list_route(methods=['post']) + def clear_history(self, request): + """ + Deletes and rebuilds the history. + """ + # Collect all history objects with their collection_string and id. + args = [] + for history_obj in History.objects.all(): + args.append((history_obj.get_collection_string(), history_obj.pk)) + + # Delete history data and history (via CASCADE) + HistoryData.objects.all().delete() + + # Trigger autoupdate. + if len(args) > 0: + inform_deleted_data(args) + + # Rebuild history. + history_instances = History.objects.build_history() + inform_changed_data(history_instances) + + # Setup response. + return Response({'detail': _('History was deleted successfully.')}) + + # Special API views class ServerTime(utils_views.APIView): @@ -755,3 +811,38 @@ class VersionView(utils_views.APIView): 'license': get_plugin_license(plugin), 'url': get_plugin_url(plugin)}) return result + + +class HistoryView(utils_views.APIView): + """ + View to retrieve the history data of OpenSlides. + + Use query paramter timestamp (UNIX timestamp) to get all elements from begin + until (including) this timestamp. + """ + http_method_names = ['get'] + + def get_context_data(self, **context): + """ + Checks if user is in admin group. If yes all history data until + (including) timestamp are added to the response data. + """ + if not in_some_groups(self.request.user.pk or 0, [GROUP_ADMIN_PK]): + self.permission_denied(self.request) + try: + timestamp = int(self.request.query_params.get('timestamp', 0)) + except (ValueError): + raise ValidationError({'detail': 'Invalid input. Timestamp should be an integer.'}) + data = [] + queryset = History.objects.select_related('full_data') + if timestamp: + queryset = queryset.filter(now__lte=datetime.datetime.fromtimestamp(timestamp)) + for instance in queryset: + data.append({ + 'full_data': instance.full_data.full_data, + 'element_id': instance.element_id, + 'timestamp': instance.now.timestamp(), + 'information': instance.information, + 'user_id': instance.user.pk if instance.user else None, + }) + return data diff --git a/openslides/motions/signals.py b/openslides/motions/signals.py index 1d6a9f85d..cb4911519 100644 --- a/openslides/motions/signals.py +++ b/openslides/motions/signals.py @@ -14,87 +14,103 @@ def create_builtin_workflows(sender, **kwargs): # If there is at least one workflow, then do nothing. return - workflow_1 = Workflow.objects.create(name='Simple Workflow') - state_1_1 = State.objects.create(name=ugettext_noop('submitted'), - workflow=workflow_1, - allow_create_poll=True, - allow_support=True, - allow_submitter_edit=True) - state_1_2 = State.objects.create(name=ugettext_noop('accepted'), - workflow=workflow_1, - action_word='Accept', - recommendation_label='Acceptance', - css_class='success', - merge_amendment_into_final=True) - state_1_3 = State.objects.create(name=ugettext_noop('rejected'), - workflow=workflow_1, - action_word='Reject', - recommendation_label='Rejection', - css_class='danger') - state_1_4 = State.objects.create(name=ugettext_noop('not decided'), - workflow=workflow_1, - action_word='Do not decide', - recommendation_label='No decision', - css_class='default') + workflow_1 = Workflow(name='Simple Workflow') + workflow_1.save(skip_autoupdate=True) + state_1_1 = State(name=ugettext_noop('submitted'), + workflow=workflow_1, + allow_create_poll=True, + allow_support=True, + allow_submitter_edit=True) + state_1_1.save(skip_autoupdate=True) + state_1_2 = State(name=ugettext_noop('accepted'), + workflow=workflow_1, + action_word='Accept', + recommendation_label='Acceptance', + css_class='success'), + merge_amendment_into_final=True) + state_1_2.save(skip_autoupdate=True) + state_1_3 = State(name=ugettext_noop('rejected'), + workflow=workflow_1, + action_word='Reject', + recommendation_label='Rejection', + css_class='danger') + state_1_3.save(skip_autoupdate=True) + state_1_4 = State(name=ugettext_noop('not decided'), + workflow=workflow_1, + action_word='Do not decide', + recommendation_label='No decision', + css_class='default') + state_1_4.save(skip_autoupdate=True) state_1_1.next_states.add(state_1_2, state_1_3, state_1_4) workflow_1.first_state = state_1_1 - workflow_1.save() + workflow_1.save(skip_autoupdate=True) - workflow_2 = Workflow.objects.create(name='Complex Workflow') - state_2_1 = State.objects.create(name=ugettext_noop('published'), - workflow=workflow_2, - allow_support=True, - allow_submitter_edit=True, - dont_set_identifier=True) - state_2_2 = State.objects.create(name=ugettext_noop('permitted'), - workflow=workflow_2, - action_word='Permit', - recommendation_label='Permission', - allow_create_poll=True, - allow_submitter_edit=True) - state_2_3 = State.objects.create(name=ugettext_noop('accepted'), - workflow=workflow_2, - action_word='Accept', - recommendation_label='Acceptance', - css_class='success', - merge_amendment_into_final=True) - state_2_4 = State.objects.create(name=ugettext_noop('rejected'), - workflow=workflow_2, - action_word='Reject', - recommendation_label='Rejection', - css_class='danger') - state_2_5 = State.objects.create(name=ugettext_noop('withdrawed'), - workflow=workflow_2, - action_word='Withdraw', - css_class='default') - state_2_6 = State.objects.create(name=ugettext_noop('adjourned'), - workflow=workflow_2, - action_word='Adjourn', - recommendation_label='Adjournment', - css_class='default') - state_2_7 = State.objects.create(name=ugettext_noop('not concerned'), - workflow=workflow_2, - action_word='Do not concern', - recommendation_label='No concernment', - css_class='default') - state_2_8 = State.objects.create(name=ugettext_noop('refered to committee'), - workflow=workflow_2, - action_word='Refer to committee', - recommendation_label='Referral to committee', - css_class='default') - state_2_9 = State.objects.create(name=ugettext_noop('needs review'), - workflow=workflow_2, - action_word='Needs review', - css_class='default') - state_2_10 = State.objects.create(name=ugettext_noop('rejected (not authorized)'), - workflow=workflow_2, - action_word='Reject (not authorized)', - recommendation_label='Rejection (not authorized)', - css_class='default') + workflow_2 = Workflow(name='Complex Workflow') + workflow_2.save(skip_autoupdate=True) + state_2_1 = State(name=ugettext_noop('published'), + workflow=workflow_2, + allow_support=True, + allow_submitter_edit=True, + dont_set_identifier=True) + state_2_1.save(skip_autoupdate=True) + state_2_2 = State(name=ugettext_noop('permitted'), + workflow=workflow_2, + action_word='Permit', + recommendation_label='Permission', + allow_create_poll=True, + allow_submitter_edit=True) + state_2_2.save(skip_autoupdate=True) + state_2_3 = State(name=ugettext_noop('accepted'), + workflow=workflow_2, + action_word='Accept', + recommendation_label='Acceptance', + css_class='success'), + merge_amendment_into_final=True) + state_2_3.save(skip_autoupdate=True) + state_2_4 = State(name=ugettext_noop('rejected'), + workflow=workflow_2, + action_word='Reject', + recommendation_label='Rejection', + css_class='danger') + state_2_4.save(skip_autoupdate=True) + state_2_5 = State(name=ugettext_noop('withdrawed'), + workflow=workflow_2, + action_word='Withdraw', + css_class='default') + state_2_5.save(skip_autoupdate=True) + state_2_6 = State(name=ugettext_noop('adjourned'), + workflow=workflow_2, + action_word='Adjourn', + recommendation_label='Adjournment', + css_class='default') + state_2_6.save(skip_autoupdate=True) + state_2_7 = State(name=ugettext_noop('not concerned'), + workflow=workflow_2, + action_word='Do not concern', + recommendation_label='No concernment', + css_class='default') + state_2_7.save(skip_autoupdate=True) + state_2_8 = State(name=ugettext_noop('refered to committee'), + workflow=workflow_2, + action_word='Refer to committee', + recommendation_label='Referral to committee', + css_class='default') + state_2_8.save(skip_autoupdate=True) + state_2_9 = State(name=ugettext_noop('needs review'), + workflow=workflow_2, + action_word='Needs review', + css_class='default') + state_2_9.save(skip_autoupdate=True) + state_2_10 = State(name=ugettext_noop('rejected (not authorized)'), + workflow=workflow_2, + action_word='Reject (not authorized)', + recommendation_label='Rejection (not authorized)', + css_class='default') + state_2_10.save(skip_autoupdate=True) state_2_1.next_states.add(state_2_2, state_2_5, state_2_10) 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() + workflow_2.save(skip_autoupdate=True) def get_permission_change_data(sender, permissions, **kwargs): diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 400a53527..53a4cbf9f 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -14,7 +14,7 @@ from rest_framework import status from ..core.config import config from ..core.models import Tag from ..utils.auth import has_perm, in_some_groups -from ..utils.autoupdate import inform_changed_data +from ..utils.autoupdate import inform_changed_data, inform_deleted_data from ..utils.exceptions import OpenSlidesError from ..utils.rest_api import ( CreateModelMixin, @@ -107,7 +107,15 @@ class MotionViewSet(ModelViewSet): motion.is_submitter(request.user) and motion.state.allow_submitter_edit)): self.permission_denied(request) - return super().destroy(request, *args, **kwargs) + result = super().destroy(request, *args, **kwargs) + + # Fire autoupdate again to save information to OpenSlides history. + inform_deleted_data( + [(motion.get_collection_string(), motion.pk)], + information='Motion deleted', + user_id=request.user.pk) + + return result def create(self, request, *args, **kwargs): """ @@ -279,6 +287,12 @@ class MotionViewSet(ModelViewSet): new_users = list(updated_motion.supporters.all()) inform_changed_data(new_users) + # Fire autoupdate again to save information to OpenSlides history. + inform_changed_data( + updated_motion, + information='Motion updated', + user_id=request.user.pk) + # We do not add serializer.data to response so nobody gets unrestricted data here. return Response() @@ -630,7 +644,7 @@ class MotionViewSet(ModelViewSet): message_list=[ugettext_noop('State set to'), ' ', motion.state.name], person=request.user, skip_autoupdate=True) - inform_changed_data(motion) + inform_changed_data(motion, information='State set to {}.'.format(motion.state.name)) return Response({'detail': message}) @detail_route(methods=['put']) diff --git a/openslides/users/models.py b/openslides/users/models.py index 311364bbd..b6ca42b10 100644 --- a/openslides/users/models.py +++ b/openslides/users/models.py @@ -11,7 +11,7 @@ from django.contrib.auth.models import ( PermissionsMixin, ) from django.core import mail -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models from django.db.models import Prefetch from django.utils import timezone @@ -62,12 +62,18 @@ class UserManager(BaseUserManager): exists, resets it. The password is (re)set to 'admin'. The user becomes member of the group 'Admin'. """ - admin, created = self.get_or_create( - username='admin', - defaults={'last_name': 'Administrator'}) + created = False + try: + admin = self.get(username='admin') + except ObjectDoesNotExist: + admin = self.model( + username='admin', + last_name='Administrator', + ) + created = True admin.default_password = 'admin' admin.password = make_password(admin.default_password) - admin.save() + admin.save(skip_autoupdate=True) admin.groups.add(GROUP_ADMIN_PK) return created diff --git a/openslides/users/signals.py b/openslides/users/signals.py index e7d021a3e..6a79f9fe7 100644 --- a/openslides/users/signals.py +++ b/openslides/users/signals.py @@ -3,7 +3,6 @@ from django.contrib.auth.models import Permission from django.db.models import Q from ..utils.auth import GROUP_ADMIN_PK, GROUP_DEFAULT_PK -from ..utils.autoupdate import inform_changed_data from .models import Group, User @@ -81,11 +80,13 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['mediafiles.can_see'], permission_dict['motions.can_see'], permission_dict['users.can_see_name'], ) - group_default = Group.objects.create(pk=GROUP_DEFAULT_PK, name='Default') + group_default = Group(pk=GROUP_DEFAULT_PK, name='Default') + group_default.save(skip_autoupdate=True) group_default.permissions.add(*base_permissions) # Admin (pk 2 == GROUP_ADMIN_PK) - group_admin = Group.objects.create(pk=GROUP_ADMIN_PK, name='Admin') + group_admin = Group(pk=GROUP_ADMIN_PK, name='Admin') + group_admin.save(skip_autoupdate=True) # Delegates (pk 3) delegates_permissions = ( @@ -102,7 +103,8 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['motions.can_create'], permission_dict['motions.can_support'], permission_dict['users.can_see_name'], ) - group_delegates = Group.objects.create(pk=3, name='Delegates') + group_delegates = Group(pk=3, name='Delegates') + group_delegates.save(skip_autoupdate=True) group_delegates.permissions.add(*delegates_permissions) # Staff (pk 4) @@ -132,17 +134,10 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['users.can_manage'], permission_dict['users.can_see_extra_data'], permission_dict['mediafiles.can_see_hidden'],) - group_staff = Group.objects.create(pk=4, name='Staff') + group_staff = Group(pk=4, name='Staff') + group_staff.save(skip_autoupdate=True) group_staff.permissions.add(*staff_permissions) - # Add users.can_see_name permission to staff/admin - # group to ensure proper management possibilities - # TODO: Remove this redundancy after cleanup of the permission system. - group_staff.permissions.add( - permission_dict['users.can_see_name']) - group_admin.permissions.add( - permission_dict['users.can_see_name']) - # Committees (pk 5) committees_permissions = ( permission_dict['agenda.can_see'], @@ -155,13 +150,13 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['motions.can_create'], permission_dict['motions.can_support'], permission_dict['users.can_see_name'], ) - group_committee = Group.objects.create(pk=5, name='Committees') + group_committee = Group(pk=5, name='Committees') + group_committee.save(skip_autoupdate=True) group_committee.permissions.add(*committees_permissions) # Create or reset admin user User.objects.create_or_reset_admin_user() # After each group was created, the permissions (many to many fields) where - # added to the group. So we have to update the cache by calling - # inform_changed_data(). - inform_changed_data((group_default, group_admin, group_delegates, group_staff, group_committee)) + # added to the group. But we do not have to update the cache by calling + # inform_changed_data() because the cache is updated on server start. diff --git a/openslides/users/views.py b/openslides/users/views.py index e0e8d0a81..36e1f7700 100644 --- a/openslides/users/views.py +++ b/openslides/users/views.py @@ -336,7 +336,13 @@ class GroupViewSet(ModelViewSet): for receiver, signal_collections in signal_results: for cachable in signal_collections: for full_data in all_full_data.get(cachable.get_collection_string(), {}): - elements.append(Element(id=full_data['id'], collection_string=cachable.get_collection_string(), full_data=full_data)) + elements.append(Element( + id=full_data['id'], + collection_string=cachable.get_collection_string(), + full_data=full_data, + information='', + user_id=None, + disable_history=True)) inform_changed_elements(elements) # TODO: Some permissions are deleted. diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index cfd359014..8514b7c1d 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -1,3 +1,4 @@ +import itertools import threading from typing import Any, Dict, Iterable, List, Optional, Tuple, Union @@ -15,6 +16,9 @@ Element = TypedDict( 'id': int, 'collection_string': str, 'full_data': Optional[Dict[str, Any]], + 'information': str, + 'user_id': Optional[int], + 'disable_history': bool, } ) @@ -30,12 +34,17 @@ AutoupdateFormat = TypedDict( ) -def inform_changed_data(instances: Union[Iterable[Model], Model]) -> None: +def inform_changed_data( + instances: Union[Iterable[Model], Model], + information: str = '', + user_id: Optional[int] = None) -> None: """ Informs the autoupdate system and the caching system about the creation or update of an element. The argument instances can be one instance or an iterable over instances. + + History creation is enabled. """ root_instances = set() if not isinstance(instances, Iterable): @@ -54,7 +63,11 @@ def inform_changed_data(instances: Union[Iterable[Model], Model]) -> None: elements[key] = Element( id=root_instance.get_rest_pk(), collection_string=root_instance.get_collection_string(), - full_data=root_instance.get_full_data()) + full_data=root_instance.get_full_data(), + information=information, + user_id=user_id, + disable_history=False, + ) bundle = autoupdate_bundle.get(threading.get_ident()) if bundle is not None: @@ -65,15 +78,27 @@ def inform_changed_data(instances: Union[Iterable[Model], Model]) -> None: handle_changed_elements(elements.values()) -def inform_deleted_data(deleted_elements: Iterable[Tuple[str, int]]) -> None: +def inform_deleted_data( + deleted_elements: Iterable[Tuple[str, int]], + information: str = '', + user_id: Optional[int] = None) -> None: """ Informs the autoupdate system and the caching system about the deletion of elements. + + History creation is enabled. """ elements: Dict[str, Element] = {} for deleted_element in deleted_elements: key = deleted_element[0] + str(deleted_element[1]) - elements[key] = Element(id=deleted_element[1], collection_string=deleted_element[0], full_data=None) + elements[key] = Element( + id=deleted_element[1], + collection_string=deleted_element[0], + full_data=None, + information=information, + user_id=user_id, + disable_history=False, + ) bundle = autoupdate_bundle.get(threading.get_ident()) if bundle is not None: @@ -86,8 +111,11 @@ def inform_deleted_data(deleted_elements: Iterable[Tuple[str, int]]) -> None: def inform_changed_elements(changed_elements: Iterable[Element]) -> None: """ - Informs the autoupdate system about some collection elements. This is - used just to send some data to all users. + Informs the autoupdate system about some elements. This is used just to send + some data to all users. + + If you want to save history information, user id or disable history you + have to put information or flag inside the elements. """ elements = {} for changed_element in changed_elements: @@ -135,7 +163,7 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: Does nothing if elements is empty. """ - async def update_cache() -> int: + async def update_cache(elements: Iterable[Element]) -> int: """ Async helper function to update the cache. @@ -147,12 +175,12 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: cache_elements[element_id] = element['full_data'] return await element_cache.change_elements(cache_elements) - async def async_handle_collection_elements() -> None: + async def async_handle_collection_elements(elements: Iterable[Element]) -> None: """ Async helper function to update cache and send autoupdate. """ # Update cache - change_id = await update_cache() + change_id = await update_cache(elements) # Send autoupdate channel_layer = get_channel_layer() @@ -165,7 +193,36 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: ) if elements: - # TODO: Save histroy here using sync code + # Save histroy here using sync code. + history_instances = save_history(elements) - # Update cache and send autoupdate - async_to_sync(async_handle_collection_elements)() + # Convert history instances to Elements. + history_elements: List[Element] = [] + for history_instance in history_instances: + history_elements.append(Element( + id=history_instance.get_rest_pk(), + collection_string=history_instance.get_collection_string(), + full_data=history_instance.get_full_data(), + information='', + user_id=None, + disable_history=True, # This does not matter because history elements can never be part of the history itself. + )) + + # Chain elements and history elements. + itertools.chain(elements, history_elements) + + # Update cache and send autoupdate using async code. + async_to_sync(async_handle_collection_elements)( + itertools.chain(elements, history_elements) + ) + + +def save_history(elements: Iterable[Element]) -> Iterable: # TODO: Try to write Iterable[History] here + """ + Thin wrapper around the call of history saving manager method. + + This is separated to patch it during tests. + """ + from ..core.models import History + + return History.objects.add_elements(elements) diff --git a/openslides/utils/projector.py b/openslides/utils/projector.py index 8975adecf..1cbc10275 100644 --- a/openslides/utils/projector.py +++ b/openslides/utils/projector.py @@ -53,8 +53,8 @@ def register_projector_elements(elements: Generator[Type[ProjectorElement], None Has to be called in the app.ready method. """ - for Element in elements: - element = Element() + for AppProjectorElement in elements: + element = AppProjectorElement() projector_elements[element.name] = element # type: ignore diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 5083cd195..70e5903de 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -1,12 +1,10 @@ from typing import Any, Dict, List -from asgiref.sync import sync_to_async from django.db import DEFAULT_DB_ALIAS, connections from django.test.utils import CaptureQueriesContext from openslides.core.config import config from openslides.users.models import User -from openslides.utils.autoupdate import Element, inform_changed_elements class TConfig: @@ -55,17 +53,6 @@ class TUser: return elements -async def set_config(key, value): - """ - Set a config variable in the element_cache without hitting the database. - """ - collection_string = config.get_collection_string() - config_id = config.key_to_id[key] # type: ignore - full_data = {'id': config_id, 'key': key, 'value': value} - await sync_to_async(inform_changed_elements)([ - Element(id=config_id, collection_string=collection_string, full_data=full_data)]) - - def count_queries(func, *args, **kwargs) -> int: context = CaptureQueriesContext(connections[DEFAULT_DB_ALIAS]) with context: diff --git a/tests/integration/utils/test_consumers.py b/tests/integration/utils/test_consumers.py index 642823305..a1d41d51d 100644 --- a/tests/integration/utils/test_consumers.py +++ b/tests/integration/utils/test_consumers.py @@ -1,5 +1,6 @@ import asyncio from importlib import import_module +from unittest.mock import patch import pytest from asgiref.sync import sync_to_async @@ -13,7 +14,11 @@ from django.contrib.auth import ( from openslides.asgi import application from openslides.core.config import config -from openslides.utils.autoupdate import inform_deleted_data +from openslides.utils.autoupdate import ( + Element, + inform_changed_elements, + inform_deleted_data, +) from openslides.utils.cache import element_cache from ...unit.utils.cache_provider import ( @@ -21,7 +26,7 @@ from ...unit.utils.cache_provider import ( Collection2, get_cachable_provider, ) -from ..helpers import TConfig, TUser, set_config +from ..helpers import TConfig, TUser @pytest.fixture(autouse=True) @@ -64,15 +69,31 @@ async def communicator(get_communicator): yield get_communicator() +@pytest.fixture +async def set_config(): + """ + Set a config variable in the element_cache without hitting the database. + """ + async def _set_config(key, value): + with patch('openslides.utils.autoupdate.save_history'): + collection_string = config.get_collection_string() + config_id = config.key_to_id[key] # type: ignore + full_data = {'id': config_id, 'key': key, 'value': value} + await sync_to_async(inform_changed_elements)([ + Element(id=config_id, collection_string=collection_string, full_data=full_data, information='', user_id=None, disable_history=True)]) + + return _set_config + + @pytest.mark.asyncio -async def test_normal_connection(get_communicator): +async def test_normal_connection(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) connected, __ = await get_communicator().connect() assert connected @pytest.mark.asyncio -async def test_connection_with_change_id(get_communicator): +async def test_connection_with_change_id(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('change_id=0') await communicator.connect() @@ -93,7 +114,7 @@ async def test_connection_with_change_id(get_communicator): @pytest.mark.asyncio -async def test_connection_with_change_id_get_restricted_data_with_restricted_data_cache(get_communicator): +async def test_connection_with_change_id_get_restricted_data_with_restricted_data_cache(get_communicator, set_config): """ Test, that the returned data is the restricted_data when restricted_data_cache is activated """ @@ -116,7 +137,7 @@ async def test_connection_with_change_id_get_restricted_data_with_restricted_dat @pytest.mark.asyncio -async def test_connection_with_invalid_change_id(get_communicator): +async def test_connection_with_invalid_change_id(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('change_id=invalid') connected, __ = await communicator.connect() @@ -125,7 +146,7 @@ async def test_connection_with_invalid_change_id(get_communicator): @pytest.mark.asyncio -async def test_connection_with_to_big_change_id(get_communicator): +async def test_connection_with_to_big_change_id(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('change_id=100') @@ -136,7 +157,7 @@ async def test_connection_with_to_big_change_id(get_communicator): @pytest.mark.asyncio -async def test_changed_data_autoupdate_off(communicator): +async def test_changed_data_autoupdate_off(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -146,7 +167,7 @@ async def test_changed_data_autoupdate_off(communicator): @pytest.mark.asyncio -async def test_changed_data_autoupdate_on(get_communicator): +async def test_changed_data_autoupdate_on(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('autoupdate=on') await communicator.connect() @@ -191,13 +212,14 @@ async def test_with_user(): @pytest.mark.asyncio -async def test_receive_deleted_data(get_communicator): +async def test_receive_deleted_data(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('autoupdate=on') await communicator.connect() # Delete test element - await sync_to_async(inform_deleted_data)([(Collection1().get_collection_string(), 1)]) + with patch('openslides.utils.autoupdate.save_history'): + await sync_to_async(inform_deleted_data)([(Collection1().get_collection_string(), 1)]) response = await communicator.receive_json_from() type = response.get('type') @@ -207,7 +229,7 @@ async def test_receive_deleted_data(get_communicator): @pytest.mark.asyncio -async def test_send_notify(communicator): +async def test_send_notify(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -223,7 +245,7 @@ async def test_send_notify(communicator): @pytest.mark.asyncio -async def test_invalid_websocket_message_type(communicator): +async def test_invalid_websocket_message_type(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -234,7 +256,7 @@ async def test_invalid_websocket_message_type(communicator): @pytest.mark.asyncio -async def test_invalid_websocket_message_no_id(communicator): +async def test_invalid_websocket_message_no_id(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -245,7 +267,7 @@ async def test_invalid_websocket_message_no_id(communicator): @pytest.mark.asyncio -async def test_send_unknown_type(communicator): +async def test_send_unknown_type(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -257,7 +279,7 @@ async def test_send_unknown_type(communicator): @pytest.mark.asyncio -async def test_request_constants(communicator, settings): +async def test_request_constants(communicator, settings, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -270,7 +292,7 @@ async def test_request_constants(communicator, settings): @pytest.mark.asyncio -async def test_send_get_elements(communicator): +async def test_send_get_elements(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -291,7 +313,7 @@ async def test_send_get_elements(communicator): @pytest.mark.asyncio -async def test_send_get_elements_to_big_change_id(communicator): +async def test_send_get_elements_to_big_change_id(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -304,7 +326,7 @@ async def test_send_get_elements_to_big_change_id(communicator): @pytest.mark.asyncio -async def test_send_get_elements_to_small_change_id(communicator): +async def test_send_get_elements_to_small_change_id(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -318,7 +340,7 @@ async def test_send_get_elements_to_small_change_id(communicator): @pytest.mark.asyncio -async def test_send_connect_twice_with_clear_change_id_cache(communicator): +async def test_send_connect_twice_with_clear_change_id_cache(communicator, set_config): """ Test, that a second request with change_id+1 from the first request, returns an error. @@ -338,7 +360,7 @@ async def test_send_connect_twice_with_clear_change_id_cache(communicator): @pytest.mark.asyncio -async def test_send_connect_twice_with_clear_change_id_cache_same_change_id_then_first_request(communicator): +async def test_send_connect_twice_with_clear_change_id_cache_same_change_id_then_first_request(communicator, set_config): """ Test, that a second request with the change_id from the first request, returns all data. @@ -360,7 +382,7 @@ async def test_send_connect_twice_with_clear_change_id_cache_same_change_id_then @pytest.mark.asyncio -async def test_request_changed_elements_no_douple_elements(communicator): +async def test_request_changed_elements_no_douple_elements(communicator, set_config): """ Test, that when an elements is changed twice, it is only returned onces when ask a range of change ids. @@ -386,7 +408,7 @@ async def test_request_changed_elements_no_douple_elements(communicator): @pytest.mark.asyncio -async def test_send_invalid_get_elements(communicator): +async def test_send_invalid_get_elements(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -399,7 +421,7 @@ async def test_send_invalid_get_elements(communicator): @pytest.mark.asyncio -async def test_turn_on_autoupdate(communicator): +async def test_turn_on_autoupdate(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -418,7 +440,7 @@ async def test_turn_on_autoupdate(communicator): @pytest.mark.asyncio -async def test_turn_off_autoupdate(get_communicator): +async def test_turn_off_autoupdate(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('autoupdate=on') await communicator.connect() diff --git a/tests/unit/motions/test_views.py b/tests/unit/motions/test_views.py index e50734d7d..f42638c82 100644 --- a/tests/unit/motions/test_views.py +++ b/tests/unit/motions/test_views.py @@ -21,7 +21,8 @@ class MotionViewSetUpdate(TestCase): @patch('openslides.motions.views.has_perm') @patch('openslides.motions.views.config') def test_simple_update(self, mock_config, mock_has_perm, mock_icd): - self.request.user = 1 + self.request.user = MagicMock() + self.request.user.pk = 1 self.request.data.get.return_value = MagicMock() mock_has_perm.return_value = True diff --git a/tests/unit/users/test_models.py b/tests/unit/users/test_models.py index 5f20a1872..59ab834df 100644 --- a/tests/unit/users/test_models.py +++ b/tests/unit/users/test_models.py @@ -1,6 +1,8 @@ from unittest import TestCase from unittest.mock import MagicMock, call, patch +from django.core.exceptions import ObjectDoesNotExist + from openslides.users.models import UserManager @@ -127,7 +129,7 @@ class UserManagerCreateOrResetAdminUser(TestCase): """ admin_user = MagicMock() manager = UserManager() - manager.get_or_create = MagicMock(return_value=(admin_user, False)) + manager.get = MagicMock(return_value=(admin_user)) manager.create_or_reset_admin_user() @@ -139,7 +141,7 @@ class UserManagerCreateOrResetAdminUser(TestCase): """ admin_user = MagicMock() manager = UserManager() - manager.get_or_create = MagicMock(return_value=(admin_user, False)) + manager.get = MagicMock(return_value=(admin_user)) staff_group = MagicMock(name="Staff") mock_group.objects.get_or_create = MagicMock(return_value=(staff_group, True)) @@ -150,16 +152,15 @@ class UserManagerCreateOrResetAdminUser(TestCase): self.assertEqual( admin_user.default_password, 'admin') - admin_user.save.assert_called_once_with() + admin_user.save.assert_called_once_with(skip_autoupdate=True) @patch('openslides.users.models.User') def test_return_value(self, mock_user, mock_group, mock_permission): """ Tests that the method returns True when a user is created. """ - admin_user = MagicMock() manager = UserManager() - manager.get_or_create = MagicMock(return_value=(admin_user, True)) + manager.get = MagicMock(side_effect=ObjectDoesNotExist) manager.model = mock_user staff_group = MagicMock(name="Staff") @@ -179,7 +180,7 @@ class UserManagerCreateOrResetAdminUser(TestCase): """ admin_user = MagicMock(username='admin', last_name='Administrator') manager = UserManager() - manager.get_or_create = MagicMock(return_value=(admin_user, True)) + manager.get = MagicMock(side_effect=ObjectDoesNotExist) manager.model = mock_user staff_group = MagicMock(name="Staff") From 0c62c1c86417f5cde32c9d9c25129ce5cee3a21b Mon Sep 17 00:00:00 2001 From: Sean Engelhardt Date: Fri, 9 Nov 2018 13:44:39 +0100 Subject: [PATCH 2/2] History mode on client side Add view for full history and History Repom TimeTravelService Add function time travel routine Updated the HTTP Service, fixed usage of storage, OSStatus Service, fixed loading of the history data --- client/src/app/core/query-params.ts | 30 ++++ .../src/app/core/services/app-load.service.ts | 4 +- .../app/core/services/data-store.service.ts | 19 ++- client/src/app/core/services/http.service.ts | 86 ++++++++---- .../openslides-status.service.spec.ts | 17 +++ .../services/openslides-status.service.ts | 42 ++++++ .../src/app/core/services/storage.service.ts | 16 ++- .../core/services/time-travel.service.spec.ts | 16 +++ .../app/core/services/time-travel.service.ts | 119 ++++++++++++++++ .../app/core/services/websocket.service.ts | 32 +---- client/src/app/shared/models/core/history.ts | 39 ++++++ .../assignment-repository.service.spec.ts | 3 +- .../services/assignment-repository.service.ts | 7 +- client/src/app/site/base/base-repository.ts | 8 +- client/src/app/site/base/base-view.ts | 10 +- .../services/config-repository.service.ts | 5 + .../history-list/history-list.component.html | 58 ++++++++ .../history-list/history-list.component.scss | 26 ++++ .../history-list.component.spec.ts | 26 ++++ .../history-list/history-list.component.ts | 112 +++++++++++++++ .../site/history/history-routing.module.ts | 17 +++ client/src/app/site/history/history.config.ts | 20 +++ client/src/app/site/history/history.module.ts | 16 +++ .../app/site/history/models/view-history.ts | 132 ++++++++++++++++++ .../history-repository.service.spec.ts | 18 +++ .../services/history-repository.service.ts | 102 ++++++++++++++ .../services/mediafile-repository.service.ts | 2 +- .../services/category-repository.service.ts | 8 +- ...hange-recommendation-repository.service.ts | 6 +- .../services/local-permissions.service.ts | 6 +- .../services/motion-repository.service.ts | 12 +- .../statute-paragraph-repository.service.ts | 5 +- client/src/app/site/site-routing.module.ts | 4 + client/src/app/site/site.component.html | 4 + client/src/app/site/site.component.scss | 28 +++- client/src/app/site/site.component.ts | 11 +- .../tags/services/tag-repository.service.ts | 5 +- .../services/group-repository.service.ts | 6 +- .../users/services/user-repository.service.ts | 6 +- openslides/motions/signals.py | 4 +- 40 files changed, 986 insertions(+), 101 deletions(-) create mode 100644 client/src/app/core/query-params.ts create mode 100644 client/src/app/core/services/openslides-status.service.spec.ts create mode 100644 client/src/app/core/services/openslides-status.service.ts create mode 100644 client/src/app/core/services/time-travel.service.spec.ts create mode 100644 client/src/app/core/services/time-travel.service.ts create mode 100644 client/src/app/shared/models/core/history.ts create mode 100644 client/src/app/site/history/components/history-list/history-list.component.html create mode 100644 client/src/app/site/history/components/history-list/history-list.component.scss create mode 100644 client/src/app/site/history/components/history-list/history-list.component.spec.ts create mode 100644 client/src/app/site/history/components/history-list/history-list.component.ts create mode 100644 client/src/app/site/history/history-routing.module.ts create mode 100644 client/src/app/site/history/history.config.ts create mode 100644 client/src/app/site/history/history.module.ts create mode 100644 client/src/app/site/history/models/view-history.ts create mode 100644 client/src/app/site/history/services/history-repository.service.spec.ts create mode 100644 client/src/app/site/history/services/history-repository.service.ts diff --git a/client/src/app/core/query-params.ts b/client/src/app/core/query-params.ts new file mode 100644 index 000000000..54c50c0de --- /dev/null +++ b/client/src/app/core/query-params.ts @@ -0,0 +1,30 @@ + +type QueryParamValue = string | number | boolean; + +/** + * A key value mapping for params, that should be appended to the url on a new connection. + */ +export interface QueryParams { + [key: string]: QueryParamValue; +} + +/** + * Formats query params for the url. + * + * @param queryParams + * @returns the formatted query params as string + */ +export function formatQueryParams(queryParams: QueryParams = {}): string { + let params = ''; + const keys: string[] = Object.keys(queryParams); + if (keys.length > 0) { + params = + '?' + + keys + .map(key => { + return key + '=' + queryParams[key].toString(); + }) + .join('&'); + } + return params; +} diff --git a/client/src/app/core/services/app-load.service.ts b/client/src/app/core/services/app-load.service.ts index c229c66e5..662a6b1ba 100644 --- a/client/src/app/core/services/app-load.service.ts +++ b/client/src/app/core/services/app-load.service.ts @@ -11,6 +11,7 @@ import { AssignmentsAppConfig } from '../../site/assignments/assignments.config' import { UsersAppConfig } from '../../site/users/users.config'; import { TagAppConfig } from '../../site/tags/tag.config'; import { MainMenuService } from './main-menu.service'; +import { HistoryAppConfig } from 'app/site/history/history.config'; /** * A list of all app configurations of all delivered apps. @@ -23,7 +24,8 @@ const appConfigs: AppConfig[] = [ MotionsAppConfig, MediafileAppConfig, TagAppConfig, - UsersAppConfig + UsersAppConfig, + HistoryAppConfig ]; /** diff --git a/client/src/app/core/services/data-store.service.ts b/client/src/app/core/services/data-store.service.ts index 844c76409..bbd2537b7 100644 --- a/client/src/app/core/services/data-store.service.ts +++ b/client/src/app/core/services/data-store.service.ts @@ -330,12 +330,25 @@ export class DataStoreService { /** * Resets the DataStore and set the given models as the new content. * @param models A list of models to set the DataStore to. - * @param newMaxChangeId Optional. If given, the max change id will be updated. + * @param newMaxChangeId Optional. If given, the max change id will be updated + * and the store flushed to the storage */ - public async set(models: BaseModel[], newMaxChangeId?: number): Promise { + public async set(models?: BaseModel[], newMaxChangeId?: number): Promise { + const modelStoreReference = this.modelStore; this.modelStore = {}; this.jsonStore = {}; - await this.add(models, newMaxChangeId); + // Inform about the deletion + Object.keys(modelStoreReference).forEach(collectionString => { + Object.keys(modelStoreReference[collectionString]).forEach(id => { + this.deletedSubject.next({ + collection: collectionString, + id: +id + }); + }) + }); + if (models && models.length) { + await this.add(models, newMaxChangeId); + } } /** diff --git a/client/src/app/core/services/http.service.ts b/client/src/app/core/services/http.service.ts index 5a549858c..79abaa0f3 100644 --- a/client/src/app/core/services/http.service.ts +++ b/client/src/app/core/services/http.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { TranslateService } from '@ngx-translate/core'; +import { formatQueryParams, QueryParams } from '../query-params'; +import { OpenSlidesStatusService } from './openslides-status.service'; /** * Enum for different HTTPMethods @@ -32,34 +34,45 @@ export class HttpService { * * @param http The HTTP Client * @param translate + * @param timeTravel requests are only allowed if history mode is disabled */ - public constructor(private http: HttpClient, private translate: TranslateService) { - this.defaultHeaders = new HttpHeaders().set('Content-Type', 'application/json') + public constructor( + private http: HttpClient, + private translate: TranslateService, + private OSStatus: OpenSlidesStatusService + ) { + this.defaultHeaders = new HttpHeaders().set('Content-Type', 'application/json'); } /** - * Send the a http request the the given URL. + * Send the a http request the the given path. * Optionally accepts a request body. * - * @param url the target url, usually starting with /rest + * @param path the target path, usually starting with /rest * @param method the required HTTP method (i.e get, post, put) * @param data optional, if sending a data body is required + * @param queryParams optional queryparams to append to the path * @param customHeader optional custom HTTP header of required * @returns a promise containing a generic */ - private async send(url: string, method: HTTPMethod, data?: any, customHeader?: HttpHeaders): Promise { - if (!url.endsWith('/')) { - url += '/'; + private async send(path: string, method: HTTPMethod, data?: any, queryParams?: QueryParams, customHeader?: HttpHeaders): Promise { + // end early, if we are in history mode + if (this.OSStatus.isInHistoryMode && method !== HTTPMethod.GET) { + throw this.handleError('You cannot make changes while in history mode'); } + if (!path.endsWith('/')) { + path += '/'; + } + + const url = path + formatQueryParams(queryParams); const options = { body: data, headers: customHeader ? customHeader : this.defaultHeaders }; try { - const response = await this.http.request(method, url, options).toPromise(); - return response; + return await this.http.request(method, url, options).toPromise(); } catch (e) { throw this.handleError(e); } @@ -73,6 +86,12 @@ export class HttpService { */ private handleError(e: any): string { let error = this.translate.instant('Error') + ': '; + + // If the rror is a string already, return it. + if (typeof e === 'string') { + return error + e; + } + // If the error is no HttpErrorResponse, it's not clear what is wrong. if (!(e instanceof HttpErrorResponse)) { console.error('Unknown error thrown by the http client: ', e); @@ -119,57 +138,62 @@ export class HttpService { } /** - * Exectures a get on a url with a certain object - * @param url The url to send the request to. + * Executes a get on a path with a certain object + * @param path The path to send the request to. * @param data An optional payload for the request. + * @param queryParams Optional params appended to the path as the query part of the url. * @param header optional HTTP header if required * @returns A promise holding a generic */ - public async get(url: string, data?: any, header?: HttpHeaders): Promise { - return await this.send(url, HTTPMethod.GET, data, header); + public async get(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise { + return await this.send(path, HTTPMethod.GET, data, queryParams, header); } /** - * Exectures a post on a url with a certain object - * @param url The url to send the request to. + * Executes a post on a path with a certain object + * @param path The path to send the request to. * @param data An optional payload for the request. + * @param queryParams Optional params appended to the path as the query part of the url. * @param header optional HTTP header if required * @returns A promise holding a generic */ - public async post(url: string, data?: any, header?: HttpHeaders): Promise { - return await this.send(url, HTTPMethod.POST, data, header); + public async post(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise { + return await this.send(path, HTTPMethod.POST, data, queryParams, header); } /** - * Exectures a put on a url with a certain object - * @param url The url to send the request to. - * @param data The payload for the request. + * Executes a put on a path with a certain object + * @param path The path to send the request to. + * @param data An optional payload for the request. + * @param queryParams Optional params appended to the path as the query part of the url. * @param header optional HTTP header if required * @returns A promise holding a generic */ - public async patch(url: string, data: any, header?: HttpHeaders): Promise { - return await this.send(url, HTTPMethod.PATCH, data, header); + public async patch(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise { + return await this.send(path, HTTPMethod.PATCH, data, queryParams, header); } /** - * Exectures a put on a url with a certain object - * @param url The url to send the request to. - * @param data: The payload for the request. + * Executes a put on a path with a certain object + * @param path The path to send the request to. + * @param data An optional payload for the request. + * @param queryParams Optional params appended to the path as the query part of the url. * @param header optional HTTP header if required * @returns A promise holding a generic */ - public async put(url: string, data: any, header?: HttpHeaders): Promise { - return await this.send(url, HTTPMethod.PUT, data, header); + public async put(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise { + return await this.send(path, HTTPMethod.PUT, data, queryParams, header); } /** * Makes a delete request. - * @param url The url to send the request to. - * @param data An optional data to send in the requestbody. + * @param url The path to send the request to. + * @param data An optional payload for the request. + * @param queryParams Optional params appended to the path as the query part of the url. * @param header optional HTTP header if required * @returns A promise holding a generic */ - public async delete(url: string, data?: any, header?: HttpHeaders): Promise { - return await this.send(url, HTTPMethod.DELETE, data, header); + public async delete(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise { + return await this.send(path, HTTPMethod.DELETE, data, queryParams, header); } } diff --git a/client/src/app/core/services/openslides-status.service.spec.ts b/client/src/app/core/services/openslides-status.service.spec.ts new file mode 100644 index 000000000..aae2d5e9b --- /dev/null +++ b/client/src/app/core/services/openslides-status.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; +import { OpenSlidesStatusService } from './openslides-status.service'; + +describe('OpenSlidesStatusService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [OpenSlidesStatusService] + }); + }); + + it('should be created', inject([OpenSlidesStatusService], (service: OpenSlidesStatusService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/core/services/openslides-status.service.ts b/client/src/app/core/services/openslides-status.service.ts new file mode 100644 index 000000000..28e1db6cb --- /dev/null +++ b/client/src/app/core/services/openslides-status.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; + +/** + * Holds information about OpenSlides. This is not included into other services to + * avoid circular dependencies. + */ +@Injectable({ + providedIn: 'root' +}) +export class OpenSlidesStatusService { + + /** + * Saves, if OpenSlides is in the history mode. + */ + private historyMode = false; + + /** + * Returns, if OpenSlides is in the history mode. + */ + public get isInHistoryMode(): boolean { + return this.historyMode; + } + + /** + * Ctor, does nothing. + */ + public constructor() {} + + /** + * Enters the histroy mode + */ + public enterHistoryMode(): void { + this.historyMode = true; + } + + /** + * Leaves the histroy mode + */ + public leaveHistroyMode(): void { + this.historyMode = false; + } +} diff --git a/client/src/app/core/services/storage.service.ts b/client/src/app/core/services/storage.service.ts index fc56b1d2d..40fd599bc 100644 --- a/client/src/app/core/services/storage.service.ts +++ b/client/src/app/core/services/storage.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { LocalStorage } from '@ngx-pwa/local-storage'; +import { OpenSlidesStatusService } from './openslides-status.service'; /** * Provides an async API to an key-value store using ngx-pwa which is internally @@ -13,7 +14,7 @@ export class StorageService { * Constructor to create the StorageService. Needs the localStorage service. * @param localStorage */ - public constructor(private localStorage: LocalStorage) {} + public constructor(private localStorage: LocalStorage, private OSStatus: OpenSlidesStatusService) {} /** * Sets the item into the store asynchronously. @@ -21,6 +22,7 @@ export class StorageService { * @param item */ public async set(key: string, item: any): Promise { + this.assertNotHistroyMode(); if (item === null || item === undefined) { await this.remove(key); // You cannot do a setItem with null or undefined... } else { @@ -48,6 +50,7 @@ export class StorageService { * @param key The key to remove the value from */ public async remove(key: string): Promise { + this.assertNotHistroyMode(); if (!(await this.localStorage.removeItem(key).toPromise())) { throw new Error('Could not delete the item.'); } @@ -57,9 +60,18 @@ export class StorageService { * Clear the whole cache */ public async clear(): Promise { - console.log('clear storage'); + this.assertNotHistroyMode(); if (!(await this.localStorage.clear().toPromise())) { throw new Error('Could not clear the storage.'); } } + + /** + * Throws an error, if we are in history mode. + */ + private assertNotHistroyMode(): void { + if (this.OSStatus.isInHistoryMode) { + throw new Error('You cannot use the storageService in histroy mode.'); + } + } } diff --git a/client/src/app/core/services/time-travel.service.spec.ts b/client/src/app/core/services/time-travel.service.spec.ts new file mode 100644 index 000000000..60b32fb50 --- /dev/null +++ b/client/src/app/core/services/time-travel.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { TimeTravelService } from './time-travel.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('TimeTravelService', () => { + beforeEach(() => TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [TimeTravelService] + })); + + it('should be created', () => { + const service: TimeTravelService = TestBed.get(TimeTravelService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/services/time-travel.service.ts b/client/src/app/core/services/time-travel.service.ts new file mode 100644 index 000000000..dbc37d6e9 --- /dev/null +++ b/client/src/app/core/services/time-travel.service.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@angular/core'; + +import { environment } from 'environments/environment'; +import { CollectionStringModelMapperService } from './collectionStringModelMapper.service'; +import { History } from 'app/shared/models/core/history'; +import { DataStoreService } from './data-store.service'; +import { WebsocketService } from './websocket.service'; +import { BaseModel } from 'app/shared/models/base/base-model'; +import { OpenSlidesStatusService } from './openslides-status.service'; +import { OpenSlidesService } from './openslides.service'; +import { HttpService } from './http.service'; + +/** + * Interface for full history data objects. + * The are not too different from the history-objects, + * but contain full-data and a timestamp in contrast to a date + */ +interface HistoryData { + element_id: string; + full_data: BaseModel; + information: string; + timestamp: number; + user_id: number; +} + +/** + * Service to enable browsing OpenSlides in a previous version. + * + * This should stop auto updates, save the current ChangeID and overwrite the DataStore with old Values + * from the servers History. + * + * Restoring is nor possible yet. Simply reload + */ +@Injectable({ + providedIn: 'root' +}) +export class TimeTravelService { + /** + * Constructs the time travel service + * + * @param httpService To fetch the history data + * @param webSocketService to disable websocket connection + * @param modelMapperService to cast history objects into models + * @param DS to overwrite the dataStore + * @param OSStatus Sets the history status + * @param OpenSlides For restarting OpenSlide when exiting the history mode + */ + public constructor( + private httpService: HttpService, + private webSocketService: WebsocketService, + private modelMapperService: CollectionStringModelMapperService, + private DS: DataStoreService, + private OSStatus: OpenSlidesStatusService, + private OpenSlides: OpenSlidesService + ) { } + + /** + * Main entry point to set OpenSlides to another history point. + * + * @param history the desired point in the history of OpenSlides + */ + public async loadHistoryPoint(history: History): Promise { + await this.stopTime(); + const fullDataHistory: HistoryData[] = await this.getHistoryData(history); + for (const historyObject of fullDataHistory) { + let collectionString: string; + let id: string; + [collectionString, id] = historyObject.element_id.split(':') + + if (historyObject.full_data) { + const targetClass = this.modelMapperService.getModelConstructor(collectionString); + await this.DS.add([new targetClass(historyObject.full_data)]) + } else { + await this.DS.remove(collectionString, [+id]); + } + } + } + + /** + * Leaves the history mode. Just restart OpenSlides: + * The active user is chacked, a new WS connection established and + * all missed autoupdates are requested. + */ + public async resumeTime(): Promise { + await this.DS.set(); + await this.OpenSlides.reboot(); + this.OSStatus.leaveHistroyMode(); + } + + /** + * Read the history on a given time + * + * @param date the Date object + * @returns the full history on the given date + */ + private async getHistoryData(history: History): Promise { + const historyUrl = '/core/history/' + const queryParams = { timestamp: Math.ceil(+history.unixtime) }; + return this.httpService.get(environment.urlPrefix + historyUrl, null, queryParams); + } + + /** + * Clears the DataStore and stops the WebSocket connection + */ + private async stopTime(): Promise { + this.webSocketService.close(); + await this.cleanDataStore(); + this.OSStatus.enterHistoryMode(); + } + + /** + * Clean the DataStore to inject old Data. + * Remove everything "but" the history. + */ + private async cleanDataStore(): Promise { + const historyArchive = this.DS.getAll(History); + await this.DS.set(historyArchive); + } +} diff --git a/client/src/app/core/services/websocket.service.ts b/client/src/app/core/services/websocket.service.ts index 624830e81..dcf15f742 100644 --- a/client/src/app/core/services/websocket.service.ts +++ b/client/src/app/core/services/websocket.service.ts @@ -2,15 +2,7 @@ import { Injectable, NgZone, EventEmitter } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; import { TranslateService } from '@ngx-translate/core'; - -type QueryParamValue = string | number | boolean; - -/** - * A key value mapping for params, that should be appendet to the url on a new connection. - */ -interface QueryParams { - [key: string]: QueryParamValue; -} +import { formatQueryParams, QueryParams } from '../query-params'; /** * The generic message format in which messages are send and recieved by the server. @@ -116,7 +108,7 @@ export class WebsocketService { // Create the websocket let socketPath = location.protocol === 'https:' ? 'wss://' : 'ws://'; socketPath += window.location.hostname + ':' + window.location.port + '/ws/'; - socketPath += this.formatQueryParams(queryParams); + socketPath += formatQueryParams(queryParams); console.log('connect to', socketPath); this.websocket = new WebSocket(socketPath); @@ -225,24 +217,4 @@ export class WebsocketService { } this.websocket.send(JSON.stringify(message)); } - - /** - * Formats query params for the url. - * @param queryParams - * @returns the formatted query params as string - */ - private formatQueryParams(queryParams: QueryParams = {}): string { - let params = ''; - const keys: string[] = Object.keys(queryParams); - if (keys.length > 0) { - params = - '?' + - keys - .map(key => { - return key + '=' + queryParams[key].toString(); - }) - .join('&'); - } - return params; - } } diff --git a/client/src/app/shared/models/core/history.ts b/client/src/app/shared/models/core/history.ts new file mode 100644 index 000000000..fc9e49018 --- /dev/null +++ b/client/src/app/shared/models/core/history.ts @@ -0,0 +1,39 @@ + +import { BaseModel } from '../base/base-model'; + +/** + * Representation of a history object. + * + * @ignore + */ +export class History extends BaseModel { + public id: number; + public element_id: string; + public now: string; + public information: string; + public user_id: number; + + /** + * return a date our of the given timestamp + * + * @returns a Data object + */ + public get date(): Date { + return new Date(this.now); + } + + /** + * Converts the timestamp to unix time + */ + public get unixtime(): number { + return Date.parse(this.now) / 1000; + } + + public constructor(input?: any) { + super('core/history', input); + } + + public getTitle(): string { + return this.element_id; + } +} diff --git a/client/src/app/site/assignments/services/assignment-repository.service.spec.ts b/client/src/app/site/assignments/services/assignment-repository.service.spec.ts index c694cfae7..97f1dcb96 100644 --- a/client/src/app/site/assignments/services/assignment-repository.service.spec.ts +++ b/client/src/app/site/assignments/services/assignment-repository.service.spec.ts @@ -1,9 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { AssignmentRepositoryService } from './assignment-repository.service'; +import { E2EImportsModule } from 'e2e-imports.module'; describe('AssignmentRepositoryService', () => { - beforeEach(() => TestBed.configureTestingModule({})); + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); it('should be created', () => { const service: AssignmentRepositoryService = TestBed.get(AssignmentRepositoryService); diff --git a/client/src/app/site/assignments/services/assignment-repository.service.ts b/client/src/app/site/assignments/services/assignment-repository.service.ts index 4d00e5431..c922b410f 100644 --- a/client/src/app/site/assignments/services/assignment-repository.service.ts +++ b/client/src/app/site/assignments/services/assignment-repository.service.ts @@ -21,8 +21,13 @@ export class AssignmentRepositoryService extends BaseRepository = new BehaviorSubject([]); /** + * Construction routine for the base repository * + * @param DS: The DataStore + * @param collectionStringModelMapperService Mapping strings to their corresponding classes * @param baseModelCtor The model constructor of which this repository is about. * @param depsModelCtors A list of constructors that are used in the view model. * If one of those changes, the view models will be updated. @@ -33,7 +37,7 @@ export abstract class BaseRepository, - protected depsModelCtors?: ModelConstructor[] + protected depsModelCtors?: ModelConstructor[], ) { super(); this.setup(); diff --git a/client/src/app/site/base/base-view.ts b/client/src/app/site/base/base-view.ts index 526ee53ee..7c17eb5f3 100644 --- a/client/src/app/site/base/base-view.ts +++ b/client/src/app/site/base/base-view.ts @@ -1,8 +1,10 @@ -import { BaseComponent } from '../../base.component'; -import { Title } from '@angular/platform-browser'; -import { TranslateService } from '@ngx-translate/core'; -import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; import { OnDestroy } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; + +import { TranslateService } from '@ngx-translate/core'; + +import { BaseComponent } from '../../base.component'; /** * A base class for all views. Implements a generic error handling by raising a snack bar diff --git a/client/src/app/site/config/services/config-repository.service.ts b/client/src/app/site/config/services/config-repository.service.ts index c7acb7a58..fd0461f6c 100644 --- a/client/src/app/site/config/services/config-repository.service.ts +++ b/client/src/app/site/config/services/config-repository.service.ts @@ -86,6 +86,11 @@ export class ConfigRepositoryService extends BaseRepository /** * Constructor for ConfigRepositoryService. Requests the constants from the server and creates the config group structure. + * + * @param DS The DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects + * @param http OpenSlides own HTTP Service */ public constructor( DS: DataStoreService, diff --git a/client/src/app/site/history/components/history-list/history-list.component.html b/client/src/app/site/history/components/history-list/history-list.component.html new file mode 100644 index 000000000..9d4ccc2b4 --- /dev/null +++ b/client/src/app/site/history/components/history-list/history-list.component.html @@ -0,0 +1,58 @@ + + +
History
+ + + +
+ + + + + Time + {{ history.getLocaleString('DE-de') }} + + + + + Info + {{ history.information }} + + + + + Element + + + +
{{ getElementInfo(history) }}
+
+ {{ 'No information available' | translate }} ({{ history.element_id }}) +
+
+
+ + + + User + {{ history.user }} + + + + +
+ + + + + + diff --git a/client/src/app/site/history/components/history-list/history-list.component.scss b/client/src/app/site/history/components/history-list/history-list.component.scss new file mode 100644 index 000000000..3e36f60e7 --- /dev/null +++ b/client/src/app/site/history/components/history-list/history-list.component.scss @@ -0,0 +1,26 @@ +.os-listview-table { + /** Time */ + .mat-column-time { + flex: 1 0 50px; + } + + /** Element */ + .mat-column-element { + flex: 3 0 50px; + } + + /** Info */ + .mat-column-info { + flex: 1 0 50px; + } + + /** User */ + .mat-column-user { + flex: 1 0 50px; + } +} + +.no-info { + font-style: italic; + color: slategray; // TODO: Colors per theme +} diff --git a/client/src/app/site/history/components/history-list/history-list.component.spec.ts b/client/src/app/site/history/components/history-list/history-list.component.spec.ts new file mode 100644 index 000000000..edd96bac4 --- /dev/null +++ b/client/src/app/site/history/components/history-list/history-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; +import { HistoryListComponent } from './history-list.component'; + +describe('HistoryListComponent', () => { + let component: HistoryListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [HistoryListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HistoryListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/history/components/history-list/history-list.component.ts b/client/src/app/site/history/components/history-list/history-list.component.ts new file mode 100644 index 000000000..5ecd21bba --- /dev/null +++ b/client/src/app/site/history/components/history-list/history-list.component.ts @@ -0,0 +1,112 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { MatSnackBar } from '@angular/material'; +import { Subject } from 'rxjs'; + +import { TranslateService } from '@ngx-translate/core'; + +import { ListViewBaseComponent } from 'app/site/base/list-view-base'; +import { HistoryRepositoryService } from '../../services/history-repository.service'; +import { ViewHistory } from '../../models/view-history'; + +/** + * A list view for the history. + * + * Should display all changes that have been made in OpenSlides. + */ +@Component({ + selector: 'os-history-list', + templateUrl: './history-list.component.html', + styleUrls: ['./history-list.component.scss'] +}) +export class HistoryListComponent extends ListViewBaseComponent implements OnInit { + /** + * Subject determine when the custom timestamp subject changes + */ + public customTimestampChanged: Subject = new Subject(); + + /** + * Constructor for the history list component + * + * @param titleService Setting the title + * @param translate Handle translations + * @param matSnackBar Showing errors and messages + * @param repo The history repository + */ + public constructor( + titleService: Title, + translate: TranslateService, + matSnackBar: MatSnackBar, + private repo: HistoryRepositoryService + ) { + super(titleService, translate, matSnackBar); + } + + /** + * Init function for the history list. + */ + public ngOnInit(): void { + super.setTitle('History'); + this.initTable(); + + this.repo.getViewModelListObservable().subscribe(history => { + this.sortAndPublish(history); + }); + } + + /** + * Sorts the given ViewHistory array and sets it in the table data source + * + * @param unsortedHistoryList + */ + private sortAndPublish(unsortedHistoryList: ViewHistory[]): void { + const sortedList = unsortedHistoryList.map(history => history); + sortedList.sort((a, b) => b.history.unixtime - a.history.unixtime); + this.dataSource.data = sortedList; + } + + /** + * Returns the row definition for the table + * + * @returns an array of strings that contains the required row definition + */ + public getRowDef(): string[] { + return ['time', 'element', 'info', 'user']; + } + + /** + * Tries get the title of the BaseModel element corresponding to + * a history object. + * + * @param history the history + * @returns the title of an old element or null if it could not be found + */ + public getElementInfo(history: ViewHistory): string { + const oldElementTitle = this.repo.getOldModelInfo(history.getCollectionString(), history.getModelID()); + + if (oldElementTitle) { + return oldElementTitle; + } else { + return null; + } + } + + /** + * Click handler for rows in the history table. + * Serves as an entry point for the time travel routine + * + * @param history Represents the selected element + */ + public onClickRow(history: ViewHistory): void { + this.repo.browseHistory(history).then(() => { + this.raiseError(`Temporarily reset OpenSlides to the state from ${history.getLocaleString('DE-de')}`); + }); + } + + /** + * Handler for the delete all button + */ + public onDeleteAllButton(): void { + this.repo.delete(); + } +} diff --git a/client/src/app/site/history/history-routing.module.ts b/client/src/app/site/history/history-routing.module.ts new file mode 100644 index 000000000..558dc44d0 --- /dev/null +++ b/client/src/app/site/history/history-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { HistoryListComponent } from './components/history-list/history-list.component'; + +/** + * Define the routes for the history module + */ +const routes: Routes = [{ path: '', component: HistoryListComponent }]; + +/** + * Define the routing component and setup the routes + */ +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class HistoryRoutingModule {} diff --git a/client/src/app/site/history/history.config.ts b/client/src/app/site/history/history.config.ts new file mode 100644 index 000000000..657b2afa4 --- /dev/null +++ b/client/src/app/site/history/history.config.ts @@ -0,0 +1,20 @@ +import { AppConfig } from '../base/app-config'; +import { History } from 'app/shared/models/core/history'; + +/** + * Config object for history. + * Hooks into the navigation. + */ +export const HistoryAppConfig: AppConfig = { + name: 'history', + models: [{ collectionString: 'core/history', model: History }], + mainMenuEntries: [ + { + route: '/history', + displayName: 'History', + icon: 'history', + weight: 1200, + permission: 'core.view_history' + } + ] +}; diff --git a/client/src/app/site/history/history.module.ts b/client/src/app/site/history/history.module.ts new file mode 100644 index 000000000..a8a2084a9 --- /dev/null +++ b/client/src/app/site/history/history.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { HistoryRoutingModule } from './history-routing.module'; +import { SharedModule } from '../../shared/shared.module'; +import { HistoryListComponent } from './components/history-list/history-list.component'; + +/** + * App module for the history feature. + * Declares the used components. + */ +@NgModule({ + imports: [CommonModule, HistoryRoutingModule, SharedModule], + declarations: [HistoryListComponent] +}) +export class HistoryModule {} diff --git a/client/src/app/site/history/models/view-history.ts b/client/src/app/site/history/models/view-history.ts new file mode 100644 index 000000000..1018497fe --- /dev/null +++ b/client/src/app/site/history/models/view-history.ts @@ -0,0 +1,132 @@ +import { BaseViewModel } from 'app/site/base/base-view-model'; +import { History } from 'app/shared/models/core/history'; +import { User } from 'app/shared/models/users/user'; +import { BaseModel } from 'app/shared/models/base/base-model'; + +/** + * View model for history objects + */ +export class ViewHistory extends BaseViewModel { + /** + * Private BaseModel of the history + */ + private _history: History; + + /** + * Real representation of the user who altered the history. + * Determined from `History.user_id` + */ + private _user: User; + + /** + * Read the history property + */ + public get history(): History { + return this._history ? this._history : null; + } + + /** + * Read the user property + */ + public get user(): User { + return this._user ? this._user : null; + } + + /** + * Get the ID of the history object + * Required by BaseViewModel + * + * @returns the ID as number + */ + public get id(): number { + return this.history ? this.history.id : null; + } + + /** + * Get the elementIs of the history object + * + * @returns the element ID as String + */ + public get element_id(): string { + return this.history ? this.history.element_id : null; + } + + /** + * Get the information about the history + * + * @returns a string with the information to the history object + */ + public get information(): string { + return this.history ? this.history.information : null; + } + + /** + * Get the time of the history as number + * + * @returns the unix timestamp as number + */ + public get now(): string { + return this.history ? this.history.now : null; + } + + /** + * Construction of a ViewHistory + * + * @param history the real history BaseModel + * @param user the real user BaseModel + */ + public constructor(history?: History, user?: User) { + super(); + this._history = history; + this._user = user; + } + + /** + * Converts the date (this.now) to a time and date string. + * + * @param locale locale indicator, i.e 'de-DE' + * @returns a human readable kind of time and date representation + */ + public getLocaleString(locale: string): string { + return this.history.date ? this.history.date.toLocaleString(locale) : null; + } + + /** + * Converts elementID into collection string + * @returns the CollectionString to the model + */ + public getCollectionString(): string { + return this.element_id.split(":")[0] + } + + /** + * Extract the models ID from the elementID + * @returns a model id + */ + public getModelID(): number { + return +this.element_id.split(":")[1] + } + + /** + * Get the history objects title + * Required by BaseViewModel + * + * @returns history.getTitle which returns the element_id + */ + public getTitle(): string { + return this.history.getTitle(); + } + + /** + * Updates the history object with new values + * + * @param update potentially the new values for history or it's components. + */ + public updateValues(update: BaseModel): void { + if (update instanceof History && this.history.id === update.id) { + this._history = update; + } else if (this.history && update instanceof User && this.history.user_id === update.id) { + this._user = update; + } + } +} diff --git a/client/src/app/site/history/services/history-repository.service.spec.ts b/client/src/app/site/history/services/history-repository.service.spec.ts new file mode 100644 index 000000000..fc8a86317 --- /dev/null +++ b/client/src/app/site/history/services/history-repository.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { HistoryRepositoryService } from './history-repository.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('HistoryRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [HistoryRepositoryService] + }); + }); + + it('should be created', () => { + const service = TestBed.get(HistoryRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/history/services/history-repository.service.ts b/client/src/app/site/history/services/history-repository.service.ts new file mode 100644 index 000000000..36796e7ce --- /dev/null +++ b/client/src/app/site/history/services/history-repository.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@angular/core'; + +import { CollectionStringModelMapperService } from 'app/core/services/collectionStringModelMapper.service'; +import { DataStoreService } from 'app/core/services/data-store.service'; +import { BaseRepository } from 'app/site/base/base-repository'; +import { History } from 'app/shared/models/core/history'; +import { User } from 'app/shared/models/users/user'; +import { Identifiable } from 'app/shared/models/base/identifiable'; +import { HttpService } from 'app/core/services/http.service'; +import { ViewHistory } from '../models/view-history'; +import { TimeTravelService } from 'app/core/services/time-travel.service'; +import { BaseModel } from 'app/shared/models/base/base-model'; + +/** + * Repository for the history. + * + * Gets new history objects/entries and provides them for the view. + */ +@Injectable({ + providedIn: 'root' +}) +export class HistoryRepositoryService extends BaseRepository { + /** + * Constructs the history repository + * + * @param DS The DataStore + * @param mapperService mapps the models to the collection string + * @param httpService OpenSlides own HTTP service + * @param timeTravel To change the time + */ + public constructor( + DS: DataStoreService, + mapperService: CollectionStringModelMapperService, + private httpService: HttpService, + private timeTravel: TimeTravelService + ) { + super(DS, mapperService, History, [User]); + } + + /** + * Clients usually do not need to create a history object themselves + * @ignore + */ + public async create(): Promise { + throw new Error('You cannot create a history object'); + } + + /** + * Clients usually do not need to modify existing history objects + * @ignore + */ + public async update(): Promise { + throw new Error('You cannot update a history object'); + } + + /** + * Sends a post-request to delete history objects + */ + public async delete(): Promise { + const restPath = 'rest/core/history/clear_history/'; + await this.httpService.post(restPath); + } + + /** + * Get the ListTitle of a history Element from the dataStore + * using the collection string and the ID. + * + * @param collectionString the models collection string + * @param id the models id + * @returns the ListTitle or null if the model was deleted already + */ + public getOldModelInfo(collectionString: string, id: number): string { + const oldModel: BaseModel = this.DS.get(collectionString, id); + if (oldModel) { + return oldModel.getListTitle(); + } else { + return null; + } + } + + /** + * Creates a new ViewHistory objects out of a historyObject + * + * @param history the source history object + * @return a new ViewHistory object + */ + public createViewModel(history: History): ViewHistory { + const user = this.DS.get(User, history.user_id); + return new ViewHistory(history, user); + } + + /** + * Get the full data on the given date and use the + * TimeTravelService to browse the history on the + * given date + * + * @param viewHistory determines to point to travel back to + */ + public async browseHistory(viewHistory: ViewHistory): Promise { + return this.timeTravel.loadHistoryPoint(viewHistory.history); + } +} diff --git a/client/src/app/site/mediafiles/services/mediafile-repository.service.ts b/client/src/app/site/mediafiles/services/mediafile-repository.service.ts index a8c0b56e7..0b6e63d91 100644 --- a/client/src/app/site/mediafiles/services/mediafile-repository.service.ts +++ b/client/src/app/site/mediafiles/services/mediafile-repository.service.ts @@ -78,7 +78,7 @@ export class MediafileRepositoryService extends BaseRepository { const restPath = `rest/mediafiles/mediafile/`; const emptyHeader = new HttpHeaders(); - return this.httpService.post(restPath, file, emptyHeader); + return this.httpService.post(restPath, file, {}, emptyHeader); } /** diff --git a/client/src/app/site/motions/services/category-repository.service.ts b/client/src/app/site/motions/services/category-repository.service.ts index ec966d5ea..976d0650b 100644 --- a/client/src/app/site/motions/services/category-repository.service.ts +++ b/client/src/app/site/motions/services/category-repository.service.ts @@ -27,9 +27,11 @@ export class CategoryRepositoryService extends BaseRepository { - this.configMinSupporters = supporters; - } - ); + this.configService.get('motions_min_supporters').subscribe(supporters => (this.configMinSupporters = supporters)); } /** diff --git a/client/src/app/site/motions/services/motion-repository.service.ts b/client/src/app/site/motions/services/motion-repository.service.ts index 15bd26d1a..721763431 100644 --- a/client/src/app/site/motions/services/motion-repository.service.ts +++ b/client/src/app/site/motions/services/motion-repository.service.ts @@ -40,15 +40,19 @@ import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragr providedIn: 'root' }) export class MotionRepositoryService extends BaseRepository { + /** * Creates a MotionRepository * * Converts existing and incoming motions to ViewMotions * Handles CRUD using an observer to the DataStore - * @param {DataStoreService} DS - * @param {DataSendService} dataSend - * @param {LinenumberingService} lineNumbering - * @param {DiffService} diff + * + * @param DS The DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects + * @param httpService OpenSlides own Http service + * @param lineNumbering Line numbering for motion text + * @param diff Display changes in motion text as diff. */ public constructor( DS: DataStoreService, diff --git a/client/src/app/site/motions/services/statute-paragraph-repository.service.ts b/client/src/app/site/motions/services/statute-paragraph-repository.service.ts index c3625c79f..b010f2442 100644 --- a/client/src/app/site/motions/services/statute-paragraph-repository.service.ts +++ b/client/src/app/site/motions/services/statute-paragraph-repository.service.ts @@ -22,7 +22,10 @@ export class StatuteParagraphRepositoryService extends BaseRepository + You are using the history mode of OpenSlides. Changes will not be saved. + Exit + diff --git a/client/src/app/site/site.component.scss b/client/src/app/site/site.component.scss index 248309967..fe739fe2e 100644 --- a/client/src/app/site/site.component.scss +++ b/client/src/app/site/site.component.scss @@ -15,8 +15,7 @@ } .os-logo-container:focus, .os-logo-container:active, -.os-logo-container:hover, - { +.os-logo-container:hover { border: none; outline: none; } @@ -42,6 +41,31 @@ mat-sidenav-container { padding-bottom: 70px; } +.history-mode-indicator { + position: fixed; + width: 100%; + z-index: 10; + background: repeating-linear-gradient(45deg, #ffee00, #ffee00 10px, #070600 10px, #000000 20px); + text-align: center; + line-height: 20px; + height: 20px; + + span { + padding: 2px; + color: #000000; + background: #ffee00; + } + + a { + padding: 2px; + cursor: pointer; + font-weight: bold; + text-decoration: none; + background: #ffee00; + color: #000000; + } +} + main { display: flex; flex-direction: column; diff --git a/client/src/app/site/site.component.ts b/client/src/app/site/site.component.ts index 81b7e1102..d6f5d584b 100644 --- a/client/src/app/site/site.component.ts +++ b/client/src/app/site/site.component.ts @@ -10,6 +10,8 @@ import { pageTransition, navItemAnim } from 'app/shared/animations'; import { MatDialog, MatSidenav } from '@angular/material'; import { ViewportService } from '../core/services/viewport.service'; import { MainMenuService } from '../core/services/main-menu.service'; +import { OpenSlidesStatusService } from 'app/core/services/openslides-status.service'; +import { TimeTravelService } from 'app/core/services/time-travel.service'; @Component({ selector: 'os-site', @@ -48,11 +50,14 @@ export class SiteComponent extends BaseComponent implements OnInit { * Constructor * * @param authService - * @param router + * @param route * @param operator * @param vp * @param translate * @param dialog + * @param mainMenuService + * @param OSStatus + * @param timeTravel */ public constructor( private authService: AuthService, @@ -61,7 +66,9 @@ export class SiteComponent extends BaseComponent implements OnInit { public vp: ViewportService, public translate: TranslateService, public dialog: MatDialog, - public mainMenuService: MainMenuService // used in the component + public mainMenuService: MainMenuService, + public OSStatus: OpenSlidesStatusService, + public timeTravel: TimeTravelService ) { super(); diff --git a/client/src/app/site/tags/services/tag-repository.service.ts b/client/src/app/site/tags/services/tag-repository.service.ts index 902574335..2b1dbc635 100644 --- a/client/src/app/site/tags/services/tag-repository.service.ts +++ b/client/src/app/site/tags/services/tag-repository.service.ts @@ -25,7 +25,10 @@ export class TagRepositoryService extends BaseRepository { * Creates a TagRepository * Converts existing and incoming Tags to ViewTags * Handles CRUD using an observer to the DataStore - * @param DataSend + * + * @param DS DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects */ public constructor( protected DS: DataStoreService, diff --git a/client/src/app/site/users/services/group-repository.service.ts b/client/src/app/site/users/services/group-repository.service.ts index 9ea9b17a8..7a591005c 100644 --- a/client/src/app/site/users/services/group-repository.service.ts +++ b/client/src/app/site/users/services/group-repository.service.ts @@ -33,8 +33,10 @@ export class GroupRepositoryService extends BaseRepository { /** * Constructor calls the parent constructor - * @param DS Store - * @param dataSend Sending Data + * @param DS The DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects + * @param constants reading out the OpenSlides constants */ public constructor( DS: DataStoreService, diff --git a/client/src/app/site/users/services/user-repository.service.ts b/client/src/app/site/users/services/user-repository.service.ts index 33acc7ec6..bed081c51 100644 --- a/client/src/app/site/users/services/user-repository.service.ts +++ b/client/src/app/site/users/services/user-repository.service.ts @@ -19,7 +19,11 @@ import { CollectionStringModelMapperService } from '../../../core/services/colle }) export class UserRepositoryService extends BaseRepository { /** - * Constructor calls the parent constructor + * Constructor for the user repo + * + * @param DS The DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects */ public constructor( DS: DataStoreService, diff --git a/openslides/motions/signals.py b/openslides/motions/signals.py index cb4911519..ffaefacd1 100644 --- a/openslides/motions/signals.py +++ b/openslides/motions/signals.py @@ -26,7 +26,7 @@ def create_builtin_workflows(sender, **kwargs): workflow=workflow_1, action_word='Accept', recommendation_label='Acceptance', - css_class='success'), + css_class='success', merge_amendment_into_final=True) state_1_2.save(skip_autoupdate=True) state_1_3 = State(name=ugettext_noop('rejected'), @@ -64,7 +64,7 @@ def create_builtin_workflows(sender, **kwargs): workflow=workflow_2, action_word='Accept', recommendation_label='Acceptance', - css_class='success'), + css_class='success', merge_amendment_into_final=True) state_2_3.save(skip_autoupdate=True) state_2_4 = State(name=ugettext_noop('rejected'),