From a0f554674b67efc7f520861dc5b2634acc97c73c Mon Sep 17 00:00:00 2001 From: Oskar Hahn Date: Sun, 23 Dec 2018 11:05:38 +0100 Subject: [PATCH] New projector system. Add first slides --- openslides/agenda/apps.py | 7 +- openslides/agenda/models.py | 14 +-- openslides/agenda/projector.py | 102 ++++++++++++------- openslides/assignments/apps.py | 7 +- openslides/assignments/models.py | 29 +----- openslides/assignments/projector.py | 54 +++------- openslides/core/apps.py | 17 ++-- openslides/core/exceptions.py | 4 - openslides/core/models.py | 87 +--------------- openslides/core/projector.py | 74 ++++++++------ openslides/core/serializers.py | 3 +- openslides/core/websocket.py | 49 +++++++++ openslides/mediafiles/apps.py | 7 +- openslides/mediafiles/models.py | 13 --- openslides/mediafiles/projector.py | 29 +++--- openslides/motions/apps.py | 7 +- openslides/motions/models.py | 26 +---- openslides/motions/projector.py | 66 ++++-------- openslides/topics/apps.py | 7 +- openslides/topics/models.py | 13 --- openslides/topics/projector.py | 40 +++----- openslides/users/apps.py | 7 +- openslides/users/models.py | 13 --- openslides/users/projector.py | 29 +++--- openslides/utils/autoupdate.py | 8 ++ openslides/utils/cache.py | 18 +++- openslides/utils/consumers.py | 22 ++++ openslides/utils/projector.py | 119 +++++++++++----------- tests/integration/core/test_views.py | 20 +--- tests/integration/helpers.py | 43 +++++++- tests/integration/utils/test_consumers.py | 69 ++++++++++++- tests/unit/agenda/test_projector.py | 117 +++++++++++++++++++++ 32 files changed, 599 insertions(+), 521 deletions(-) create mode 100644 tests/unit/agenda/test_projector.py diff --git a/openslides/agenda/apps.py b/openslides/agenda/apps.py index f8a3c2122..fb8ba7772 100644 --- a/openslides/agenda/apps.py +++ b/openslides/agenda/apps.py @@ -2,21 +2,18 @@ from typing import Any, Dict, Set from django.apps import AppConfig -from ..utils.projector import register_projector_elements - class AgendaAppConfig(AppConfig): name = "openslides.agenda" verbose_name = "OpenSlides Agenda" angular_site_module = True - angular_projector_module = True def ready(self): # Import all required stuff. from django.db.models.signals import pre_delete, post_save from ..core.signals import permission_change from ..utils.rest_api import router - from .projector import get_projector_elements + from .projector import register_projector_elements from .signals import ( get_permission_change_data, listen_to_related_object_post_delete, @@ -27,7 +24,7 @@ class AgendaAppConfig(AppConfig): from ..utils.access_permissions import required_user # Define projector elements. - register_projector_elements(get_projector_elements()) + register_projector_elements() # Connect signals. post_save.connect( diff --git a/openslides/agenda/models.py b/openslides/agenda/models.py index 1d98dc276..89c78cce2 100644 --- a/openslides/agenda/models.py +++ b/openslides/agenda/models.py @@ -10,7 +10,7 @@ from django.utils import timezone from django.utils.translation import ugettext as _, ugettext_lazy from openslides.core.config import config -from openslides.core.models import Countdown, Projector +from openslides.core.models import Countdown from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError from openslides.utils.models import RESTModelMixin @@ -291,18 +291,6 @@ class Item(RESTModelMixin, models.Model): def __str__(self): return self.title - def delete(self, skip_autoupdate=False, *args, **kwargs): - """ - Customized method to delete an agenda item. Ensures that a respective - list of speakers projector element is disabled. - """ - Projector.remove_any( - skip_autoupdate=skip_autoupdate, name="agenda/list-of-speakers", id=self.pk - ) - return super().delete( # type: ignore - skip_autoupdate=skip_autoupdate, *args, **kwargs - ) - @property def title(self): """ diff --git a/openslides/agenda/projector.py b/openslides/agenda/projector.py index ffcdeb6be..83ef74f29 100644 --- a/openslides/agenda/projector.py +++ b/openslides/agenda/projector.py @@ -1,57 +1,89 @@ -from typing import Generator, Type +from collections import defaultdict +from typing import Any, Dict, List, Tuple -from ..core.exceptions import ProjectorException -from ..utils.projector import ProjectorElement -from .models import Item +from ..utils.projector import AllData, register_projector_element -class ItemListSlide(ProjectorElement): +# Important: All functions have to be prune. This means, that thay can only +# access the data, that they get as argument and do not have any +# side effects. They are called from an async context. So they have +# to be fast! + + +def get_tree( + all_data: AllData, parent_id: int = 0 +) -> List[Tuple[int, List[Tuple[int, List[Any]]]]]: """ - Slide definitions for Item model. + Build the item tree from all_data. - This is only for item list slides. + Only build the tree from elements unterneath parent_id. - Set 'id' to None to get a list slide of all root items. Set 'id' to an - integer to get a list slide of the children of the metioned item. - - Additionally set 'tree' to True to get also children of children. + Returns a list of two element tuples where the first element is the item title + and the second a List with children as two element tuples. """ - name = "agenda/item-list" + # Build a dict from an item_id to all its children + children: Dict[int, List[int]] = defaultdict(list) + for item in sorted( + all_data["agenda/item"].values(), key=lambda item: item["weight"] + ): + if item["type"] == 1: # only normal items + children[item["parent_id"] or 0].append(item["id"]) - def check_data(self): - pk = self.config_entry.get("id") - if pk is not None: - # Children slide. - if not Item.objects.filter(pk=pk).exists(): - raise ProjectorException("Item does not exist.") + def get_children( + item_ids: List[int] + ) -> List[Tuple[int, List[Tuple[int, List[Any]]]]]: + return [ + (all_data["agenda/item"][item_id]["title"], get_children(children[item_id])) + for item_id in item_ids + ] + + return get_children(children[parent_id]) -class ListOfSpeakersSlide(ProjectorElement): +def items(config: Dict[str, Any], all_data: AllData) -> Dict[str, Any]: """ - Slide definitions for Item model. - This is only for list of speakers slide. You have to set 'id'. + Item list slide. + + Returns all root items or all children of an item. """ + root_item_id = config.get("id") or None + show_tree = config.get("tree") or False - name = "agenda/list-of-speakers" + if show_tree: + items = get_tree(all_data, root_item_id or 0) + else: + items = [] + for item in sorted( + all_data["agenda/item"].values(), key=lambda item: item["weight"] + ): + if item["parent_id"] == root_item_id and item["type"] == 1: + items.append(item["title"]) - def check_data(self): - if not Item.objects.filter(pk=self.config_entry.get("id")).exists(): - raise ProjectorException("Item does not exist.") - - def update_data(self): - return {"agenda_item_id": self.config_entry.get("id")} + return {"items": items} -class CurrentListOfSpeakersSlide(ProjectorElement): +def list_of_speakers(config: Dict[str, Any], all_data: AllData) -> Dict[str, Any]: """ - Slide for the current list of speakers. + List of speakers slide. + + Returns all usernames, that are on the list of speaker of a slide. """ + item_id = config.get("id") or 0 # item_id 0 means current_list_of_speakers - name = "agenda/current-list-of-speakers" + # TODO: handle item_id == 0 + + try: + item = all_data["agenda/item"][item_id] + except KeyError: + return {"error": "Item {} does not exist".format(item_id)} + + user_ids = [] + for speaker in item["speakers"]: + user_ids.append(speaker["user"]) + return {"user_ids": user_ids} -def get_projector_elements() -> Generator[Type[ProjectorElement], None, None]: - yield ItemListSlide - yield ListOfSpeakersSlide - yield CurrentListOfSpeakersSlide +def register_projector_elements() -> None: + register_projector_element("agenda/item-list", items) + register_projector_element("agenda/list-of-speakers", list_of_speakers) diff --git a/openslides/assignments/apps.py b/openslides/assignments/apps.py index 1f2ad5b95..f33e3705b 100644 --- a/openslides/assignments/apps.py +++ b/openslides/assignments/apps.py @@ -3,14 +3,11 @@ from typing import Any, Dict, List, Set from django.apps import AppConfig from mypy_extensions import TypedDict -from ..utils.projector import register_projector_elements - class AssignmentsAppConfig(AppConfig): name = "openslides.assignments" verbose_name = "OpenSlides Assignments" angular_site_module = True - angular_projector_module = True def ready(self): # Import all required stuff. @@ -18,12 +15,12 @@ class AssignmentsAppConfig(AppConfig): from ..utils.access_permissions import required_user from ..utils.rest_api import router from . import serializers # noqa - from .projector import get_projector_elements + from .projector import register_projector_elements from .signals import get_permission_change_data from .views import AssignmentViewSet, AssignmentPollViewSet # Define projector elements. - register_projector_elements(get_projector_elements()) + register_projector_elements() # Connect signals. permission_change.connect( diff --git a/openslides/assignments/models.py b/openslides/assignments/models.py index 42ffb2182..4e2465b25 100644 --- a/openslides/assignments/models.py +++ b/openslides/assignments/models.py @@ -10,7 +10,7 @@ from django.utils.translation import ugettext as _, ugettext_noop from openslides.agenda.models import Item, Speaker from openslides.core.config import config -from openslides.core.models import Projector, Tag +from openslides.core.models import Tag from openslides.poll.models import ( BaseOption, BasePoll, @@ -159,18 +159,6 @@ class Assignment(RESTModelMixin, models.Model): def __str__(self): return self.title - def delete(self, skip_autoupdate=False, *args, **kwargs): - """ - Customized method to delete an assignment. Ensures that a respective - assignment projector element is disabled. - """ - Projector.remove_any( - skip_autoupdate=skip_autoupdate, name="assignments/assignment", id=self.pk - ) - return super().delete( # type: ignore - skip_autoupdate=skip_autoupdate, *args, **kwargs - ) - @property def candidates(self): """ @@ -432,21 +420,6 @@ class AssignmentPoll( # type: ignore class Meta: default_permissions = () - def delete(self, skip_autoupdate=False, *args, **kwargs): - """ - Customized method to delete an assignment poll. Ensures that a respective - assignment projector element (with poll, so called poll slide) is disabled. - """ - Projector.remove_any( - skip_autoupdate=skip_autoupdate, - name="assignments/assignment", - id=self.assignment.pk, - poll=self.pk, - ) - return super().delete( # type: ignore - skip_autoupdate=skip_autoupdate, *args, **kwargs - ) - def get_assignment(self): return self.assignment diff --git a/openslides/assignments/projector.py b/openslides/assignments/projector.py index 507bbc1a6..ae854d11a 100644 --- a/openslides/assignments/projector.py +++ b/openslides/assignments/projector.py @@ -1,45 +1,23 @@ -from typing import Generator, Type +from typing import Any, Dict -from ..core.exceptions import ProjectorException -from ..utils.projector import ProjectorElement -from .models import Assignment, AssignmentPoll +from ..utils.projector import register_projector_element -class AssignmentSlide(ProjectorElement): +# Important: All functions have to be prune. This means, that thay can only +# access the data, that they get as argument and do not have any +# side effects. They are called from an async context. So they have +# to be fast! + + +def assignment( + config: Dict[str, Any], all_data: Dict[str, Dict[int, Dict[str, Any]]] +) -> Dict[str, Any]: """ - Slide definitions for Assignment model. - - You can send a poll id to get a poll slide. + Assignment slide. """ - - name = "assignments/assignment" - - def check_data(self): - if not Assignment.objects.filter(pk=self.config_entry.get("id")).exists(): - raise ProjectorException("Election does not exist.") - poll_id = self.config_entry.get("poll") - if poll_id: - # Poll slide. - try: - poll = AssignmentPoll.objects.get(pk=poll_id) - except AssignmentPoll.DoesNotExist: - raise ProjectorException("Poll does not exist.") - if poll.assignment_id != self.config_entry.get("id"): - raise ProjectorException( - "Assignment id and poll do not belong together." - ) - - def update_data(self): - data = None - try: - assignment = Assignment.objects.get(pk=self.config_entry.get("id")) - except Assignment.DoesNotExist: - # Assignment does not exist, so just do nothing. - pass - else: - data = {"agenda_item_id": assignment.agenda_item_id} - return data + poll_id = config.get("tree") # noqa + return {"error": "TODO"} -def get_projector_elements() -> Generator[Type[ProjectorElement], None, None]: - yield AssignmentSlide +def register_projector_elements() -> None: + register_projector_element("assignments/assignment", assignment) diff --git a/openslides/core/apps.py b/openslides/core/apps.py index c06c660e5..50f980e9c 100644 --- a/openslides/core/apps.py +++ b/openslides/core/apps.py @@ -7,21 +7,16 @@ from django.apps import AppConfig from django.conf import settings from django.db.models.signals import post_migrate -from ..utils.projector import register_projector_elements - class CoreAppConfig(AppConfig): name = "openslides.core" verbose_name = "OpenSlides Core" angular_site_module = True - angular_projector_module = True def ready(self): # Import all required stuff. from .config import config - from ..utils.rest_api import router - from ..utils.cache import element_cache - from .projector import get_projector_elements + from .projector import register_projector_elements from . import serializers # noqa from .signals import ( delete_django_app_permissions, @@ -38,15 +33,18 @@ class CoreAppConfig(AppConfig): ProjectorViewSet, TagViewSet, ) - from ..utils.constants import set_constants, get_constants_from_apps from .websocket import ( NotifyWebsocketClientMessage, ConstantsWebsocketClientMessage, GetElementsWebsocketClientMessage, AutoupdateWebsocketClientMessage, + ListenToProjectors, ) - from ..utils.websocket import register_client_message from ..utils.access_permissions import required_user + from ..utils.cache import element_cache + from ..utils.constants import set_constants, get_constants_from_apps + from ..utils.rest_api import router + from ..utils.websocket import register_client_message # Collect all config variables before getting the constants. config.collect_config_variables_from_apps() @@ -64,7 +62,7 @@ class CoreAppConfig(AppConfig): set_constants(get_constants_from_apps()) # Define projector elements. - register_projector_elements(get_projector_elements()) + register_projector_elements() # Connect signals. post_permission_creation.connect( @@ -114,6 +112,7 @@ class CoreAppConfig(AppConfig): register_client_message(ConstantsWebsocketClientMessage()) register_client_message(GetElementsWebsocketClientMessage()) register_client_message(AutoupdateWebsocketClientMessage()) + register_client_message(ListenToProjectors()) # register required_users required_user.add_collection_string( diff --git a/openslides/core/exceptions.py b/openslides/core/exceptions.py index f01a9f275..e803a4a59 100644 --- a/openslides/core/exceptions.py +++ b/openslides/core/exceptions.py @@ -1,10 +1,6 @@ from openslides.utils.exceptions import OpenSlidesError -class ProjectorException(OpenSlidesError): - pass - - class TagException(OpenSlidesError): pass diff --git a/openslides/core/models.py b/openslides/core/models.py index 6ecd853c0..46f014b3e 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -7,7 +7,6 @@ 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, @@ -17,7 +16,6 @@ from .access_permissions import ( ProjectorMessageAccessPermissions, TagAccessPermissions, ) -from .exceptions import ProjectorException class ProjectorManager(models.Manager): @@ -34,6 +32,7 @@ class ProjectorManager(models.Manager): class Projector(RESTModelMixin, models.Model): + # TODO: Fix docstring """ Model for all projectors. @@ -103,66 +102,6 @@ class Projector(RESTModelMixin, models.Model): ("can_see_frontpage", "Can see the front page"), ) - @property - def elements(self): - """ - Retrieve all projector elements given in the config field. For - every element the method check_and_update_data() is called and its - result is also used. - """ - # Get all elements from all apps. - elements = get_all_projector_elements() - - # Parse result - result = {} - for key, value in self.config.items(): - # Use a copy here not to change the origin value in the config field. - result[key] = value.copy() - result[key]["uuid"] = key - element = elements.get(value["name"]) - if element is None: - result[key]["error"] = "Projector element does not exist." - else: - try: - result[key].update( - element.check_and_update_data( - projector_object=self, config_entry=value - ) - ) - except ProjectorException as e: - result[key]["error"] = str(e) - return result - - @classmethod - def remove_any(cls, skip_autoupdate=False, **kwargs): - """ - Removes all projector elements from all projectors with matching kwargs. - Additional properties of active projector elements are ignored: - - Example: Sending {'name': 'assignments/assignment', 'id': 1} will remove - also projector elements with {'name': 'assignments/assignment', 'id': 1, 'poll': 2}. - """ - # Loop over all projectors. - for projector in cls.objects.all(): - change_projector_config = False - projector_config = {} - # Loop over all projector elements of this projector. - for key, value in projector.config.items(): - # Check if the kwargs match this element. - for kwarg_key, kwarg_value in kwargs.items(): - if not value.get(kwarg_key) == kwarg_value: - # No match so the element should stay. Write it into - # new config field and break the loop. - projector_config[key] = value - break - else: - # All kwargs match this projector element. So mark this - # projector to be changed. Do not write it into new config field. - change_projector_config = True - if change_projector_config: - projector.config = projector_config - projector.save(skip_autoupdate=skip_autoupdate) - class ProjectionDefault(RESTModelMixin, models.Model): """ @@ -275,18 +214,6 @@ class ProjectorMessage(RESTModelMixin, models.Model): class Meta: default_permissions = () - def delete(self, skip_autoupdate=False, *args, **kwargs): - """ - Customized method to delete a projector message. Ensures that a respective - projector message projector element is disabled. - """ - Projector.remove_any( - skip_autoupdate=skip_autoupdate, name="core/projector-message", id=self.pk - ) - return super().delete( # type: ignore - skip_autoupdate=skip_autoupdate, *args, **kwargs - ) - class Countdown(RESTModelMixin, models.Model): """ @@ -306,18 +233,6 @@ class Countdown(RESTModelMixin, models.Model): class Meta: default_permissions = () - def delete(self, skip_autoupdate=False, *args, **kwargs): - """ - Customized method to delete a countdown. Ensures that a respective - countdown projector element is disabled. - """ - Projector.remove_any( - skip_autoupdate=skip_autoupdate, name="core/countdown", id=self.pk - ) - return super().delete( # type: ignore - skip_autoupdate=skip_autoupdate, *args, **kwargs - ) - def control(self, action, skip_autoupdate=False): if action not in ("start", "stop", "reset"): raise ValueError( diff --git a/openslides/core/projector.py b/openslides/core/projector.py index 3ae29f8ba..fa9b29a97 100644 --- a/openslides/core/projector.py +++ b/openslides/core/projector.py @@ -1,43 +1,57 @@ -from typing import Generator, Type +from typing import Any, Dict -from ..utils.projector import ProjectorElement -from .exceptions import ProjectorException -from .models import Countdown, ProjectorMessage +from ..utils.projector import register_projector_element -class Clock(ProjectorElement): +# Important: All functions have to be prune. This means, that thay can only +# access the data, that they get as argument and do not have any +# side effects. They are called from an async context. So they have +# to be fast! + + +def countdown( + config: Dict[str, Any], all_data: Dict[str, Dict[int, Dict[str, Any]]] +) -> Dict[str, Any]: """ - Clock on the projector. + Countdown slide. + + Returns the full_data of the countdown element. + + config = { + name: 'core/countdown', + id: 5, # Countdown ID + } """ + countdown_id = config.get("id") or 1 - name = "core/clock" + try: + return all_data["core/countdown"][countdown_id] + except KeyError: + return {"error": "Countdown {} does not exist".format(countdown_id)} -class CountdownElement(ProjectorElement): +def message( + config: Dict[str, Any], all_data: Dict[str, Dict[int, Dict[str, Any]]] +) -> Dict[str, Any]: """ - Countdown slide for the projector. + Message slide. + + Returns the full_data of the message element. + + config = { + name: 'core/projector-message', + id: 5, # ProjectorMessage ID + } """ + message_id = config.get("id") or 1 - name = "core/countdown" - - def check_data(self): - if not Countdown.objects.filter(pk=self.config_entry.get("id")).exists(): - raise ProjectorException("Countdown does not exists.") + try: + return all_data["core/projector-message"][message_id] + except KeyError: + return {"error": "Message {} does not exist".format(message_id)} -class ProjectorMessageElement(ProjectorElement): - """ - Short message on the projector. Rendered as overlay. - """ - - name = "core/projector-message" - - def check_data(self): - if not ProjectorMessage.objects.filter(pk=self.config_entry.get("id")).exists(): - raise ProjectorException("Message does not exists.") - - -def get_projector_elements() -> Generator[Type[ProjectorElement], None, None]: - yield Clock - yield CountdownElement - yield ProjectorMessageElement +def register_projector_elements() -> None: + register_projector_element("core/countdown", countdown) + register_projector_element("core/projector-message", message) + # TODO: Deside if we need a clock slide diff --git a/openslides/core/serializers.py b/openslides/core/serializers.py index 75675a41a..aba0c4266 100644 --- a/openslides/core/serializers.py +++ b/openslides/core/serializers.py @@ -56,7 +56,7 @@ class ProjectorSerializer(ModelSerializer): Serializer for core.models.Projector objects. """ - config = JSONSerializerField(write_only=True) + config = JSONSerializerField() projectiondefaults = ProjectionDefaultSerializer(many=True, read_only=True) class Meta: @@ -64,7 +64,6 @@ class ProjectorSerializer(ModelSerializer): fields = ( "id", "config", - "elements", "scale", "scroll", "name", diff --git a/openslides/core/websocket.py b/openslides/core/websocket.py index c8f8885ab..d779eb579 100644 --- a/openslides/core/websocket.py +++ b/openslides/core/websocket.py @@ -1,6 +1,7 @@ from typing import Any from ..utils.constants import get_constants +from ..utils.projector import get_projectot_data from ..utils.websocket import ( BaseWebsocketClientMessage, ProtocollAsyncJsonWebsocketConsumer, @@ -111,3 +112,51 @@ class AutoupdateWebsocketClientMessage(BaseWebsocketClientMessage): await consumer.channel_layer.group_discard( "autoupdate", consumer.channel_name ) + + +class ListenToProjectors(BaseWebsocketClientMessage): + """ + The client tells, to which projector it listens. + + Therefor it sends a list of projector ids. If it sends an empty list, it does + not want to get any projector information. + """ + + identifier = "listenToProjectors" + schema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "titel": "Listen to projectors", + "description": "Listen to zero, one or more projectors..", + "type": "object", + "properties": { + "projector_ids": { + "type": "array", + "items": {"type": "integer"}, + "uniqueItems": True, + } + }, + "required": ["projector_ids"], + } + + async def receive_content( + self, consumer: "ProtocollAsyncJsonWebsocketConsumer", content: Any, id: str + ) -> None: + consumer.listen_projector_ids = content["projector_ids"] + if consumer.listen_projector_ids: + # listen to projector group + await consumer.channel_layer.group_add("projector", consumer.channel_name) + else: + # do not listen to projector group + await consumer.channel_layer.group_discard( + "projector", consumer.channel_name + ) + + # Send projector data + if consumer.listen_projector_ids: + projector_data = await get_projectot_data(consumer.listen_projector_ids) + for projector_id, data in projector_data.items(): + consumer.projector_hash[projector_id] = hash(str(data)) + + await consumer.send_json( + type="projector", content=projector_data, in_response=id + ) diff --git a/openslides/mediafiles/apps.py b/openslides/mediafiles/apps.py index 6357bd8c4..252c44b36 100644 --- a/openslides/mediafiles/apps.py +++ b/openslides/mediafiles/apps.py @@ -2,27 +2,24 @@ from typing import Any, Dict, Set from django.apps import AppConfig -from ..utils.projector import register_projector_elements - class MediafilesAppConfig(AppConfig): name = "openslides.mediafiles" verbose_name = "OpenSlides Mediafiles" angular_site_module = True - angular_projector_module = True def ready(self): # Import all required stuff. from openslides.core.signals import permission_change from openslides.utils.rest_api import router - from .projector import get_projector_elements + from .projector import register_projector_elements from .signals import get_permission_change_data from .views import MediafileViewSet from . import serializers # noqa from ..utils.access_permissions import required_user # Define projector elements. - register_projector_elements(get_projector_elements()) + register_projector_elements() # Connect signals. permission_change.connect( diff --git a/openslides/mediafiles/models.py b/openslides/mediafiles/models.py index 9238af6b0..1e5580932 100644 --- a/openslides/mediafiles/models.py +++ b/openslides/mediafiles/models.py @@ -3,7 +3,6 @@ from django.db import models from django.utils.translation import ugettext as _ from ..core.config import config -from ..core.models import Projector from ..utils.autoupdate import inform_changed_data from ..utils.models import RESTModelMixin from .access_permissions import MediafileAccessPermissions @@ -67,18 +66,6 @@ class Mediafile(RESTModelMixin, models.Model): inform_changed_data(self.uploader) return result - def delete(self, skip_autoupdate=False, *args, **kwargs): - """ - Customized method to delete a mediafile. Ensures that a respective - mediafile projector element is disabled. - """ - Projector.remove_any( - skip_autoupdate=skip_autoupdate, name="mediafiles/mediafile", id=self.pk - ) - return super().delete( # type: ignore - skip_autoupdate=skip_autoupdate, *args, **kwargs - ) - def get_filesize(self): """ Transforms bytes to kilobytes or megabytes. Returns the size as string. diff --git a/openslides/mediafiles/projector.py b/openslides/mediafiles/projector.py index 2a6b0dee3..80683ff9f 100644 --- a/openslides/mediafiles/projector.py +++ b/openslides/mediafiles/projector.py @@ -1,21 +1,22 @@ -from typing import Generator, Type +from typing import Any, Dict -from ..core.exceptions import ProjectorException -from ..utils.projector import ProjectorElement -from .models import Mediafile +from ..utils.projector import register_projector_element -class MediafileSlide(ProjectorElement): +# Important: All functions have to be prune. This means, that thay can only +# access the data, that they get as argument and do not have any +# side effects. They are called from an async context. So they have +# to be fast! + + +def mediafile( + config: Dict[str, Any], all_data: Dict[str, Dict[int, Dict[str, Any]]] +) -> Dict[str, Any]: """ - Slide definitions for Mediafile model. + Slide for Mediafile. """ - - name = "mediafiles/mediafile" - - def check_data(self): - if not Mediafile.objects.filter(pk=self.config_entry.get("id")).exists(): - raise ProjectorException("File does not exist.") + return {"error": "TODO"} -def get_projector_elements() -> Generator[Type[ProjectorElement], None, None]: - yield MediafileSlide +def register_projector_elements() -> None: + register_projector_element("mediafiles/mediafile", mediafile) diff --git a/openslides/motions/apps.py b/openslides/motions/apps.py index 686c920dc..833bfcd0d 100644 --- a/openslides/motions/apps.py +++ b/openslides/motions/apps.py @@ -3,20 +3,17 @@ from typing import Any, Dict, Set from django.apps import AppConfig from django.db.models.signals import post_migrate -from ..utils.projector import register_projector_elements - class MotionsAppConfig(AppConfig): name = "openslides.motions" verbose_name = "OpenSlides Motion" angular_site_module = True - angular_projector_module = True def ready(self): # Import all required stuff. from openslides.core.signals import permission_change from openslides.utils.rest_api import router - from .projector import get_projector_elements + from .projector import register_projector_elements from .signals import create_builtin_workflows, get_permission_change_data from . import serializers # noqa from .views import ( @@ -33,7 +30,7 @@ class MotionsAppConfig(AppConfig): from ..utils.access_permissions import required_user # Define projector elements. - register_projector_elements(get_projector_elements()) + register_projector_elements() # Connect signals. post_migrate.connect( diff --git a/openslides/motions/models.py b/openslides/motions/models.py index 3071ae61e..b5fedd80c 100644 --- a/openslides/motions/models.py +++ b/openslides/motions/models.py @@ -12,7 +12,7 @@ from jsonfield import JSONField from openslides.agenda.models import Item from openslides.core.config import config -from openslides.core.models import Projector, Tag +from openslides.core.models import Tag from openslides.mediafiles.models import Mediafile from openslides.poll.models import ( BaseOption, @@ -310,18 +310,6 @@ class Motion(RESTModelMixin, models.Model): if not skip_autoupdate: inform_changed_data(self) - def delete(self, skip_autoupdate=False, *args, **kwargs): - """ - Customized method to delete a motion. Ensures that a respective - motion projector element is disabled. - """ - Projector.remove_any( - skip_autoupdate=skip_autoupdate, name="motions/motion", id=self.pk - ) - return super().delete( # type: ignore - skip_autoupdate=skip_autoupdate, *args, **kwargs - ) - def set_identifier(self): """ Sets the motion identifier automaticly according to the config value if @@ -893,18 +881,6 @@ class MotionBlock(RESTModelMixin, models.Model): def __str__(self): return self.title - def delete(self, skip_autoupdate=False, *args, **kwargs): - """ - Customized method to delete a motion block. Ensures that a respective - motion block projector element is disabled. - """ - Projector.remove_any( - skip_autoupdate=skip_autoupdate, name="motions/motion-block", id=self.pk - ) - return super().delete( # type: ignore - skip_autoupdate=skip_autoupdate, *args, **kwargs - ) - """ Container for runtime information for agenda app (on create or update of this instance). """ diff --git a/openslides/motions/projector.py b/openslides/motions/projector.py index c01c68864..75ca6565f 100644 --- a/openslides/motions/projector.py +++ b/openslides/motions/projector.py @@ -1,56 +1,32 @@ -from typing import Generator, Type +from typing import Any, Dict -from ..core.exceptions import ProjectorException -from ..utils.projector import ProjectorElement -from .models import Motion, MotionBlock +from ..utils.projector import register_projector_element -class MotionSlide(ProjectorElement): +# Important: All functions have to be prune. This means, that thay can only +# access the data, that they get as argument and do not have any +# side effects. They are called from an async context. So they have +# to be fast! + + +def motion( + config: Dict[str, Any], all_data: Dict[str, Dict[int, Dict[str, Any]]] +) -> Dict[str, Any]: """ - Slide definitions for Motion model. + Motion slide. """ - - name = "motions/motion" - - def check_data(self): - if not Motion.objects.filter(pk=self.config_entry.get("id")).exists(): - raise ProjectorException("Motion does not exist.") - - def update_data(self): - data = None - try: - motion = Motion.objects.get(pk=self.config_entry.get("id")) - except Motion.DoesNotExist: - # Motion does not exist, so just do nothing. - pass - else: - data = {"agenda_item_id": motion.agenda_item_id} - return data + return {"error": "TODO"} -class MotionBlockSlide(ProjectorElement): +def motion_block( + config: Dict[str, Any], all_data: Dict[str, Dict[int, Dict[str, Any]]] +) -> Dict[str, Any]: """ - Slide definitions for a block of motions (MotionBlock model). + Motion slide. """ - - name = "motions/motion-block" - - def check_data(self): - if not MotionBlock.objects.filter(pk=self.config_entry.get("id")).exists(): - raise ProjectorException("MotionBlock does not exist.") - - def update_data(self): - data = None - try: - motion_block = MotionBlock.objects.get(pk=self.config_entry.get("id")) - except MotionBlock.DoesNotExist: - # MotionBlock does not exist, so just do nothing. - pass - else: - data = {"agenda_item_id": motion_block.agenda_item_id} - return data + return {"error": "TODO"} -def get_projector_elements() -> Generator[Type[ProjectorElement], None, None]: - yield MotionSlide - yield MotionBlockSlide +def register_projector_elements() -> None: + register_projector_element("motion/motion", motion) + register_projector_element("motion/motion-block", motion_block) diff --git a/openslides/topics/apps.py b/openslides/topics/apps.py index f18b59684..bbe0a09cb 100644 --- a/openslides/topics/apps.py +++ b/openslides/topics/apps.py @@ -1,25 +1,22 @@ from django.apps import AppConfig -from ..utils.projector import register_projector_elements - class TopicsAppConfig(AppConfig): name = "openslides.topics" verbose_name = "OpenSlides Topics" angular_site_module = True - angular_projector_module = True def ready(self): # Import all required stuff. from openslides.core.signals import permission_change from ..utils.rest_api import router - from .projector import get_projector_elements + from .projector import register_projector_elements from .signals import get_permission_change_data from .views import TopicViewSet from . import serializers # noqa # Define projector elements. - register_projector_elements(get_projector_elements()) + register_projector_elements() # Connect signals. permission_change.connect( diff --git a/openslides/topics/models.py b/openslides/topics/models.py index 9a977a537..84b04b443 100644 --- a/openslides/topics/models.py +++ b/openslides/topics/models.py @@ -4,7 +4,6 @@ from django.contrib.contenttypes.fields import GenericRelation from django.db import models from ..agenda.models import Item -from ..core.models import Projector from ..mediafiles.models import Mediafile from ..utils.models import RESTModelMixin from .access_permissions import TopicAccessPermissions @@ -47,18 +46,6 @@ class Topic(RESTModelMixin, models.Model): def __str__(self): return self.title - def delete(self, skip_autoupdate=False, *args, **kwargs): - """ - Customized method to delete a topic. Ensures that a respective - topic projector element is disabled. - """ - Projector.remove_any( - skip_autoupdate=skip_autoupdate, name="topics/topic", id=self.pk - ) - return super().delete( # type: ignore - skip_autoupdate=skip_autoupdate, *args, **kwargs - ) - """ Container for runtime information for agenda app (on create or update of this instance). """ diff --git a/openslides/topics/projector.py b/openslides/topics/projector.py index 4e8c51f0e..3e39aa7ba 100644 --- a/openslides/topics/projector.py +++ b/openslides/topics/projector.py @@ -1,32 +1,22 @@ -from typing import Generator, Type +from typing import Any, Dict -from ..core.exceptions import ProjectorException -from ..utils.projector import ProjectorElement -from .models import Topic +from ..utils.projector import register_projector_element -class TopicSlide(ProjectorElement): +# Important: All functions have to be prune. This means, that thay can only +# access the data, that they get as argument and do not have any +# side effects. They are called from an async context. So they have +# to be fast! + + +def topic( + config: Dict[str, Any], all_data: Dict[str, Dict[int, Dict[str, Any]]] +) -> Dict[str, Any]: """ - Slide definitions for topic model. + Topic slide. """ - - name = "topics/topic" - - def check_data(self): - if not Topic.objects.filter(pk=self.config_entry.get("id")).exists(): - raise ProjectorException("Topic does not exist.") - - def update_data(self): - data = None - try: - topic = Topic.objects.get(pk=self.config_entry.get("id")) - except Topic.DoesNotExist: - # Topic does not exist, so just do nothing. - pass - else: - data = {"agenda_item_id": topic.agenda_item_id} - return data + return {"error": "TODO"} -def get_projector_elements() -> Generator[Type[ProjectorElement], None, None]: - yield TopicSlide +def register_projector_elements() -> None: + register_projector_element("topics/topic", topic) diff --git a/openslides/users/apps.py b/openslides/users/apps.py index 09b902acc..17f45ad34 100644 --- a/openslides/users/apps.py +++ b/openslides/users/apps.py @@ -2,26 +2,23 @@ from django.apps import AppConfig from django.conf import settings from django.contrib.auth.signals import user_logged_in -from ..utils.projector import register_projector_elements - class UsersAppConfig(AppConfig): name = "openslides.users" verbose_name = "OpenSlides Users" angular_site_module = True - angular_projector_module = True def ready(self): # Import all required stuff. from . import serializers # noqa from ..core.signals import post_permission_creation, permission_change from ..utils.rest_api import router - from .projector import get_projector_elements + from .projector import register_projector_elements from .signals import create_builtin_groups_and_admin, get_permission_change_data from .views import GroupViewSet, PersonalNoteViewSet, UserViewSet # Define projector elements. - register_projector_elements(get_projector_elements()) + register_projector_elements() # Connect signals. post_permission_creation.connect( diff --git a/openslides/users/models.py b/openslides/users/models.py index 35a3215cf..0af1fdf5a 100644 --- a/openslides/users/models.py +++ b/openslides/users/models.py @@ -18,7 +18,6 @@ from django.utils import timezone from jsonfield import JSONField from ..core.config import config -from ..core.models import Projector from ..utils.auth import GROUP_ADMIN_PK from ..utils.models import RESTModelMixin from .access_permissions import ( @@ -197,18 +196,6 @@ class User(RESTModelMixin, PermissionsMixin, AbstractBaseUser): kwargs["skip_autoupdate"] = True return super().save(*args, **kwargs) - def delete(self, skip_autoupdate=False, *args, **kwargs): - """ - Customized method to delete an user. Ensures that a respective - user projector element is disabled. - """ - Projector.remove_any( - skip_autoupdate=skip_autoupdate, name="users/user", id=self.pk - ) - return super().delete( # type: ignore - skip_autoupdate=skip_autoupdate, *args, **kwargs - ) - def has_perm(self, perm): """ This method is closed. Do not use it but use openslides.utils.auth.has_perm. diff --git a/openslides/users/projector.py b/openslides/users/projector.py index 6ae22f2b5..4dac2304a 100644 --- a/openslides/users/projector.py +++ b/openslides/users/projector.py @@ -1,21 +1,22 @@ -from typing import Generator, Type +from typing import Any, Dict -from ..core.exceptions import ProjectorException -from ..utils.projector import ProjectorElement -from .models import User +from ..utils.projector import register_projector_element -class UserSlide(ProjectorElement): +# Important: All functions have to be prune. This means, that thay can only +# access the data, that they get as argument and do not have any +# side effects. They are called from an async context. So they have +# to be fast! + + +def user( + config: Dict[str, Any], all_data: Dict[str, Dict[int, Dict[str, Any]]] +) -> Dict[str, Any]: """ - Slide definitions for User model. + User slide. """ - - name = "users/user" - - def check_data(self): - if not User.objects.filter(pk=self.config_entry.get("id")).exists(): - raise ProjectorException("User does not exist.") + return {"error": "TODO"} -def get_projector_elements() -> Generator[Type[ProjectorElement], None, None]: - yield UserSlide +def register_projector_elements() -> None: + register_projector_element("users/user", user) diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index 495f2e477..011301cfc 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -8,6 +8,7 @@ from django.db.models import Model from mypy_extensions import TypedDict from .cache import element_cache, get_element_id +from .projector import get_projectot_data Element = TypedDict( @@ -192,6 +193,13 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: "autoupdate", {"type": "send_data", "change_id": change_id} ) + projector_data = await get_projectot_data() + # Send projector + channel_layer = get_channel_layer() + await channel_layer.group_send( + "projector", {"type": "projector_changed", "data": projector_data} + ) + if elements: # Save histroy here using sync code. history_instances = save_history(elements) diff --git a/openslides/utils/cache.py b/openslides/utils/cache.py index f338656b1..d45c45e5d 100644 --- a/openslides/utils/cache.py +++ b/openslides/utils/cache.py @@ -154,16 +154,28 @@ class ElementCache: async def get_all_full_data(self) -> Dict[str, List[Dict[str, Any]]]: """ - Returns all full_data. If it does not exist, it is created. + Returns all full_data. The returned value is a dict where the key is the collection_string and the value is a list of data. """ + all_data = await self.get_all_full_data_ordered() out: Dict[str, List[Dict[str, Any]]] = defaultdict(list) + for collection_string, collection_data in all_data.items(): + for data in collection_data.values(): + out[collection_string].append(data) + return dict(out) + + async def get_all_full_data_ordered(self) -> Dict[str, Dict[int, Dict[str, Any]]]: + """ + Like get_all_full_data but orders the element of one collection by there + id. + """ + out: Dict[str, Dict[int, Dict[str, Any]]] = defaultdict(dict) full_data = await self.cache_provider.get_all_data() for element_id, data in full_data.items(): - collection_string, __ = split_element_id(element_id) - out[collection_string].append(json.loads(data.decode())) + collection_string, id = split_element_id(element_id) + out[collection_string][id] = json.loads(data.decode()) return dict(out) async def get_full_data( diff --git a/openslides/utils/consumers.py b/openslides/utils/consumers.py index 622986692..f0366ec6d 100644 --- a/openslides/utils/consumers.py +++ b/openslides/utils/consumers.py @@ -15,6 +15,10 @@ class SiteConsumer(ProtocollAsyncJsonWebsocketConsumer): groups = ["site"] + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.projector_hash: Dict[int, int] = {} + super().__init__(*args, **kwargs) + async def connect(self) -> None: """ A user connects to the site. @@ -110,3 +114,21 @@ class SiteConsumer(ProtocollAsyncJsonWebsocketConsumer): all_data=False, ), ) + + async def projector_changed(self, event: Dict[str, Any]) -> None: + """ + The projector has changed. + """ + all_projector_data = event["data"] + projector_data: Dict[int, Dict[str, Any]] = {} + for projector_id in self.listen_projector_ids: + data = all_projector_data.get( + projector_id, {"error": "No data for projector {}".format(projector_id)} + ) + new_hash = hash(str(data)) + if new_hash != self.projector_hash[projector_id]: + projector_data[projector_id] = data + self.projector_hash[projector_id] = new_hash + + if projector_data: + await self.send_json(type="projector", content=projector_data) diff --git a/openslides/utils/projector.py b/openslides/utils/projector.py index dfb77bf6b..f8f25b6ee 100644 --- a/openslides/utils/projector.py +++ b/openslides/utils/projector.py @@ -1,70 +1,69 @@ -from typing import Any, Dict, Generator, Optional, Type +from typing import Any, Callable, Dict, List + +from .cache import element_cache -class ProjectorElement: +AllData = Dict[str, Dict[int, Dict[str, Any]]] +ProjectorElementCallable = Callable[[Dict[str, Any], AllData], Dict[str, Any]] + + +projector_elements: Dict[str, ProjectorElementCallable] = {} + + +def register_projector_element(name: str, element: ProjectorElementCallable) -> None: """ - Base class for an element on the projector. - - Every app which wants to add projector elements has to create classes - subclassing from this base class with different names. The name attribute - has to be set. - """ - - name: Optional[str] = None - - def check_and_update_data(self, projector_object: Any, config_entry: Any) -> Any: - """ - Checks projector element data via self.check_data() and updates - them via self.update_data(). The projector object and the config - entry have to be given. - """ - self.projector_object = projector_object - self.config_entry = config_entry - assert ( - self.config_entry.get("name") == self.name - ), "To get data of a projector element, the correct config entry has to be given." - self.check_data() - return self.update_data() or {} - - def check_data(self) -> None: - """ - Method can be overridden to validate projector element data. This - may raise ProjectorException in case of an error. - - Default: Does nothing. - """ - pass - - def update_data(self) -> Dict[Any, Any]: - """ - Method can be overridden to update the projector element data - output. This should return a dictonary. Use this for server - calculated data which have to be forwared to the client. - - Default: Does nothing. - """ - pass - - -projector_elements: Dict[str, ProjectorElement] = {} - - -def register_projector_elements( - elements: Generator[Type[ProjectorElement], None, None] -) -> None: - """ - Registers projector elements for later use. + Registers a projector element. Has to be called in the app.ready method. """ - for AppProjectorElement in elements: - element = AppProjectorElement() - projector_elements[element.name] = element # type: ignore + projector_elements[name] = element -def get_all_projector_elements() -> Dict[str, ProjectorElement]: +async def get_projectot_data( + projector_ids: List[int] = None +) -> Dict[int, Dict[str, Any]]: """ - Returns all projector elements that where registered with - register_projector_elements() + Callculates and returns the data for one or all projectors. """ - return projector_elements + if projector_ids is None: + projector_ids = [] + + all_data = await element_cache.get_all_full_data_ordered() + projector_data: Dict[int, Dict[str, Dict[str, Any]]] = {} + + for projector_id, projector in all_data.get("core/projector", {}).items(): + if projector_ids and projector_id not in projector_ids: + # only render the projector in question. + continue + + projector_data[projector_id] = {} + if not projector["config"]: + projector_data[projector_id] = { + "error": {"error": "Projector has no config"} + } + continue + + for uuid, projector_config in projector["config"].items(): + projector_element = projector_elements.get(projector_config["name"]) + if projector_element is None: + projector_data[projector_id][uuid] = { + "error": "Projector element {} does not exist".format( + projector_config["name"] + ) + } + else: + projector_data[projector_id][uuid] = projector_element( + projector_config, all_data + ) + return projector_data + + +def get_config(all_data: AllData, key: str) -> Any: + """ + Returns the config value from all_data. + """ + from ..core.config import config + + return all_data[config.get_collection_string()][config.get_key_to_id()[key]][ + "value" + ] diff --git a/tests/integration/core/test_views.py b/tests/integration/core/test_views.py index 9e296ce10..a42c962f7 100644 --- a/tests/integration/core/test_views.py +++ b/tests/integration/core/test_views.py @@ -29,19 +29,7 @@ class ProjectorAPI(TestCase): default_projector.save() response = self.client.get(reverse("projector-detail", args=["1"])) - content = json.loads(response.content.decode()) self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual( - content["elements"], - { - "aae4a07b26534cfb9af4232f361dce73": { - "id": topic.id, - "uuid": "aae4a07b26534cfb9af4232f361dce73", - "name": "topics/topic", - "agenda_item_id": topic.agenda_item_id, - } - }, - ) def test_invalid_slide_on_default_projector(self): self.client.login(username="admin", password="admin") @@ -60,12 +48,8 @@ class ProjectorAPI(TestCase): content, { "id": 1, - "elements": { - "fc6ef43b624043068c8e6e7a86c5a1b0": { - "name": "invalid_slide", - "uuid": "fc6ef43b624043068c8e6e7a86c5a1b0", - "error": "Projector element does not exist.", - } + "config": { + "fc6ef43b624043068c8e6e7a86c5a1b0": {"name": "invalid_slide"} }, "scale": 0, "scroll": 0, diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index d739d251f..1a4537a1c 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -4,7 +4,9 @@ from django.db import DEFAULT_DB_ALIAS, connections from django.test.utils import CaptureQueriesContext from openslides.core.config import config +from openslides.core.models import Projector from openslides.users.models import User +from openslides.utils.projector import get_config, register_projector_element class TConfig: @@ -33,7 +35,7 @@ class TConfig: class TUser: """ - Cachable, that fills the cache with the default values of the config variables. + Cachable, that fills the cache with fake users. """ def get_collection_string(self) -> str: @@ -68,6 +70,45 @@ class TUser: return elements +class TProjector: + """ + Cachable, that mocks the projector. + """ + + def get_collection_string(self) -> str: + return Projector.get_collection_string() + + def get_elements(self) -> List[Dict[str, Any]]: + return [ + {"id": 1, "config": {"uid1": {"name": "test/slide1", "id": 1}}}, + {"id": 2, "config": {"uid2": {"name": "test/slide2", "id": 1}}}, + ] + + async def restrict_elements( + self, user_id: int, elements: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + return elements + + +def slide1( + config: Dict[str, Any], all_data: Dict[str, Dict[int, Dict[str, Any]]] +) -> Dict[str, Any]: + """ + Slide that shows the general_event_name. + """ + return {"name": "slide1", "event_name": get_config(all_data, "general_event_name")} + + +def slide2( + config: Dict[str, Any], all_data: Dict[str, Dict[int, Dict[str, Any]]] +) -> Dict[str, Any]: + return {"name": "slide2"} + + +register_projector_element("test/slide1", slide1) +register_projector_element("test/slide2", slide2) + + 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 b7820be7b..5b43d1c60 100644 --- a/tests/integration/utils/test_consumers.py +++ b/tests/integration/utils/test_consumers.py @@ -18,7 +18,7 @@ from openslides.utils.autoupdate import ( from openslides.utils.cache import element_cache from ...unit.utils.cache_provider import Collection1, Collection2, get_cachable_provider -from ..helpers import TConfig, TUser +from ..helpers import TConfig, TProjector, TUser @pytest.fixture(autouse=True) @@ -31,7 +31,7 @@ async def prepare_element_cache(settings): await element_cache.cache_provider.clear_cache() orig_cachable_provider = element_cache.cachable_provider element_cache.cachable_provider = get_cachable_provider( - [Collection1(), Collection2(), TConfig(), TUser()] + [Collection1(), Collection2(), TConfig(), TUser(), TProjector()] ) element_cache._cachables = None await sync_to_async(element_cache.ensure_cache)() @@ -512,3 +512,68 @@ async def test_turn_off_autoupdate(get_communicator, set_config): # Change a config value await set_config("general_event_name", "Test Event") assert await communicator.receive_nothing() + + +@pytest.mark.asyncio +async def test_listen_to_projector(communicator, set_config): + await set_config("general_system_enable_anonymous", True) + await communicator.connect() + + await communicator.send_json_to( + { + "type": "listenToProjectors", + "content": {"projector_ids": [1]}, + "id": "test_id", + } + ) + response = await communicator.receive_json_from() + + type = response.get("type") + content = response.get("content") + assert type == "projector" + assert content == {"1": {"uid1": {"name": "slide1", "event_name": "OpenSlides"}}} + + +@pytest.mark.asyncio +async def test_update_projector(communicator, set_config): + await set_config("general_system_enable_anonymous", True) + await communicator.connect() + await communicator.send_json_to( + { + "type": "listenToProjectors", + "content": {"projector_ids": [1]}, + "id": "test_id", + } + ) + await communicator.receive_json_from() + + # Change a config value + await set_config("general_event_name", "Test Event") + response = await communicator.receive_json_from() + + type = response.get("type") + content = response.get("content") + assert type == "projector" + assert content == {"1": {"uid1": {"event_name": "Test Event", "name": "slide1"}}} + + +@pytest.mark.asyncio +async def test_update_projector_to_current_value(communicator, set_config): + """ + When a value does not change, the projector should not be updated. + """ + await set_config("general_system_enable_anonymous", True) + await communicator.connect() + await communicator.send_json_to( + { + "type": "listenToProjectors", + "content": {"projector_ids": [1]}, + "id": "test_id", + } + ) + await communicator.receive_json_from() + + # Change a config value to current_value + await set_config("general_event_name", "OpenSlides") + + assert await communicator.receive_nothing() diff --git a/tests/unit/agenda/test_projector.py b/tests/unit/agenda/test_projector.py new file mode 100644 index 000000000..2b89f048c --- /dev/null +++ b/tests/unit/agenda/test_projector.py @@ -0,0 +1,117 @@ +from typing import Any, Dict + +import pytest + +from openslides.agenda import projector + + +@pytest.fixture +def all_data(): + all_data = { + "agenda/item": { + 1: { + "id": 1, + "item_number": "", + "title": "Item1", + "title_with_type": "Item1", + "comment": None, + "closed": False, + "type": 1, + "is_internal": False, + "is_hidden": False, + "duration": None, + "speakers": [], + "speaker_list_closed": False, + "content_object": {"collection": "topics/topic", "id": 1}, + "weight": 10, + "parent_id": None, + }, + 2: { + "id": 2, + "item_number": "", + "title": "item2", + "title_with_type": "item2", + "comment": None, + "closed": False, + "type": 1, + "is_internal": False, + "is_hidden": False, + "duration": None, + "speakers": [], + "speaker_list_closed": False, + "content_object": {"collection": "topics/topic", "id": 1}, + "weight": 20, + "parent_id": None, + }, + # hidden item + 3: { + "id": 3, + "item_number": "", + "title": "item3", + "title_with_type": "item3", + "comment": None, + "closed": True, + "type": 2, + "is_internal": False, + "is_hidden": True, + "duration": None, + "speakers": [], + "speaker_list_closed": False, + "content_object": {"collection": "topics/topic", "id": 1}, + "weight": 30, + "parent_id": None, + }, + # Child of item 1 + 4: { + "id": 4, + "item_number": "", + "title": "item4", + "title_with_type": "item4", + "comment": None, + "closed": True, + "type": 1, + "is_internal": False, + "is_hidden": False, + "duration": None, + "speakers": [], + "speaker_list_closed": False, + "content_object": {"collection": "topics/topic", "id": 1}, + "weight": 0, + "parent_id": 1, + }, + } + } + + return all_data + + +def test_items(all_data): + config: Dict[str, Any] = {} + + data = projector.items(config, all_data) + + assert data == {"items": ["Item1", "item2"]} + + +def test_items_parent(all_data): + config: Dict[str, Any] = {"id": 1} + + data = projector.items(config, all_data) + + assert data == {"items": ["item4"]} + + +def test_items_tree(all_data): + config: Dict[str, Any] = {"tree": True} + + data = projector.items(config, all_data) + + assert data == {"items": [("Item1", [("item4", [])]), ("item2", [])]} + + +def test_items_tree_parent(all_data): + config: Dict[str, Any] = {"tree": True, "id": 1} + + data = projector.items(config, all_data) + + assert data == {"items": [("item4", [])]}