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/exceptions.py b/openslides/motion/exceptions.py new file mode 100644 index 000000000..f0e482804 --- /dev/null +++ b/openslides/motion/exceptions.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" + openslides.motion.exceptions + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + Exceptions for the motion app. + + :copyright: (c) 2011-2013 by the OpenSlides team, see AUTHORS. + :license: GNU GPL, see LICENSE for more details. +""" + +from openslides.utils.exceptions import OpenSlidesError + + +class MotionError(OpenSlidesError): + """Exception raised when errors in the motion accure.""" + pass + + +class WorkflowError(OpenSlidesError): + """Exception raised when errors in a workflow or state accure.""" + pass 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 aafefdbca..24e30c0c9 100644 --- a/openslides/motion/models.py +++ b/openslides/motion/models.py @@ -33,8 +33,7 @@ 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) +from .exceptions import MotionError, WorkflowError class Motion(SlideMixin, models.Model): @@ -43,7 +42,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 +54,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 +86,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 +103,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 +119,12 @@ class Motion(SlideMixin, models.Model): else: new_data = False - need_new_version = config['motion_create_new_version'] == 'ALLWASY_CREATE_NEW_VERSION' # TODO: Tippfehler + # 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 +149,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 +239,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 +265,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 +290,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 +306,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.""" @@ -374,7 +350,7 @@ class Motion(SlideMixin, models.Model): def get_agenda_title(self): """Return a title for the Agenda.""" - return self.last_version.title # TODO: nutze active_version + return self.last_version.title ## def get_agenda_title_supplement(self): ## number = self.number or '[%s]' % ugettext('no number') @@ -395,13 +371,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)), @@ -455,7 +431,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 +440,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 +449,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 +463,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. @@ -553,7 +529,7 @@ class MotionLog(models.Model): motion = models.ForeignKey(Motion, related_name='log_messages') """The motion to witch the object belongs.""" - message = models.CharField(max_length=255) # TODO: arguments in message, not translatable + message = models.CharField(max_length=255) """The log message. Should be in english. @@ -577,11 +553,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 +623,100 @@ 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 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 }}

possible stats:

diff --git a/openslides/motion/urls.py b/openslides/motion/urls.py index 8ff7c0d33..a2b6b49e2 100644 --- a/openslides/motion/urls.py +++ b/openslides/motion/urls.py @@ -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\d+)/set_state/(?P[a-z]{3})/$', + url(r'^(?P\d+)/set_state/(?P\d+)/$', 'set_state', name='motion_set_state', ), diff --git a/openslides/motion/views.py b/openslides/motion/views.py index d8a907a08..74818314c 100644 --- a/openslides/motion/views.py +++ b/openslides/motion/views.py @@ -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) diff --git a/openslides/motion/workflow.py b/openslides/motion/workflow.py index 2ad1435af..42d1e1814 100644 --- a/openslides/motion/workflow.py +++ b/openslides/motion/workflow.py @@ -1,144 +1,55 @@ #!/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. + This file is only for development. It will be moved out of + the openslides module before the next release. :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 # Rename to auto_activate_version - - 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 and lives here only for development. """ - 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() diff --git a/openslides/utils/exceptions.py b/openslides/utils/exceptions.py new file mode 100644 index 000000000..2f757a97c --- /dev/null +++ b/openslides/utils/exceptions.py @@ -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 diff --git a/tests/motion/test_models.py b/tests/motion/test_models.py index 64bb831f3..9c647f296 100644 --- a/tests/motion/test_models.py +++ b/tests/motion/test_models.py @@ -12,17 +12,17 @@ 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 +from openslides.motion.exceptions import 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 +32,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 +59,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 +98,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()