New projector system. Add first slides

This commit is contained in:
Oskar Hahn 2018-12-23 11:05:38 +01:00
parent 73e1853758
commit a0f554674b
32 changed files with 599 additions and 521 deletions

View File

@ -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(

View File

@ -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):
"""

View File

@ -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)

View File

@ -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(

View File

@ -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

View File

@ -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)

View File

@ -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(

View File

@ -1,10 +1,6 @@
from openslides.utils.exceptions import OpenSlidesError
class ProjectorException(OpenSlidesError):
pass
class TagException(OpenSlidesError):
pass

View File

@ -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(

View File

@ -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

View File

@ -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",

View File

@ -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
)

View File

@ -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(

View File

@ -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.

View File

@ -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)

View File

@ -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(

View File

@ -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).
"""

View File

@ -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)

View File

@ -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(

View File

@ -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).
"""

View File

@ -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)

View File

@ -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(

View File

@ -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.

View File

@ -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)

View File

@ -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)

View File

@ -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(

View File

@ -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)

View File

@ -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"
]

View File

@ -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,

View File

@ -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:

View File

@ -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()

View File

@ -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", [])]}