dd4754d045
Before this commit, there where two different locks when updating the restricted data cache. A future lock, what is faster but only works in the same thread. The other lock is in redis, it is not so fast, but also works in many threads. The future lock was buggy, because on a second call of update_restricted_data the same future was reused. So on the second run, the future was already done. I don't see any way to delete. The last client would have to delete it, but there is no way to find out which client the last one is.
621 lines
21 KiB
Python
621 lines
21 KiB
Python
import datetime
|
|
import os
|
|
from typing import Any, Dict
|
|
|
|
from django.conf import settings
|
|
from django.contrib.staticfiles import finders
|
|
from django.contrib.staticfiles.views import serve
|
|
from django.db.models import F
|
|
from django.http import Http404, HttpResponse
|
|
from django.utils.timezone import now
|
|
from django.views import static
|
|
from django.views.generic.base import View
|
|
|
|
from .access_permissions import (
|
|
ChatMessageAccessPermissions,
|
|
ConfigAccessPermissions,
|
|
CountdownAccessPermissions,
|
|
HistoryAccessPermissions,
|
|
ProjectorAccessPermissions,
|
|
ProjectorMessageAccessPermissions,
|
|
TagAccessPermissions,
|
|
)
|
|
from .config import config
|
|
from .exceptions import ConfigError, ConfigNotFound
|
|
from .models import (
|
|
ChatMessage,
|
|
ConfigStore,
|
|
Countdown,
|
|
History,
|
|
HistoryData,
|
|
ProjectionDefault,
|
|
Projector,
|
|
ProjectorMessage,
|
|
Tag,
|
|
)
|
|
from .. import __license__ as license, __url__ as url, __version__ as version
|
|
from ..users.models import User
|
|
from ..utils import views as utils_views
|
|
from ..utils.arguments import arguments
|
|
from ..utils.auth import GROUP_ADMIN_PK, anonymous_is_enabled, has_perm, in_some_groups
|
|
from ..utils.autoupdate import inform_changed_data, inform_deleted_data
|
|
from ..utils.plugins import (
|
|
get_plugin_description,
|
|
get_plugin_license,
|
|
get_plugin_url,
|
|
get_plugin_verbose_name,
|
|
get_plugin_version,
|
|
)
|
|
from ..utils.rest_api import (
|
|
GenericViewSet,
|
|
ListModelMixin,
|
|
ModelViewSet,
|
|
Response,
|
|
RetrieveModelMixin,
|
|
ValidationError,
|
|
detail_route,
|
|
list_route,
|
|
)
|
|
|
|
|
|
# Special Django views
|
|
|
|
|
|
class IndexView(View):
|
|
"""
|
|
The primary view for the OpenSlides client. Serves static files. If a file
|
|
does not exist or a directory is requested, the index.html is delivered instead.
|
|
"""
|
|
|
|
cache: Dict[str, str] = {}
|
|
"""
|
|
Saves the path to the index.html.
|
|
|
|
May be extended later to cache every template.
|
|
"""
|
|
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
|
|
no_caching = arguments.get("no_template_caching", False)
|
|
if "index" not in self.cache or no_caching:
|
|
self.cache["index"] = finders.find("index.html")
|
|
|
|
self.index_document_root, self.index_path = os.path.split(self.cache["index"])
|
|
|
|
def get(self, request, path, **kwargs) -> HttpResponse:
|
|
"""
|
|
Tries to serve the requested file. If it is not found or a directory is
|
|
requested, the index.html is delivered.
|
|
"""
|
|
try:
|
|
response = serve(request, path, insecure=True, **kwargs)
|
|
except Http404:
|
|
response = static.serve(
|
|
request,
|
|
self.index_path,
|
|
document_root=self.index_document_root,
|
|
**kwargs,
|
|
)
|
|
return response
|
|
|
|
|
|
# Viewsets for the REST API
|
|
|
|
|
|
class ProjectorViewSet(ModelViewSet):
|
|
"""
|
|
API endpoint for the projector slide info.
|
|
|
|
There are the following views: See strings in check_view_permissions().
|
|
"""
|
|
|
|
access_permissions = ProjectorAccessPermissions()
|
|
queryset = Projector.objects.all()
|
|
|
|
def check_view_permissions(self):
|
|
"""
|
|
Returns True if the user has required permissions.
|
|
"""
|
|
if self.action in ("list", "retrieve"):
|
|
result = self.get_access_permissions().check_permissions(self.request.user)
|
|
elif self.action == "metadata":
|
|
result = has_perm(self.request.user, "core.can_see_projector")
|
|
elif self.action in (
|
|
"create",
|
|
"update",
|
|
"partial_update",
|
|
"destroy",
|
|
"control_view",
|
|
"set_scroll",
|
|
"set_projectiondefault",
|
|
"project",
|
|
):
|
|
result = has_perm(self.request.user, "core.can_see_projector") and has_perm(
|
|
self.request.user, "core.can_manage_projector"
|
|
)
|
|
else:
|
|
result = False
|
|
return result
|
|
|
|
def destroy(self, *args, **kwargs):
|
|
"""
|
|
REST API operation for DELETE requests.
|
|
|
|
Assigns all ProjectionDefault objects from this projector to the
|
|
default projector (pk=1).
|
|
"""
|
|
projector_instance = self.get_object()
|
|
for projection_default in ProjectionDefault.objects.all():
|
|
if projection_default.projector.id == projector_instance.id:
|
|
projection_default.projector_id = 1
|
|
projection_default.save()
|
|
return super(ProjectorViewSet, self).destroy(*args, **kwargs)
|
|
|
|
@detail_route(methods=["post"])
|
|
def project(self, request, pk):
|
|
"""
|
|
Sets the `elements` and `elements_preview` and adds one item to the
|
|
`elements_history`.
|
|
|
|
`request.data` can have three arguments: `append_to_history`, `elements`
|
|
and `preview`. Non of them is required.
|
|
|
|
`append_to_history` adds one element to the end of the history_elements.
|
|
`elements` and `preview` preplaces the coresponding fields in the
|
|
database.
|
|
|
|
If `delete_last_history_element` is True, the last element is deleted.
|
|
Note: You cannot give `append_to_history` and `delete_last_history_element`
|
|
at the same time.
|
|
"""
|
|
projector = self.get_object()
|
|
elements = request.data.get("elements")
|
|
preview = request.data.get("preview")
|
|
history_element = request.data.get("append_to_history")
|
|
delete_last_history_element = request.data.get(
|
|
"delete_last_history_element", False
|
|
)
|
|
|
|
changed_data = {}
|
|
if elements is not None:
|
|
changed_data["elements"] = elements
|
|
if preview is not None:
|
|
changed_data["elements_preview"] = preview
|
|
if history_element is not None and delete_last_history_element is False:
|
|
history = projector.elements_history + [history_element]
|
|
changed_data["elements_history"] = history
|
|
if history_element is None and delete_last_history_element is True:
|
|
history = projector.elements_history[:-1]
|
|
changed_data["elements_history"] = history
|
|
|
|
serializer = self.get_serializer(projector, data=changed_data, partial=True)
|
|
serializer.is_valid(raise_exception=True)
|
|
self.perform_update(serializer)
|
|
|
|
return Response()
|
|
|
|
@detail_route(methods=["post"])
|
|
def control_view(self, request, pk):
|
|
"""
|
|
REST API operation to control the projector view, i. e. scale and
|
|
scroll the projector.
|
|
|
|
It expects a POST request to
|
|
/rest/core/projector/<pk>/control_view/ with a dictionary with an
|
|
action ('scale' or 'scroll') and a direction ('up', 'down' or
|
|
'reset').
|
|
|
|
Example:
|
|
|
|
{
|
|
"action": "scale",
|
|
"direction": "up"
|
|
}
|
|
"""
|
|
if not isinstance(request.data, dict):
|
|
raise ValidationError({"detail": "Data must be a dictionary."})
|
|
if request.data.get("action") not in ("scale", "scroll") or request.data.get(
|
|
"direction"
|
|
) not in ("up", "down", "reset"):
|
|
raise ValidationError(
|
|
{
|
|
"detail": "Data must be a dictionary with an action ('scale' or 'scroll') "
|
|
"and a direction ('up', 'down' or 'reset')."
|
|
}
|
|
)
|
|
|
|
projector_instance = self.get_object()
|
|
if request.data["action"] == "scale":
|
|
if request.data["direction"] == "up":
|
|
projector_instance.scale = F("scale") + 1
|
|
elif request.data["direction"] == "down":
|
|
projector_instance.scale = F("scale") - 1
|
|
else:
|
|
# request.data['direction'] == 'reset'
|
|
projector_instance.scale = 0
|
|
else:
|
|
# request.data['action'] == 'scroll'
|
|
if request.data["direction"] == "up":
|
|
projector_instance.scroll = F("scroll") + 1
|
|
elif request.data["direction"] == "down":
|
|
projector_instance.scroll = F("scroll") - 1
|
|
else:
|
|
# request.data['direction'] == 'reset'
|
|
projector_instance.scroll = 0
|
|
|
|
projector_instance.save(skip_autoupdate=True)
|
|
projector_instance.refresh_from_db()
|
|
inform_changed_data(projector_instance)
|
|
return Response()
|
|
|
|
@detail_route(methods=["post"])
|
|
def set_scroll(self, request, pk):
|
|
"""
|
|
REST API operation to scroll the projector.
|
|
|
|
It expects a POST request to
|
|
/rest/core/projector/<pk>/set_scroll/ with a new value for scroll.
|
|
"""
|
|
if not isinstance(request.data, int):
|
|
raise ValidationError({"detail": "Data must be an int."})
|
|
|
|
projector_instance = self.get_object()
|
|
projector_instance.scroll = request.data
|
|
|
|
projector_instance.save()
|
|
message = f"Setting scroll to {request.data} was successful."
|
|
return Response({"detail": message})
|
|
|
|
@detail_route(methods=["post"])
|
|
def set_projectiondefault(self, request, pk):
|
|
"""
|
|
REST API operation to set a projectiondefault to the requested projector. The argument
|
|
has to be an int representing the pk from the projectiondefault to be set.
|
|
|
|
It expects a POST request to
|
|
/rest/core/projector/<pk>/set_projectiondefault/ with the projectiondefault id as the argument
|
|
"""
|
|
if not isinstance(request.data, int):
|
|
raise ValidationError({"detail": "Data must be an int."})
|
|
|
|
try:
|
|
projectiondefault = ProjectionDefault.objects.get(pk=request.data)
|
|
except ProjectionDefault.DoesNotExist:
|
|
raise ValidationError(
|
|
{
|
|
"detail": f"The projectiondefault with pk={request.data} was not found."
|
|
}
|
|
)
|
|
else:
|
|
projector_instance = self.get_object()
|
|
projectiondefault.projector = projector_instance
|
|
projectiondefault.save()
|
|
|
|
return Response()
|
|
|
|
|
|
class TagViewSet(ModelViewSet):
|
|
"""
|
|
API endpoint for tags.
|
|
|
|
There are the following views: metadata, list, retrieve, create,
|
|
partial_update, update and destroy.
|
|
"""
|
|
|
|
access_permissions = TagAccessPermissions()
|
|
queryset = Tag.objects.all()
|
|
|
|
def check_view_permissions(self):
|
|
"""
|
|
Returns True if the user has required permissions.
|
|
"""
|
|
if self.action in ("list", "retrieve"):
|
|
result = self.get_access_permissions().check_permissions(self.request.user)
|
|
elif self.action == "metadata":
|
|
# Every authenticated user can see the metadata.
|
|
# Anonymous users can do so if they are enabled.
|
|
result = self.request.user.is_authenticated or anonymous_is_enabled()
|
|
elif self.action in ("create", "partial_update", "update", "destroy"):
|
|
result = has_perm(self.request.user, "core.can_manage_tags")
|
|
else:
|
|
result = False
|
|
return result
|
|
|
|
|
|
class ConfigViewSet(ModelViewSet):
|
|
"""
|
|
API endpoint for the config.
|
|
|
|
There are the following views: metadata, list, retrieve, update and
|
|
partial_update.
|
|
"""
|
|
|
|
access_permissions = ConfigAccessPermissions()
|
|
queryset = ConfigStore.objects.all()
|
|
|
|
def check_view_permissions(self):
|
|
"""
|
|
Returns True if the user has required permissions.
|
|
"""
|
|
if self.action in ("list", "retrieve"):
|
|
result = self.get_access_permissions().check_permissions(self.request.user)
|
|
elif self.action == "metadata":
|
|
# Every authenticated user can see the metadata and list or
|
|
# retrieve the config. Anonymous users can do so if they are
|
|
# enabled.
|
|
result = self.request.user.is_authenticated or anonymous_is_enabled()
|
|
elif self.action in ("partial_update", "update"):
|
|
# The user needs 'core.can_manage_logos_and_fonts' for all config values
|
|
# starting with 'logo' and 'font'. For all other config values th euser needs
|
|
# the default permissions 'core.can_manage_config'.
|
|
pk = self.kwargs["pk"]
|
|
if pk.startswith("logo") or pk.startswith("font"):
|
|
result = has_perm(self.request.user, "core.can_manage_logos_and_fonts")
|
|
else:
|
|
result = has_perm(self.request.user, "core.can_manage_config")
|
|
else:
|
|
result = False
|
|
return result
|
|
|
|
def update(self, request, *args, **kwargs):
|
|
"""
|
|
Updates a config variable. Only managers can do this.
|
|
|
|
Example: {"value": 42}
|
|
"""
|
|
key = kwargs["pk"]
|
|
value = request.data.get("value")
|
|
if value is None:
|
|
raise ValidationError({"detail": "Invalid input. Config value is missing."})
|
|
|
|
# Validate and change value.
|
|
try:
|
|
config[key] = value
|
|
except ConfigNotFound:
|
|
raise Http404
|
|
except ConfigError as err:
|
|
raise ValidationError({"detail": str(err)})
|
|
|
|
# Return response.
|
|
return Response({"key": key, "value": value})
|
|
|
|
|
|
class ChatMessageViewSet(ModelViewSet):
|
|
"""
|
|
API endpoint for chat messages.
|
|
|
|
There are the following views: metadata, list, retrieve and create.
|
|
The views partial_update, update and destroy are disabled.
|
|
"""
|
|
|
|
access_permissions = ChatMessageAccessPermissions()
|
|
queryset = ChatMessage.objects.all()
|
|
|
|
def check_view_permissions(self):
|
|
"""
|
|
Returns True if the user has required permissions.
|
|
"""
|
|
if self.action in ("list", "retrieve"):
|
|
result = self.get_access_permissions().check_permissions(self.request.user)
|
|
elif self.action in ("metadata", "create"):
|
|
# We do not want anonymous users to use the chat even the anonymous
|
|
# group has the permission core.can_use_chat.
|
|
result = self.request.user.is_authenticated and has_perm(
|
|
self.request.user, "core.can_use_chat"
|
|
)
|
|
elif self.action == "clear":
|
|
result = has_perm(self.request.user, "core.can_use_chat") and has_perm(
|
|
self.request.user, "core.can_manage_chat"
|
|
)
|
|
else:
|
|
result = False
|
|
return result
|
|
|
|
def perform_create(self, serializer):
|
|
"""
|
|
Customized method to inject the request.user into serializer's save
|
|
method so that the request.user can be saved into the model field.
|
|
"""
|
|
serializer.save(user=self.request.user)
|
|
# Send chatter via autoupdate because users without permission
|
|
# to see users may not have it but can get it now.
|
|
inform_changed_data([self.request.user])
|
|
|
|
@list_route(methods=["post"])
|
|
def clear(self, request):
|
|
"""
|
|
Deletes all chat messages.
|
|
"""
|
|
# Collect all chat messages with their collection_string and id
|
|
chatmessages = ChatMessage.objects.all()
|
|
args = []
|
|
for chatmessage in chatmessages:
|
|
args.append((chatmessage.get_collection_string(), chatmessage.pk))
|
|
chatmessages.delete()
|
|
# Trigger autoupdate and setup response.
|
|
if args:
|
|
inform_deleted_data(args)
|
|
return Response({"detail": "All chat messages deleted successfully."})
|
|
|
|
|
|
class ProjectorMessageViewSet(ModelViewSet):
|
|
"""
|
|
API endpoint for messages.
|
|
|
|
There are the following views: list, retrieve, create, update,
|
|
partial_update and destroy.
|
|
"""
|
|
|
|
access_permissions = ProjectorMessageAccessPermissions()
|
|
queryset = ProjectorMessage.objects.all()
|
|
|
|
def check_view_permissions(self):
|
|
"""
|
|
Returns True if the user has required permissions.
|
|
"""
|
|
if self.action in ("list", "retrieve"):
|
|
result = self.get_access_permissions().check_permissions(self.request.user)
|
|
elif self.action in ("create", "partial_update", "update", "destroy"):
|
|
result = has_perm(self.request.user, "core.can_manage_projector")
|
|
else:
|
|
result = False
|
|
return result
|
|
|
|
|
|
class CountdownViewSet(ModelViewSet):
|
|
"""
|
|
API endpoint for Countdown.
|
|
|
|
There are the following views: list, retrieve, create, update,
|
|
partial_update and destroy.
|
|
"""
|
|
|
|
access_permissions = CountdownAccessPermissions()
|
|
queryset = Countdown.objects.all()
|
|
|
|
def check_view_permissions(self):
|
|
"""
|
|
Returns True if the user has required permissions.
|
|
"""
|
|
if self.action in ("list", "retrieve"):
|
|
result = self.get_access_permissions().check_permissions(self.request.user)
|
|
elif self.action in ("create", "partial_update", "update", "destroy"):
|
|
result = has_perm(self.request.user, "core.can_manage_projector")
|
|
else:
|
|
result = False
|
|
return result
|
|
|
|
|
|
class HistoryViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet):
|
|
"""
|
|
API endpoint for History.
|
|
|
|
There are the following views: list, retrieve, clear_history.
|
|
"""
|
|
|
|
access_permissions = HistoryAccessPermissions()
|
|
queryset = History.objects.all()
|
|
|
|
def check_view_permissions(self):
|
|
"""
|
|
Returns True if the user has required permissions.
|
|
"""
|
|
if self.action in ("list", "retrieve"):
|
|
result = self.get_access_permissions().check_permissions(self.request.user)
|
|
elif self.action == "clear_history":
|
|
result = in_some_groups(self.request.user.pk or 0, [GROUP_ADMIN_PK])
|
|
else:
|
|
result = False
|
|
return result
|
|
|
|
@list_route(methods=["post"])
|
|
def clear_history(self, request):
|
|
"""
|
|
Deletes and rebuilds the history.
|
|
"""
|
|
# Collect all history objects with their collection_string and id.
|
|
args = []
|
|
for history_obj in History.objects.all():
|
|
args.append((history_obj.get_collection_string(), history_obj.pk))
|
|
|
|
# Delete history data and history (via CASCADE)
|
|
HistoryData.objects.all().delete()
|
|
|
|
# Trigger autoupdate.
|
|
if args:
|
|
inform_deleted_data(args)
|
|
|
|
# Rebuild history.
|
|
history_instances = History.objects.build_history()
|
|
inform_changed_data(history_instances)
|
|
|
|
# Setup response.
|
|
return Response({"detail": "History was deleted successfully."})
|
|
|
|
|
|
# Special API views
|
|
|
|
|
|
class ServerTime(utils_views.APIView):
|
|
"""
|
|
Returns the server time as UNIX timestamp.
|
|
"""
|
|
|
|
http_method_names = ["get"]
|
|
|
|
def get_context_data(self, **context):
|
|
return now().timestamp()
|
|
|
|
|
|
class VersionView(utils_views.APIView):
|
|
"""
|
|
Returns a dictionary with the OpenSlides version and the version of all
|
|
plugins.
|
|
"""
|
|
|
|
http_method_names = ["get"]
|
|
|
|
def get_context_data(self, **context):
|
|
result: Dict[str, Any] = {
|
|
"openslides_version": version,
|
|
"openslides_license": license,
|
|
"openslides_url": url,
|
|
"plugins": [],
|
|
"no_name_yet_users": User.objects.filter(last_login__isnull=False).count(),
|
|
}
|
|
# Versions of plugins.
|
|
for plugin in settings.INSTALLED_PLUGINS:
|
|
result["plugins"].append(
|
|
{
|
|
"verbose_name": get_plugin_verbose_name(plugin),
|
|
"description": get_plugin_description(plugin),
|
|
"version": get_plugin_version(plugin),
|
|
"license": get_plugin_license(plugin),
|
|
"url": get_plugin_url(plugin),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
class HistoryView(utils_views.APIView):
|
|
"""
|
|
View to retrieve the history data of OpenSlides.
|
|
|
|
Use query paramter timestamp (UNIX timestamp) to get all elements from begin
|
|
until (including) this timestamp.
|
|
"""
|
|
|
|
http_method_names = ["get"]
|
|
|
|
def get_context_data(self, **context):
|
|
"""
|
|
Checks if user is in admin group. If yes all history data until
|
|
(including) timestamp are added to the response data.
|
|
"""
|
|
if not in_some_groups(self.request.user.pk or 0, [GROUP_ADMIN_PK]):
|
|
self.permission_denied(self.request)
|
|
try:
|
|
timestamp = int(self.request.query_params.get("timestamp", 0))
|
|
except ValueError:
|
|
raise ValidationError(
|
|
{"detail": "Invalid input. Timestamp should be an integer."}
|
|
)
|
|
data = []
|
|
queryset = History.objects.select_related("full_data")
|
|
if timestamp:
|
|
queryset = queryset.filter(
|
|
now__lte=datetime.datetime.fromtimestamp(timestamp)
|
|
)
|
|
for instance in queryset:
|
|
data.append(
|
|
{
|
|
"full_data": instance.full_data.full_data,
|
|
"element_id": instance.element_id,
|
|
"timestamp": instance.now.timestamp(),
|
|
"information": instance.information,
|
|
"user_id": instance.user.pk if instance.user else None,
|
|
}
|
|
)
|
|
return data
|