import jsonschema from django.contrib.auth import get_user_model from django.db import transaction from openslides.core.config import config from openslides.utils.autoupdate import inform_changed_data from openslides.utils.exceptions import OpenSlidesError from openslides.utils.rest_api import ( GenericViewSet, ListModelMixin, Response, RetrieveModelMixin, UpdateModelMixin, ValidationError, detail_route, list_route, ) from .access_permissions import ItemAccessPermissions from .models import Item, Speaker from ..utils.auth import has_perm # Viewsets for the REST API class ItemViewSet(ListModelMixin, RetrieveModelMixin, UpdateModelMixin, GenericViewSet): """ API endpoint for agenda items. There are some views, see check_view_permissions. """ access_permissions = ItemAccessPermissions() queryset = Item.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", "manage_speaker", "tree"): result = has_perm(self.request.user, "agenda.can_see") # For manage_speaker and tree requests the rest of the check is # done in the specific method. See below. elif self.action in ("partial_update", "update", "sort", "assign"): result = ( has_perm(self.request.user, "agenda.can_see") and has_perm(self.request.user, "agenda.can_see_internal_items") and has_perm(self.request.user, "agenda.can_manage") ) elif self.action in ("speak", "sort_speakers"): result = has_perm(self.request.user, "agenda.can_see") and has_perm( self.request.user, "agenda.can_manage_list_of_speakers" ) elif self.action in ("numbering",): result = has_perm(self.request.user, "agenda.can_see") and has_perm( self.request.user, "agenda.can_manage" ) else: result = False return result def update(self, *args, **kwargs): """ Customized view endpoint to update all children if the item type has changed. """ old_type = self.get_object().type response = super().update(*args, **kwargs) # Update all children if the item type has changed. item = self.get_object() if old_type != item.type: items_to_update = [] # Recursively add children to items_to_update. def add_item(item): items_to_update.append(item) for child in item.children.all(): add_item(child) add_item(item) inform_changed_data(items_to_update) return response @detail_route(methods=["POST", "PATCH", "DELETE"]) def manage_speaker(self, request, pk=None): """ Special view endpoint to add users to the list of speakers or remove them. Send POST {'user': } to add a new speaker. Omit data to add yourself. Send DELETE {'speaker': } or DELETE {'speaker': [, , ...]} to remove one or more speakers from the list of speakers. Omit data to remove yourself. Send PATCH {'user': , 'marked': } to mark the speaker. Checks also whether the requesting user can do this. He needs at least the permissions 'agenda.can_see' (see self.check_view_permissions()). In case of adding himself the permission 'agenda.can_be_speaker' is required. In case of adding someone else the permission 'agenda.can_manage' is required. In case of removing someone else 'agenda.can_manage' is required. In case of removing himself no other permission is required. """ # Retrieve item. item = self.get_object() if request.method == "POST": # Retrieve user_id user_id = request.data.get("user") # Check permissions and other conditions. Get user instance. if user_id is None: # Add oneself if not has_perm(self.request.user, "agenda.can_be_speaker"): self.permission_denied(request) if item.speaker_list_closed: raise ValidationError({"detail": "The list of speakers is closed."}) user = self.request.user else: # Add someone else. if not has_perm( self.request.user, "agenda.can_manage_list_of_speakers" ): self.permission_denied(request) try: user = get_user_model().objects.get(pk=int(user_id)) except (ValueError, get_user_model().DoesNotExist): raise ValidationError({"detail": "User does not exist."}) # Try to add the user. This ensurse that a user is not twice in the # list of coming speakers. try: Speaker.objects.add(user, item) except OpenSlidesError as e: raise ValidationError({"detail": str(e)}) # Send new speaker via autoupdate because users without permission # to see users may not have it but can get it now. inform_changed_data([user]) # Toggle 'marked' for the speaker elif request.method == "PATCH": # Check permissions if not has_perm(self.request.user, "agenda.can_manage_list_of_speakers"): self.permission_denied(request) # Retrieve user_id user_id = request.data.get("user") try: user = get_user_model().objects.get(pk=int(user_id)) except (ValueError, get_user_model().DoesNotExist): raise ValidationError({"detail": "User does not exist."}) marked = request.data.get("marked") if not isinstance(marked, bool): raise ValidationError({"detail": "Marked has to be a bool."}) queryset = Speaker.objects.filter(item=item, user=user, begin_time=None) try: # We assume that there aren't multiple entries for speakers that # did not yet begin to speak, because this # is forbidden by the Manager's add method. We assume that # there is only one speaker instance or none. speaker = queryset.get() except Speaker.DoesNotExist: raise ValidationError( {"detail": "The user is not in the list of speakers."} ) else: speaker.marked = marked speaker.save() else: # request.method == 'DELETE' speaker_ids = request.data.get("speaker") # Check permissions and other conditions. Get speaker instance. if speaker_ids is None: # Remove oneself queryset = Speaker.objects.filter( item=item, user=self.request.user ).exclude(weight=None) try: # We assume that there aren't multiple entries because this # is forbidden by the Manager's add method. We assume that # there is only one speaker instance or none. speaker = queryset.get() except Speaker.DoesNotExist: raise ValidationError( {"detail": "You are not on the list of speakers."} ) else: speaker.delete() else: # Remove someone else. if not has_perm( self.request.user, "agenda.can_manage_list_of_speakers" ): self.permission_denied(request) if isinstance(speaker_ids, int): speaker_ids = [speaker_ids] deleted_speaker_count = 0 for speaker_id in speaker_ids: try: speaker = Speaker.objects.get(pk=int(speaker_id)) except (ValueError, Speaker.DoesNotExist): pass else: speaker.delete(skip_autoupdate=True) deleted_speaker_count += 1 # send autoupdate if speakers are deleted if deleted_speaker_count: inform_changed_data(item) return Response() @detail_route(methods=["PUT", "DELETE"]) def speak(self, request, pk=None): """ Special view endpoint to begin and end speech of speakers. Send PUT {'speaker': } to begin speech. Omit data to begin speech of the next speaker. Send DELETE to end speech of current speaker. """ # Retrieve item. item = self.get_object() if request.method == "PUT": # Retrieve speaker_id speaker_id = request.data.get("speaker") if speaker_id is None: speaker = item.get_next_speaker() if speaker is None: raise ValidationError({"detail": "The list of speakers is empty."}) else: try: speaker = Speaker.objects.get(pk=int(speaker_id)) except (ValueError, Speaker.DoesNotExist): raise ValidationError({"detail": "Speaker does not exist."}) speaker.begin_speech() message = "User is now speaking." else: # request.method == 'DELETE' try: # We assume that there aren't multiple entries because this # is forbidden by the Model's begin_speech method. We assume that # there is only one speaker instance or none. current_speaker = ( Speaker.objects.filter(item=item, end_time=None) .exclude(begin_time=None) .get() ) except Speaker.DoesNotExist: raise ValidationError( { "detail": f"There is no one speaking at the moment according to {item}." } ) current_speaker.end_speech() message = "The speech is finished now." # Initiate response. return Response({"detail": message}) @detail_route(methods=["POST"]) def sort_speakers(self, request, pk=None): """ Special view endpoint to sort the list of speakers. Expects a list of IDs of the speakers. """ # Retrieve item. item = self.get_object() # Check data speaker_ids = request.data.get("speakers") if not isinstance(speaker_ids, list): raise ValidationError({"detail": "Invalid data."}) # Get all speakers speakers = {} for speaker in item.speakers.filter(begin_time=None): speakers[speaker.pk] = speaker # Check and sort speakers valid_speakers = [] for speaker_id in speaker_ids: if not isinstance(speaker_id, int) or speakers.get(speaker_id) is None: raise ValidationError({"detail": "Invalid data."}) valid_speakers.append(speakers[speaker_id]) weight = 0 with transaction.atomic(): for speaker in valid_speakers: speaker.weight = weight speaker.save(skip_autoupdate=True) weight += 1 # send autoupdate inform_changed_data(item) # Initiate response. return Response({"detail": "List of speakers successfully sorted."}) @list_route(methods=["post"]) def numbering(self, request): """ Auto numbering of the agenda according to the config. Manually added item numbers will be overwritten. """ if not config["agenda_enable_numbering"]: raise ValidationError( {"detail": "Numbering of agenda items is deactivated."} ) Item.objects.number_all(numeral_system=config["agenda_numeral_system"]) return Response({"detail": "The agenda has been numbered."}) @list_route(methods=["post"]) def sort(self, request): """ Sort agenda items. Also checks parent field to prevent hierarchical loops. """ nodes = request.data.get("nodes", []) parent_id = request.data.get("parent_id") items = [] with transaction.atomic(): for index, node in enumerate(nodes): item = Item.objects.get(pk=node["id"]) item.parent_id = parent_id item.weight = index item.save(skip_autoupdate=True) items.append(item) # Now check consistency. TODO: Try to use less DB queries. item = Item.objects.get(pk=node["id"]) ancestor = item.parent while ancestor is not None: if ancestor == item: raise ValidationError( { "detail": "There must not be a hierarchical loop. Please reload the page." } ) ancestor = ancestor.parent inform_changed_data(items) return Response({"detail": "The agenda has been sorted."}) @list_route(methods=["post"]) @transaction.atomic def assign(self, request): """ Assign multiple agenda items to a new parent item. Send POST {... see schema ...} to assign the new parent. This aslo checks the parent field to prevent hierarchical loops. """ schema = { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Agenda items assign new parent schema", "description": "An object containing an array of agenda item ids and the new parent id the items should be assigned to.", "type": "object", "propterties": { "items": { "description": "An array of agenda item ids where the items should be assigned to the new parent id.", "type": "array", "items": {"type": "integer"}, "minItems": 1, "uniqueItems": True, }, "parent_id": { "description": "The agenda item id of the new parent item.", "type": "integer", }, }, "required": ["items", "parent_id"], } # Validate request data. try: jsonschema.validate(request.data, schema) except jsonschema.ValidationError as err: raise ValidationError({"detail": str(err)}) # Check parent item try: parent = Item.objects.get(pk=request.data["parent_id"]) except Item.DoesNotExist: raise ValidationError( {"detail": f"Parent item {request.data['parent_id']} does not exist"} ) # Collect ancestors ancestors = [parent.pk] grandparent = parent.parent while grandparent is not None: ancestors.append(grandparent.pk) grandparent = grandparent.parent # First validate all items before changeing them. items = [] for item_id in request.data["items"]: # Prevent hierarchical loops. if item_id in ancestors: raise ValidationError( { "detail": f"Assigning item {item_id} to one of its children is not possible." } ) # Check every item try: items.append(Item.objects.get(pk=item_id)) except Item.DoesNotExist: raise ValidationError({"detail": f"Item {item_id} does not exist"}) # OK, assign new parents. for item in items: # Assign new parent. item.parent = parent item.save(skip_autoupdate=True) # Now inform all clients. inform_changed_data(items) # Send response. return Response({"detail": f"{len(items)} items successfully assigned."})