597 lines
19 KiB
Python
597 lines
19 KiB
Python
from typing import Dict, Optional
|
|
|
|
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, has_perm
|
|
from ..utils.autoupdate import inform_changed_data
|
|
from ..utils.rest_api import (
|
|
BooleanField,
|
|
CharField,
|
|
DecimalField,
|
|
DictField,
|
|
Field,
|
|
IdPrimaryKeyRelatedField,
|
|
IntegerField,
|
|
JSONField,
|
|
ModelSerializer,
|
|
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():
|
|
raise ValidationError({"detail": f"Workflow {value} does not exist."})
|
|
|
|
|
|
class StatuteParagraphSerializer(ModelSerializer):
|
|
"""
|
|
Serializer for motion.models.StatuteParagraph objects.
|
|
"""
|
|
|
|
class Meta:
|
|
model = StatuteParagraph
|
|
fields = ("id", "title", "text", "weight")
|
|
|
|
|
|
class CategorySerializer(ModelSerializer):
|
|
"""
|
|
Serializer for motion.models.Category objects.
|
|
"""
|
|
|
|
class Meta:
|
|
model = Category
|
|
fields = ("id", "name", "prefix", "parent", "weight", "level")
|
|
read_only_fields = ("parent", "weight")
|
|
|
|
|
|
class MotionBlockSerializer(ModelSerializer):
|
|
"""
|
|
Serializer for motion.models.Category objects.
|
|
"""
|
|
|
|
agenda_create = BooleanField(write_only=True, required=False, allow_null=True)
|
|
agenda_type = IntegerField(
|
|
write_only=True, required=False, min_value=1, max_value=3, allow_null=True
|
|
)
|
|
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_create",
|
|
"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.
|
|
"""
|
|
agenda_create = validated_data.pop("agenda_create", None)
|
|
agenda_type = validated_data.pop("agenda_type", None)
|
|
agenda_parent_id = validated_data.pop("agenda_parent_id", None)
|
|
request_user = validated_data.pop("request_user") # this should always be there
|
|
motion_block = MotionBlock(**validated_data)
|
|
if has_perm(request_user, "agenda.can_manage"):
|
|
motion_block.agenda_item_update_information["create"] = agenda_create
|
|
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.
|
|
"""
|
|
|
|
restriction = JSONField(required=False)
|
|
|
|
class Meta:
|
|
model = State
|
|
fields = (
|
|
"id",
|
|
"name",
|
|
"recommendation_label",
|
|
"css_class",
|
|
"restriction",
|
|
"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.
|
|
"""
|
|
|
|
# states = StateSerializer(many=True, read_only=True)
|
|
states = IdPrimaryKeyRelatedField(many=True, read_only=True)
|
|
|
|
class Meta:
|
|
model = Workflow
|
|
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(
|
|
name="new",
|
|
workflow=workflow,
|
|
allow_create_poll=True,
|
|
allow_support=True,
|
|
allow_submitter_edit=True,
|
|
)
|
|
workflow.first_state = first_state
|
|
workflow.save()
|
|
return workflow
|
|
|
|
|
|
class AmendmentParagraphsJSONSerializerField(Field):
|
|
"""
|
|
Serializer for motions's amendment_paragraphs JSONField.
|
|
"""
|
|
|
|
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.
|
|
"""
|
|
if not isinstance(data, list):
|
|
raise ValidationError({"detail": "Data must be a list."})
|
|
for paragraph in data:
|
|
if not isinstance(paragraph, str) and paragraph is not None:
|
|
raise ValidationError(
|
|
{"detail": "Paragraph must be either a string or null/None."}
|
|
)
|
|
return data
|
|
|
|
|
|
class MotionPollSerializer(ModelSerializer):
|
|
"""
|
|
Serializer for motion.models.MotionPoll objects.
|
|
"""
|
|
|
|
yes = SerializerMethodField()
|
|
no = SerializerMethodField()
|
|
abstain = SerializerMethodField()
|
|
votes = DictField(
|
|
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 = (
|
|
"id",
|
|
"motion",
|
|
"yes",
|
|
"no",
|
|
"abstain",
|
|
"votesvalid",
|
|
"votesinvalid",
|
|
"votescast",
|
|
"votes",
|
|
"has_votes",
|
|
)
|
|
validators = (default_votes_validator,)
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
# The following dictionary is just a cache for several votes.
|
|
self._votes_dicts: Dict[int, Dict[int, int]] = {}
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def get_yes(self, obj):
|
|
try:
|
|
result: Optional[str] = str(self.get_votes_dict(obj)["Yes"])
|
|
except KeyError:
|
|
result = None
|
|
return result
|
|
|
|
def get_no(self, obj):
|
|
try:
|
|
result: Optional[str] = str(self.get_votes_dict(obj)["No"])
|
|
except KeyError:
|
|
result = None
|
|
return result
|
|
|
|
def get_abstain(self, obj):
|
|
try:
|
|
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.
|
|
votes = validated_data.get("votes")
|
|
if votes:
|
|
if len(votes) != len(instance.get_vote_values()):
|
|
raise ValidationError(
|
|
{
|
|
"detail": f"You have to submit data for {len(instance.get_vote_values())} vote values."
|
|
}
|
|
)
|
|
for vote_value in votes.keys():
|
|
if vote_value not in instance.get_vote_values():
|
|
raise ValidationError(
|
|
{"detail": f"Vote value {vote_value} is invalid."}
|
|
)
|
|
instance.set_vote_objects_with_values(
|
|
instance.get_options().get(), votes, skip_autoupdate=True
|
|
)
|
|
|
|
# Update remaining writeable fields.
|
|
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
|
|
|
|
|
|
class MotionChangeRecommendationSerializer(ModelSerializer):
|
|
"""
|
|
Serializer for motion.models.MotionChangeRecommendation objects.
|
|
"""
|
|
|
|
class Meta:
|
|
model = MotionChangeRecommendation
|
|
fields = (
|
|
"id",
|
|
"motion",
|
|
"rejected",
|
|
"internal",
|
|
"type",
|
|
"other_description",
|
|
"line_from",
|
|
"line_to",
|
|
"text",
|
|
"creation_time",
|
|
)
|
|
|
|
def is_title_cr(self, data):
|
|
return int(data["line_from"]) == 0 and int(data["line_to"]) == 0
|
|
|
|
def validate(self, data):
|
|
# Change recommendations for titles are stored as plain-text, thus they don't need to be html-escaped
|
|
if "text" in data and not self.is_title_cr(data):
|
|
data["text"] = validate_html(data["text"])
|
|
return data
|
|
|
|
|
|
class MotionCommentSectionSerializer(ModelSerializer):
|
|
"""
|
|
Serializer for motion.models.MotionCommentSection objects.
|
|
"""
|
|
|
|
read_groups = IdPrimaryKeyRelatedField(
|
|
many=True, required=False, queryset=get_group_model().objects.all()
|
|
)
|
|
|
|
write_groups = IdPrimaryKeyRelatedField(
|
|
many=True, required=False, queryset=get_group_model().objects.all()
|
|
)
|
|
|
|
class Meta:
|
|
model = MotionCommentSection
|
|
fields = ("id", "name", "read_groups", "write_groups", "weight")
|
|
read_only_fields = ("weight",)
|
|
|
|
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.
|
|
"""
|
|
|
|
read_groups_id = SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = MotionComment
|
|
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()]
|
|
|
|
|
|
class SubmitterSerializer(ModelSerializer):
|
|
"""
|
|
Serializer for motion.models.Submitter objects.
|
|
"""
|
|
|
|
class Meta:
|
|
model = Submitter
|
|
fields = ("id", "user", "motion", "weight")
|
|
|
|
|
|
class MotionSerializer(ModelSerializer):
|
|
"""
|
|
Serializer for motion.models.Motion objects.
|
|
"""
|
|
|
|
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(
|
|
min_value=1, required=False, validators=[validate_workflow_field]
|
|
)
|
|
agenda_create = BooleanField(write_only=True, required=False, allow_null=True)
|
|
agenda_type = IntegerField(
|
|
write_only=True, required=False, min_value=1, max_value=3, allow_null=True
|
|
)
|
|
agenda_parent_id = IntegerField(write_only=True, required=False, min_value=1)
|
|
submitters = SubmitterSerializer(many=True, read_only=True)
|
|
change_recommendations = IdPrimaryKeyRelatedField(many=True, read_only=True)
|
|
|
|
class Meta:
|
|
model = Motion
|
|
fields = (
|
|
"id",
|
|
"identifier",
|
|
"title",
|
|
"text",
|
|
"amendment_paragraphs",
|
|
"modified_final_version",
|
|
"reason",
|
|
"parent",
|
|
"category",
|
|
"category_weight",
|
|
"comments",
|
|
"motion_block",
|
|
"origin",
|
|
"submitters",
|
|
"supporters",
|
|
"state",
|
|
"state_extension",
|
|
"state_restriction",
|
|
"statute_paragraph",
|
|
"workflow_id",
|
|
"recommendation",
|
|
"recommendation_extension",
|
|
"tags",
|
|
"attachments",
|
|
"polls",
|
|
"agenda_item_id",
|
|
"list_of_speakers_id",
|
|
"agenda_create",
|
|
"agenda_type",
|
|
"agenda_parent_id",
|
|
"sort_parent",
|
|
"weight",
|
|
"created",
|
|
"last_modified",
|
|
"change_recommendations",
|
|
)
|
|
read_only_fields = (
|
|
"state",
|
|
"recommendation",
|
|
"weight",
|
|
"category_weight",
|
|
) # Some other fields are also read_only. See definitions above.
|
|
|
|
def validate(self, data):
|
|
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.
|
|
if "amendment_paragraphs" in data:
|
|
data["amendment_paragraphs"] = list(
|
|
map(
|
|
lambda entry: validate_html(entry)
|
|
if isinstance(entry, str)
|
|
else None,
|
|
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."})
|
|
|
|
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()
|
|
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"))
|
|
if has_perm(validated_data["request_user"], "agenda.can_manage"):
|
|
motion.agenda_item_update_information["create"] = validated_data.get(
|
|
"agenda_create"
|
|
)
|
|
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()
|
|
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.
|
|
- 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
|
|
if "workflow_id" in validated_data:
|
|
workflow_id = validated_data.pop("workflow_id")
|
|
|
|
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)
|
|
|
|
# Check for changed workflow
|
|
if workflow_id is not None and workflow_id != motion.workflow_id:
|
|
motion.reset_state(workflow_id)
|
|
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
|