Work on motion workflow system.

Also: Insert new base exception for OpenSlides.

Also: Insert a workflow field to the state class. Update tests. Rename versioning attribute.
This commit is contained in:
Norman Jäckel 2013-02-06 23:56:21 +01:00
parent 44ea7c835d
commit f2dde228c9
14 changed files with 551 additions and 257 deletions

View File

@ -28,10 +28,6 @@ SESSION_COOKIE_NAME = 'OpenSlidesSessionID'
ugettext = lambda s: s ugettext = lambda s: s
MOTION_WORKFLOW = (
('default', ugettext('default'), 'openslides.motion.workflow.default_workflow'),
)
LANGUAGES = ( LANGUAGES = (
('de', ugettext('German')), ('de', ugettext('German')),
('en', ugettext('English')), ('en', ugettext('English')),

View File

@ -3,7 +3,7 @@
openslides.motion openslides.motion
~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~
The OpenSlides motion app appends the functionality to OpenSlides, to The OpenSlides motion app appends the functionality to OpenSlides to
manage motions. manage motions.
:copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS. :copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS.

View File

@ -0,0 +1,280 @@
[
{
"pk":1,
"model":"motion.workflow",
"fields":{
"name":"Simple Workflow",
"first_state":1
}
},
{
"pk":2,
"model":"motion.workflow",
"fields":{
"name":"Complex Workflow",
"first_state":5
}
},
{
"pk":1,
"model":"motion.state",
"fields":{
"name":"submitted",
"workflow":1,
"dont_set_new_version_active":false,
"allow_submitter_edit":true,
"next_states":[
2,
3,
4
],
"allow_support":true,
"action_word":"",
"icon":"",
"versioning":false,
"allow_create_poll":true
}
},
{
"pk":2,
"model":"motion.state",
"fields":{
"name":"accepted",
"workflow":1,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"accept",
"icon":"",
"versioning":false,
"allow_create_poll":false
}
},
{
"pk":3,
"model":"motion.state",
"fields":{
"name":"rejected",
"workflow":1,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"reject",
"icon":"",
"versioning":false,
"allow_create_poll":false
}
},
{
"pk":4,
"model":"motion.state",
"fields":{
"name":"not decided",
"workflow":1,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"do not decide",
"icon":"",
"versioning":false,
"allow_create_poll":false
}
},
{
"pk":5,
"model":"motion.state",
"fields":{
"name":"published",
"workflow":2,
"dont_set_new_version_active":false,
"allow_submitter_edit":true,
"next_states":[
6,
9,
14
],
"allow_support":true,
"action_word":"",
"icon":"",
"versioning":false,
"allow_create_poll":false
}
},
{
"pk":6,
"model":"motion.state",
"fields":{
"name":"permitted",
"workflow":2,
"dont_set_new_version_active":true,
"allow_submitter_edit":true,
"next_states":[
7,
8,
9,
10,
11,
12,
13
],
"allow_support":false,
"action_word":"permit",
"icon":"",
"versioning":true,
"allow_create_poll":true
}
},
{
"pk":7,
"model":"motion.state",
"fields":{
"name":"accepted",
"workflow":2,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"accept",
"icon":"",
"versioning":true,
"allow_create_poll":false
}
},
{
"pk":8,
"model":"motion.state",
"fields":{
"name":"rejected",
"workflow":2,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"reject",
"icon":"",
"versioning":true,
"allow_create_poll":false
}
},
{
"pk":9,
"model":"motion.state",
"fields":{
"name":"withdrawed",
"workflow":2,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"withdraw",
"icon":"",
"versioning":true,
"allow_create_poll":false
}
},
{
"pk":10,
"model":"motion.state",
"fields":{
"name":"adjourned",
"workflow":2,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"adjourn",
"icon":"",
"versioning":true,
"allow_create_poll":false
}
},
{
"pk":11,
"model":"motion.state",
"fields":{
"name":"not concerned",
"workflow":2,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"",
"icon":"",
"versioning":true,
"allow_create_poll":false
}
},
{
"pk":12,
"model":"motion.state",
"fields":{
"name":"commited a bill",
"workflow":2,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"commit a bill",
"icon":"",
"versioning":true,
"allow_create_poll":false
}
},
{
"pk":13,
"model":"motion.state",
"fields":{
"name":"needs review",
"workflow":2,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"",
"icon":"",
"versioning":true,
"allow_create_poll":false
}
},
{
"pk":14,
"model":"motion.state",
"fields":{
"name":"rejected (not authorized)",
"workflow":2,
"dont_set_new_version_active":false,
"allow_submitter_edit":false,
"next_states":[
],
"allow_support":false,
"action_word":"reject (not authorized)",
"icon":"",
"versioning":true,
"allow_create_poll":false
}
}
]

View File

@ -15,28 +15,27 @@ from django.utils.translation import ugettext as _
from openslides.utils.forms import CssClassMixin from openslides.utils.forms import CssClassMixin
from openslides.utils.person import PersonFormField, MultiplePersonFormField from openslides.utils.person import PersonFormField, MultiplePersonFormField
from .models import Motion from .models import Motion, Workflow
from .workflow import motion_workflow_choices
class BaseMotionForm(forms.ModelForm, CssClassMixin): class BaseMotionForm(forms.ModelForm, CssClassMixin):
"""Base FormClass for a Motion. """Base FormClass for a Motion.
For it's own, it append the version data es fields. For it's own, it append the version data to the fields.
The Class can be mixed with the following Mixins to add fields for the The class can be mixed with the following mixins to add fields for the
submitter, supporters etc. submitter, supporters etc.
""" """
title = forms.CharField(widget=forms.TextInput(), label=_("Title")) title = forms.CharField(widget=forms.TextInput(), label=_("Title"))
"""Title of the Motion. Will be saved in a MotionVersion object.""" """Title of the motion. Will be saved in a MotionVersion object."""
text = forms.CharField(widget=forms.Textarea(), label=_("Text")) text = forms.CharField(widget=forms.Textarea(), label=_("Text"))
"""Text of the Motion. Will be saved in a MotionVersion object.""" """Text of the motion. Will be saved in a MotionVersion object."""
reason = forms.CharField( reason = forms.CharField(
widget=forms.Textarea(), required=False, label=_("Reason")) widget=forms.Textarea(), required=False, label=_("Reason"))
"""Reason of the Motion. will be saved in a MotionVersion object.""" """Reason of the motion. will be saved in a MotionVersion object."""
class Meta: class Meta:
model = Motion model = Motion
@ -57,7 +56,7 @@ class MotionSubmitterMixin(forms.ModelForm):
"""Mixin to append the submitter field to a MotionForm.""" """Mixin to append the submitter field to a MotionForm."""
submitter = MultiplePersonFormField(label=_("Submitter")) submitter = MultiplePersonFormField(label=_("Submitter"))
"""Submitter of the Motion. Can be one or more persons.""" """Submitter of the motion. Can be one or more persons."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Fill in the submitter of the motion as default value.""" """Fill in the submitter of the motion as default value."""
@ -71,7 +70,7 @@ class MotionSupporterMixin(forms.ModelForm):
"""Mixin to append the supporter field to a Motionform.""" """Mixin to append the supporter field to a Motionform."""
supporter = MultiplePersonFormField(required=False, label=_("Supporters")) supporter = MultiplePersonFormField(required=False, label=_("Supporters"))
"""Supporter of the Motion. Can be one or more persons.""" """Supporter of the motion. Can be one or more persons."""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Fill in the supporter of the motions as default value.""" """Fill in the supporter of the motions as default value."""
@ -81,12 +80,12 @@ class MotionSupporterMixin(forms.ModelForm):
super(MotionSupporterMixin, self).__init__(*args, **kwargs) super(MotionSupporterMixin, self).__init__(*args, **kwargs)
class MotionCreateNewVersionMixin(forms.ModelForm): class MotionDisableVersioningMixin(forms.ModelForm):
"""Mixin to add the option to the form, to choose, to create a new version.""" """Mixin to add the option to the form to choose to disable versioning."""
new_version = forms.BooleanField( disable_versioning = forms.BooleanField(
required=False, label=_("Create new version"), initial=True, required=False, label=_("Don't create a new version"),
help_text=_("Trivial changes don't create a new version.")) help_text=_("Don't create a new version. Useful e. g. for trivial changes."))
"""BooleanField to decide, if a new version will be created, or the """BooleanField to decide, if a new version will be created, or the
last_version will be used.""" last_version will be used."""
@ -131,18 +130,13 @@ class ConfigForm(CssClassMixin, forms.Form):
label=_("Preamble text for PDF document (all motions)") label=_("Preamble text for PDF document (all motions)")
) )
motion_create_new_version = forms.ChoiceField( motion_allow_disable_versioning = forms.BooleanField(
widget=forms.Select(), label=_("Allow to disable versioning"),
label=_("Create new versions"),
required=False, required=False,
choices=(
('ALLWASY_CREATE_NEW_VERSION', _('create allways a new versions')),
('NEVER_CREATE_NEW_VERSION', _('create never a new version')),
('ASK_USER', _('Let the user choose if he wants to create a new version')))
) )
motion_workflow = forms.ChoiceField( motion_workflow = forms.ChoiceField(
widget=forms.Select(), widget=forms.Select(),
label=_("Workflow for the motions"), label=_("Workflow of new motions"),
required=True, required=True,
choices=motion_workflow_choices()) choices=[(workflow.pk, workflow.name) for workflow in Workflow.objects.all()])

View File

@ -24,6 +24,7 @@ from django.utils.translation import ugettext_lazy, ugettext_noop, ugettext as _
from openslides.utils.utils import _propper_unicode from openslides.utils.utils import _propper_unicode
from openslides.utils.person import PersonField from openslides.utils.person import PersonField
from openslides.utils.exceptions import OpenSlidesError
from openslides.config.models import config from openslides.config.models import config
from openslides.config.signals import default_config_value from openslides.config.signals import default_config_value
from openslides.poll.models import ( from openslides.poll.models import (
@ -33,8 +34,10 @@ from openslides.projector.api import register_slidemodel
from openslides.projector.models import SlideMixin from openslides.projector.models import SlideMixin
from openslides.agenda.models import Item from openslides.agenda.models import Item
from .workflow import (motion_workflow_choices, get_state, State, WorkflowError,
DUMMY_STATE) class MotionError(OpenSlidesError):
"""Exception raised when errors in the motion accure."""
pass
class Motion(SlideMixin, models.Model): class Motion(SlideMixin, models.Model):
@ -43,7 +46,7 @@ class Motion(SlideMixin, models.Model):
This class is the main entry point to all other classes related to a motion. This class is the main entry point to all other classes related to a motion.
""" """
prefix = "motion" prefix = 'motion'
"""Prefix for the slide system.""" """Prefix for the slide system."""
active_version = models.ForeignKey('MotionVersion', null=True, active_version = models.ForeignKey('MotionVersion', null=True,
@ -55,11 +58,10 @@ class Motion(SlideMixin, models.Model):
version. Like the Sighted versions on Wikipedia. version. Like the Sighted versions on Wikipedia.
""" """
state_id = models.CharField(max_length=3) state = models.ForeignKey('State', null=True) # TODO: Check whether null=True is necessary.
"""The id of a state object. """The related state object.
This Attribute is used be motion.state to identify the current state of the This attribute is to get the current state of the motion.
motion.
""" """
identifier = models.CharField(max_length=255, null=True, blank=True, identifier = models.CharField(max_length=255, null=True, blank=True,
@ -88,9 +90,9 @@ class Motion(SlideMixin, models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Save the motion. """Save the motion.
1. Set the state of a new motion to the default motion. 1. Set the state of a new motion to the default state.
2. Save the motion object. 2. Save the motion object.
3. Save the version Data. 3. Save the version data.
4. Set the active version for the motion. 4. Set the active version for the motion.
A new version will be saved if motion.new_version was called A new version will be saved if motion.new_version was called
@ -105,7 +107,7 @@ class Motion(SlideMixin, models.Model):
the config 'motion_create_new_version' is set to the config 'motion_create_new_version' is set to
'ALLWASY_CREATE_NEW_VERSION'. 'ALLWASY_CREATE_NEW_VERSION'.
""" """
if not self.state_id: if not self.state:
self.reset_state() self.reset_state()
super(Motion, self).save(*args, **kwargs) super(Motion, self).save(*args, **kwargs)
@ -121,11 +123,12 @@ class Motion(SlideMixin, models.Model):
else: else:
new_data = False new_data = False
need_new_version = config['motion_create_new_version'] == 'ALLWASY_CREATE_NEW_VERSION' # TODO: Check everything here. The decision whether to create a new version has to be done in the view. Update docstings too.
need_new_version = self.state.versioning
if hasattr(self, '_new_version') or (new_data and need_new_version): if hasattr(self, '_new_version') or (new_data and need_new_version):
version = self.new_version version = self.new_version
del self._new_version del self._new_version
version.motion = self # Test if this line is realy neccessary. version.motion = self # TODO: Test if this line is really neccessary.
elif new_data and not need_new_version: elif new_data and not need_new_version:
version = self.last_version version = self.last_version
else: else:
@ -150,16 +153,16 @@ class Motion(SlideMixin, models.Model):
version.version_number = version_number + 1 version.version_number = version_number + 1
version.save() version.save()
# Set the active Version of this motion. This has to be done after the # Set the active version of this motion. This has to be done after the
# version is saved to the db # version is saved to the database
if not self.state.version_permission or self.active_version is None: if not self.state.dont_set_new_version_active or self.active_version is None:
self.active_version = version self.active_version = version
self.save() self.save()
def get_absolute_url(self, link='detail'): def get_absolute_url(self, link='detail'):
"""Return an URL for this version. """Return an URL for this version.
The keywordargument 'link' can be 'detail', 'view', 'edit' or 'delete'. The keyword argument 'link' can be 'detail', 'view', 'edit' or 'delete'.
""" """
if link == 'view' or link == 'detail': if link == 'view' or link == 'detail':
return reverse('motion_detail', args=[str(self.id)]) return reverse('motion_detail', args=[str(self.id)])
@ -240,7 +243,7 @@ class Motion(SlideMixin, models.Model):
@property @property
def new_version(self): def new_version(self):
"""Return a Version object, not saved in the database. """Return a version object, not saved in the database.
On the first call, it creates a new version. On any later call, it On the first call, it creates a new version. On any later call, it
use the existing new version. use the existing new version.
@ -266,8 +269,8 @@ class Motion(SlideMixin, models.Model):
def set_version(self, version): def set_version(self, version):
"""Set the 'active' version object. """Set the 'active' version object.
The keyargument 'version' can be a MotionVersion object or the The keyword argument 'version' can be a MotionVersion object or the
version_number of a VersionObject or None. version_number of a version object or None.
If the argument is None, the newest version will be used. If the argument is None, the newest version will be used.
""" """
@ -291,7 +294,7 @@ class Motion(SlideMixin, models.Model):
@property @property
def last_version(self): def last_version(self):
"""Return the newest version of the motion.""" """Return the newest version of the motion."""
# TODO: Fix the case, that the motion has no Version # TODO: Fix the case, that the motion has no version
try: try:
return self.versions.order_by('-version_number')[0] return self.versions.order_by('-version_number')[0]
except IndexError: except IndexError:
@ -307,62 +310,39 @@ class Motion(SlideMixin, models.Model):
def support(self, person): def support(self, person):
"""Add 'person' as a supporter of this motion.""" """Add 'person' as a supporter of this motion."""
if self.state.support: if self.state.allow_support:
if not self.is_supporter(person): if not self.is_supporter(person):
MotionSupporter(motion=self, person=person).save() MotionSupporter(motion=self, person=person).save()
else: else:
raise WorkflowError("You can not support a motion in state %s" % self.state.name) raise WorkflowError('You can not support a motion in state %s.' % self.state.name)
def unsupport(self, person): def unsupport(self, person):
"""Remove 'person' as supporter from this motion.""" """Remove 'person' as supporter from this motion."""
if self.state.support: if self.state.allow_support:
self.supporter.filter(person=person).delete() self.supporter.filter(person=person).delete()
else: else:
raise WorkflowError("You can not unsupport a motion in state %s" % self.state.name) raise WorkflowError('You can not unsupport a motion in state %s.' % self.state.name)
def create_poll(self): def create_poll(self):
"""Create a new poll for this motion. """Create a new poll for this motion.
Return the new poll object. Return the new poll object.
""" """
if self.state.create_poll: if self.state.allow_create_poll:
# TODO: auto increment the poll_number in the Database # TODO: auto increment the poll_number in the database
poll_number = self.polls.aggregate(Max('poll_number'))['poll_number__max'] or 0 poll_number = self.polls.aggregate(Max('poll_number'))['poll_number__max'] or 0
poll = MotionPoll.objects.create(motion=self, poll_number=poll_number + 1) poll = MotionPoll.objects.create(motion=self, poll_number=poll_number + 1)
poll.set_options() poll.set_options()
return poll return poll
else: else:
raise WorkflowError("You can not create a poll in state %s" % self.state.name) raise WorkflowError('You can not create a poll in state %s.' % self.state.name)
def get_state(self):
"""Return the state of the motion.
State is a State object. See openslides.motion.workflow for more informations.
"""
try:
return get_state(self.state_id)
except WorkflowError:
return DUMMY_STATE
def set_state(self, next_state):
"""Set the state of this motion.
The keyargument 'next_state' has to be a State object or an id of a
State object.
"""
if not isinstance(next_state, State):
next_state = get_state(next_state)
if next_state in self.state.next_states:
self.state_id = next_state.id
else:
raise WorkflowError('%s is not a valid next_state' % next_state)
state = property(get_state, set_state)
"""The state of the motion as Ste object."""
def reset_state(self): def reset_state(self):
"""Set the state to the default state.""" """Set the state to the default state. If the motion is new, it chooses the default workflow from config."""
self.state_id = get_state('default').id if self.state:
self.state = self.state.workflow.first_state
else:
self.state = Workflow.objects.get(pk=config['motion_workflow']).first_state
def slide(self): def slide(self):
"""Return the slide dict.""" """Return the slide dict."""
@ -395,13 +375,13 @@ class Motion(SlideMixin, models.Model):
""" """
actions = { actions = {
'edit': ((self.is_submitter(person) and 'edit': ((self.is_submitter(person) and
self.state.edit_as_submitter) or self.state.allow_submitter_edit) or
person.has_perm('motion.can_manage_motion')), person.has_perm('motion.can_manage_motion')),
'create_poll': (person.has_perm('motion.can_manage_motion') and 'create_poll': (person.has_perm('motion.can_manage_motion') and
self.state.create_poll), self.state.allow_create_poll),
'support': (self.state.support and 'support': (self.state.allow_support and
config['motion_min_supporters'] > 0 and config['motion_min_supporters'] > 0 and
not self.is_submitter(person)), not self.is_submitter(person)),
@ -410,7 +390,7 @@ class Motion(SlideMixin, models.Model):
} }
actions['delete'] = actions['edit'] # TODO: Only if the motion has no number actions['delete'] = actions['edit'] # TODO: Only if the motion has no number
actions['unsupport'] = actions['support'] actions['unsupport'] = actions['support']
actions['reset_state'] = 'change_state' actions['reset_state'] = actions['change_state']
return actions return actions
def write_log(self, message, person=None): def write_log(self, message, person=None):
@ -455,7 +435,7 @@ class MotionVersion(models.Model):
A MotionVersion object saves some date of the motion.""" A MotionVersion object saves some date of the motion."""
motion = models.ForeignKey(Motion, related_name='versions') motion = models.ForeignKey(Motion, related_name='versions')
"""The Motion, to witch the version belongs.""" """The motion to which the version belongs."""
version_number = models.PositiveIntegerField(default=1) version_number = models.PositiveIntegerField(default=1)
"""An id for this version in realation to a motion. """An id for this version in realation to a motion.
@ -464,7 +444,7 @@ class MotionVersion(models.Model):
""" """
title = models.CharField(max_length=255, verbose_name=ugettext_lazy("Title")) title = models.CharField(max_length=255, verbose_name=ugettext_lazy("Title"))
"""The Title of a motion.""" """The title of a motion."""
text = models.TextField(verbose_name=_("Text")) text = models.TextField(verbose_name=_("Text"))
"""The text of a motion.""" """The text of a motion."""
@ -473,10 +453,10 @@ class MotionVersion(models.Model):
"""The reason for a motion.""" """The reason for a motion."""
rejected = models.BooleanField(default=False) rejected = models.BooleanField(default=False)
"""Saves, if the version is rejected.""" """Saves if the version is rejected."""
creation_time = models.DateTimeField(auto_now=True) creation_time = models.DateTimeField(auto_now=True)
"""Time, when the version was saved.""" """Time when the version was saved."""
#identifier = models.CharField(max_length=255, verbose_name=ugettext_lazy("Version identifier")) #identifier = models.CharField(max_length=255, verbose_name=ugettext_lazy("Version identifier"))
#note = models.TextField(null=True, blank=True) #note = models.TextField(null=True, blank=True)
@ -487,7 +467,7 @@ class MotionVersion(models.Model):
def __unicode__(self): def __unicode__(self):
"""Return a string, representing this object.""" """Return a string, representing this object."""
counter = self.version_number or _('new') counter = self.version_number or _('new')
return "%s Version %s" % (self.motion, counter) return "%s Version %s" % (self.motion, counter) # TODO: Should this really be self.motion or the title of the specific version?
def get_absolute_url(self, link='detail'): def get_absolute_url(self, link='detail'):
"""Return the URL of this Version. """Return the URL of this Version.
@ -577,11 +557,6 @@ class MotionLog(models.Model):
return "%s %s by %s" % (self.time, _(self.message), self.person) return "%s %s by %s" % (self.time, _(self.message), self.person)
class MotionError(Exception):
"""Exception raised when errors in the motion accure."""
pass
class MotionVote(BaseVote): class MotionVote(BaseVote):
"""Saves the votes for a MotionPoll. """Saves the votes for a MotionPoll.
@ -652,3 +627,105 @@ class MotionPoll(CountInvalid, CountVotesCast, BasePoll):
"""Apend the fields for invalid and votecast to the ModelForm.""" """Apend the fields for invalid and votecast to the ModelForm."""
CountInvalid.append_pollform_fields(self, fields) CountInvalid.append_pollform_fields(self, fields)
CountVotesCast.append_pollform_fields(self, fields) CountVotesCast.append_pollform_fields(self, fields)
class WorkflowError(OpenSlidesError):
"""Exception raised when errors in a workflow or state accure."""
pass
class State(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.
"""
name = models.CharField(max_length=255)
"""A string representing the state."""
action_word = models.CharField(max_length=255)
"""An alternative string to be used for a button to switch to this state."""
workflow = models.ForeignKey('Workflow')
"""A many-to-one relation to a workflow."""
next_states = models.ManyToManyField('self', symmetrical=False)
"""A many-to-many relation to all states, that can be choosen from this state."""
icon = models.CharField(max_length=255)
"""A string representing the url to the icon-image."""
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."""
versioning = models.BooleanField(default=False)
"""
If true, editing the motion will create a new version by default.
This behavior can be changed by the form and view, e. g. via the
MotionDisableVersioningMixin.
"""
dont_set_new_version_active = models.BooleanField(default=False)
"""If true, new versions are not automaticly set active."""
def __unicode__(self):
"""Returns the name of the state."""
return self.name
def save(self, **kwargs):
"""Saves a state to the database.
Used to check the integrity before saving.
"""
self.check_next_states()
super(State, self).save(**kwargs)
def get_action_word(self):
"""Returns the alternative name of the state if it exists."""
return self.action_word or self.name
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:
raise WorkflowError('%s can not be next state of %s because it does not belong to the same workflow.' % (state, self))
class Workflow(models.Model):
"""Defines a workflow for a motion."""
name = models.CharField(max_length=255)
"""A string representing the workflow."""
first_state = models.OneToOneField(State, related_name='+')
"""A one-to-one relation to a state, the starting point for the workflow."""
def __unicode__(self):
"""Returns the name of the workflow."""
return self.name
def save(self, **kwargs):
"""Saves a workflow to 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 not self.first_state.workflow == self:
raise WorkflowError('%s can not be first state of %s because it does not belong to it.' % (self.first_state, self))

View File

@ -5,6 +5,9 @@
~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~
Functions to generate the PDFs for the motion app. Functions to generate the PDFs for the motion app.
:copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS.
:license: GNU GPL, see LICENSE for more details.
""" """
from reportlab.lib import colors from reportlab.lib import colors

View File

@ -1,8 +1,8 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
openslides.motion.signales openslides.motion.signals
~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~
Signals for the motion app. Signals for the motion app.
@ -26,5 +26,5 @@ def default_config(sender, key, **kwargs):
'motion_pdf_ballot_papers_number': '8', 'motion_pdf_ballot_papers_number': '8',
'motion_pdf_title': _('Motions'), 'motion_pdf_title': _('Motions'),
'motion_pdf_preamble': '', 'motion_pdf_preamble': '',
'motion_create_new_version': 'ALLWASY_CREATE_NEW_VERSION', 'motion_allow_disable_versioning': False,
'motion_workflow': 'default'}.get(key) 'motion_workflow': 1}.get(key)

View File

@ -4,7 +4,7 @@
openslides.motion.slides openslides.motion.slides
~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~
Defines the Slides for the motion app. Defines the slides for the motion app.
:copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS. :copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS.
:license: GNU GPL, see LICENSE for more details. :license: GNU GPL, see LICENSE for more details.

View File

@ -16,8 +16,8 @@
<p>State: {{ motion.state }}</p> <p>State: {{ motion.state }}</p>
<h4>possible stats:</h4> <h4>possible stats:</h4>
<ul> <ul>
{% for state in motion.state.next_states %} {% for state in motion.state.next_states.all %}
<li><a href="{% url 'motion_set_state' motion.pk state.id %}">{{ state }}</a></li> <li><a href="{% url 'motion_set_state' motion.pk state.pk %}">{{ state }}</a></li>
{% endfor %} {% endfor %}
<li><a href="{% url 'motion_reset_state' motion.pk %}">Reset State</a></li> <li><a href="{% url 'motion_reset_state' motion.pk %}">Reset State</a></li>
</ul> </ul>

View File

@ -12,6 +12,7 @@
from django.conf.urls import url, patterns from django.conf.urls import url, patterns
urlpatterns = patterns('openslides.motion.views', urlpatterns = patterns('openslides.motion.views',
url(r'^$', url(r'^$',
'motion_list', 'motion_list',
@ -78,7 +79,7 @@ urlpatterns = patterns('openslides.motion.views',
name='motion_poll_delete', name='motion_poll_delete',
), ),
url(r'^(?P<pk>\d+)/set_state/(?P<state>[a-z]{3})/$', url(r'^(?P<pk>\d+)/set_state/(?P<state>\d+)/$',
'set_state', 'set_state',
name='motion_set_state', name='motion_set_state',
), ),

View File

@ -6,7 +6,7 @@
Views for the motion app. Views for the motion app.
Will automaticly imported from openslides.motion.urls.py The views are automaticly imported from openslides.motion.urls.
:copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS. :copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS.
:license: GNU GPL, see LICENSE for more details. :license: GNU GPL, see LICENSE for more details.
@ -32,10 +32,9 @@ from openslides.projector.projector import Widget, SLIDE
from openslides.config.models import config from openslides.config.models import config
from openslides.agenda.models import Item from openslides.agenda.models import Item
from .models import Motion, MotionSubmitter, MotionSupporter, MotionPoll, MotionVersion from .models import Motion, MotionSubmitter, MotionSupporter, MotionPoll, MotionVersion, State, WorkflowError
from .forms import (BaseMotionForm, MotionSubmitterMixin, MotionSupporterMixin, from .forms import (BaseMotionForm, MotionSubmitterMixin, MotionSupporterMixin,
MotionCreateNewVersionMixin, ConfigForm) MotionDisableVersioningMixin, ConfigForm)
from .workflow import WorkflowError
from .pdf import motions_to_pdf, motion_to_pdf from .pdf import motions_to_pdf, motion_to_pdf
@ -81,7 +80,7 @@ motion_detail = MotionDetailView.as_view()
class MotionMixin(object): class MotionMixin(object):
"""Mixin for MotionViewsClasses, to save the version data.""" """Mixin for MotionViewsClasses to save the version data."""
def manipulate_object(self, form): def manipulate_object(self, form):
"""Save the version data into the motion object before it is saved in """Save the version data into the motion object before it is saved in
@ -125,8 +124,9 @@ class MotionMixin(object):
form_classes.append(MotionSubmitterMixin) form_classes.append(MotionSubmitterMixin)
if config['motion_min_supporters'] > 0: if config['motion_min_supporters'] > 0:
form_classes.append(MotionSupporterMixin) form_classes.append(MotionSupporterMixin)
if config['motion_create_new_version'] == 'ASK_USER': if self.object:
form_classes.append(MotionCreateNewVersionMixin) if config['motion_allow_disable_versioning'] and self.object.state.versioning:
form_classes.append(MotionDisableVersioningMixin)
return type('MotionForm', tuple(form_classes), {}) return type('MotionForm', tuple(form_classes), {})
@ -267,7 +267,7 @@ class SupportView(SingleObjectMixin, QuestionMixin, RedirectView):
def case_yes(self): def case_yes(self):
"""Append or remove the request.user from the motion. """Append or remove the request.user from the motion.
First the methode checks the permissions, and writes a log message after First the method checks the permissions, and writes a log message after
appending or removing the user. appending or removing the user.
""" """
if self.check_permission(self.request): if self.check_permission(self.request):
@ -395,8 +395,8 @@ class MotionSetStateView(SingleObjectMixin, RedirectView):
if self.reset: if self.reset:
self.object.reset_state() self.object.reset_state()
else: else:
self.object.state = kwargs['state'] self.object.state = State.objects.get(pk=kwargs['state'])
except WorkflowError, e: except WorkflowError, e: # TODO: Is a WorkflowError still possible here?
messages.error(request, e) messages.error(request, e)
else: else:
self.object.save() self.object.save()
@ -484,7 +484,7 @@ class Config(FormView):
'motion_pdf_ballot_papers_number': config['motion_pdf_ballot_papers_number'], 'motion_pdf_ballot_papers_number': config['motion_pdf_ballot_papers_number'],
'motion_pdf_title': config['motion_pdf_title'], 'motion_pdf_title': config['motion_pdf_title'],
'motion_pdf_preamble': config['motion_pdf_preamble'], 'motion_pdf_preamble': config['motion_pdf_preamble'],
'motion_create_new_version': config['motion_create_new_version'], 'motion_allow_disable_versioning': config['motion_allow_disable_versioning'],
'motion_workflow': config['motion_workflow'], 'motion_workflow': config['motion_workflow'],
} }
@ -495,7 +495,7 @@ class Config(FormView):
config['motion_pdf_ballot_papers_number'] = form.cleaned_data['motion_pdf_ballot_papers_number'] config['motion_pdf_ballot_papers_number'] = form.cleaned_data['motion_pdf_ballot_papers_number']
config['motion_pdf_title'] = form.cleaned_data['motion_pdf_title'] config['motion_pdf_title'] = form.cleaned_data['motion_pdf_title']
config['motion_pdf_preamble'] = form.cleaned_data['motion_pdf_preamble'] config['motion_pdf_preamble'] = form.cleaned_data['motion_pdf_preamble']
config['motion_create_new_version'] = form.cleaned_data['motion_create_new_version'] config['motion_allow_disable_versioning'] = form.cleaned_data['motion_allow_disable_versioning']
config['motion_workflow'] = form.cleaned_data['motion_workflow'] config['motion_workflow'] = form.cleaned_data['motion_workflow']
messages.success(self.request, _('Motion settings successfully saved.')) messages.success(self.request, _('Motion settings successfully saved.'))
return super(Config, self).form_valid(form) return super(Config, self).form_valid(form)

View File

@ -1,144 +1,51 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
openslides.utils.workflow openslides.motion.workflow
~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~
Defines the States for motions. All States are linked together with there
'next_state' attributes. Together there are a workflow.
:copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS. :copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS.
:license: GNU GPL, see LICENSE for more details. :license: GNU GPL, see LICENSE for more details.
""" """
from django.conf import settings
from django.core import exceptions
from django.utils.importlib import import_module
from django.utils.translation import ugettext_noop from django.utils.translation import ugettext_noop
from openslides.config.models import config from .models import Workflow, State
_workflow = None
class State(object): def init_builtin_workflows():
"""Define a state for a motion."""
def __init__(self, id, name, next_states=[], create_poll=False, support=False,
edit_as_submitter=False, version_permission=True):
"""Set attributes for the state.
The Arguments are:
- 'id' a unique id for the state.
- 'name' a string representing the state.
- 'next_states' a list with all states, that can be choosen from this state.
All the other arguments are boolean values. If True, the specific action for
motions in this state.
- 'create_poll': polls can be created in this state.
- 'support': persons can support the motion in this state.
- 'edit_as_submitter': the submitter can edit the motion in this state.
- 'version_permission': new versions are not permitted.
"""
self.id = id
self.name = name
self.next_states = next_states
self.create_poll = create_poll
self.support = support
self.edit_as_submitter = edit_as_submitter
self.version_permission = version_permission
def __unicode__(self):
"""Return the name of the state."""
return self.name
class WorkflowError(Exception):
"""Exception raised when errors in a state accure."""
pass
def motion_workflow_choices():
"""Return all possible workflows.
The possible workflows can be set in the settings with the setting
'MOTION_WORKFLOW'.
""" """
for workflow in settings.MOTION_WORKFLOW: Saves a simple and a complex workflow into the database. This function is only called manually.
yield workflow[0], workflow[1]
def get_state(state='default'):
"""Return a state object.
The argument 'state' has to be a state_id.
If the argument 'state' is 'default', the default state is returned.
The default state is the state object choosen in the config tab.
""" """
global _workflow workflow_1 = Workflow(name=ugettext_noop('Simple Workflow'), id=1)
if _workflow is not None: state_1_1 = State.objects.create(name=ugettext_noop('submitted'), workflow=workflow_1,
try: allow_create_poll=True, allow_support=True, allow_submitter_edit=True)
return _workflow[state] state_1_2 = State.objects.create(name=ugettext_noop('accepted'), workflow=workflow_1, action_word=ugettext_noop('accept'))
except KeyError: state_1_3 = State.objects.create(name=ugettext_noop('rejected'), workflow=workflow_1, action_word=ugettext_noop('reject'))
raise WorkflowError('Unknown state: %s' % state) state_1_4 = State.objects.create(name=ugettext_noop('not decided'), workflow=workflow_1, action_word=ugettext_noop('do not decide'))
_workflow = {} state_1_1.next_states.add(state_1_2, state_1_3, state_1_4)
for workflow in settings.MOTION_WORKFLOW: state_1_1.save() # Is this neccessary?
if workflow[0] == config['motion_workflow']: workflow_1.first_state = state_1_1
try: workflow_1.save()
wf_module, wf_default_state_name = workflow[2].rsplit('.', 1)
except ValueError:
raise exceptions.ImproperlyConfigured(
'%s isn\'t a workflow module' % workflow[2])
try:
mod = import_module(wf_module)
except ImportError as e:
raise exceptions.ImproperlyConfigured(
'Error importing workflow %s: "%s"' % (wf_module, e))
try:
default_state = getattr(mod, wf_default_state_name)
except AttributeError:
raise exceptions.ImproperlyConfigured(
'Workflow module "%s" does not define a "%s" State'
% (wf_module, wf_default_state_name))
_workflow['default'] = default_state
break
else:
raise ImproperlyConfigured('Unknown workflow %s' % conf['motion_workflow'])
populate_workflow(default_state, _workflow) workflow_2 = Workflow(name=ugettext_noop('Complex Workflow'), id=2)
return get_state(state) state_2_1 = State.objects.create(name=ugettext_noop('published'), workflow=workflow_2, allow_support=True, allow_submitter_edit=True)
state_2_2 = State.objects.create(name=ugettext_noop('permitted'), workflow=workflow_2, action_word=ugettext_noop('permit'),
allow_create_poll=True, allow_submitter_edit=True, versioning=True, dont_set_new_version_active=True)
def populate_workflow(state, workflow): state_2_3 = State.objects.create(name=ugettext_noop('accepted'), workflow=workflow_2, action_word=ugettext_noop('accept'), versioning=True)
"""Append all 'next_states' from state to the workflow. state_2_4 = State.objects.create(name=ugettext_noop('rejected'), workflow=workflow_2, action_word=ugettext_noop('reject'), versioning=True)
state_2_5 = State.objects.create(name=ugettext_noop('withdrawed'), workflow=workflow_2,
The argument state has to be a state object. action_word=ugettext_noop('withdraw'), versioning=True)
state_2_6 = State.objects.create(name=ugettext_noop('adjourned'), workflow=workflow_2, action_word=ugettext_noop('adjourn'), versioning=True)
The argument workflow has to be a dictonary. state_2_7 = State.objects.create(name=ugettext_noop('not concerned'), workflow=workflow_2, versioning=True)
state_2_8 = State.objects.create(name=ugettext_noop('commited a bill'), workflow=workflow_2,
Calls this function recrusiv with all next_states from the next_states states. action_word=ugettext_noop('commit a bill'), versioning=True)
""" state_2_9 = State.objects.create(name=ugettext_noop('needs review'), workflow=workflow_2, versioning=True)
workflow[state.id] = state state_2_10 = State.objects.create(name=ugettext_noop('rejected (not authorized)'), workflow=workflow_2,
for s in state.next_states: action_word=ugettext_noop('reject (not authorized)'), versioning=True)
if s.id not in workflow: state_2_1.next_states.add(state_2_2, state_2_5, state_2_10)
populate_workflow(s, workflow) state_2_2.next_states.add(state_2_3, state_2_4, state_2_5, state_2_6, state_2_7, state_2_8, state_2_9)
state_2_1.save() # Is this neccessary?
state_2_2.save() # Is this neccessary?
DUMMY_STATE = State('dummy', ugettext_noop('Unknwon state')) workflow_2.first_state = state_2_1
"""A dummy state object. Returned, if the state_id is not known.""" workflow_2.save()
default_workflow = State('pub', ugettext_noop('Published'), support=True,
edit_as_submitter=True, version_permission=False)
"""Default Workflow for OpenSlides."""
default_workflow.next_states = [
State('per', ugettext_noop('Permitted'), create_poll=True, edit_as_submitter=True, next_states=[
State('acc', ugettext_noop('Accepted')),
State('rej', ugettext_noop('Rejected')),
State('wit', ugettext_noop('Withdrawed')),
State('adj', ugettext_noop('Adjourned')),
State('noc', ugettext_noop('Not Concerned')),
State('com', ugettext_noop('Commited a bill')),
State('rev', ugettext_noop('Needs Review'))]),
State('nop', ugettext_noop('Rejected (not authorized)'))]

View File

@ -0,0 +1,15 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
openslides.utils.exceptions
~~~~~~~~~~~~~~~~~~~~~~~~~~~
Base Exception for OpenSlides.
:copyright: 2011, 2012 by OpenSlides team, see AUTHORS.
:license: GNU GPL, see LICENSE for more details.
"""
class OpenSlidesError(Exception):
pass

View File

@ -12,17 +12,16 @@ from django.test import TestCase
from openslides.participant.models import User from openslides.participant.models import User
from openslides.config.models import config from openslides.config.models import config
from openslides.motion.models import Motion from openslides.motion.models import Motion, Workflow, State, WorkflowError
from openslides.motion.workflow import WorkflowError
class ModelTest(TestCase): class ModelTest(TestCase):
def setUp(self): def setUp(self):
self.motion = Motion.objects.create(title='v1') self.motion = Motion.objects.create(title='v1')
self.test_user = User.objects.create(username='blub') self.test_user = User.objects.create(username='blub')
self.workflow = Workflow.objects.get(pk=1)
def test_create_new_version(self): def test_create_new_version(self):
config['motion_create_new_version'] = 'ALLWASY_CREATE_NEW_VERSION'
motion = Motion.objects.create(title='m1') motion = Motion.objects.create(title='m1')
self.assertEqual(motion.versions.count(), 1) self.assertEqual(motion.versions.count(), 1)
@ -32,13 +31,16 @@ class ModelTest(TestCase):
motion.title = 'new title' motion.title = 'new title'
motion.save() motion.save()
self.assertEqual(motion.versions.count(), 3) self.assertEqual(motion.versions.count(), 2)
motion.save()
self.assertEqual(motion.versions.count(), 2)
motion.state = State.objects.create(name='automatic_versioning', workflow=self.workflow, versioning=True)
motion.text = 'new text'
motion.save() motion.save()
self.assertEqual(motion.versions.count(), 3) self.assertEqual(motion.versions.count(), 3)
config['motion_create_new_version'] = 'NEVER_CREATE_NEW_VERSION'
motion.text = 'new text'
motion.save() motion.save()
self.assertEqual(motion.versions.count(), 3) self.assertEqual(motion.versions.count(), 3)
@ -56,6 +58,7 @@ class ModelTest(TestCase):
def test_version(self): def test_version(self):
motion = Motion.objects.create(title='v1') motion = Motion.objects.create(title='v1')
motion.state = State.objects.create(name='automatic_versioning', workflow=self.workflow, versioning=True)
motion.title = 'v2' motion.title = 'v2'
motion.save() motion.save()
v2_version = motion.version v2_version = motion.version
@ -94,24 +97,42 @@ class ModelTest(TestCase):
self.motion.unsupport(self.test_user) self.motion.unsupport(self.test_user)
def test_poll(self): def test_poll(self):
self.motion.state = 'per' self.motion.state = State.objects.get(pk=1)
poll = self.motion.create_poll() poll = self.motion.create_poll()
self.assertEqual(poll.poll_number, 1) self.assertEqual(poll.poll_number, 1)
def test_state(self): def test_state(self):
self.motion.reset_state() self.motion.reset_state()
self.assertEqual(self.motion.state.id, 'pub') self.assertEqual(self.motion.state.name, 'submitted')
self.motion.state = State.objects.get(pk=5)
self.assertEqual(self.motion.state.name, 'published')
with self.assertRaises(WorkflowError): with self.assertRaises(WorkflowError):
self.motion.create_poll() self.motion.create_poll()
self.motion.set_state('per') self.motion.state = State.objects.get(pk=6)
self.assertEqual(self.motion.state.id, 'per') self.assertEqual(self.motion.state.name, 'permitted')
self.assertEqual(self.motion.state.get_action_word(), 'permit')
with self.assertRaises(WorkflowError): with self.assertRaises(WorkflowError):
self.motion.support(self.test_user) self.motion.support(self.test_user)
with self.assertRaises(WorkflowError): with self.assertRaises(WorkflowError):
self.motion.unsupport(self.test_user) self.motion.unsupport(self.test_user)
with self.assertRaises(WorkflowError): def test_new_states_or_workflows(self):
self.motion.set_state('per') workflow_1 = Workflow(name='W1', id=1000)
state_1 = State.objects.create(name='S1', workflow=workflow_1)
workflow_1.first_state = state_1
workflow_1.save()
workflow_2 = Workflow(name='W2', id=2000)
state_2 = State.objects.create(name='S2', workflow=workflow_2)
workflow_2.first_state = state_2
workflow_2.save()
state_3 = State.objects.create(name='S3', workflow=workflow_1)
with self.assertRaises(WorkflowError):
workflow_2.first_state = state_3
workflow_2.save()
with self.assertRaises(WorkflowError):
state_1.next_states.add(state_2)
state_1.save()