OpenSlides/openslides/motions/views.py
GabrielMeyer 76d760bd41 Replaces the old angular2tree with a custom drag&drop tree
Calculates the direction of the moving.

Finishes the moving of nodes in same level

Adds some style

Sets the padding dynamically

Adds placeholder depends on the horizontal movement

Set the placeholder at the correct place, so the user can see, where he will drop the moved node

Finishes moving of nodes

- Old parents change their option to expand.
- New parents change their option to expand.
- If the user moves a node between nodes with a higher level, the node will be moved to the next index with same or lower level.

Fixes the visibility of moved node

- If the new parent is not visible, the moved node will not be seen.

If the user moves an expanded node, the new parent should expanded, too, if it's not already.

Sending successfully data to the server

- Sorting the items

Handles moving nodes between parent and children

- If the user moves a node between a parent and its children, the children will be relinked to the moved node as their new parent.

Replaces the old `sorting-tree` to a new one

- The new `sorted-tree` replaces the old `sorting-tree`.
- The old package `angular-tree-component` was removed.
- The user will only see the buttons to save or cancel his changes, if he made changes.
- The buttons, that do not work currently, were removed.

Adds a guard to check if the user made changes.

- If the user made changes but he has not saved them, then there is a dialog that will prompt to ask for confirmation.

Before cancelling the changes the user has to confirm this.
2019-03-19 13:19:35 +01:00

1475 lines
54 KiB
Python

import re
from typing import List
import jsonschema
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction
from django.db.models.deletion import ProtectedError
from django.http.request import QueryDict
from rest_framework import status
from ..core.config import config
from ..core.models import Tag
from ..utils.auth import has_perm, in_some_groups
from ..utils.autoupdate import inform_changed_data, inform_deleted_data
from ..utils.rest_api import (
CreateModelMixin,
DestroyModelMixin,
GenericViewSet,
ModelViewSet,
Response,
ReturnDict,
UpdateModelMixin,
ValidationError,
detail_route,
list_route,
)
from ..utils.views import TreeSortMixin
from .access_permissions import (
CategoryAccessPermissions,
MotionAccessPermissions,
MotionBlockAccessPermissions,
MotionChangeRecommendationAccessPermissions,
MotionCommentSectionAccessPermissions,
StatuteParagraphAccessPermissions,
WorkflowAccessPermissions,
)
from .exceptions import WorkflowError
from .models import (
Category,
Motion,
MotionBlock,
MotionChangeRecommendation,
MotionComment,
MotionCommentSection,
MotionPoll,
State,
StatuteParagraph,
Submitter,
Workflow,
)
from .serializers import MotionPollSerializer, StateSerializer
# Viewsets for the REST API
class MotionViewSet(TreeSortMixin, ModelViewSet):
"""
API endpoint for motions.
There are a lot of views. See check_view_permissions().
"""
access_permissions = MotionAccessPermissions()
queryset = Motion.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", "partial_update", "update", "destroy"):
result = has_perm(self.request.user, "motions.can_see")
# For partial_update, update and destroy requests the rest of the check is
# done in the update method. See below.
elif self.action in ("create", "set_state"):
result = has_perm(self.request.user, "motions.can_see")
# For create the rest of the check is done in the respective method. See below.
elif self.action in (
"manage_multiple_state",
"set_recommendation",
"manage_multiple_recommendation",
"follow_recommendation",
"manage_multiple_submitters",
"manage_multiple_tags",
"create_poll",
):
result = has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage_metadata"
)
elif self.action in ("sort", "manage_comments"):
result = has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage"
)
elif self.action == "support":
result = has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_support"
)
else:
result = False
return result
def destroy(self, request, *args, **kwargs):
"""
Destroy is allowed if the user has manage permissions, or he is the submitter and
the current state allows to edit the motion.
"""
motion = self.get_object()
if not (
(
has_perm(request.user, "motions.can_manage")
or motion.is_submitter(request.user)
and motion.state.allow_submitter_edit
)
):
self.permission_denied(request)
result = super().destroy(request, *args, **kwargs)
# Fire autoupdate again to save information to OpenSlides history.
inform_deleted_data(
[(motion.get_collection_string(), motion.pk)],
information=["Motion deleted"],
user_id=request.user.pk,
)
return result
def create(self, request, *args, **kwargs):
"""
Customized view endpoint to create a new motion.
"""
# This is a hack to make request.data mutable. Otherwise fields can not be deleted.
if isinstance(request.data, QueryDict):
request.data._mutable = True
# Check if amendment request and if parent motion exists. Check also permissions.
if request.data.get("parent_id") is not None:
# Amendment
if not has_perm(self.request.user, "motions.can_create_amendments"):
self.permission_denied(request)
try:
parent_motion = Motion.objects.get(pk=request.data["parent_id"])
except Motion.DoesNotExist:
raise ValidationError({"detail": "The parent motion does not exist."})
else:
# Common motion
if not has_perm(self.request.user, "motions.can_create"):
self.permission_denied(request)
parent_motion = None
# Check permission to send some data.
if not has_perm(request.user, "motions.can_manage"):
# Remove fields that the user is not allowed to send.
# The list() is required because we want to use del inside the loop.
keys = list(request.data.keys())
whitelist = [
"title",
"text",
"reason",
"category_id",
"statute_paragraph_id",
"workflow_id",
]
if parent_motion is not None:
# For creating amendments.
whitelist.extend(
[
"parent_id",
"amendment_paragraphs",
"motion_block_id", # This and the category_id will be set to the matching
# values from parent_motion.
]
)
request.data["category_id"] = parent_motion.category_id
request.data["motion_block_id"] = parent_motion.motion_block_id
for key in keys:
if key not in whitelist:
del request.data[key]
# Validate data and create motion.
# Attention: Even user without permission can_manage_metadata is allowed
# to create a new motion and set such metadata like category, motion block and origin.
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
motion = serializer.save(request_user=request.user)
# Check for submitters and make ids unique
if isinstance(request.data, QueryDict):
submitters_id = request.data.getlist("submitters_id")
else:
submitters_id = request.data.get("submitters_id")
if submitters_id is None:
submitters_id = []
if not isinstance(submitters_id, list):
raise ValidationError(
{"detail": "If submitters_id is given, it has to be a list."}
)
submitters_id_unique = set()
for id in submitters_id:
try:
submitters_id_unique.add(int(id))
except ValueError:
continue
submitters = []
for submitter_id in submitters_id_unique:
try:
submitters.append(get_user_model().objects.get(pk=submitter_id))
except get_user_model().DoesNotExist:
continue # Do not add users that do not exist
# Add the request user, if he is authenticated and no submitters were given:
if not submitters and request.user.is_authenticated:
submitters.append(request.user)
# create all submitters
for submitter in submitters:
Submitter.objects.add(submitter, motion)
# Write the log message and initiate response.
motion.write_log(["Motion created"], request.user)
# Send new submitters and supporters via autoupdate because users
# without permission to see users may not have them but can get it now.
# TODO: Skip history.
new_users = list(motion.submitters.all())
new_users.extend(motion.supporters.all())
inform_changed_data(new_users)
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
motion, information=["Motion created"], user_id=request.user.pk
)
headers = self.get_success_headers(serializer.data)
# Strip out response data so nobody gets unrestricted data.
data = ReturnDict(id=serializer.data.get("id"), serializer=serializer)
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
def update(self, request, *args, **kwargs):
"""
Customized view endpoint to update a motion.
Checks also whether the requesting user can update the motion. He
needs at least the permissions 'motions.can_see' (see
self.check_view_permissions()). Also check manage permission or
submitter and state.
"""
# This is a hack to make request.data mutable. Otherwise fields can not be deleted.
if isinstance(request.data, QueryDict):
request.data._mutable = True
# Get motion.
motion = self.get_object()
# Check permissions.
if (
not has_perm(request.user, "motions.can_manage")
and not has_perm(request.user, "motions.can_manage_metadata")
and not (
motion.is_submitter(request.user) and motion.state.allow_submitter_edit
)
):
self.permission_denied(request)
# Check permission to send only some data.
# Attention: Users with motions.can_manage permission can change all
# fields even if they do not have motions.can_manage_metadata
# permission.
if not has_perm(request.user, "motions.can_manage"):
# Remove fields that the user is not allowed to change.
# The list() is required because we want to use del inside the loop.
keys = list(request.data.keys())
whitelist: List[str] = []
# Add title, text and reason to the whitelist only, if the user is the submitter.
if motion.is_submitter(request.user) and motion.state.allow_submitter_edit:
whitelist.extend(("title", "text", "reason"))
if has_perm(request.user, "motions.can_manage_metadata"):
whitelist.extend(
("category_id", "motion_block_id", "origin", "supporters_id")
)
for key in keys:
if key not in whitelist:
del request.data[key]
# Validate data and update motion.
serializer = self.get_serializer(
motion, data=request.data, partial=kwargs.get("partial", False)
)
serializer.is_valid(raise_exception=True)
updated_motion = serializer.save()
# Write the log message, check removal of supporters and initiate response.
# TODO: Log if a motion was updated.
updated_motion.write_log(["Motion updated"], request.user)
if (
config["motions_remove_supporters"]
and updated_motion.state.allow_support
and not has_perm(request.user, "motions.can_manage")
):
updated_motion.supporters.clear()
updated_motion.write_log(["All supporters removed"], request.user)
# Send new supporters via autoupdate because users
# without permission to see users may not have them but can get it now.
# TODO: Skip history.
new_users = list(updated_motion.supporters.all())
inform_changed_data(new_users)
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
updated_motion, information=["Motion updated"], user_id=request.user.pk
)
# We do not add serializer.data to response so nobody gets unrestricted data here.
return Response()
@list_route(methods=["post"])
def sort(self, request):
"""
Sorts all motions represented in a tree of ids. The request data should be a list (the root)
of all main agenda items. Each node is a dict with an id and optional children:
{
id: <the id>
children: [
<children, optional>
]
}
Every id has to be given.
"""
return self.sort_tree(request, Motion, "weight", "sort_parent_id")
@detail_route(methods=["POST", "DELETE"])
def manage_comments(self, request, pk=None):
"""
Create, update and delete motion comments.
Send a POST request with {'section_id': <id>, 'comment': '<comment>'}
to create a new comment or update an existing comment.
Send a DELETE request with just {'section_id': <id>} to delete the comment.
For every request, the user must have read and write permission for the given field.
"""
motion = self.get_object()
# Get the comment section
section_id = request.data.get("section_id")
if not section_id or not isinstance(section_id, int):
raise ValidationError(
{"detail": "You have to provide a section_id of type int."}
)
try:
section = MotionCommentSection.objects.get(pk=section_id)
except MotionCommentSection.DoesNotExist:
raise ValidationError(
{"detail": f"A comment section with id {section_id} does not exist."}
)
# the request user needs to see and write to the comment section
if not in_some_groups(
request.user, list(section.read_groups.values_list("pk", flat=True))
) or not in_some_groups(
request.user, list(section.write_groups.values_list("pk", flat=True))
):
raise ValidationError(
{
"detail": "You are not allowed to see or write to the comment section."
}
)
if request.method == "POST": # Create or update
# validate comment
comment_value = request.data.get("comment", "")
if not isinstance(comment_value, str):
raise ValidationError({"detail": "The comment should be a string."})
comment, created = MotionComment.objects.get_or_create(
motion=motion, section=section, defaults={"comment": comment_value}
)
if not created:
comment.comment = comment_value
comment.save()
# write log
motion.write_log([f"Comment {section.name} updated"], request.user)
message = ["Comment {arg1} updated", section.name]
else: # DELETE
try:
comment = MotionComment.objects.get(motion=motion, section=section)
except MotionComment.DoesNotExist:
# Be silent about not existing comments, but do not create a log entry.
pass
else:
comment.delete()
motion.write_log([f"Comment {section.name} deleted"], request.user)
message = ["Comment {arg1} deleted", section.name]
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
motion, information=message, user_id=request.user.pk, restricted=True
)
return Response({"detail": message})
@list_route(methods=["post"])
@transaction.atomic
def manage_multiple_submitters(self, request):
"""
Set or reset submitters of multiple motions.
Send POST {"motions": [... see schema ...]} to changed the submitters.
"""
motions = request.data.get("motions")
schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Motion manage multiple submitters schema",
"description": "An array of motion ids with the respective user ids that should be set as submitter.",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"description": "The id of the motion.", "type": "integer"},
"submitters": {
"description": "An array of user ids the should become submitters. Use an empty array to clear submitter field.",
"type": "array",
"items": {"type": "integer"},
"uniqueItems": True,
},
},
"required": ["id", "submitters"],
},
"uniqueItems": True,
}
# Validate request data.
try:
jsonschema.validate(motions, schema)
except jsonschema.ValidationError as err:
raise ValidationError({"detail": str(err)})
motion_result = []
new_submitters = []
for item in motions:
# Get motion.
try:
motion = Motion.objects.get(pk=item["id"])
except Motion.DoesNotExist:
raise ValidationError({"detail": f"Motion {item['id']} does not exist"})
# Remove all submitters.
Submitter.objects.filter(motion=motion).delete()
# Set new submitters.
for submitter_id in item["submitters"]:
try:
submitter = get_user_model().objects.get(pk=submitter_id)
except get_user_model().DoesNotExist:
raise ValidationError(
{"detail": f"Submitter {submitter_id} does not exist"}
)
Submitter.objects.add(submitter, motion)
new_submitters.append(submitter)
# Finish motion.
motion_result.append(motion)
# Now inform all clients.
inform_changed_data(
motion_result, information=["Submitters changed"], user_id=request.user.pk
)
# Also send all new submitters via autoupdate because users without
# permission to see users may not have them but can get it now.
# TODO: Skip history.
inform_changed_data(new_submitters)
# Send response.
return Response(
{"detail": f"{len(motion_result)} motions successfully updated."}
)
@detail_route(methods=["post", "delete"])
def support(self, request, pk=None):
"""
Special view endpoint to support a motion or withdraw support
(unsupport).
Send POST to support and DELETE to unsupport.
"""
# Retrieve motion and allowed actions.
motion = self.get_object()
# Support or unsupport motion.
if request.method == "POST":
# Support motion.
if not (
motion.state.allow_support
and config["motions_min_supporters"] > 0
and not motion.is_submitter(request.user)
and not motion.is_supporter(request.user)
):
raise ValidationError({"detail": "You can not support this motion."})
motion.supporters.add(request.user)
motion.write_log(["Motion supported"], request.user)
# Send new supporter via autoupdate because users without permission
# to see users may not have it but can get it now.
# TODO: Skip history.
inform_changed_data([request.user])
message = "You have supported this motion successfully."
else:
# Unsupport motion.
# request.method == 'DELETE'
if not motion.state.allow_support or not motion.is_supporter(request.user):
raise ValidationError({"detail": "You can not unsupport this motion."})
motion.supporters.remove(request.user)
motion.write_log(["Motion unsupported"], request.user)
message = "You have unsupported this motion successfully."
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
motion, information=["Supporters changed"], user_id=request.user.pk
)
# Initiate response.
return Response({"detail": message})
@detail_route(methods=["put"])
def set_state(self, request, pk=None):
"""
Special view endpoint to set and reset a state of a motion.
Send PUT {'state': <state_id>} to set and just PUT {} to reset the
state. Only managers can use this view.
If a state is given, it must be a next or previous state.
"""
# Retrieve motion and state.
motion = self.get_object()
state = request.data.get("state")
# Set or reset state.
if state is not None:
# Check data and set state.
if not has_perm(request.user, "motions.can_manage_metadata") and not (
motion.is_submitter(request.user) and motion.state.allow_submitter_edit
):
self.permission_denied(request)
try:
state_id = int(state)
except ValueError:
raise ValidationError(
{"detail": "Invalid data. State must be an integer."}
)
if not motion.state.is_next_or_previous_state_id(state_id):
raise ValidationError(
{"detail": f"You can not set the state to {state_id}."}
)
motion.set_state(state_id)
else:
# Reset state.
if not has_perm(self.request.user, "motions.can_manage_metadata"):
self.permission_denied(request)
motion.reset_state()
# Save motion.
motion.save(
update_fields=["state", "identifier", "identifier_number", "last_modified"],
skip_autoupdate=True,
)
message = f"The state of the motion was set to {motion.state.name}."
# Write the log message and initiate response.
motion.write_log(
message_list=[f"State set to {motion.state.name}"],
person=request.user,
skip_autoupdate=True,
)
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
motion,
information=["State set to {arg1}", motion.state.name],
user_id=request.user.pk,
)
return Response({"detail": message})
@list_route(methods=["post"])
@transaction.atomic
def manage_multiple_state(self, request):
"""
Set or reset states of multiple motions.
Send POST {"motions": [... see schema ...]} to changed the states.
"""
motions = request.data.get("motions")
schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Motion manage multiple state schema",
"description": "An array of motion ids with the respective state ids that should be set as new state.",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"description": "The id of the motion.", "type": "integer"},
"state": {
"description": "The state id the should become the new state.",
"type": "integer",
"minimum": 1,
},
},
"required": ["id", "state"],
},
"uniqueItems": True,
}
# Validate request data.
try:
jsonschema.validate(motions, schema)
except jsonschema.ValidationError as err:
raise ValidationError({"detail": str(err)})
motion_result = []
for item in motions:
# Get motion.
try:
motion = Motion.objects.get(pk=item["id"])
except Motion.DoesNotExist:
raise ValidationError({"detail": f"Motion {item['id']} does not exist"})
# Set or reset state.
state_id = item["state"]
valid_states = State.objects.filter(workflow=motion.workflow_id)
if state_id not in [item.id for item in valid_states]:
# States of different workflows are not allowed.
raise ValidationError(
{"detail": f"You can not set the state to {state_id}."}
)
motion.set_state(state_id)
# Save motion.
motion.save(
update_fields=[
"state",
"identifier",
"identifier_number",
"last_modified",
],
skip_autoupdate=True,
)
# Write the log message.
motion.write_log(
message_list=["State set to", " ", motion.state.name],
person=request.user,
skip_autoupdate=True,
)
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
motion,
information=["State set to {arg1}", motion.state.name],
user_id=request.user.pk,
)
# Finish motion.
motion_result.append(motion)
# Send response.
return Response(
{"detail": f"State of {len(motion_result)} motions successfully set."}
)
@detail_route(methods=["put"])
def set_recommendation(self, request, pk=None):
"""
Special view endpoint to set a recommendation of a motion.
Send PUT {'recommendation': <state_id>} to set and just PUT {} to
reset the recommendation. Only managers can use this view.
"""
# Retrieve motion and recommendation state.
motion = self.get_object()
recommendation_state = request.data.get("recommendation")
# Set or reset recommendation.
if recommendation_state is not None:
# Check data and set recommendation.
try:
recommendation_state_id = int(recommendation_state)
except ValueError:
raise ValidationError(
{"detail": "Invalid data. Recommendation must be an integer."}
)
recommendable_states = State.objects.filter(
workflow=motion.workflow_id, recommendation_label__isnull=False
)
if recommendation_state_id not in [
item.id for item in recommendable_states
]:
raise ValidationError(
{
"detail": f"You can not set the recommendation to {recommendation_state_id}."
}
)
motion.set_recommendation(recommendation_state_id)
else:
# Reset recommendation.
motion.recommendation = None
# Save motion.
motion.save(
update_fields=["recommendation", "last_modified"], skip_autoupdate=True
)
label = (
motion.recommendation.recommendation_label
if motion.recommendation
else "None"
)
message = f"The recommendation of the motion was set to {label}."
# Write the log message and initiate response.
motion.write_log(
message_list=["Recommendation set to", " ", label],
person=request.user,
skip_autoupdate=True,
)
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
motion,
information=["Recommendation set to {arg1}", label],
user_id=request.user.pk,
)
return Response({"detail": message})
@list_route(methods=["post"])
@transaction.atomic
def manage_multiple_recommendation(self, request):
"""
Set or reset recommendations of multiple motions.
Send POST {"motions": [... see schema ...]} to changed the recommendations.
"""
motions = request.data.get("motions")
schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Motion manage multiple recommendations schema",
"description": "An array of motion ids with the respective state ids that should be set as recommendation.",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"description": "The id of the motion.", "type": "integer"},
"recommendation": {
"description": "The state id the should become recommendation. Use 0 to clear recommendation field.",
"type": "integer",
},
},
"required": ["id", "recommendation"],
},
"uniqueItems": True,
}
# Validate request data.
try:
jsonschema.validate(motions, schema)
except jsonschema.ValidationError as err:
raise ValidationError({"detail": str(err)})
motion_result = []
for item in motions:
# Get motion.
try:
motion = Motion.objects.get(pk=item["id"])
except Motion.DoesNotExist:
raise ValidationError({"detail": f"Motion {item['id']} does not exist"})
# Set or reset recommendation.
recommendation_state_id = item["recommendation"]
if recommendation_state_id == 0:
# Reset recommendation.
motion.recommendation = None
else:
# Check data and set recommendation.
recommendable_states = State.objects.filter(
workflow=motion.workflow_id, recommendation_label__isnull=False
)
if recommendation_state_id not in [
item.id for item in recommendable_states
]:
raise ValidationError(
{
"detail": "You can not set the recommendation to {recommendation_state_id}."
}
)
motion.set_recommendation(recommendation_state_id)
# Save motion.
motion.save(
update_fields=["recommendation", "last_modified"], skip_autoupdate=True
)
label = (
motion.recommendation.recommendation_label
if motion.recommendation
else "None"
)
# Write the log message.
motion.write_log(
message_list=["Recommendation set to", " ", label],
person=request.user,
skip_autoupdate=True,
)
# Fire autoupdate and save information to OpenSlides history.
inform_changed_data(
motion,
information=["Recommendation set to {arg1}", label],
user_id=request.user.pk,
)
# Finish motion.
motion_result.append(motion)
# Send response.
return Response(
{"detail": f"{len(motion_result)} motions successfully updated."}
)
@detail_route(methods=["post"])
def follow_recommendation(self, request, pk=None):
motion = self.get_object()
if motion.recommendation is None:
raise ValidationError({"detail": "Cannot set an empty recommendation."})
motion.follow_recommendation()
# Save and write log.
motion.save(
update_fields=[
"state",
"identifier",
"identifier_number",
"state_extension",
"last_modified",
],
skip_autoupdate=True,
)
motion.write_log(
message_list=["State set to", " ", motion.state.name],
person=request.user,
skip_autoupdate=True,
)
# Now send all changes to the clients.
inform_changed_data(
motion,
information=["State set to {arg1}", motion.state.name],
user_id=request.user.pk,
)
return Response({"detail": "Recommendation followed successfully."})
@detail_route(methods=["post"])
def create_poll(self, request, pk=None):
"""
View to create a poll. It is a POST request without any data.
"""
motion = self.get_object()
if not motion.state.allow_create_poll:
raise ValidationError(
{"detail": "You can not create a poll in this motion state."}
)
try:
with transaction.atomic():
poll = motion.create_poll(skip_autoupdate=True)
except WorkflowError as err:
raise ValidationError({"detail": err})
motion.write_log(["Vote created"], request.user, skip_autoupdate=True)
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
motion, information=["Vote created"], user_id=request.user.pk
)
return Response(
{"detail": "Vote created successfully.", "createdPollId": poll.pk}
)
@list_route(methods=["post"])
@transaction.atomic
def manage_multiple_tags(self, request):
"""
Set or reset tags of multiple motions.
Send POST {"motions": [... see schema ...]} to changed the tags.
"""
motions = request.data.get("motions")
schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Motion manage multiple tags schema",
"description": "An array of motion ids with the respective tags ids that should be set as tag.",
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"description": "The id of the motion.", "type": "integer"},
"tags": {
"description": "An array of tag ids the should become tags. Use an empty array to clear tag field.",
"type": "array",
"items": {"type": "integer"},
"uniqueItems": True,
},
},
"required": ["id", "tags"],
},
"uniqueItems": True,
}
# Validate request data.
try:
jsonschema.validate(motions, schema)
except jsonschema.ValidationError as err:
raise ValidationError({"detail": str(err)})
motion_result = []
for item in motions:
# Get motion.
try:
motion = Motion.objects.get(pk=item["id"])
except Motion.DoesNotExist:
raise ValidationError({"detail": f"Motion {item['id']} does not exist"})
# Set new tags
for tag_id in item["tags"]:
if not Tag.objects.filter(pk=tag_id).exists():
raise ValidationError({"detail": f"Tag {tag_id} does not exist"})
motion.tags.set(item["tags"])
# Finish motion.
motion_result.append(motion)
# Now inform all clients.
inform_changed_data(motion_result)
# Send response.
return Response(
{"detail": f"{len(motion_result)} motions successfully updated."}
)
class MotionPollViewSet(UpdateModelMixin, DestroyModelMixin, GenericViewSet):
"""
API endpoint for motion polls.
There are the following views: update, partial_update and destroy.
"""
queryset = MotionPoll.objects.all()
serializer_class = MotionPollSerializer
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
return has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage_metadata"
)
def update(self, *args, **kwargs):
"""
Customized view endpoint to update a motion poll.
"""
response = super().update(*args, **kwargs)
poll = self.get_object()
poll.motion.write_log(["Vote updated"], self.request.user)
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
poll.motion, information=["Vote updated"], user_id=self.request.user.pk
)
return response
def destroy(self, *args, **kwargs):
"""
Customized view endpoint to delete a motion poll.
"""
poll = self.get_object()
result = super().destroy(*args, **kwargs)
poll.motion.write_log(["Vote deleted"], self.request.user)
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
poll.motion, information=["Vote deleted"], user_id=self.request.user.pk
)
return result
class MotionChangeRecommendationViewSet(ModelViewSet):
"""
API endpoint for motion change recommendations.
There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy.
"""
access_permissions = MotionChangeRecommendationAccessPermissions()
queryset = MotionChangeRecommendation.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, "motions.can_see")
elif self.action in ("create", "destroy", "partial_update", "update"):
result = has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage"
)
else:
result = False
return result
def create(self, request, *args, **kwargs):
"""
Creating a Change Recommendation, custom exception handling
"""
try:
return super().create(request, *args, **kwargs)
except DjangoValidationError as err:
return Response({"detail": err.message}, status=400)
def perform_create(self, serializer):
"""
Customized method to add history information.
"""
instance = serializer.save()
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
instance,
information=["Motion change recommendation created"],
user_id=self.request.user.pk,
)
def perform_update(self, serializer):
"""
Customized method to add history information.
"""
instance = serializer.save()
# Fire autoupdate again to save information to OpenSlides history.
inform_changed_data(
instance,
information=["Motion change recommendation updated"],
user_id=self.request.user.pk,
)
def destroy(self, request, *args, **kwargs):
"""
Customized method to add history information.
"""
instance = self.get_object()
result = super().destroy(request, *args, **kwargs)
# Fire autoupdate again to save information to OpenSlides history.
inform_deleted_data(
[(instance.get_collection_string(), instance.pk)],
information=["Motion change recommendation deleted"],
user_id=request.user.pk,
)
return result
class MotionCommentSectionViewSet(ModelViewSet):
"""
API endpoint for motion comment fields.
"""
access_permissions = MotionCommentSectionAccessPermissions()
queryset = MotionCommentSection.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", "destroy", "update", "partial_update"):
result = has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage"
)
else:
result = False
return result
def destroy(self, *args, **kwargs):
"""
Customized view endpoint to delete a motion comment section. Will return
an error for the user, if still comments for this section exist.
"""
try:
result = super().destroy(*args, **kwargs)
except ProtectedError as err:
# The protected objects can just be motion comments.
motions = [f'"{comment.motion}"' for comment in err.protected_objects.all()]
count = len(motions)
motions_verbose = ", ".join(motions[:3])
if count > 3:
motions_verbose += ", ..."
if count == 1:
msg = f"This section has still comments in motion {motions_verbose}."
else:
msg = f"This section has still comments in motions {motions_verbose}."
msg += " " + "Please remove all comments before deletion."
raise ValidationError({"detail": msg})
return result
class StatuteParagraphViewSet(ModelViewSet):
"""
API endpoint for statute paragraphs.
There are the following views: list, retrieve, create,
partial_update, update and destroy.
"""
access_permissions = StatuteParagraphAccessPermissions()
queryset = StatuteParagraph.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, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage"
)
else:
result = False
return result
class CategoryViewSet(ModelViewSet):
"""
API endpoint for categories.
There are the following views: metadata, list, retrieve, create,
partial_update, update, destroy and numbering.
"""
access_permissions = CategoryAccessPermissions()
queryset = Category.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, "motions.can_see")
elif self.action in (
"create",
"partial_update",
"update",
"destroy",
"numbering",
):
result = has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage"
)
else:
result = False
return result
@detail_route(methods=["post"])
def numbering(self, request, pk=None):
"""
Special view endpoint to number all motions in this category.
Only managers can use this view.
Send POST {'motions': [<list of motion ids>]} to sort the given
motions in a special order. Ids of motions which do not belong to
the category are just ignored. Send just POST {} to sort all
motions in the category by database id.
Amendments will get a new identifier prefix if the old prefix matches
the old parent motion identifier.
"""
category = self.get_object()
number = 0
instances = []
# If MOTION_IDENTIFIER_WITHOUT_BLANKS is set, don't use blanks when building identifier.
without_blank = (
hasattr(settings, "MOTION_IDENTIFIER_WITHOUT_BLANKS")
and settings.MOTION_IDENTIFIER_WITHOUT_BLANKS
)
# Prepare ordered list of motions.
if not category.prefix:
prefix = ""
elif without_blank:
prefix = category.prefix
else:
prefix = f"{category.prefix} "
motions = category.motion_set.all()
motion_list = request.data.get("motions")
if motion_list:
motion_dict = {}
for motion in motions.filter(id__in=motion_list):
motion_dict[motion.pk] = motion
motions = [motion_dict[pk] for pk in motion_list]
# Change identifiers.
error_message = None
try:
with transaction.atomic():
# Collect old and new identifiers.
motions_to_be_sorted = []
for motion in motions:
if motion.is_amendment():
parent_identifier = motion.parent.identifier or ""
if without_blank:
prefix = f"{parent_identifier}{config['motions_amendments_prefix']}"
else:
prefix = f"{parent_identifier} {config['motions_amendments_prefix']} "
number += 1
new_identifier = (
f"{prefix}{motion.extend_identifier_number(number)}"
)
motions_to_be_sorted.append(
{
"motion": motion,
"old_identifier": motion.identifier,
"new_identifier": new_identifier,
"number": number,
}
)
# Remove old identifiers
for motion in motions:
motion.identifier = None
# This line is to skip agenda item autoupdate. See agenda/signals.py.
motion.agenda_item_update_information["skip_autoupdate"] = True
motion.save(skip_autoupdate=True)
# Set new identifers and change identifiers of amendments.
for obj in motions_to_be_sorted:
if Motion.objects.filter(identifier=obj["new_identifier"]).exists():
# Set the error message and let the code run into an IntegrityError
new_identifier = obj["new_identifier"]
error_message = (
f'Numbering aborted because the motion identifier "{new_identifier}" '
"already exists outside of this category."
)
motion = obj["motion"]
motion.identifier = obj["new_identifier"]
motion.identifier_number = obj["number"]
motion.save(skip_autoupdate=True)
instances.append(motion)
instances.append(motion.agenda_item)
# Change identifiers of amendments.
for child in motion.get_amendments_deep():
if child.identifier and child.identifier.startswith(
obj["old_identifier"]
):
child.identifier = re.sub(
obj["old_identifier"],
obj["new_identifier"],
child.identifier,
count=1,
)
# This line is to skip agenda item autoupdate. See agenda/signals.py.
child.agenda_item_update_information[
"skip_autoupdate"
] = True
child.save(skip_autoupdate=True)
instances.append(child)
instances.append(child.agenda_item)
except IntegrityError:
if error_message is None:
error_message = "Error: At least one identifier of this category does already exist in another category."
response = Response({"detail": error_message}, status=400)
else:
inform_changed_data(
instances, information=["Number set"], user_id=request.user.pk
)
message = f"All motions in category {category} numbered " "successfully."
response = Response({"detail": message})
return response
class MotionBlockViewSet(ModelViewSet):
"""
API endpoint for motion blocks.
There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy.
"""
access_permissions = MotionBlockAccessPermissions()
queryset = MotionBlock.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, "motions.can_see")
elif self.action in (
"create",
"partial_update",
"update",
"destroy",
"follow_recommendations",
):
result = has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage"
)
else:
result = False
return result
@detail_route(methods=["post"])
def follow_recommendations(self, request, pk=None):
"""
View to set the states of all motions of this motion block each to
its recommendation. It is a POST request without any data.
"""
motion_block = self.get_object()
with transaction.atomic():
for motion in motion_block.motion_set.all():
# Follow recommendation.
motion.follow_recommendation()
motion.save(skip_autoupdate=True)
# Write the log message.
motion.write_log(
message_list=["State set to", " ", motion.state.name],
person=request.user,
skip_autoupdate=True,
)
# Fire autoupdate and save information to OpenSlides history.
inform_changed_data(
motion,
information=["State set to {arg1}", motion.state.name],
user_id=request.user.pk,
)
return Response({"detail": "Followed recommendations successfully."})
class ProtectedErrorMessageMixin:
def getProtectedErrorMessage(self, name, error):
# The protected objects can just be motions..
motions = ['"' + str(m) + '"' for m in error.protected_objects.all()]
count = len(motions)
motions_verbose = ", ".join(motions[:3])
if count > 3:
motions_verbose += ", ..."
if count == 1:
msg = f"This {name} is assigned to motion {motions_verbose}."
else:
msg = f"This {name} is assigned to motions {motions_verbose}."
return f"{msg} Please remove all assignments before deletion."
class WorkflowViewSet(ModelViewSet, ProtectedErrorMessageMixin):
"""
API endpoint for workflows.
There are the following views: metadata, list, retrieve, create,
partial_update, update and destroy.
"""
access_permissions = WorkflowAccessPermissions()
queryset = Workflow.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, "motions.can_see")
elif self.action in ("create", "partial_update", "update", "destroy"):
result = has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage"
)
else:
result = False
return result
def destroy(self, *args, **kwargs):
"""
Customized view endpoint to delete a workflow.
"""
try:
result = super().destroy(*args, **kwargs)
except ProtectedError as err:
msg = self.getProtectedErrorMessage("workflow", err)
raise ValidationError({"detail": msg})
return result
class StateViewSet(
CreateModelMixin,
UpdateModelMixin,
DestroyModelMixin,
GenericViewSet,
ProtectedErrorMessageMixin,
):
"""
API endpoint for workflow states.
There are the following views: create, update, partial_update and destroy.
"""
queryset = State.objects.all()
serializer_class = StateSerializer
def check_view_permissions(self):
"""
Returns True if the user has required permissions.
"""
return has_perm(self.request.user, "motions.can_see") and has_perm(
self.request.user, "motions.can_manage"
)
def destroy(self, *args, **kwargs):
"""
Customized view endpoint to delete a state.
"""
state = self.get_object()
if state.workflow.first_state.pk == state.pk:
# is this the first state of the workflow?
raise ValidationError(
{"detail": "You cannot delete the first state of the workflow."}
)
try:
result = super().destroy(*args, **kwargs)
except ProtectedError as err:
msg = self.getProtectedErrorMessage("workflow", err)
raise ValidationError({"detail": msg})
return result