OpenSlides/openslides/motions/models.py

1224 lines
38 KiB
Python
Raw Normal View History

2018-08-22 22:00:08 +02:00
from typing import Any, Dict
from django.conf import settings
2018-06-12 14:17:02 +02:00
from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db import IntegrityError, models, transaction
from django.db.models import Max
from django.utils import formats, timezone
from jsonfield import JSONField
2011-07-31 10:46:29 +02:00
from openslides.agenda.models import Item
2015-06-29 12:08:15 +02:00
from openslides.core.config import config
2018-12-23 11:05:38 +01:00
from openslides.core.models import Tag
from openslides.mediafiles.models import Mediafile
from openslides.poll.models import (
BaseOption,
BasePoll,
BaseVote,
CollectDefaultVotesMixin,
)
from openslides.utils.autoupdate import inform_changed_data
2018-06-12 14:17:02 +02:00
from openslides.utils.exceptions import OpenSlidesError
2015-06-29 12:08:15 +02:00
from openslides.utils.models import RESTModelMixin
from .access_permissions import (
CategoryAccessPermissions,
MotionAccessPermissions,
MotionBlockAccessPermissions,
2016-09-10 18:49:38 +02:00
MotionChangeRecommendationAccessPermissions,
MotionCommentSectionAccessPermissions,
StatuteParagraphAccessPermissions,
WorkflowAccessPermissions,
)
from .exceptions import WorkflowError
from ..utils.models import CASCADE_AND_AUTOUODATE, SET_NULL_AND_AUTOUPDATE
2013-02-17 17:07:44 +01:00
class StatuteParagraph(RESTModelMixin, models.Model):
"""
Model for parts of the statute
"""
2019-01-06 16:22:33 +01:00
access_permissions = StatuteParagraphAccessPermissions()
title = models.CharField(max_length=255)
"""Title of the statute paragraph."""
text = models.TextField()
"""Content of the statute paragraph."""
weight = models.IntegerField(default=10000)
"""
A weight field to sort statute paragraphs.
"""
class Meta:
default_permissions = ()
2019-01-06 16:22:33 +01:00
ordering = ["weight", "title"]
def __str__(self):
return self.title
class MotionManager(models.Manager):
2016-09-30 20:42:58 +02:00
"""
Customized model manager to support our get_full_queryset method.
"""
2019-01-06 16:22:33 +01:00
def get_full_queryset(self):
2016-09-30 20:42:58 +02:00
"""
Returns the normal queryset with all motions. In the background we
join and prefetch all related models.
"""
2019-01-06 16:22:33 +01:00
return (
self.get_queryset()
.select_related("state")
.prefetch_related(
"state__workflow",
"comments",
"comments__section",
"comments__section__read_groups",
"agenda_items",
"log_messages",
"polls",
"attachments",
"tags",
"submitters",
"supporters",
"change_recommendations",
2019-01-06 16:22:33 +01:00
)
)
2015-06-29 13:31:07 +02:00
class Motion(RESTModelMixin, models.Model):
2013-04-19 14:12:49 +02:00
"""
2016-09-30 20:42:58 +02:00
Model for motions.
2013-01-26 12:28:51 +01:00
2013-02-05 18:46:46 +01:00
This class is the main entry point to all other classes related to a motion.
"""
2019-01-06 16:22:33 +01:00
access_permissions = MotionAccessPermissions()
2019-01-06 16:22:33 +01:00
can_see_permission = "motions.can_see"
2013-01-26 12:28:51 +01:00
objects = MotionManager()
title = models.CharField(max_length=255)
"""The title of a motion."""
text = models.TextField()
"""The text of a motion."""
2013-01-26 15:25:54 +01:00
amendment_paragraphs = JSONField(null=True)
"""
If paragraph-based, diff-enabled amendment style is used, this field stores an array of strings or null values.
Each entry corresponds to a paragraph of the text of the original motion.
If the entry is null, then the paragraph remains unchanged.
If the entry is a string, this is the new text of the paragraph.
amendment_paragraphs and text are mutually exclusive.
2013-02-05 18:46:46 +01:00
"""
2013-01-06 12:07:37 +01:00
modified_final_version = models.TextField(null=True, blank=True)
"""A field to copy in the final version of the motion and edit it there."""
reason = models.TextField(null=True, blank=True)
"""The reason for a motion."""
state = models.ForeignKey(
2019-01-06 16:22:33 +01:00
"State",
related_name="+",
on_delete=models.PROTECT, # Do not let the user delete states, that are used for motions
2019-01-06 16:22:33 +01:00
null=True,
) # TODO: Check whether null=True is necessary.
2013-04-19 14:12:49 +02:00
"""
The related state object.
2013-01-06 12:07:37 +01:00
This attribute is to get the current state of the motion.
2013-01-26 12:28:51 +01:00
"""
2013-01-06 12:07:37 +01:00
state_extension = models.TextField(blank=True, null=True)
"""
A text field fo additional information about the state.
"""
recommendation = models.ForeignKey(
"State", related_name="+", on_delete=SET_NULL_AND_AUTOUPDATE, null=True
2019-01-06 16:22:33 +01:00
)
"""
The recommendation of a person or committee for this motion.
"""
recommendation_extension = models.TextField(blank=True, null=True)
"""
A text field fo additional information about the recommendation.
"""
2019-01-06 16:22:33 +01:00
identifier = models.CharField(max_length=255, null=True, blank=True, unique=True)
2013-04-19 14:12:49 +02:00
"""
A string as human readable identifier for the motion.
"""
2013-02-05 18:46:46 +01:00
2013-03-12 22:03:56 +01:00
identifier_number = models.IntegerField(null=True)
2013-04-19 14:12:49 +02:00
"""
Counts the number of the motion in one category.
2013-03-12 22:03:56 +01:00
Needed to find the next free motion identifier.
2013-03-12 22:03:56 +01:00
"""
weight = models.IntegerField(default=10000)
"""
A weight field to sort motions.
"""
sort_parent = models.ForeignKey(
2019-01-06 16:22:33 +01:00
"self",
on_delete=SET_NULL_AND_AUTOUPDATE,
null=True,
blank=True,
2019-01-06 16:22:33 +01:00
related_name="children",
)
"""
A parent field for multi-depth sorting of motions.
"""
category = models.ForeignKey(
"Category", on_delete=SET_NULL_AND_AUTOUPDATE, null=True, blank=True
2019-01-06 16:22:33 +01:00
)
2013-04-19 14:12:49 +02:00
"""
ForeignKey to one category of motions.
"""
2013-03-11 20:17:19 +01:00
motion_block = models.ForeignKey(
"MotionBlock", on_delete=SET_NULL_AND_AUTOUPDATE, null=True, blank=True
2019-01-06 16:22:33 +01:00
)
"""
ForeignKey to one block of motions.
"""
2016-07-13 14:45:40 +02:00
origin = models.CharField(max_length=255, blank=True)
"""
A string to describe the origin of this motion e. g. that it was
discussed at another assembly/conference.
"""
attachments = models.ManyToManyField(Mediafile, blank=True)
"""
Many to many relation to mediafile objects.
"""
parent = models.ForeignKey(
2019-01-06 16:22:33 +01:00
"self",
on_delete=SET_NULL_AND_AUTOUPDATE,
null=True,
blank=True,
2019-01-06 16:22:33 +01:00
related_name="amendments",
)
2014-12-25 10:58:52 +01:00
"""
Field for amendments to reference to the motion that should be altered.
Null if the motion is not an amendment.
"""
2011-07-31 10:46:29 +02:00
statute_paragraph = models.ForeignKey(
StatuteParagraph,
on_delete=SET_NULL_AND_AUTOUPDATE,
null=True,
blank=True,
2019-01-06 16:22:33 +01:00
related_name="motions",
)
"""
Field to reference to a statute paragraph if this motion is a
statute-amendment.
Null if the motion is not a statute-amendment.
"""
tags = models.ManyToManyField(Tag, blank=True)
"""
Tags to categorise motions.
"""
2011-07-31 10:46:29 +02:00
2019-01-06 16:22:33 +01:00
supporters = models.ManyToManyField(
settings.AUTH_USER_MODEL, related_name="motion_supporters", blank=True
)
"""
Users who support this motion.
"""
2019-01-19 10:27:50 +01:00
created = models.DateTimeField(auto_now_add=True)
"""
Timestamp when motion is created.
"""
last_modified = models.DateTimeField(auto_now=True)
"""
Timestamp when motion is modified.
"""
2016-09-30 20:42:58 +02:00
# In theory there could be one then more agenda_item. But we support only
# one. See the property agenda_item.
2019-01-06 16:22:33 +01:00
agenda_items = GenericRelation(Item, related_name="motions")
2013-01-06 12:07:37 +01:00
class Meta:
2015-12-10 00:20:59 +01:00
default_permissions = ()
2013-01-06 12:07:37 +01:00
permissions = (
2019-01-06 16:22:33 +01:00
("can_see", "Can see motions"),
("can_create", "Can create motions"),
("can_create_amendments", "Can create amendments"),
2019-01-06 16:22:33 +01:00
("can_support", "Can support motions"),
("can_manage_metadata", "Can manage motion metadata"),
("can_manage", "Can manage motions"),
2013-01-06 12:07:37 +01:00
)
2019-01-06 16:22:33 +01:00
ordering = ("identifier",)
2019-01-12 23:01:42 +01:00
verbose_name = "Motion"
def __str__(self):
2013-04-19 14:12:49 +02:00
"""
Return the title of this motion.
2013-04-19 14:12:49 +02:00
"""
return self.title
2013-01-06 12:07:37 +01:00
# TODO: Use transaction
def save(self, skip_autoupdate=False, *args, **kwargs):
2013-04-19 14:12:49 +02:00
"""
Save the motion.
2013-02-05 18:46:46 +01:00
1. Set the state of a new motion to the default state.
2. Ensure that the identifier is not an empty string.
3. Save the motion object.
2013-01-26 12:28:51 +01:00
"""
if not self.state:
self.reset_state()
# Solves the problem, that there can only be one motion with an empty
# string as identifier.
if not self.identifier and isinstance(self.identifier, str):
2013-04-19 14:12:49 +02:00
self.identifier = None
# Try to save the motion until it succeeds with a correct identifier.
while True:
try:
# Always skip autoupdate. Maybe we run it later in this method.
with transaction.atomic():
2019-01-06 16:22:33 +01:00
super(Motion, self).save( # type: ignore
skip_autoupdate=True, *args, **kwargs
)
except IntegrityError:
# Identifier is already used.
2019-01-06 16:22:33 +01:00
if hasattr(self, "_identifier_prefix"):
# Calculate a new one and try again.
self.identifier_number, self.identifier = self.increment_identifier_number(
2019-01-06 16:22:33 +01:00
self.identifier_number, self._identifier_prefix
)
else:
# Do not calculate a new one but reraise the IntegrityError.
# The error is caught in the category sort view.
raise
else:
# Save was successful. End loop.
break
2013-02-05 18:46:46 +01:00
if not skip_autoupdate:
inform_changed_data(self)
2013-02-03 13:24:29 +01:00
2013-03-12 22:03:56 +01:00
def set_identifier(self):
"""
2014-12-25 10:58:52 +01:00
Sets the motion identifier automaticly according to the config value if
it is not set yet.
"""
# The identifier is already set or should be set manually.
2019-01-06 16:22:33 +01:00
if config["motions_identifier"] == "manually" or self.identifier:
2013-03-12 23:35:08 +01:00
# Do not set an identifier.
return
2014-12-25 10:58:52 +01:00
# If MOTION_IDENTIFIER_WITHOUT_BLANKS is set, don't use blanks when building identifier.
2019-01-06 16:22:33 +01:00
without_blank = (
hasattr(settings, "MOTION_IDENTIFIER_WITHOUT_BLANKS")
and settings.MOTION_IDENTIFIER_WITHOUT_BLANKS
)
# Build prefix.
2014-12-25 10:58:52 +01:00
if self.is_amendment():
2019-01-06 16:22:33 +01:00
parent_identifier = self.parent.identifier or ""
if without_blank:
2019-01-12 23:01:42 +01:00
prefix = f"{parent_identifier} {config['motions_amendments_prefix']} "
else:
2019-01-12 23:01:42 +01:00
prefix = f"{parent_identifier} {config['motions_amendments_prefix']} "
2014-12-25 10:58:52 +01:00
elif self.category is None or not self.category.prefix:
2019-01-06 16:22:33 +01:00
prefix = ""
2013-03-12 23:35:08 +01:00
else:
if without_blank:
2019-01-12 23:01:42 +01:00
prefix = self.category.prefix
else:
2019-01-12 23:01:42 +01:00
prefix = f"{self.category.prefix} "
self._identifier_prefix = prefix
2013-03-12 22:03:56 +01:00
2017-10-13 10:55:11 +02:00
# Use the already assigned identifier_number, if the motion has one.
# Else get the biggest number.
if self.identifier_number is not None:
number = self.identifier_number
initial_increment = False
else:
# Find all motions that should be included in the calculations.
if self.is_amendment():
motions = self.parent.amendments.all()
# The motions should be counted per category.
2019-01-06 16:22:33 +01:00
elif config["motions_identifier"] == "per_category":
2017-10-13 10:55:11 +02:00
motions = Motion.objects.filter(category=self.category)
# The motions should be counted over all.
else:
motions = Motion.objects.all()
2019-01-06 16:22:33 +01:00
number = (
motions.aggregate(Max("identifier_number"))["identifier_number__max"]
or 0
)
2017-10-13 10:55:11 +02:00
initial_increment = True
# Calculate new identifier.
2017-10-13 10:55:11 +02:00
number, identifier = self.increment_identifier_number(
2019-01-06 16:22:33 +01:00
number, prefix, initial_increment=initial_increment
)
# Set identifier and identifier_number.
self.identifier = identifier
self.identifier_number = number
2017-10-13 10:55:11 +02:00
def increment_identifier_number(self, number, prefix, initial_increment=True):
"""
Helper method. It increments the number until a free identifier
number is found. Returns new number and identifier.
"""
2017-10-13 10:55:11 +02:00
if initial_increment:
number += 1
2019-01-12 23:01:42 +01:00
identifier = f"{prefix}{self.extend_identifier_number(number)}"
while Motion.objects.filter(identifier=identifier).exists():
2013-03-12 22:03:56 +01:00
number += 1
2019-01-12 23:01:42 +01:00
identifier = f"{prefix}{self.extend_identifier_number(number)}"
return number, identifier
2013-03-12 22:03:56 +01:00
def extend_identifier_number(self, number):
"""
Returns the number used in the set_identifier method with leading
zero charaters according to the settings value
MOTION_IDENTIFIER_MIN_DIGITS.
"""
result = str(number)
2019-01-06 16:22:33 +01:00
if (
hasattr(settings, "MOTION_IDENTIFIER_MIN_DIGITS")
and settings.MOTION_IDENTIFIER_MIN_DIGITS
):
if not isinstance(settings.MOTION_IDENTIFIER_MIN_DIGITS, int):
2019-01-06 16:22:33 +01:00
raise ImproperlyConfigured(
"Settings value MOTION_IDENTIFIER_MIN_DIGITS must be an integer."
)
result = (
"0" * (settings.MOTION_IDENTIFIER_MIN_DIGITS - len(str(number)))
+ result
)
return result
def is_submitter(self, user):
2013-04-19 14:12:49 +02:00
"""
Returns True if user is a submitter of this motion, else False.
2013-04-19 14:12:49 +02:00
"""
2018-06-12 14:17:02 +02:00
return self.submitters.filter(user=user).exists()
def is_supporter(self, user):
2013-04-19 14:12:49 +02:00
"""
Returns True if user is a supporter of this motion, else False.
2013-04-19 14:12:49 +02:00
"""
return user in self.supporters.all()
def create_poll(self, skip_autoupdate=False):
2013-04-19 14:12:49 +02:00
"""
Create a new poll for this motion.
2013-02-05 18:46:46 +01:00
Return the new poll object.
2013-02-01 16:33:45 +01:00
"""
if self.state.allow_create_poll:
poll = MotionPoll(motion=self)
poll.save(skip_autoupdate=skip_autoupdate)
poll.set_options(skip_autoupdate=skip_autoupdate)
return poll
else:
2019-01-06 16:22:33 +01:00
raise WorkflowError(
2019-01-12 23:01:42 +01:00
f"You can not create a poll in state {self.state.name}."
2019-01-06 16:22:33 +01:00
)
2013-02-01 16:33:45 +01:00
@property
def workflow_id(self):
"""
Returns the id of the workflow of the motion.
"""
return self.state.workflow.pk
2013-03-12 22:03:56 +01:00
def set_state(self, state):
2013-04-19 14:12:49 +02:00
"""
Set the state of the motion.
2013-03-12 22:03:56 +01:00
'state' can be the id of a state object or a state object.
2013-03-12 22:03:56 +01:00
"""
2019-01-12 23:01:42 +01:00
if isinstance(state, int):
2013-03-12 22:03:56 +01:00
state = State.objects.get(pk=state)
if not state.dont_set_identifier:
2013-03-12 22:03:56 +01:00
self.set_identifier()
self.state = state
def reset_state(self, workflow=None):
2013-04-19 14:12:49 +02:00
"""
Set the state to the default state. If an identifier was set
automatically, reset the identifier and identifier_number.
2013-04-19 14:12:49 +02:00
'workflow' can be a workflow, an id of a workflow or None.
If the motion is new and workflow is None, it chooses the default
workflow from config.
2013-04-19 14:12:49 +02:00
"""
2019-01-12 23:01:42 +01:00
if isinstance(workflow, int):
workflow = Workflow.objects.get(pk=workflow)
if workflow is not None:
new_state = workflow.first_state
elif self.state:
new_state = self.state.workflow.first_state
else:
2019-01-06 16:22:33 +01:00
new_state = (
Workflow.objects.get(pk=config["motions_workflow"]).first_state
or Workflow.objects.get(pk=config["motions_workflow"]).states.all()[0]
)
self.set_state(new_state)
def set_recommendation(self, recommendation):
"""
Set the recommendation of the motion.
'recommendation' can be the id of a state object or a state object.
"""
2019-01-12 23:01:42 +01:00
if isinstance(recommendation, int):
recommendation = State.objects.get(pk=recommendation)
self.recommendation = recommendation
def follow_recommendation(self):
"""
Set the state of this motion to its recommendation.
"""
if self.recommendation is not None:
self.set_state(self.recommendation)
2019-01-19 21:36:30 +01:00
if (
self.recommendation_extension is not None
and self.state.show_state_extension_field
and self.recommendation.show_recommendation_extension_field
):
self.state_extension = self.recommendation_extension
"""
Container for runtime information for agenda app (on create or update of this instance).
"""
2018-08-22 22:00:08 +02:00
agenda_item_update_information: Dict[str, Any] = {}
2019-02-15 12:17:08 +01:00
def get_agenda_title_information(self):
return {"title": self.title, "identifier": self.identifier}
@property
def agenda_item(self):
"""
Returns the related agenda item.
"""
2016-09-30 20:42:58 +02:00
# We support only one agenda item so just return the first element of
# the queryset.
return self.agenda_items.all()[0]
@property
def agenda_item_id(self):
"""
Returns the id of the agenda item object related to this object.
"""
return self.agenda_item.pk
2013-02-02 00:51:08 +01:00
def write_log(self, message_list, person=None, skip_autoupdate=False):
"""
Write a log message.
2013-02-05 18:46:46 +01:00
2019-01-12 23:01:42 +01:00
The message should be in English.
2013-02-05 18:46:46 +01:00
"""
if person and not person.is_authenticated:
person = None
motion_log = MotionLog(motion=self, message_list=message_list, person=person)
motion_log.save(skip_autoupdate=skip_autoupdate)
2014-12-25 10:58:52 +01:00
def is_amendment(self):
"""
Returns True if the motion is an amendment.
A motion is a amendment if amendments are activated in the config and
the motion has a parent.
"""
2019-01-06 16:22:33 +01:00
return config["motions_amendments_enabled"] and self.parent is not None
2014-12-25 10:58:52 +01:00
def is_paragraph_based_amendment(self):
"""
Returns True if the motion is an amendment that stores the changes on a per-paragraph-basis
and is therefore eligible to be shown in diff-view.
"""
return self.is_amendment() and self.amendment_paragraphs
def get_amendments_deep(self):
"""
Generator that yields all amendments of this motion including all
amendment decendents.
. """
for amendment in self.amendments.all():
yield amendment
yield from amendment.get_amendments_deep()
def get_paragraph_based_amendments(self):
"""
Returns a list of all paragraph-based amendments to this motion
"""
2019-01-06 16:22:33 +01:00
return list(
filter(
lambda amend: amend.is_paragraph_based_amendment(),
self.amendments.all(),
)
)
class MotionCommentSection(RESTModelMixin, models.Model):
"""
The model for comment sections for motions. Each comment is related to one section, so
each motions has the ability to have comments from the same section.
"""
2019-01-06 16:22:33 +01:00
access_permissions = MotionCommentSectionAccessPermissions()
name = models.CharField(max_length=255)
"""
The name of the section.
"""
read_groups = models.ManyToManyField(
2019-01-06 16:22:33 +01:00
settings.AUTH_GROUP_MODEL, blank=True, related_name="read_comments"
)
"""
These groups have read-access to the section.
"""
write_groups = models.ManyToManyField(
2019-01-06 16:22:33 +01:00
settings.AUTH_GROUP_MODEL, blank=True, related_name="write_comments"
)
"""
These groups have write-access to the section.
"""
class Meta:
default_permissions = ()
class MotionComment(RESTModelMixin, models.Model):
"""
Represents a motion comment. A comment is always related to a motion and a comment
section. The section determinates the title of the category.
"""
comment = models.TextField()
"""
The comment.
"""
motion = models.ForeignKey(
2019-01-06 16:22:33 +01:00
Motion, on_delete=models.CASCADE, related_name="comments"
)
"""
The motion where this comment belongs to.
"""
section = models.ForeignKey(
2019-01-06 16:22:33 +01:00
MotionCommentSection, on_delete=models.PROTECT, related_name="comments"
)
"""
The section of the comment.
"""
class Meta:
default_permissions = ()
2019-01-06 16:22:33 +01:00
unique_together = ("motion", "section")
def get_root_rest_element(self):
"""
Returns the motion to this instance which is the root REST element.
"""
return self.motion
2018-06-12 14:17:02 +02:00
class SubmitterManager(models.Manager):
"""
Manager for Submitter model. Provides a customized add method.
"""
2019-01-06 16:22:33 +01:00
2018-06-12 14:17:02 +02:00
def add(self, user, motion, skip_autoupdate=False):
"""
Customized manager method to prevent anonymous users to be a
submitter and that someone is not twice a submitter. Cares also
for the initial sorting of the submitters.
"""
if self.filter(user=user, motion=motion).exists():
2019-01-12 23:01:42 +01:00
raise OpenSlidesError(f"{user} is already a submitter.")
2018-06-12 14:17:02 +02:00
if isinstance(user, AnonymousUser):
2019-01-12 23:01:42 +01:00
raise OpenSlidesError("An anonymous user can not be a submitter.")
2019-01-06 16:22:33 +01:00
weight = (
self.filter(motion=motion).aggregate(models.Max("weight"))["weight__max"]
or 0
)
2018-06-12 14:17:02 +02:00
submitter = self.model(user=user, motion=motion, weight=weight + 1)
submitter.save(force_insert=True, skip_autoupdate=skip_autoupdate)
return submitter
class Submitter(RESTModelMixin, models.Model):
"""
M2M Model for submitters.
"""
objects = SubmitterManager()
"""
Use custom Manager.
"""
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=CASCADE_AND_AUTOUODATE)
2018-06-12 14:17:02 +02:00
"""
ForeignKey to the user who is the submitter.
"""
motion = models.ForeignKey(
2019-01-06 16:22:33 +01:00
Motion, on_delete=models.CASCADE, related_name="submitters"
)
2018-06-12 14:17:02 +02:00
"""
ForeignKey to the motion.
"""
weight = models.IntegerField(null=True)
class Meta:
default_permissions = ()
def __str__(self):
return str(self.user)
def get_root_rest_element(self):
"""
Returns the motion to this instance which is the root REST element.
"""
return self.motion
2016-09-10 18:49:38 +02:00
class MotionChangeRecommendationManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
2019-01-06 16:22:33 +01:00
2016-09-10 18:49:38 +02:00
def get_full_queryset(self):
"""
Returns the normal queryset with all change recommendations. In the background we
join and prefetch all related models.
"""
return self.get_queryset()
class MotionChangeRecommendation(RESTModelMixin, models.Model):
"""
A MotionChangeRecommendation object saves change recommendations for a specific Motion
2016-09-10 18:49:38 +02:00
"""
access_permissions = MotionChangeRecommendationAccessPermissions()
objects = MotionChangeRecommendationManager()
motion = models.ForeignKey(
Motion, on_delete=CASCADE_AND_AUTOUODATE, related_name="change_recommendations"
2019-01-06 16:22:33 +01:00
)
"""The motion to which the change recommendation belongs."""
2016-09-10 18:49:38 +02:00
rejected = models.BooleanField(default=False)
"""If true, this change recommendation has been rejected"""
2016-09-10 18:49:38 +02:00
internal = models.BooleanField(default=False)
"""If true, this change recommendation can not be seen by regular users"""
type = models.PositiveIntegerField(default=0)
2017-11-17 12:10:46 +01:00
"""Replacement (0), Insertion (1), Deletion (2), Other (3)"""
other_description = models.TextField(blank=True)
"""The description text for type 'other'"""
2016-09-10 18:49:38 +02:00
line_from = models.PositiveIntegerField()
"""The number or the first affected line"""
line_to = models.PositiveIntegerField()
"""The number or the last affected line (inclusive)"""
text = models.TextField(blank=True)
"""The replacement for the section of the original text specified by motion, line_from and line_to"""
2016-09-10 18:49:38 +02:00
author = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True
2019-01-06 16:22:33 +01:00
)
2016-09-10 18:49:38 +02:00
"""A user object, who created this change recommendation. Optional."""
creation_time = models.DateTimeField(auto_now=True)
"""Time when the change recommendation was saved."""
def collides_with_other_recommendation(self, recommendations):
for recommendation in recommendations:
2019-01-06 16:22:33 +01:00
if not (
self.line_from < recommendation.line_from
and self.line_to <= recommendation.line_from
) and not (
self.line_from >= recommendation.line_to
and self.line_to > recommendation.line_to
):
return True
return False
def save(self, *args, **kwargs):
2019-01-06 16:22:33 +01:00
recommendations = MotionChangeRecommendation.objects.filter(
motion=self.motion
).exclude(pk=self.pk)
if self.collides_with_other_recommendation(recommendations):
2019-01-06 16:22:33 +01:00
raise ValidationError(
2019-01-12 23:01:42 +01:00
f"The recommendation collides with an existing one (line {self.line_from} - {self.line_to})."
2019-01-06 16:22:33 +01:00
)
return super().save(*args, **kwargs)
2016-09-10 18:49:38 +02:00
class Meta:
default_permissions = ()
def __str__(self):
"""Return a string, representing this object."""
2019-01-12 23:01:42 +01:00
return f"Recommendation for Motion {self.motion_id}, line {self.line_from} - {self.line_to}"
2016-09-10 18:49:38 +02:00
class Category(RESTModelMixin, models.Model):
"""
Model for categories of motions.
"""
2019-01-06 16:22:33 +01:00
access_permissions = CategoryAccessPermissions()
name = models.CharField(max_length=255)
"""Name of the category."""
prefix = models.CharField(blank=True, max_length=32)
"""Prefix of the category.
Used to build the identifier of a motion.
"""
2013-03-11 20:17:19 +01:00
2013-03-11 21:29:56 +01:00
class Meta:
2015-12-10 00:20:59 +01:00
default_permissions = ()
2019-01-06 16:22:33 +01:00
ordering = ["prefix"]
2013-02-05 18:46:46 +01:00
2015-12-10 00:20:59 +01:00
def __str__(self):
return self.name
2013-02-01 12:51:54 +01:00
class MotionBlockManager(models.Manager):
"""
Customized model manager to support our get_full_queryset method.
"""
2019-01-06 16:22:33 +01:00
def get_full_queryset(self):
"""
Returns the normal queryset with all motion blocks. In the
background the related agenda item is prefetched from the database.
"""
2019-01-06 16:22:33 +01:00
return self.get_queryset().prefetch_related("agenda_items")
class MotionBlock(RESTModelMixin, models.Model):
"""
Model for blocks of motions.
"""
2019-01-06 16:22:33 +01:00
access_permissions = MotionBlockAccessPermissions()
objects = MotionBlockManager()
title = models.CharField(max_length=255)
# In theory there could be one then more agenda_item. But we support only
# one. See the property agenda_item.
2019-01-06 16:22:33 +01:00
agenda_items = GenericRelation(Item, related_name="topics")
class Meta:
2019-01-12 23:01:42 +01:00
verbose_name = "Motion block"
default_permissions = ()
def __str__(self):
return self.title
"""
Container for runtime information for agenda app (on create or update of this instance).
"""
2018-08-22 22:00:08 +02:00
agenda_item_update_information: Dict[str, Any] = {}
@property
def agenda_item(self):
"""
Returns the related agenda item.
"""
# We support only one agenda item so just return the first element of
# the queryset.
return self.agenda_items.all()[0]
@property
def agenda_item_id(self):
"""
Returns the id of the agenda item object related to this object.
"""
return self.agenda_item.pk
2019-02-15 12:17:08 +01:00
def get_agenda_title_information(self):
return {"title": self.title}
class MotionLog(RESTModelMixin, models.Model):
2013-02-05 18:46:46 +01:00
"""Save a logmessage for a motion."""
motion = models.ForeignKey(
2019-01-06 16:22:33 +01:00
Motion, on_delete=models.CASCADE, related_name="log_messages"
)
2013-02-05 18:46:46 +01:00
"""The motion to witch the object belongs."""
message_list = JSONField()
"""
The log message. It should be a list of strings in English.
2013-02-05 18:46:46 +01:00
"""
person = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=SET_NULL_AND_AUTOUPDATE, null=True
2019-01-06 16:22:33 +01:00
)
"""A user object, who created the log message. Optional."""
2013-02-05 18:46:46 +01:00
time = models.DateTimeField(auto_now=True)
2013-02-05 18:46:46 +01:00
"""The Time, when the loged action was performed."""
class Meta:
2015-12-10 00:20:59 +01:00
default_permissions = ()
2019-01-06 16:22:33 +01:00
ordering = ["-time"]
def __str__(self):
"""
Return a string, representing the log message.
"""
localtime = timezone.localtime(self.time)
2019-01-06 16:22:33 +01:00
time = formats.date_format(localtime, "DATETIME_FORMAT")
2019-01-12 23:01:42 +01:00
message_list = "".join(self.message_list)
time_and_messages = f"{time} {message_list}"
if self.person is not None:
2019-01-12 23:01:42 +01:00
return f"{time_and_messages} by {self.person}"
2013-06-19 21:40:59 +02:00
return time_and_messages
def get_root_rest_element(self):
"""
Returns the motion to this instance which is the root REST element.
"""
return self.motion
2013-02-03 14:14:07 +01:00
class MotionVote(RESTModelMixin, BaseVote):
2013-02-05 18:46:46 +01:00
"""Saves the votes for a MotionPoll.
There should allways be three MotionVote objects for each poll,
one for 'yes', 'no', and 'abstain'."""
2019-01-06 16:22:33 +01:00
option = models.ForeignKey("MotionOption", on_delete=models.CASCADE)
2013-02-05 18:46:46 +01:00
"""The option object, to witch the vote belongs."""
2013-02-01 12:51:54 +01:00
2015-12-10 00:20:59 +01:00
class Meta:
default_permissions = ()
def get_root_rest_element(self):
"""
Returns the motion to this instance which is the root REST element.
"""
return self.option.poll.motion
2013-02-01 12:51:54 +01:00
class MotionOption(RESTModelMixin, BaseOption):
2013-02-05 18:46:46 +01:00
"""Links between the MotionPollClass and the MotionVoteClass.
There should be one MotionOption object for each poll."""
2019-01-06 16:22:33 +01:00
poll = models.ForeignKey("MotionPoll", on_delete=models.CASCADE)
2013-02-05 18:46:46 +01:00
"""The poll object, to witch the object belongs."""
2013-02-01 12:51:54 +01:00
vote_class = MotionVote
2013-02-05 18:46:46 +01:00
"""The VoteClass, to witch this Class links."""
2013-02-01 12:51:54 +01:00
2015-12-10 00:20:59 +01:00
class Meta:
default_permissions = ()
def get_root_rest_element(self):
"""
Returns the motion to this instance which is the root REST element.
"""
return self.poll.motion
2013-02-01 12:51:54 +01:00
2017-08-23 20:51:06 +02:00
# TODO: remove the type-ignoring in the next line, after this is solved:
# https://github.com/python/mypy/issues/3855
class MotionPoll(RESTModelMixin, CollectDefaultVotesMixin, BasePoll): # type: ignore
"""The Class to saves the vote result for a motion poll."""
2013-02-05 18:46:46 +01:00
2019-01-06 16:22:33 +01:00
motion = models.ForeignKey(Motion, on_delete=models.CASCADE, related_name="polls")
2013-02-05 18:46:46 +01:00
"""The motion to witch the object belongs."""
2013-02-01 12:51:54 +01:00
option_class = MotionOption
2013-02-05 18:46:46 +01:00
"""The option class, witch links between this object the the votes."""
2019-01-06 16:22:33 +01:00
vote_values = ["Yes", "No", "Abstain"]
2013-02-05 18:46:46 +01:00
"""The possible anwers for the poll. 'Yes, 'No' and 'Abstain'."""
2013-02-01 12:51:54 +01:00
2015-12-10 00:20:59 +01:00
class Meta:
default_permissions = ()
def __str__(self):
"""
Representation method only for debugging purposes.
"""
2019-01-12 23:01:42 +01:00
return f"MotionPoll for motion {self.motion}"
def set_options(self, skip_autoupdate=False):
2013-02-05 18:46:46 +01:00
"""Create the option class for this poll."""
2014-03-27 20:38:13 +01:00
# TODO: maybe it is possible with .create() to call this without poll=self
# or call this in save()
self.get_option_class()(poll=self).save(skip_autoupdate=skip_autoupdate)
2013-02-01 12:51:54 +01:00
def get_percent_base_choice(self):
2019-01-06 16:22:33 +01:00
return config["motions_poll_100_percent_base"]
def get_root_rest_element(self):
"""
Returns the motion to this instance which is the root REST element.
"""
return self.motion
class State(RESTModelMixin, models.Model):
"""
Defines a state for a motion.
Every state belongs to a workflow. All states of a workflow are linked
together via 'next_states'. One of these states is the first state, but
this is saved in the workflow table (one-to-one relation). In every
state you can configure some handling of a motion. See the following
fields for more information.
Additionally every motion can refer to one state as recommendation of
an person or committee (see config 'motions_recommendations_by'). This
means that the person or committee recommends to set the motion to this
state.
"""
ALL = 0
EXTENDED_MANAGERS_AND_SUBMITTER = 1
EXTENDED_MANAGERS = 2
MANAGERS_ONLY = 3
ACCESS_LEVELS = (
(ALL, "All users with permission to see motions"),
(
EXTENDED_MANAGERS_AND_SUBMITTER,
"Submitters, managers and users with permission to manage metadata",
),
(
EXTENDED_MANAGERS,
"Only managers and users with permission to manage metadata",
),
(MANAGERS_ONLY, "Only managers"),
)
name = models.CharField(max_length=255)
"""A string representing the state."""
recommendation_label = models.CharField(max_length=255, null=True)
"""A string for a recommendation to set the motion to this state."""
workflow = models.ForeignKey(
2019-01-06 16:22:33 +01:00
"Workflow", on_delete=models.CASCADE, related_name="states"
)
"""A many-to-one relation to a workflow."""
2019-01-06 16:22:33 +01:00
next_states = models.ManyToManyField("self", symmetrical=False, blank=True)
"""A many-to-many relation to all states, that can be choosen from this state."""
2019-01-06 16:22:33 +01:00
css_class = models.CharField(max_length=255, default="primary")
"""
A css class string for showing the state name in a coloured label based on bootstrap,
e.g. 'danger' (red), 'success' (green), 'warning' (yellow), 'default' (grey).
Default value is 'primary' (blue).
"""
access_level = models.IntegerField(choices=ACCESS_LEVELS, default=0)
"""
Defines which users may see motions in this state e. g. only managers,
users with permission to manage metadata and submitters.
"""
allow_support = models.BooleanField(default=False)
"""If true, persons can support the motion in this state."""
allow_create_poll = models.BooleanField(default=False)
"""If true, polls can be created in this state."""
allow_submitter_edit = models.BooleanField(default=False)
"""If true, the submitter can edit the motion in this state."""
dont_set_identifier = models.BooleanField(default=False)
2014-12-25 10:58:52 +01:00
"""
Decides if the motion gets an identifier.
If true, the motion does not get an identifier if the state change to
2014-12-25 10:58:52 +01:00
this one, else it does.
"""
2013-03-12 22:03:56 +01:00
show_state_extension_field = models.BooleanField(default=False)
"""
If true, an additional input field (from motion comment) is visible
to extend the state name. The full state name is composed of the given
state name and the entered value of this input field.
"""
2018-11-04 11:11:48 +01:00
merge_amendment_into_final = models.SmallIntegerField(default=0)
"""
Relevant for amendments:
1: Amendments of this status or recommendation will be merged into the
2018-11-04 11:11:48 +01:00
final version of the motion.
0: Undefined.
-1: Amendments of this status or recommendation will not be merged into the
final version of the motion.
(Hint: The status field takes precedence. That means, if status is 1 or -1,
this is the final decision. The recommendation only is considered if the
status is 0)
"""
show_recommendation_extension_field = models.BooleanField(default=False)
"""
If true, an additional input field (from motion comment) is visible
to extend the recommendation label. The full recommendation string is
composed of the given recommendation label and the entered value of this input field.
"""
2015-12-10 00:20:59 +01:00
class Meta:
default_permissions = ()
def __str__(self):
"""Returns the name of the state."""
return self.name
def save(self, **kwargs):
"""Saves a state in the database.
Used to check the integrity before saving. Also used to check that
recommendation_label is not an empty string.
"""
self.check_next_states()
2019-01-06 16:22:33 +01:00
if self.recommendation_label == "":
raise WorkflowError(
2019-01-12 23:01:42 +01:00
f"The field recommendation_label of {self} must not "
"be an empty string."
2019-01-06 16:22:33 +01:00
)
super(State, self).save(**kwargs)
def check_next_states(self):
"""Checks whether all next states of a state belong to the correct workflow."""
# No check if it is a new state which has not been saved yet.
if not self.id:
return
for state in self.next_states.all():
if not state.workflow == self.workflow:
2019-01-06 16:22:33 +01:00
raise WorkflowError(
2019-01-12 23:01:42 +01:00
f"{state} can not be next state of {self} because it does not belong to the same workflow."
2019-01-06 16:22:33 +01:00
)
2019-02-08 09:48:58 +01:00
def is_next_or_previous_state_id(self, state_id):
""" Returns true, if the given state id is a valid next or previous state """
next_state_ids = [item.id for item in self.next_states.all()]
previous_state_ids = [
item.id for item in State.objects.filter(next_states__in=[self.id])
]
return state_id in next_state_ids or state_id in previous_state_ids
def get_root_rest_element(self):
"""
Returns the workflow to this instance which is the root REST element.
"""
return self.workflow
class WorkflowManager(models.Manager):
2016-09-30 20:42:58 +02:00
"""
Customized model manager to support our get_full_queryset method.
"""
2019-01-06 16:22:33 +01:00
def get_full_queryset(self):
2016-09-30 20:42:58 +02:00
"""
Returns the normal queryset with all workflows. In the background
the first state is joined and all states and next states are
prefetched from the database.
"""
2019-01-06 16:22:33 +01:00
return (
self.get_queryset()
.select_related("first_state")
.prefetch_related("states", "states__next_states")
)
class Workflow(RESTModelMixin, models.Model):
"""
Defines a workflow for a motion.
"""
2019-01-06 16:22:33 +01:00
access_permissions = WorkflowAccessPermissions()
objects = WorkflowManager()
name = models.CharField(max_length=255)
"""A string representing the workflow."""
first_state = models.OneToOneField(
State, on_delete=models.CASCADE, related_name="+", null=True
2019-01-06 16:22:33 +01:00
)
"""A one-to-one relation to a state, the starting point for the workflow."""
2015-12-10 00:20:59 +01:00
class Meta:
default_permissions = ()
def __str__(self):
"""Returns the name of the workflow."""
return self.name
def save(self, **kwargs):
"""Saves a workflow in the database.
Used to check the integrity before saving.
"""
self.check_first_state()
super(Workflow, self).save(**kwargs)
def check_first_state(self):
"""Checks whether the first_state itself belongs to the workflow."""
if self.first_state and not self.first_state.workflow == self:
raise WorkflowError(
2019-01-12 23:01:42 +01:00
f"{self.first_state} can not be first state of {self} because it "
"does not belong to it."
2019-01-06 16:22:33 +01:00
)