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
MOTION_WORKFLOW = (
('default', ugettext('default'), 'openslides.motion.workflow.default_workflow'),
)
LANGUAGES = (
('de', ugettext('German')),
('en', ugettext('English')),

View File

@ -3,7 +3,7 @@
openslides.motion
~~~~~~~~~~~~~~~~~
The OpenSlides motion app appends the functionality to OpenSlides, to
The OpenSlides motion app appends the functionality to OpenSlides to
manage motions.
: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.person import PersonFormField, MultiplePersonFormField
from .models import Motion
from .workflow import motion_workflow_choices
from .models import Motion, Workflow
class BaseMotionForm(forms.ModelForm, CssClassMixin):
"""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.
"""
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 of the Motion. Will be saved in a MotionVersion object."""
"""Text of the motion. Will be saved in a MotionVersion object."""
reason = forms.CharField(
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:
model = Motion
@ -57,7 +56,7 @@ class MotionSubmitterMixin(forms.ModelForm):
"""Mixin to append the submitter field to a MotionForm."""
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):
"""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."""
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):
"""Fill in the supporter of the motions as default value."""
@ -81,12 +80,12 @@ class MotionSupporterMixin(forms.ModelForm):
super(MotionSupporterMixin, self).__init__(*args, **kwargs)
class MotionCreateNewVersionMixin(forms.ModelForm):
"""Mixin to add the option to the form, to choose, to create a new version."""
class MotionDisableVersioningMixin(forms.ModelForm):
"""Mixin to add the option to the form to choose to disable versioning."""
new_version = forms.BooleanField(
required=False, label=_("Create new version"), initial=True,
help_text=_("Trivial changes don't create a new version."))
disable_versioning = forms.BooleanField(
required=False, label=_("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
last_version will be used."""
@ -131,18 +130,13 @@ class ConfigForm(CssClassMixin, forms.Form):
label=_("Preamble text for PDF document (all motions)")
)
motion_create_new_version = forms.ChoiceField(
widget=forms.Select(),
label=_("Create new versions"),
motion_allow_disable_versioning = forms.BooleanField(
label=_("Allow to disable versioning"),
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(
widget=forms.Select(),
label=_("Workflow for the motions"),
label=_("Workflow of new motions"),
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.person import PersonField
from openslides.utils.exceptions import OpenSlidesError
from openslides.config.models import config
from openslides.config.signals import default_config_value
from openslides.poll.models import (
@ -33,8 +34,10 @@ from openslides.projector.api import register_slidemodel
from openslides.projector.models import SlideMixin
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):
@ -43,7 +46,7 @@ class Motion(SlideMixin, models.Model):
This class is the main entry point to all other classes related to a motion.
"""
prefix = "motion"
prefix = 'motion'
"""Prefix for the slide system."""
active_version = models.ForeignKey('MotionVersion', null=True,
@ -55,11 +58,10 @@ class Motion(SlideMixin, models.Model):
version. Like the Sighted versions on Wikipedia.
"""
state_id = models.CharField(max_length=3)
"""The id of a state object.
state = models.ForeignKey('State', null=True) # TODO: Check whether null=True is necessary.
"""The related state object.
This Attribute is used be motion.state to identify the current state of the
motion.
This attribute is to get the current state of the motion.
"""
identifier = models.CharField(max_length=255, null=True, blank=True,
@ -88,9 +90,9 @@ class Motion(SlideMixin, models.Model):
def save(self, *args, **kwargs):
"""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.
3. Save the version Data.
3. Save the version data.
4. Set the active version for the motion.
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
'ALLWASY_CREATE_NEW_VERSION'.
"""
if not self.state_id:
if not self.state:
self.reset_state()
super(Motion, self).save(*args, **kwargs)
@ -121,11 +123,12 @@ class Motion(SlideMixin, models.Model):
else:
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):
version = 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:
version = self.last_version
else:
@ -150,16 +153,16 @@ class Motion(SlideMixin, models.Model):
version.version_number = version_number + 1
version.save()
# Set the active Version of this motion. This has to be done after the
# version is saved to the db
if not self.state.version_permission or self.active_version is None:
# Set the active version of this motion. This has to be done after the
# version is saved to the database
if not self.state.dont_set_new_version_active or self.active_version is None:
self.active_version = version
self.save()
def get_absolute_url(self, link='detail'):
"""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':
return reverse('motion_detail', args=[str(self.id)])
@ -240,7 +243,7 @@ class Motion(SlideMixin, models.Model):
@property
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
use the existing new version.
@ -266,8 +269,8 @@ class Motion(SlideMixin, models.Model):
def set_version(self, version):
"""Set the 'active' version object.
The keyargument 'version' can be a MotionVersion object or the
version_number of a VersionObject or None.
The keyword argument 'version' can be a MotionVersion object or the
version_number of a version object or None.
If the argument is None, the newest version will be used.
"""
@ -291,7 +294,7 @@ class Motion(SlideMixin, models.Model):
@property
def last_version(self):
"""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:
return self.versions.order_by('-version_number')[0]
except IndexError:
@ -307,62 +310,39 @@ class Motion(SlideMixin, models.Model):
def support(self, person):
"""Add 'person' as a supporter of this motion."""
if self.state.support:
if self.state.allow_support:
if not self.is_supporter(person):
MotionSupporter(motion=self, person=person).save()
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):
"""Remove 'person' as supporter from this motion."""
if self.state.support:
if self.state.allow_support:
self.supporter.filter(person=person).delete()
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):
"""Create a new poll for this motion.
Return the new poll object.
"""
if self.state.create_poll:
# TODO: auto increment the poll_number in the Database
if self.state.allow_create_poll:
# TODO: auto increment the poll_number in the database
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.set_options()
return poll
else:
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."""
raise WorkflowError('You can not create a poll in state %s.' % self.state.name)
def reset_state(self):
"""Set the state to the default state."""
self.state_id = get_state('default').id
"""Set the state to the default state. If the motion is new, it chooses the default workflow from config."""
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):
"""Return the slide dict."""
@ -395,13 +375,13 @@ class Motion(SlideMixin, models.Model):
"""
actions = {
'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')),
'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
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['unsupport'] = actions['support']
actions['reset_state'] = 'change_state'
actions['reset_state'] = actions['change_state']
return actions
def write_log(self, message, person=None):
@ -455,7 +435,7 @@ class MotionVersion(models.Model):
A MotionVersion object saves some date of the motion."""
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)
"""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"))
"""The Title of a motion."""
"""The title of a motion."""
text = models.TextField(verbose_name=_("Text"))
"""The text of a motion."""
@ -473,10 +453,10 @@ class MotionVersion(models.Model):
"""The reason for a motion."""
rejected = models.BooleanField(default=False)
"""Saves, if the version is rejected."""
"""Saves if the version is rejected."""
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"))
#note = models.TextField(null=True, blank=True)
@ -487,7 +467,7 @@ class MotionVersion(models.Model):
def __unicode__(self):
"""Return a string, representing this object."""
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'):
"""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)
class MotionError(Exception):
"""Exception raised when errors in the motion accure."""
pass
class MotionVote(BaseVote):
"""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."""
CountInvalid.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.
:copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS.
:license: GNU GPL, see LICENSE for more details.
"""
from reportlab.lib import colors

View File

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

View File

@ -4,7 +4,7 @@
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.
:license: GNU GPL, see LICENSE for more details.

View File

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

View File

@ -12,6 +12,7 @@
from django.conf.urls import url, patterns
urlpatterns = patterns('openslides.motion.views',
url(r'^$',
'motion_list',
@ -78,7 +79,7 @@ urlpatterns = patterns('openslides.motion.views',
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',
name='motion_set_state',
),

View File

@ -6,7 +6,7 @@
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.
: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.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,
MotionCreateNewVersionMixin, ConfigForm)
from .workflow import WorkflowError
MotionDisableVersioningMixin, ConfigForm)
from .pdf import motions_to_pdf, motion_to_pdf
@ -81,7 +80,7 @@ motion_detail = MotionDetailView.as_view()
class MotionMixin(object):
"""Mixin for MotionViewsClasses, to save the version data."""
"""Mixin for MotionViewsClasses to save the version data."""
def manipulate_object(self, form):
"""Save the version data into the motion object before it is saved in
@ -125,8 +124,9 @@ class MotionMixin(object):
form_classes.append(MotionSubmitterMixin)
if config['motion_min_supporters'] > 0:
form_classes.append(MotionSupporterMixin)
if config['motion_create_new_version'] == 'ASK_USER':
form_classes.append(MotionCreateNewVersionMixin)
if self.object:
if config['motion_allow_disable_versioning'] and self.object.state.versioning:
form_classes.append(MotionDisableVersioningMixin)
return type('MotionForm', tuple(form_classes), {})
@ -267,7 +267,7 @@ class SupportView(SingleObjectMixin, QuestionMixin, RedirectView):
def case_yes(self):
"""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.
"""
if self.check_permission(self.request):
@ -395,8 +395,8 @@ class MotionSetStateView(SingleObjectMixin, RedirectView):
if self.reset:
self.object.reset_state()
else:
self.object.state = kwargs['state']
except WorkflowError, e:
self.object.state = State.objects.get(pk=kwargs['state'])
except WorkflowError, e: # TODO: Is a WorkflowError still possible here?
messages.error(request, e)
else:
self.object.save()
@ -484,7 +484,7 @@ class Config(FormView):
'motion_pdf_ballot_papers_number': config['motion_pdf_ballot_papers_number'],
'motion_pdf_title': config['motion_pdf_title'],
'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'],
}
@ -495,7 +495,7 @@ class Config(FormView):
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_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']
messages.success(self.request, _('Motion settings successfully saved.'))
return super(Config, self).form_valid(form)

View File

@ -1,144 +1,51 @@
#!/usr/bin/env python
# -*- 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.
: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 openslides.config.models import config
_workflow = None
from .models import Workflow, State
class State(object):
"""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'.
def init_builtin_workflows():
"""
for workflow in settings.MOTION_WORKFLOW:
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.
Saves a simple and a complex workflow into the database. This function is only called manually.
"""
global _workflow
if _workflow is not None:
try:
return _workflow[state]
except KeyError:
raise WorkflowError('Unknown state: %s' % state)
_workflow = {}
for workflow in settings.MOTION_WORKFLOW:
if workflow[0] == config['motion_workflow']:
try:
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'])
workflow_1 = Workflow(name=ugettext_noop('Simple Workflow'), id=1)
state_1_1 = State.objects.create(name=ugettext_noop('submitted'), workflow=workflow_1,
allow_create_poll=True, allow_support=True, allow_submitter_edit=True)
state_1_2 = State.objects.create(name=ugettext_noop('accepted'), workflow=workflow_1, action_word=ugettext_noop('accept'))
state_1_3 = State.objects.create(name=ugettext_noop('rejected'), workflow=workflow_1, action_word=ugettext_noop('reject'))
state_1_4 = State.objects.create(name=ugettext_noop('not decided'), workflow=workflow_1, action_word=ugettext_noop('do not decide'))
state_1_1.next_states.add(state_1_2, state_1_3, state_1_4)
state_1_1.save() # Is this neccessary?
workflow_1.first_state = state_1_1
workflow_1.save()
populate_workflow(default_state, _workflow)
return get_state(state)
def populate_workflow(state, workflow):
"""Append all 'next_states' from state to the workflow.
The argument state has to be a state object.
The argument workflow has to be a dictonary.
Calls this function recrusiv with all next_states from the next_states states.
"""
workflow[state.id] = state
for s in state.next_states:
if s.id not in workflow:
populate_workflow(s, workflow)
DUMMY_STATE = State('dummy', ugettext_noop('Unknwon state'))
"""A dummy state object. Returned, if the state_id is not known."""
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)'))]
workflow_2 = Workflow(name=ugettext_noop('Complex Workflow'), id=2)
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)
state_2_3 = State.objects.create(name=ugettext_noop('accepted'), workflow=workflow_2, action_word=ugettext_noop('accept'), versioning=True)
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,
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)
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,
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)
state_2_10 = State.objects.create(name=ugettext_noop('rejected (not authorized)'), workflow=workflow_2,
action_word=ugettext_noop('reject (not authorized)'), versioning=True)
state_2_1.next_states.add(state_2_2, state_2_5, state_2_10)
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?
workflow_2.first_state = state_2_1
workflow_2.save()

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.config.models import config
from openslides.motion.models import Motion
from openslides.motion.workflow import WorkflowError
from openslides.motion.models import Motion, Workflow, State, WorkflowError
class ModelTest(TestCase):
def setUp(self):
self.motion = Motion.objects.create(title='v1')
self.test_user = User.objects.create(username='blub')
self.workflow = Workflow.objects.get(pk=1)
def test_create_new_version(self):
config['motion_create_new_version'] = 'ALLWASY_CREATE_NEW_VERSION'
motion = Motion.objects.create(title='m1')
self.assertEqual(motion.versions.count(), 1)
@ -32,13 +31,16 @@ class ModelTest(TestCase):
motion.title = 'new title'
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()
self.assertEqual(motion.versions.count(), 3)
config['motion_create_new_version'] = 'NEVER_CREATE_NEW_VERSION'
motion.text = 'new text'
motion.save()
self.assertEqual(motion.versions.count(), 3)
@ -56,6 +58,7 @@ class ModelTest(TestCase):
def test_version(self):
motion = Motion.objects.create(title='v1')
motion.state = State.objects.create(name='automatic_versioning', workflow=self.workflow, versioning=True)
motion.title = 'v2'
motion.save()
v2_version = motion.version
@ -94,24 +97,42 @@ class ModelTest(TestCase):
self.motion.unsupport(self.test_user)
def test_poll(self):
self.motion.state = 'per'
self.motion.state = State.objects.get(pk=1)
poll = self.motion.create_poll()
self.assertEqual(poll.poll_number, 1)
def test_state(self):
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):
self.motion.create_poll()
self.motion.set_state('per')
self.assertEqual(self.motion.state.id, 'per')
self.motion.state = State.objects.get(pk=6)
self.assertEqual(self.motion.state.name, 'permitted')
self.assertEqual(self.motion.state.get_action_word(), 'permit')
with self.assertRaises(WorkflowError):
self.motion.support(self.test_user)
with self.assertRaises(WorkflowError):
self.motion.unsupport(self.test_user)
with self.assertRaises(WorkflowError):
self.motion.set_state('per')
def test_new_states_or_workflows(self):
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()