1753 lines
62 KiB
Python
1753 lines
62 KiB
Python
from decimal import Decimal
|
|
from typing import List, Set
|
|
|
|
import jsonschema
|
|
from django.contrib.auth import get_user_model
|
|
from django.db import transaction
|
|
from django.db.models import Case, When
|
|
from django.db.models.deletion import ProtectedError
|
|
from django.http.request import QueryDict
|
|
from rest_framework import status
|
|
|
|
from openslides.poll.views import BaseOptionViewSet, BasePollViewSet, BaseVoteViewSet
|
|
|
|
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 (
|
|
ModelViewSet,
|
|
Response,
|
|
ReturnDict,
|
|
ValidationError,
|
|
detail_route,
|
|
list_route,
|
|
)
|
|
from ..utils.views import TreeSortMixin
|
|
from .access_permissions import (
|
|
CategoryAccessPermissions,
|
|
MotionAccessPermissions,
|
|
MotionBlockAccessPermissions,
|
|
MotionChangeRecommendationAccessPermissions,
|
|
MotionCommentSectionAccessPermissions,
|
|
StateAccessPermissions,
|
|
StatuteParagraphAccessPermissions,
|
|
WorkflowAccessPermissions,
|
|
)
|
|
from .models import (
|
|
Category,
|
|
Motion,
|
|
MotionBlock,
|
|
MotionChangeRecommendation,
|
|
MotionComment,
|
|
MotionCommentSection,
|
|
MotionOption,
|
|
MotionPoll,
|
|
MotionVote,
|
|
State,
|
|
StatuteParagraph,
|
|
Submitter,
|
|
Workflow,
|
|
)
|
|
from .numbering import numbering
|
|
|
|
|
|
# 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", "manage_comments"):
|
|
result = has_perm(self.request.user, "motions.can_see")
|
|
# The rest of the check is done in the respective method. See below.
|
|
elif self.action in (
|
|
"manage_multiple_category",
|
|
"manage_multiple_motion_block",
|
|
"manage_multiple_state",
|
|
"set_recommendation",
|
|
"manage_multiple_recommendation",
|
|
"follow_recommendation",
|
|
"manage_multiple_submitters",
|
|
"manage_multiple_tags",
|
|
):
|
|
result = has_perm(self.request.user, "motions.can_see") and has_perm(
|
|
self.request.user, "motions.can_manage_metadata"
|
|
)
|
|
elif self.action == "sort":
|
|
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 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)
|
|
|
|
# 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", "amendment_paragraphs"))
|
|
|
|
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()
|
|
|
|
# Check removal of supporters and initiate response.
|
|
if (
|
|
config["motions_remove_supporters"]
|
|
and updated_motion.state.allow_support
|
|
and not has_perm(request.user, "motions.can_manage")
|
|
):
|
|
updated_motion.supporters.clear()
|
|
|
|
# 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 motions. 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": "A comment section with id {0} does not exist.",
|
|
"args": [section_id],
|
|
}
|
|
)
|
|
|
|
# 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()
|
|
|
|
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.
|
|
pass
|
|
else:
|
|
comment.delete()
|
|
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)
|
|
|
|
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": "Motion {0} does not exist", "args": [item["id"]]}
|
|
)
|
|
|
|
# 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": "Submitter {0} does not exist",
|
|
"args": [submitter_id],
|
|
}
|
|
)
|
|
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": "{0} motions successfully updated.",
|
|
"args": [len(motion_result)],
|
|
}
|
|
)
|
|
|
|
@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)
|
|
# 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)
|
|
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})
|
|
|
|
@list_route(methods=["post"])
|
|
@transaction.atomic
|
|
def manage_multiple_category(self, request):
|
|
"""
|
|
Set categories of multiple motions.
|
|
|
|
Send POST {"motions": [... see schema ...]} to changed the categories.
|
|
"""
|
|
motions = request.data.get("motions")
|
|
|
|
schema = {
|
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
"title": "Motion manage multiple categories schema",
|
|
"description": "An array of motion ids with the respective category id that should be set as category.",
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"description": "The id of the motion.", "type": "integer"},
|
|
"category": {
|
|
"description": "The id for the category that should become the new category.",
|
|
"anyOf": [{"type": "number", "minimum": 1}, {"type": "null"}],
|
|
},
|
|
},
|
|
"required": ["id", "category"],
|
|
},
|
|
"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": "Motion {0} does not exist", "args": [item["id"]]}
|
|
)
|
|
|
|
# Get category
|
|
category = None
|
|
if item["category"] is not None:
|
|
try:
|
|
category = Category.objects.get(pk=item["category"])
|
|
except Category.DoesNotExist:
|
|
raise ValidationError(
|
|
{
|
|
"detail": "Category {0} does not exist",
|
|
"args": [item["category"]],
|
|
}
|
|
)
|
|
|
|
# Set category
|
|
motion.category = category
|
|
|
|
# Save motion.
|
|
motion.save(
|
|
update_fields=["category", "last_modified"], skip_autoupdate=True
|
|
)
|
|
|
|
# Fire autoupdate again to save information to OpenSlides history.
|
|
information = (
|
|
["Category removed"]
|
|
if category is None
|
|
else ["Category set to {arg1}", category.name]
|
|
)
|
|
inform_changed_data(
|
|
motion, information=information, user_id=request.user.pk
|
|
)
|
|
|
|
# Finish motion.
|
|
motion_result.append(motion)
|
|
|
|
# Send response.
|
|
return Response(
|
|
{
|
|
"detail": "Category of {0} motions successfully set.",
|
|
"args": [len(motion_result)],
|
|
}
|
|
)
|
|
|
|
@list_route(methods=["post"])
|
|
@transaction.atomic
|
|
def manage_multiple_motion_block(self, request):
|
|
"""
|
|
Set motion blocks of multiple motions.
|
|
|
|
Send POST {"motions": [... see schema ...]} to changed the motion blocks.
|
|
"""
|
|
motions = request.data.get("motions")
|
|
|
|
schema = {
|
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
"title": "Motion manage multiple motion blocks schema",
|
|
"description": "An array of motion ids with the respective motion block id that should be set as motion block.",
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": {"description": "The id of the motion.", "type": "integer"},
|
|
"motion_block": {
|
|
"description": "The id for the motion block that should become the new motion block.",
|
|
"anyOf": [{"type": "number", "minimum": 1}, {"type": "null"}],
|
|
},
|
|
},
|
|
"required": ["id", "motion_block"],
|
|
},
|
|
"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": "Motion {0} does not exist", "args": [item["id"]]}
|
|
)
|
|
|
|
# Get motion block
|
|
motion_block = None
|
|
if item["motion_block"] is not None:
|
|
try:
|
|
motion_block = MotionBlock.objects.get(pk=item["motion_block"])
|
|
except MotionBlock.DoesNotExist:
|
|
raise ValidationError(
|
|
{
|
|
"detail": "MotionBlock {0} does not exist",
|
|
"args": [item["motion_block"]],
|
|
}
|
|
)
|
|
|
|
# Set motion bock
|
|
motion.motion_block = motion_block
|
|
|
|
# Save motion.
|
|
motion.save(
|
|
update_fields=["motion_block", "last_modified"], skip_autoupdate=True
|
|
)
|
|
|
|
# Fire autoupdate again to save information to OpenSlides history.
|
|
information = (
|
|
["Motion block removed"]
|
|
if motion_block is None
|
|
else ["Motion block set to {arg1}", motion_block.title]
|
|
)
|
|
inform_changed_data(
|
|
motion, information=information, user_id=request.user.pk
|
|
)
|
|
|
|
# Finish motion.
|
|
motion_result.append(motion)
|
|
|
|
# Send response.
|
|
return Response(
|
|
{
|
|
"detail": "Motion block of {0} motions successfully set.",
|
|
"args": [len(motion_result)],
|
|
}
|
|
)
|
|
|
|
@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": "You can not set the state to {0}.", "args": [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}."
|
|
|
|
# Send submitters and supporters via autoupdate because users without
|
|
# users.can_see may see them now.
|
|
inform_changed_data(map(lambda s: s.user, motion.submitters.all()))
|
|
inform_changed_data(motion.supporters.all())
|
|
|
|
# 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": "Motion {0} does not exist", "args": [item["id"]]}
|
|
)
|
|
|
|
# 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": "You can not set the state to {0}.", "args": [state_id]}
|
|
)
|
|
motion.set_state(state_id)
|
|
|
|
# Save motion.
|
|
motion.save(
|
|
update_fields=[
|
|
"state",
|
|
"identifier",
|
|
"identifier_number",
|
|
"last_modified",
|
|
],
|
|
skip_autoupdate=True,
|
|
)
|
|
|
|
# Send submitters and supporters via autoupdate because users without
|
|
# users.can_see may see them now.
|
|
inform_changed_data(map(lambda s: s.user, motion.submitters.all()))
|
|
inform_changed_data(motion.supporters.all())
|
|
|
|
# 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": "State of {0} motions successfully set.",
|
|
"args": [len(motion_result)],
|
|
}
|
|
)
|
|
|
|
@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": "You can not set the recommendation to {0}.",
|
|
"args": [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"
|
|
)
|
|
|
|
# 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": "The recommendation of the motion was set to {0}.",
|
|
"args": [label],
|
|
}
|
|
)
|
|
|
|
@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": "Motion {0} does not exist", "args": [item["id"]]}
|
|
)
|
|
|
|
# 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 {0}.",
|
|
"args": [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"
|
|
)
|
|
|
|
# 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": "{0} motions successfully updated.",
|
|
"args": [len(motion_result)],
|
|
}
|
|
)
|
|
|
|
@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()
|
|
|
|
motion.save(
|
|
update_fields=[
|
|
"state",
|
|
"identifier",
|
|
"identifier_number",
|
|
"state_extension",
|
|
"last_modified",
|
|
],
|
|
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."})
|
|
|
|
@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": "Motion {0} does not exist", "args": [item["id"]]}
|
|
)
|
|
|
|
# Set new tags
|
|
for tag_id in item["tags"]:
|
|
if not Tag.objects.filter(pk=tag_id).exists():
|
|
raise ValidationError(
|
|
{"detail": "Tag {0} does not exist", "args": [tag_id]}
|
|
)
|
|
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": "{0} motions successfully updated.",
|
|
"args": [len(motion_result)],
|
|
}
|
|
)
|
|
|
|
|
|
class MotionPollViewSet(BasePollViewSet):
|
|
"""
|
|
API endpoint for motion polls.
|
|
|
|
There are the following views: update, partial_update and destroy.
|
|
"""
|
|
|
|
queryset = MotionPoll.objects.all()
|
|
|
|
required_analog_fields = ["Y", "N", "votescast", "votesvalid", "votesinvalid"]
|
|
|
|
def has_manage_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 create(self, request, *args, **kwargs):
|
|
# set default pollmethod to YNA
|
|
if "pollmethod" not in request.data:
|
|
# hack to make request.data mutable. Otherwise fields cannot be changed.
|
|
if isinstance(request.data, QueryDict):
|
|
request.data._mutable = True
|
|
request.data["pollmethod"] = MotionPoll.POLLMETHOD_YNA
|
|
return super().create(request, *args, **kwargs)
|
|
|
|
def perform_create(self, serializer):
|
|
motion = serializer.validated_data["motion"]
|
|
if not motion.state.allow_create_poll:
|
|
raise ValidationError(
|
|
{"detail": "You can not create a poll in this motion state."}
|
|
)
|
|
|
|
super().perform_create(serializer)
|
|
|
|
# Fire autoupdate again to save information to OpenSlides history.
|
|
inform_changed_data(
|
|
motion, information=["Poll created"], user_id=self.request.user.pk
|
|
)
|
|
|
|
def update(self, *args, **kwargs):
|
|
"""
|
|
Customized view endpoint to update a motion poll.
|
|
"""
|
|
response = super().update(*args, **kwargs)
|
|
|
|
# Fire autoupdate again to save information to OpenSlides history.
|
|
poll = self.get_object()
|
|
inform_changed_data(
|
|
poll.motion, information=["Poll 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)
|
|
|
|
# Fire autoupdate again to save information to OpenSlides history.
|
|
inform_changed_data(
|
|
poll.motion, information=["Poll deleted"], user_id=self.request.user.pk
|
|
)
|
|
|
|
return result
|
|
|
|
def handle_analog_vote(self, data, poll, user):
|
|
option = poll.options.get()
|
|
vote, _ = MotionVote.objects.get_or_create(option=option, value="Y")
|
|
vote.weight = data["Y"]
|
|
vote.save()
|
|
vote, _ = MotionVote.objects.get_or_create(option=option, value="N")
|
|
vote.weight = data["N"]
|
|
vote.save()
|
|
if poll.pollmethod == MotionPoll.POLLMETHOD_YNA:
|
|
vote, _ = MotionVote.objects.get_or_create(option=option, value="A")
|
|
vote.weight = data["A"]
|
|
vote.save()
|
|
inform_changed_data(option)
|
|
|
|
for field in ["votesvalid", "votesinvalid", "votescast"]:
|
|
setattr(poll, field, data.get(field))
|
|
|
|
poll.save()
|
|
|
|
def validate_vote_data(self, data, poll, user):
|
|
"""
|
|
Request data for analog:
|
|
{ "Y": <amount>, "N": <amount>, ["A": <amount>],
|
|
["votesvalid": <amount>], ["votesinvalid": <amount>], ["votescast": <amount>]}
|
|
All amounts are decimals as strings
|
|
Request data for named/pseudoanonymous is just "Y" | "N" [| "A"]
|
|
"""
|
|
if poll.type == MotionPoll.TYPE_ANALOG:
|
|
if not isinstance(data, dict):
|
|
raise ValidationError({"detail": "Data must be a dict"})
|
|
|
|
for field in ["Y", "N", "votesvalid", "votesinvalid", "votescast"]:
|
|
data[field] = self.parse_vote_value(data, field)
|
|
if poll.pollmethod == MotionPoll.POLLMETHOD_YNA:
|
|
data["A"] = self.parse_vote_value(data, "A")
|
|
|
|
else:
|
|
if poll.pollmethod == MotionPoll.POLLMETHOD_YNA and data not in (
|
|
"Y",
|
|
"N",
|
|
"A",
|
|
):
|
|
raise ValidationError("Data must be Y, N or A")
|
|
elif poll.pollmethod == MotionPoll.POLLMETHOD_YN and data not in ("Y", "N"):
|
|
raise ValidationError("Data must be Y or N")
|
|
|
|
if poll.type == MotionPoll.TYPE_PSEUDOANONYMOUS:
|
|
if user in poll.options.get().voted.all():
|
|
raise ValidationError("You already voted on this poll")
|
|
|
|
def handle_named_vote(self, data, poll, user):
|
|
option = poll.options.get()
|
|
vote, _ = MotionVote.objects.get_or_create(user=user, option=option)
|
|
self.handle_named_and_pseudoanonymous_vote(vote, data, user, option)
|
|
|
|
def handle_pseudoanonymous_vote(self, data, poll, user):
|
|
option = poll.options.get()
|
|
vote = MotionVote.objects.create(user=None, option=option)
|
|
self.handle_named_and_pseudoanonymous_vote(vote, data, user, option)
|
|
|
|
def handle_named_and_pseudoanonymous_vote(self, vote, data, user, option):
|
|
vote.value = data
|
|
vote.weight = Decimal("1")
|
|
vote.save(no_delete_on_restriction=True)
|
|
|
|
option.voted.add(user)
|
|
option.save()
|
|
|
|
|
|
class MotionOptionViewSet(BaseOptionViewSet):
|
|
queryset = MotionOption.objects.all()
|
|
|
|
def check_view_permissions(self):
|
|
return has_perm(self.request.user, "motions.can_see")
|
|
|
|
|
|
class MotionVoteViewSet(BaseVoteViewSet):
|
|
queryset = MotionVote.objects.all()
|
|
|
|
def check_view_permissions(self):
|
|
return has_perm(self.request.user, "motions.can_see")
|
|
|
|
|
|
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 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", "sort"):
|
|
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 = "This section has still comments in motion {0}."
|
|
else:
|
|
msg = "This section has still comments in motions {0}."
|
|
|
|
msg += " " + "Please remove all comments before deletion."
|
|
raise ValidationError({"detail": msg, "args": [motions_verbose]})
|
|
return result
|
|
|
|
def update(self, *args, **kwargs):
|
|
response = super().update(*args, **kwargs)
|
|
# Update all affected motioncomments to update their `read_groups_id` field,
|
|
# which is taken from the updated section.
|
|
section = self.get_object()
|
|
inform_changed_data(MotionComment.objects.filter(section=section))
|
|
return response
|
|
|
|
@list_route(methods=["post"])
|
|
def sort(self, request, *args, **kwargs):
|
|
"""
|
|
Changes the sorting of comment sections. Every id must be given exactly once.
|
|
Expected data: { ids: [<id>, <id>, ...] }
|
|
"""
|
|
# Check request data format
|
|
ids = request.data.get("ids")
|
|
if not isinstance(ids, list):
|
|
raise ValidationError({"detail": "ids must be a list"})
|
|
for id in ids:
|
|
if not isinstance(id, int):
|
|
raise ValidationError({"detail": "every id must be an int"})
|
|
|
|
# Validate, that every id is given exactly once.
|
|
ids_set = set(ids)
|
|
if len(ids_set) != len(ids):
|
|
raise ValidationError({"detail": "only unique ids are expected"})
|
|
db_ids_set = set(
|
|
list(MotionCommentSection.objects.all().values_list(flat=True))
|
|
)
|
|
if ids_set != db_ids_set:
|
|
raise ValidationError({"detail": "every id must be given"})
|
|
|
|
# Ids are ok.
|
|
preserved = Case(*[When(pk=pk, then=pos) for pos, pk in enumerate(ids)])
|
|
queryset = MotionCommentSection.objects.filter(pk__in=ids).order_by(preserved)
|
|
for index, section in enumerate(queryset):
|
|
section.weight = index + 1
|
|
section.save()
|
|
|
|
return Response()
|
|
|
|
|
|
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(TreeSortMixin, 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", "metadata"):
|
|
result = self.get_access_permissions().check_permissions(self.request.user)
|
|
elif self.action in (
|
|
"create",
|
|
"partial_update",
|
|
"update",
|
|
"destroy",
|
|
"sort_categories",
|
|
"sort_motions",
|
|
"numbering",
|
|
):
|
|
result = has_perm(self.request.user, "motions.can_see") and has_perm(
|
|
self.request.user, "motions.can_manage"
|
|
)
|
|
else:
|
|
result = False
|
|
return result
|
|
|
|
@list_route(methods=["post"])
|
|
def sort_categories(self, request):
|
|
"""
|
|
Sorts all categoreis represented in a tree of ids. The request data should be
|
|
a list (the root) of all categories. 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, Category, "weight", "parent_id")
|
|
|
|
@detail_route(methods=["post"])
|
|
@transaction.atomic
|
|
def sort_motions(self, request, pk=None):
|
|
"""
|
|
Endpoint to sort all motions in the category.
|
|
|
|
Send POST {'motions': [<list of motion ids>]} to sort the given
|
|
motions in the given order. Ids of motions with another category or
|
|
non existing motions are ignored, but all motions of this category
|
|
have to be send.
|
|
"""
|
|
category = self.get_object()
|
|
|
|
ids = request.data.get("motions", None)
|
|
if not isinstance(ids, list):
|
|
raise ValidationError("The ids must be a list.")
|
|
|
|
motions = []
|
|
motion_ids: Set[int] = set() # To detect duplicated
|
|
for id in ids:
|
|
if not isinstance(id, int):
|
|
raise ValidationError("All ids must be int.")
|
|
|
|
if id in motion_ids:
|
|
continue # Duplicate id
|
|
|
|
try:
|
|
motion = Motion.objects.get(pk=id)
|
|
except Motion.DoesNotExist:
|
|
continue # Ignore invalid ids.
|
|
|
|
if motion.category is not None and motion.category.pk == category.pk:
|
|
motions.append(motion)
|
|
motion_ids.add(id)
|
|
|
|
if Motion.objects.filter(category=category).count() != len(motions):
|
|
raise ValidationError("Not all motions for this category are given")
|
|
|
|
# assign the category_weight field:
|
|
for weight, motion in enumerate(motions, start=1):
|
|
motion.category_weight = weight
|
|
motion.save(skip_autoupdate=True)
|
|
|
|
inform_changed_data(motions)
|
|
return Response()
|
|
|
|
@detail_route(methods=["post"])
|
|
def numbering(self, request, pk=None):
|
|
"""
|
|
Special view endpoint to number all motions in this category and all
|
|
subcategories. Only managers can use this view. For the actual numbering,
|
|
see `numbering.py`.
|
|
|
|
Request args: None (implicit: the main category via URL)
|
|
"""
|
|
main_category = self.get_object()
|
|
changed_instances = numbering(main_category)
|
|
inform_changed_data(
|
|
changed_instances, information=["Number set"], user_id=request.user.pk
|
|
)
|
|
return Response(
|
|
{
|
|
"detail": "All motions in category {0} numbered successfully.",
|
|
"args": [str(main_category)],
|
|
}
|
|
)
|
|
|
|
|
|
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
|
|
|
|
def perform_create(self, serializer):
|
|
serializer.save(request_user=self.request.user)
|
|
|
|
@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)
|
|
# 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 raiseProtectedError(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 {0} is assigned to motion {1}."
|
|
else:
|
|
msg = f"This {0} is assigned to motions {1}."
|
|
raise ValidationError(
|
|
{
|
|
"detail": f"{msg} Please remove all assignments before deletion.",
|
|
"args": [name, motions_verbose],
|
|
}
|
|
)
|
|
|
|
|
|
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", "metadata"):
|
|
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
|
|
|
|
@transaction.atomic
|
|
def destroy(self, *args, **kwargs):
|
|
"""
|
|
Customized view endpoint to delete a workflow.
|
|
"""
|
|
workflow_pk = self.get_object().pk
|
|
if not Workflow.objects.exclude(pk=workflow_pk).exists():
|
|
raise ValidationError({"detail": "You cannot delete the last workflow."})
|
|
|
|
try:
|
|
result = super().destroy(*args, **kwargs)
|
|
except ProtectedError as err:
|
|
self.raiseProtectedError("workflow", err)
|
|
|
|
# Change motion default workflows in the config
|
|
if int(config["motions_workflow"]) == workflow_pk:
|
|
config["motions_workflow"] = str(Workflow.objects.first().pk)
|
|
if int(config["motions_statute_amendments_workflow"]) == workflow_pk:
|
|
config["motions_statute_amendments_workflow"] = str(
|
|
Workflow.objects.first().pk
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
class StateViewSet(ModelViewSet, ProtectedErrorMessageMixin):
|
|
"""
|
|
API endpoint for workflow states.
|
|
|
|
There are the following views: create, update, partial_update and destroy.
|
|
"""
|
|
|
|
queryset = State.objects.all()
|
|
access_permissions = StateAccessPermissions()
|
|
|
|
def check_view_permissions(self):
|
|
"""
|
|
Returns True if the user has required permissions.
|
|
"""
|
|
if self.action in ("list", "retrieve", "metadata"):
|
|
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
|
|
|
|
def destroy(self, *args, **kwargs):
|
|
"""
|
|
Customized view endpoint to delete a state.
|
|
"""
|
|
state = self.get_object()
|
|
workflow = state.workflow
|
|
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:
|
|
self.raiseProtectedError("workflow", err)
|
|
inform_changed_data(workflow)
|
|
return result
|
|
|
|
def create(self, request, *args, **kwargs):
|
|
"""
|
|
"""
|
|
result = super().create(request, *args, **kwargs)
|
|
workflow_id = request.data[
|
|
"workflow_id"
|
|
] # This must be correct, if the state was created successfully
|
|
inform_changed_data(Workflow.objects.get(pk=workflow_id))
|
|
return result
|
|
|
|
def update(self, *args, **kwargs):
|
|
"""
|
|
Sends autoupdate for all motions that are affected by the state change.
|
|
Maybe the restriction was changed, so the view permission for some
|
|
motions could have been changed.
|
|
"""
|
|
result = super().update(*args, **kwargs)
|
|
state = self.get_object()
|
|
inform_changed_data(Motion.objects.filter(state=state))
|
|
return result
|