1273 lines
51 KiB
Python
1273 lines
51 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 django.utils.translation import ugettext as _, ugettext_noop
|
|
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
|
|
from ..utils.exceptions import OpenSlidesError
|
|
from ..utils.rest_api import (
|
|
CreateModelMixin,
|
|
DestroyModelMixin,
|
|
GenericViewSet,
|
|
ModelViewSet,
|
|
Response,
|
|
ReturnDict,
|
|
UpdateModelMixin,
|
|
ValidationError,
|
|
detail_route,
|
|
list_route,
|
|
)
|
|
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(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 == 'create':
|
|
result = (has_perm(self.request.user, 'motions.can_see') and
|
|
has_perm(self.request.user, 'motions.can_create') and
|
|
(not config['motions_stop_submitting'] or
|
|
has_perm(self.request.user, 'motions.can_manage')))
|
|
elif self.action in ('set_state', 'set_recommendation', 'manage_multiple_recommendation',
|
|
'follow_recommendation', 'manage_submitters',
|
|
'sort_submitters', '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)
|
|
|
|
return super().destroy(request, *args, **kwargs)
|
|
|
|
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 parent motion exists.
|
|
if request.data.get('parent_id') is not None:
|
|
try:
|
|
parent_motion = Motion.objects.get(pk=request.data['parent_id'])
|
|
except Motion.DoesNotExist:
|
|
raise ValidationError({'detail': _('The parent motion does not exist.')})
|
|
else:
|
|
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',
|
|
]
|
|
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 len(submitters) == 0 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([ugettext_noop('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.
|
|
new_users = list(motion.submitters.all())
|
|
new_users.extend(motion.supporters.all())
|
|
inform_changed_data(new_users)
|
|
|
|
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([ugettext_noop('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([ugettext_noop('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.
|
|
new_users = list(updated_motion.supporters.all())
|
|
inform_changed_data(new_users)
|
|
|
|
# We do not add serializer.data to response so nobody gets unrestricted data here.
|
|
return Response()
|
|
|
|
@list_route(methods=['post'])
|
|
def sort(self, request):
|
|
"""
|
|
Sort motions. Also checks sort_parent field to prevent hierarchical loops.
|
|
|
|
Note: This view is not tested! Maybe needs to be refactored. Add documentation
|
|
abou the data to be send.
|
|
"""
|
|
nodes = request.data.get('nodes', [])
|
|
sort_parent_id = request.data.get('parent_id')
|
|
motions = []
|
|
with transaction.atomic():
|
|
for index, node in enumerate(nodes):
|
|
id = node['id']
|
|
motion = Motion.objects.get(pk=id)
|
|
motion.sort_parent_id = sort_parent_id
|
|
motion.weight = index
|
|
motion.save(skip_autoupdate=True)
|
|
motions.append(motion)
|
|
|
|
# Now check consistency. TODO: Try to use less DB queries.
|
|
motion = Motion.objects.get(pk=id)
|
|
ancestor = motion.sort_parent
|
|
while ancestor is not None:
|
|
if ancestor == motion:
|
|
raise ValidationError({'detail': _(
|
|
'There must not be a hierarchical loop.')})
|
|
ancestor = ancestor.sort_parent
|
|
|
|
inform_changed_data(motions)
|
|
return Response({'detail': _('The motions has been sorted.')})
|
|
|
|
@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 {} does not exist').format(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()
|
|
|
|
# write log
|
|
motion.write_log(
|
|
[ugettext_noop('Comment {} updated').format(section.name)],
|
|
request.user)
|
|
message = _('Comment {} updated').format(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(
|
|
[ugettext_noop('Comment {} deleted').format(section.name)],
|
|
request.user)
|
|
message = _('Comment {} deleted').format(section.name)
|
|
|
|
return Response({'detail': message})
|
|
|
|
@detail_route(methods=['POST', 'DELETE'])
|
|
def manage_submitters(self, request, pk=None):
|
|
"""
|
|
POST: Add a user as a submitter to this motion.
|
|
DELETE: Remove the user as a submitter from this motion.
|
|
For both cases provide ['user': <user_id>} for the user to add or remove.
|
|
"""
|
|
motion = self.get_object()
|
|
|
|
if request.method == 'POST':
|
|
user_id = request.data.get('user')
|
|
|
|
# Check permissions and other conditions. Get user instance.
|
|
if user_id is None:
|
|
raise ValidationError({'detail': _('You have to provide a user.')})
|
|
else:
|
|
try:
|
|
user = get_user_model().objects.get(pk=int(user_id))
|
|
except (ValueError, get_user_model().DoesNotExist):
|
|
raise ValidationError({'detail': _('User does not exist.')})
|
|
|
|
# Try to add the user. This ensurse that a user is not twice a submitter
|
|
try:
|
|
Submitter.objects.add(user, motion)
|
|
except OpenSlidesError as e:
|
|
raise ValidationError({'detail': str(e)})
|
|
message = _('User %s was successfully added as a submitter.') % user
|
|
|
|
# Send new submitter via autoupdate because users without permission
|
|
# to see users may not have it but can get it now.
|
|
inform_changed_data(user)
|
|
|
|
else: # DELETE
|
|
user_id = request.data.get('user')
|
|
|
|
# Check permissions and other conditions. Get user instance.
|
|
if user_id is None:
|
|
raise ValidationError({'detail': _('You have to provide a user.')})
|
|
else:
|
|
try:
|
|
user = get_user_model().objects.get(pk=int(user_id))
|
|
except (ValueError, get_user_model().DoesNotExist):
|
|
raise ValidationError({'detail': _('User does not exist.')})
|
|
|
|
queryset = Submitter.objects.filter(motion=motion, user=user)
|
|
try:
|
|
# We assume that there aren't multiple entries because this
|
|
# is forbidden by the Manager's add method. We assume that
|
|
# there is only one submitter instance or none.
|
|
submitter = queryset.get()
|
|
except Submitter.DoesNotExist:
|
|
raise ValidationError({'detail': _('The user is not a submitter.')})
|
|
else:
|
|
name = str(submitter.user)
|
|
submitter.delete()
|
|
message = _('User {} successfully removed as a submitter.').format(name)
|
|
|
|
# Initiate response.
|
|
return Response({'detail': message})
|
|
|
|
@detail_route(methods=['POST'])
|
|
def sort_submitters(self, request, pk=None):
|
|
"""
|
|
Special view endpoint to sort the submitters.
|
|
Send {'submitters': [<submitter_id_1>, <submitter_id_2>, ...]} as payload.
|
|
"""
|
|
# Retrieve motion.
|
|
motion = self.get_object()
|
|
|
|
# Check data
|
|
submitter_ids = request.data.get('submitters')
|
|
if not isinstance(submitter_ids, list):
|
|
raise ValidationError(
|
|
{'detail': _('Invalid data.')})
|
|
|
|
# Get all submitters
|
|
submitters = {}
|
|
for submitter in motion.submitters.all():
|
|
submitters[submitter.pk] = submitter
|
|
|
|
# Check and sort submitters
|
|
valid_submitters = []
|
|
for submitter_id in submitter_ids:
|
|
if not isinstance(submitter_id, int) or submitters.get(submitter_id) is None:
|
|
raise ValidationError(
|
|
{'detail': _('Invalid data.')})
|
|
valid_submitters.append(submitters[submitter_id])
|
|
weight = 1
|
|
with transaction.atomic():
|
|
for submitter in valid_submitters:
|
|
submitter.weight = weight
|
|
submitter.save(skip_autoupdate=True)
|
|
weight += 1
|
|
|
|
# send autoupdate
|
|
inform_changed_data(motion)
|
|
|
|
# Initiate response.
|
|
return Response({'detail': _('Submitters successfully sorted.')})
|
|
|
|
@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 {} does not exist'.format(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 {} does not exist'.format(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)
|
|
|
|
# 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': _('{number} motions successfully updated.').format(number=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)
|
|
motion.write_log([ugettext_noop('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.
|
|
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([ugettext_noop('Motion unsupported')], request.user)
|
|
message = _('You have unsupported this motion successfully.')
|
|
|
|
# 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.
|
|
"""
|
|
# 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.
|
|
try:
|
|
state_id = int(state)
|
|
except ValueError:
|
|
raise ValidationError({'detail': _('Invalid data. State must be an integer.')})
|
|
if state_id not in [item.id for item in motion.state.next_states.all()]:
|
|
raise ValidationError(
|
|
{'detail': _('You can not set the state to %(state_id)d.') % {'state_id': state_id}})
|
|
motion.set_state(state_id)
|
|
else:
|
|
# Reset state.
|
|
motion.reset_state()
|
|
|
|
# Save motion.
|
|
motion.save(update_fields=['state', 'identifier', 'identifier_number'], skip_autoupdate=True)
|
|
message = _('The state of the motion was set to %s.') % motion.state.name
|
|
|
|
# Write the log message and initiate response.
|
|
motion.write_log(
|
|
message_list=[ugettext_noop('State set to'), ' ', motion.state.name],
|
|
person=request.user,
|
|
skip_autoupdate=True)
|
|
inform_changed_data(motion)
|
|
return Response({'detail': message})
|
|
|
|
@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 {recommendation_state_id}.').format(
|
|
recommendation_state_id=recommendation_state_id)})
|
|
motion.set_recommendation(recommendation_state_id)
|
|
else:
|
|
# Reset recommendation.
|
|
motion.recommendation = None
|
|
|
|
# Save motion.
|
|
motion.save(update_fields=['recommendation'], skip_autoupdate=True)
|
|
label = motion.recommendation.recommendation_label if motion.recommendation else 'None'
|
|
message = _('The recommendation of the motion was set to %s.') % label
|
|
|
|
# Write the log message and initiate response.
|
|
motion.write_log(
|
|
message_list=[ugettext_noop('Recommendation set to'), ' ', label],
|
|
person=request.user,
|
|
skip_autoupdate=True)
|
|
inform_changed_data(motion)
|
|
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': 'Motion {} does not exist'.format(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 {recommendation_state_id}.').format(
|
|
recommendation_state_id=recommendation_state_id)})
|
|
motion.set_recommendation(recommendation_state_id)
|
|
|
|
# Save motion.
|
|
motion.save(update_fields=['recommendation'], skip_autoupdate=True)
|
|
label = motion.recommendation.recommendation_label if motion.recommendation else 'None'
|
|
|
|
# Write the log message.
|
|
motion.write_log(
|
|
message_list=[ugettext_noop('Recommendation set to'), ' ', label],
|
|
person=request.user,
|
|
skip_autoupdate=True)
|
|
|
|
# Finish motion.
|
|
motion_result.append(motion)
|
|
|
|
# Now inform all clients.
|
|
inform_changed_data(motion_result)
|
|
|
|
# Send response.
|
|
return Response({
|
|
'detail': _('{number} motions successfully updated.').format(number=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.')})
|
|
|
|
# Set state.
|
|
motion.set_state(motion.recommendation)
|
|
|
|
# Set the special state comment.
|
|
extension = request.data.get('state_extension')
|
|
if extension is not None:
|
|
motion.state_extension = extension
|
|
|
|
# Save and write log.
|
|
motion.save(
|
|
update_fields=['state', 'identifier', 'identifier_number', 'state_extension'],
|
|
skip_autoupdate=True)
|
|
motion.write_log(
|
|
message_list=[ugettext_noop('State set to'), ' ', motion.state.name],
|
|
person=request.user,
|
|
skip_autoupdate=True)
|
|
|
|
# Now send all changes to the clients.
|
|
inform_changed_data(motion)
|
|
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 e:
|
|
raise ValidationError({'detail': e})
|
|
motion.write_log([ugettext_noop('Vote created')], request.user, skip_autoupdate=True)
|
|
|
|
inform_changed_data(motion)
|
|
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': 'Motion {} does not exist'.format(item['id'])})
|
|
|
|
# Set new tags
|
|
for tag_id in item['tags']:
|
|
if not Tag.objects.filter(pk=tag_id).exists():
|
|
raise ValidationError({'detail': 'Tag {} does not exist'.format(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': _('{number} motions successfully updated.').format(number=len(motion_result)),
|
|
})
|
|
|
|
|
|
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([ugettext_noop('Vote updated')], self.request.user)
|
|
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([ugettext_noop('Vote deleted')], self.request.user)
|
|
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)
|
|
|
|
|
|
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 e:
|
|
# The protected objects can just be motion comments.
|
|
motions = ['"' + str(comment.motion) + '"' for comment in e.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 {}.').format(motions_verbose)
|
|
else:
|
|
msg = _('This section has still comments in motions {}.').format(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 = '%s' % category.prefix
|
|
else:
|
|
prefix = '%s ' % 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 = '%s%s' % (parent_identifier, config['motions_amendments_prefix'])
|
|
else:
|
|
prefix = '%s %s ' % (parent_identifier, config['motions_amendments_prefix'])
|
|
number += 1
|
|
new_identifier = '%s%s' % (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
|
|
error_message = _('Numbering aborted because the motion identifier "%s" '
|
|
'already exists outside of this category.') % obj['new_identifier']
|
|
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)
|
|
message = _('All motions in category {category} numbered '
|
|
'successfully.').format(category=category)
|
|
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()
|
|
instances = []
|
|
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=[ugettext_noop('State set to'), ' ', motion.state.name],
|
|
person=request.user,
|
|
skip_autoupdate=True)
|
|
instances.append(motion)
|
|
inform_changed_data(instances)
|
|
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 = _('This {} is assigned to motion {}.').format(name, motions_verbose)
|
|
else:
|
|
msg = _('This {} is assigned to motions {}.').format(name, motions_verbose)
|
|
return 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 e:
|
|
msg = self.getProtectedErrorMessage('workflow', e)
|
|
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 e:
|
|
msg = self.getProtectedErrorMessage('workflow', e)
|
|
raise ValidationError({'detail': msg})
|
|
return result
|