OpenSlides/openslides/motions/serializers.py

583 lines
18 KiB
Python
Raw Normal View History

from typing import Dict, Optional
2017-08-24 12:26:55 +02:00
import jsonschema
from django.db import transaction
from ..core.config import config
from ..poll.serializers import default_votes_validator
from ..utils.auth import get_group_model
2018-10-01 15:36:16 +02:00
from ..utils.autoupdate import inform_changed_data
from ..utils.rest_api import (
CharField,
DecimalField,
DictField,
Field,
IdPrimaryKeyRelatedField,
IntegerField,
JSONField,
ModelSerializer,
2015-10-21 21:13:45 +02:00
SerializerMethodField,
ValidationError,
)
from ..utils.validate import validate_html
from .models import (
Category,
Motion,
MotionBlock,
MotionChangeRecommendation,
MotionComment,
MotionCommentSection,
MotionPoll,
State,
StatuteParagraph,
Submitter,
Workflow,
)
def validate_workflow_field(value):
"""
Validator to ensure that the workflow with the given id exists.
"""
if not Workflow.objects.filter(pk=value).exists():
2019-01-12 23:01:42 +01:00
raise ValidationError({"detail": f"Workflow {value} does not exist."})
class StatuteParagraphSerializer(ModelSerializer):
"""
Serializer for motion.models.StatuteParagraph objects.
"""
2019-01-06 16:22:33 +01:00
class Meta:
model = StatuteParagraph
2019-01-06 16:22:33 +01:00
fields = ("id", "title", "text", "weight")
class CategorySerializer(ModelSerializer):
"""
Serializer for motion.models.Category objects.
"""
2019-01-06 16:22:33 +01:00
class Meta:
model = Category
2019-01-06 16:22:33 +01:00
fields = ("id", "name", "prefix")
class MotionBlockSerializer(ModelSerializer):
"""
Serializer for motion.models.Category objects.
"""
2019-01-06 16:22:33 +01:00
agenda_type = IntegerField(
write_only=True, required=False, min_value=1, max_value=3, allow_null=True
2019-01-06 16:22:33 +01:00
)
agenda_parent_id = IntegerField(write_only=True, required=False, min_value=1)
class Meta:
model = MotionBlock
fields = (
"id",
"title",
"agenda_item_id",
"list_of_speakers_id",
"agenda_type",
"agenda_parent_id",
"internal",
)
def create(self, validated_data):
"""
Customized create method. Set information about related agenda item
into agenda_item_update_information container.
"""
2019-01-06 16:22:33 +01:00
agenda_type = validated_data.pop("agenda_type", None)
agenda_parent_id = validated_data.pop("agenda_parent_id", None)
motion_block = MotionBlock(**validated_data)
2019-01-06 16:22:33 +01:00
motion_block.agenda_item_update_information["type"] = agenda_type
motion_block.agenda_item_update_information["parent_id"] = agenda_parent_id
motion_block.save()
return motion_block
class StateSerializer(ModelSerializer):
"""
Serializer for motion.models.State objects.
"""
2019-01-06 16:22:33 +01:00
restriction = JSONField(required=False)
class Meta:
model = State
fields = (
2019-01-06 16:22:33 +01:00
"id",
"name",
"recommendation_label",
"css_class",
"restriction",
2019-01-06 16:22:33 +01:00
"allow_support",
"allow_create_poll",
"allow_submitter_edit",
"dont_set_identifier",
"show_state_extension_field",
"merge_amendment_into_final",
"show_recommendation_extension_field",
"next_states",
"workflow",
)
def validate_restriction(self, value):
"""
Ensures that the value is a list and only contains valid values.
"""
schema = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Motion workflow state restriction field schema",
"description": "An array containing one or more explicit strings to control restriction for motions in this state.",
"type": "array",
"items": {
"type": "string",
"enum": [
"motions.can_see_internal",
"motions.can_manage_metadata",
"motions.can_manage",
"is_submitter",
],
},
}
# Validate value.
try:
jsonschema.validate(value, schema)
except jsonschema.ValidationError as err:
raise ValidationError({"detail": str(err)})
return value
class WorkflowSerializer(ModelSerializer):
"""
Serializer for motion.models.Workflow objects.
"""
2019-01-06 16:22:33 +01:00
states = StateSerializer(many=True, read_only=True)
class Meta:
model = Workflow
2019-01-06 16:22:33 +01:00
fields = ("id", "name", "states", "first_state")
read_only_fields = ("first_state",)
@transaction.atomic
def create(self, validated_data):
"""
Customized create method. Creating a new workflow does always create a
new state which is used as first state.
"""
workflow = super().create(validated_data)
first_state = State.objects.create(
2019-01-06 16:22:33 +01:00
name="new",
workflow=workflow,
allow_create_poll=True,
allow_support=True,
2019-01-06 16:22:33 +01:00
allow_submitter_edit=True,
)
workflow.first_state = first_state
workflow.save()
return workflow
class AmendmentParagraphsJSONSerializerField(Field):
"""
Serializer for motions's amendment_paragraphs JSONField.
"""
2019-01-06 16:22:33 +01:00
def to_representation(self, obj):
"""
Returns the value of the field.
"""
return obj
def to_internal_value(self, data):
"""
Checks that data is a list of strings.
"""
2019-01-12 23:01:42 +01:00
if not isinstance(data, list):
2019-01-06 16:22:33 +01:00
raise ValidationError({"detail": "Data must be a list."})
for paragraph in data:
2019-01-12 23:01:42 +01:00
if not isinstance(paragraph, str) and paragraph is not None:
2019-01-06 16:22:33 +01:00
raise ValidationError(
{"detail": "Paragraph must be either a string or null/None."}
)
return data
class MotionPollSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionPoll objects.
"""
2019-01-06 16:22:33 +01:00
2015-10-21 21:13:45 +02:00
yes = SerializerMethodField()
no = SerializerMethodField()
abstain = SerializerMethodField()
votes = DictField(
2019-01-06 16:22:33 +01:00
child=DecimalField(
max_digits=15, decimal_places=6, min_value=-2, allow_null=True
),
write_only=True,
)
has_votes = SerializerMethodField()
class Meta:
model = MotionPoll
fields = (
2019-01-06 16:22:33 +01:00
"id",
"motion",
"yes",
"no",
"abstain",
"votesvalid",
"votesinvalid",
"votescast",
"votes",
"has_votes",
)
validators = (default_votes_validator,)
2015-10-21 21:13:45 +02:00
def __init__(self, *args, **kwargs):
# The following dictionary is just a cache for several votes.
2018-08-22 22:00:08 +02:00
self._votes_dicts: Dict[int, Dict[int, int]] = {}
2019-01-12 23:01:42 +01:00
super().__init__(*args, **kwargs)
2015-10-21 21:13:45 +02:00
def get_yes(self, obj):
try:
2019-01-06 16:22:33 +01:00
result: Optional[str] = str(self.get_votes_dict(obj)["Yes"])
except KeyError:
result = None
return result
2015-10-21 21:13:45 +02:00
def get_no(self, obj):
try:
2019-01-06 16:22:33 +01:00
result: Optional[str] = str(self.get_votes_dict(obj)["No"])
except KeyError:
result = None
return result
2015-10-21 21:13:45 +02:00
def get_abstain(self, obj):
try:
2019-01-06 16:22:33 +01:00
result: Optional[str] = str(self.get_votes_dict(obj)["Abstain"])
except KeyError:
result = None
return result
def get_votes_dict(self, obj):
try:
votes_dict = self._votes_dicts[obj.pk]
except KeyError:
votes_dict = self._votes_dicts[obj.pk] = {}
for vote in obj.get_votes():
votes_dict[vote.value] = vote.weight
return votes_dict
def get_has_votes(self, obj):
"""
Returns True if this poll has some votes.
"""
return obj.has_votes()
@transaction.atomic
def update(self, instance, validated_data):
"""
Customized update method for polls. To update votes use the write
only field 'votes'.
Example data:
"votes": {"Yes": 10, "No": 4, "Abstain": -2}
"""
# Update votes.
2019-01-06 16:22:33 +01:00
votes = validated_data.get("votes")
if votes:
if len(votes) != len(instance.get_vote_values()):
2019-01-06 16:22:33 +01:00
raise ValidationError(
{
2019-01-12 23:01:42 +01:00
"detail": f"You have to submit data for {len(instance.get_vote_values())} vote values."
2019-01-06 16:22:33 +01:00
}
)
2019-01-12 23:01:42 +01:00
for vote_value in votes.keys():
if vote_value not in instance.get_vote_values():
2019-01-06 16:22:33 +01:00
raise ValidationError(
2019-01-12 23:01:42 +01:00
{"detail": f"Vote value {vote_value} is invalid."}
2019-01-06 16:22:33 +01:00
)
instance.set_vote_objects_with_values(
instance.get_options().get(), votes, skip_autoupdate=True
)
# Update remaining writeable fields.
2019-01-06 16:22:33 +01:00
instance.votesvalid = validated_data.get("votesvalid", instance.votesvalid)
instance.votesinvalid = validated_data.get(
"votesinvalid", instance.votesinvalid
)
instance.votescast = validated_data.get("votescast", instance.votescast)
instance.save()
return instance
2016-09-10 18:49:38 +02:00
class MotionChangeRecommendationSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionChangeRecommendation objects.
"""
2019-01-06 16:22:33 +01:00
2016-09-10 18:49:38 +02:00
class Meta:
model = MotionChangeRecommendation
fields = (
2019-01-06 16:22:33 +01:00
"id",
"motion",
"rejected",
"internal",
"type",
"other_description",
"line_from",
"line_to",
"text",
"creation_time",
)
2016-09-10 18:49:38 +02:00
2018-03-07 16:36:30 +01:00
def is_title_cr(self, data):
2019-01-06 16:22:33 +01:00
return int(data["line_from"]) == 0 and int(data["line_to"]) == 0
2018-03-07 16:36:30 +01:00
2017-01-20 11:34:05 +01:00
def validate(self, data):
2018-03-07 16:36:30 +01:00
# Change recommendations for titles are stored as plain-text, thus they don't need to be html-escaped
2019-01-06 16:22:33 +01:00
if "text" in data and not self.is_title_cr(data):
data["text"] = validate_html(data["text"])
2017-01-20 11:34:05 +01:00
return data
2016-09-10 18:49:38 +02:00
class MotionCommentSectionSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionCommentSection objects.
"""
2019-01-06 16:22:33 +01:00
read_groups = IdPrimaryKeyRelatedField(
2019-01-06 16:22:33 +01:00
many=True, required=False, queryset=get_group_model().objects.all()
)
write_groups = IdPrimaryKeyRelatedField(
2019-01-06 16:22:33 +01:00
many=True, required=False, queryset=get_group_model().objects.all()
)
class Meta:
model = MotionCommentSection
2019-01-06 16:22:33 +01:00
fields = ("id", "name", "read_groups", "write_groups")
2018-10-01 15:36:16 +02:00
def create(self, validated_data):
""" Call inform_changed_data on creation, so the cache includes the groups. """
section = super().create(validated_data)
inform_changed_data(section)
return section
class MotionCommentSerializer(ModelSerializer):
"""
Serializer for motion.models.MotionComment objects.
"""
2019-01-06 16:22:33 +01:00
read_groups_id = SerializerMethodField()
class Meta:
model = MotionComment
2019-01-06 16:22:33 +01:00
fields = ("id", "comment", "section", "read_groups_id")
def get_read_groups_id(self, comment):
return [group.id for group in comment.section.read_groups.all()]
2018-06-12 14:17:02 +02:00
class SubmitterSerializer(ModelSerializer):
"""
Serializer for motion.models.Submitter objects.
"""
2019-01-06 16:22:33 +01:00
2018-06-12 14:17:02 +02:00
class Meta:
model = Submitter
2019-01-06 16:22:33 +01:00
fields = ("id", "user", "motion", "weight")
2018-06-12 14:17:02 +02:00
class MotionSerializer(ModelSerializer):
"""
Serializer for motion.models.Motion objects.
"""
2019-01-06 16:22:33 +01:00
comments = MotionCommentSerializer(many=True, read_only=True)
polls = MotionPollSerializer(many=True, read_only=True)
modified_final_version = CharField(allow_blank=True, required=False)
reason = CharField(allow_blank=True, required=False)
state_restriction = SerializerMethodField()
text = CharField(allow_blank=True, required=False) # This will be checked
# during validation
title = CharField(max_length=255)
amendment_paragraphs = AmendmentParagraphsJSONSerializerField(required=False)
workflow_id = IntegerField(
2019-01-06 16:22:33 +01:00
min_value=1, required=False, validators=[validate_workflow_field]
)
agenda_type = IntegerField(
write_only=True, required=False, min_value=1, max_value=3, allow_null=True
2019-01-06 16:22:33 +01:00
)
agenda_parent_id = IntegerField(write_only=True, required=False, min_value=1)
2018-06-12 14:17:02 +02:00
submitters = SubmitterSerializer(many=True, read_only=True)
change_recommendations = MotionChangeRecommendationSerializer(
many=True, read_only=True
)
class Meta:
model = Motion
fields = (
2019-01-06 16:22:33 +01:00
"id",
"identifier",
"title",
"text",
"amendment_paragraphs",
"modified_final_version",
"reason",
"parent",
"category",
2019-04-30 13:48:21 +02:00
"category_weight",
2019-01-06 16:22:33 +01:00
"comments",
"motion_block",
"origin",
"submitters",
"supporters",
"state",
"state_extension",
"state_restriction",
2019-01-06 16:22:33 +01:00
"statute_paragraph",
"workflow_id",
"recommendation",
"recommendation_extension",
"tags",
"attachments",
"polls",
"agenda_item_id",
"list_of_speakers_id",
2019-01-06 16:22:33 +01:00
"agenda_type",
"agenda_parent_id",
"sort_parent",
"weight",
2019-01-19 10:27:50 +01:00
"created",
"last_modified",
"change_recommendations",
2019-01-06 16:22:33 +01:00
)
read_only_fields = (
"state",
"recommendation",
2019-04-30 13:48:21 +02:00
"weight",
"category_weight",
2019-01-06 16:22:33 +01:00
) # Some other fields are also read_only. See definitions above.
2017-01-20 11:34:05 +01:00
def validate(self, data):
2019-01-06 16:22:33 +01:00
if "text" in data:
data["text"] = validate_html(data["text"])
if "modified_final_version" in data:
data["modified_final_version"] = validate_html(
data["modified_final_version"]
)
if "reason" in data:
data["reason"] = validate_html(data["reason"])
# The motion text is only needed, if it is not a paragraph based amendment.
2019-01-06 16:22:33 +01:00
if "amendment_paragraphs" in data:
data["amendment_paragraphs"] = list(
map(
2019-01-12 23:01:42 +01:00
lambda entry: validate_html(entry)
if isinstance(entry, str)
else None,
2019-01-06 16:22:33 +01:00
data["amendment_paragraphs"],
)
)
data["text"] = ""
else:
if (self.partial and "text" in data and not data["text"]) or (
not self.partial and not data.get("text")
):
raise ValidationError({"detail": "The text field may not be blank."})
if config["motions_reason_required"]:
if (self.partial and "reason" in data and not data["reason"]) or (
not self.partial and not data.get("reason")
):
raise ValidationError({"detail": "The reason field may not be blank."})
2017-01-20 11:34:05 +01:00
return data
@transaction.atomic
def create(self, validated_data):
"""
Customized method to create a new motion from some data.
Set also information about related agenda item into
agenda_item_update_information container.
"""
motion = Motion()
2019-01-06 16:22:33 +01:00
motion.title = validated_data["title"]
motion.text = validated_data["text"]
motion.amendment_paragraphs = validated_data.get("amendment_paragraphs")
motion.modified_final_version = validated_data.get("modified_final_version", "")
motion.reason = validated_data.get("reason", "")
motion.identifier = validated_data.get("identifier")
motion.category = validated_data.get("category")
motion.motion_block = validated_data.get("motion_block")
motion.origin = validated_data.get("origin", "")
motion.parent = validated_data.get("parent")
motion.statute_paragraph = validated_data.get("statute_paragraph")
motion.reset_state(validated_data.get("workflow_id"))
motion.agenda_item_update_information["type"] = validated_data.get(
"agenda_type"
)
motion.agenda_item_update_information["parent_id"] = validated_data.get(
"agenda_parent_id"
)
motion.save()
2019-01-06 16:22:33 +01:00
motion.supporters.add(*validated_data.get("supporters", []))
motion.attachments.add(*validated_data.get("attachments", []))
motion.tags.add(*validated_data.get("tags", []))
return motion
@transaction.atomic
def update(self, motion, validated_data):
"""
Customized method to update a motion.
2019-04-30 13:48:21 +02:00
- If the workflow changes, the state of the motions is resetted to
the initial state of the new workflow.
- If the category changes, the category_weight is reset to the default value.
"""
workflow_id = None
2019-01-06 16:22:33 +01:00
if "workflow_id" in validated_data:
workflow_id = validated_data.pop("workflow_id")
2019-04-30 13:48:21 +02:00
old_category_id = motion.category.pk if motion.category is not None else None
new_category_id = (
validated_data["category"].pk
if validated_data.get("category") is not None
else None
)
result = super().update(motion, validated_data)
2019-04-30 13:48:21 +02:00
# Check for changed workflow
if workflow_id is not None and workflow_id != motion.workflow_id:
motion.reset_state(workflow_id)
2019-04-30 13:48:21 +02:00
motion.save(skip_autoupdate=True)
# Check for changed category
if old_category_id != new_category_id:
motion.category_weight = 10000
motion.save(skip_autoupdate=True)
inform_changed_data(motion)
return result
def get_state_restriction(self, motion):
"""
Returns the restriction of this state. The default is an empty list so everybody
with permission to see motions can see this motion.
"""
return motion.state.restriction