diff --git a/openslides/global_settings.py b/openslides/global_settings.py index a02ef47e2..e152f4da5 100644 --- a/openslides/global_settings.py +++ b/openslides/global_settings.py @@ -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')), diff --git a/openslides/motion/__init__.py b/openslides/motion/__init__.py index d9b4927bc..ea23517a7 100644 --- a/openslides/motion/__init__.py +++ b/openslides/motion/__init__.py @@ -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. diff --git a/openslides/motion/fixtures/initial_data.json b/openslides/motion/fixtures/initial_data.json new file mode 100644 index 000000000..6dc7091c5 --- /dev/null +++ b/openslides/motion/fixtures/initial_data.json @@ -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 + } + } +] diff --git a/openslides/motion/forms.py b/openslides/motion/forms.py index ed1b02155..139d4205e 100644 --- a/openslides/motion/forms.py +++ b/openslides/motion/forms.py @@ -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()]) diff --git a/openslides/motion/models.py b/openslides/motion/models.py index adcfcabec..9e8adc51e 100644 --- a/openslides/motion/models.py +++ b/openslides/motion/models.py @@ -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)) diff --git a/openslides/motion/pdf.py b/openslides/motion/pdf.py index fcee6c2e7..71fd25b82 100644 --- a/openslides/motion/pdf.py +++ b/openslides/motion/pdf.py @@ -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 diff --git a/openslides/motion/signals.py b/openslides/motion/signals.py index fb1dffd9a..3b1cb38d3 100644 --- a/openslides/motion/signals.py +++ b/openslides/motion/signals.py @@ -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) diff --git a/openslides/motion/slides.py b/openslides/motion/slides.py index e269dc243..47212d107 100644 --- a/openslides/motion/slides.py +++ b/openslides/motion/slides.py @@ -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. diff --git a/openslides/motion/templates/motion/motion_detail.html b/openslides/motion/templates/motion/motion_detail.html index dcafd3878..ccf616aab 100644 --- a/openslides/motion/templates/motion/motion_detail.html +++ b/openslides/motion/templates/motion/motion_detail.html @@ -16,8 +16,8 @@
State: {{ motion.state }}