Merge pull request #638 from normanjaeckel/MotionRework

Motion identifier setting and versioning.
This commit is contained in:
Oskar Hahn 2013-05-13 14:43:56 -07:00
commit 7a4d40283d
7 changed files with 184 additions and 148 deletions

View File

@ -99,10 +99,27 @@ class MotionDisableVersioningMixin(forms.ModelForm):
last_version will be used.""" last_version will be used."""
# TODO: Add category and identifier to the form as normal fields (the django way),
# not as 'new' field from 'new' forms.
class MotionCategoryMixin(forms.ModelForm): class MotionCategoryMixin(forms.ModelForm):
"""Mixin to let the user choose the category for the motion.""" """
Mixin to let the user choose the category for the motion.
"""
category = forms.ModelChoiceField(queryset=Category.objects.all(), required=False, label=ugettext_lazy("Category")) category = forms.ModelChoiceField(queryset=Category.objects.all(), required=False, label=ugettext_lazy("Category"))
"""
Category of the motion.
"""
def __init__(self, *args, **kwargs):
"""
Fill in the category of the motion as default value.
"""
if self.motion is not None:
category = self.motion.category
self.initial['category'] = category
super(MotionCategoryMixin, self).__init__(*args, **kwargs)
class MotionIdentifierMixin(forms.ModelForm): class MotionIdentifierMixin(forms.ModelForm):
@ -112,15 +129,9 @@ class MotionIdentifierMixin(forms.ModelForm):
identifier = forms.CharField(required=False, label=ugettext_lazy('Identifier')) identifier = forms.CharField(required=False, label=ugettext_lazy('Identifier'))
def clean_identifier(self): class Meta:
""" model = Motion
Test, that the identifier is unique fields = ('identifier',)
"""
identifier = self.cleaned_data['identifier']
if Motion.objects.filter(identifier=identifier).exists():
raise forms.ValidationError(_('The Identifier is not unique.'))
else:
return identifier
class MotionImportForm(CssClassMixin, forms.Form): class MotionImportForm(CssClassMixin, forms.Form):

View File

@ -102,87 +102,61 @@ class Motion(SlideMixin, models.Model):
return self.get_title() return self.get_title()
# TODO: Use transaction # TODO: Use transaction
def save(self, no_new_version=False, *args, **kwargs): def save(self, ignore_version_data=False, *args, **kwargs):
""" """
Save the motion. Save the motion.
1. Set the state of a new motion to the default state. 1. Set the state of a new motion to the default state.
2. Save the motion object. 2. Ensure that the identifier is not an empty string.
3. Save the version data. 3. Save the motion object.
4. Set the active version for the motion. 4. Save the version data, if ignore_version_data == False.
5. Set the active version for the motion, if ignore_version_data == False.
A new version will be saved if motion.new_version was called
between the creation of this object and the last call of motion.save()
or
If the motion has new version data (title, text, reason)
and
the config 'motion_create_new_version' is set to
'ALWAYS_CREATE_NEW_VERSION'.
If no_new_version is True, a new version will never be used.
""" """
if not self.state: if not self.state:
self.reset_state() self.reset_state()
# TODO: Bad hack here to make Motion.objects.create() work
# again. We have to remove the flag to force an INSERT given
# by Django's create() method without knowing its advantages
# because of our misuse of the save() method in the
# set_identifier() method.
kwargs.pop('force_insert', None)
if not self.identifier and self.identifier is not None: if not self.identifier and self.identifier is not None: # TODO: Why not >if self.identifier is '':<
self.identifier = None self.identifier = None
super(Motion, self).save(*args, **kwargs) super(Motion, self).save(*args, **kwargs)
if no_new_version: if not ignore_version_data:
return # Select version object
# Find out if the version data has changed
for attr in ['title', 'text', 'reason']:
if not self.versions.exists():
new_data = True
break
if getattr(self, attr) != getattr(self.last_version, attr):
new_data = True
break
else:
new_data = False
# 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 # TODO: Test if this line is really neccessary.
elif new_data and not need_new_version:
version = self.last_version version = self.last_version
else: if hasattr(self, '_new_version'):
# We do not need to save the motion version. version = self.new_version
return del self._new_version
version.motion = self # TODO: Test if this line is really neccessary.
# Save title, text and reason in the version object # Save title, text and reason in the version object
for attr in ['title', 'text', 'reason']: for attr in ['title', 'text', 'reason']:
_attr = '_%s' % attr _attr = '_%s' % attr
try: try:
setattr(version, attr, getattr(self, _attr)) setattr(version, attr, getattr(self, _attr))
delattr(self, _attr) delattr(self, _attr)
except AttributeError: except AttributeError:
if self.versions.exists(): if self.versions.exists():
# If the _attr was not set, use the value from last_version # If the _attr was not set, use the value from last_version
setattr(version, attr, getattr(self.last_version, attr)) setattr(version, attr, getattr(self.last_version, attr))
# Set version_number of the new Version (if neccessary) and save it into the DB # Set version_number of the new Version (if neccessary) and save it into the DB
if version.id is None: if version.id is None:
# TODO: auto increment the version_number in the Database # TODO: auto increment the version_number in the Database
version_number = self.versions.aggregate(Max('version_number'))['version_number__max'] or 0 version_number = self.versions.aggregate(Max('version_number'))['version_number__max'] or 0
version.version_number = version_number + 1 version.version_number = version_number + 1
version.save() version.save()
# Set the active version of this motion. This has to be done after the # Set the active version of this motion. This has to be done after the
# version is saved to the database # version is saved to the database
if self.active_version is None or not self.state.leave_old_version_active: if self.active_version is None or not self.state.leave_old_version_active:
self.active_version = version self.active_version = version
self.save() self.save(ignore_version_data=True)
def get_absolute_url(self, link='detail'): def get_absolute_url(self, link='detail'):
""" """
@ -198,12 +172,16 @@ class Motion(SlideMixin, models.Model):
return reverse('motion_delete', args=[str(self.id)]) return reverse('motion_delete', args=[str(self.id)])
def set_identifier(self): def set_identifier(self):
if config['motion_identifier'] == 'manually': """
Sets the motion identifier automaticly according to the config
value, if it is not set yet.
"""
if config['motion_identifier'] == 'manually' or self.identifier:
# Do not set an identifier. # Do not set an identifier.
return return
elif config['motion_identifier'] == 'per_category': elif config['motion_identifier'] == 'per_category':
motions = Motion.objects.filter(category=self.category) motions = Motion.objects.filter(category=self.category)
else: else: # That means: config['motion_identifier'] == 'serially_numbered'
motions = Motion.objects.all() motions = Motion.objects.all()
number = motions.aggregate(Max('identifier_number'))['identifier_number__max'] or 0 number = motions.aggregate(Max('identifier_number'))['identifier_number__max'] or 0
@ -212,23 +190,24 @@ class Motion(SlideMixin, models.Model):
else: else:
prefix = self.category.prefix + ' ' prefix = self.category.prefix + ' '
# TODO: Do not use the save() method in this method, see note in
# the save() method above.
while True: while True:
number += 1 number += 1
self.identifier = '%s%d' % (prefix, number) self.identifier = '%s%d' % (prefix, number)
self.identifier_number = number
try: try:
self.save() self.save(ignore_version_data=True)
except IntegrityError: except IntegrityError:
continue continue
else: else:
self.number = number
self.save()
break break
def get_title(self): def get_title(self):
""" """
Get the title of the motion. Get the title of the motion.
The titel is taken from motion.version. The title is taken from motion.version.
""" """
try: try:
return self._title return self._title
@ -239,7 +218,7 @@ class Motion(SlideMixin, models.Model):
""" """
Set the titel of the motion. Set the titel of the motion.
The titel will me saved into the version object, wenn motion.save() is The title will be saved into the version object, wenn motion.save() is
called. called.
""" """
self._title = title self._title = title
@ -449,10 +428,11 @@ class Motion(SlideMixin, models.Model):
If the motion is new, it chooses the default workflow from config. If the motion is new, it chooses the default workflow from config.
""" """
if self.state: if self.state:
self.state = self.state.workflow.first_state new_state = self.state.workflow.first_state
else: else:
self.state = (Workflow.objects.get(pk=config['motion_workflow']).first_state or new_state = (Workflow.objects.get(pk=config['motion_workflow']).first_state or
Workflow.objects.get(pk=config['motion_workflow']).state_set.all()[0]) Workflow.objects.get(pk=config['motion_workflow']).state_set.all()[0])
self.set_state(new_state)
def slide(self): def slide(self):
""" """

View File

@ -98,15 +98,15 @@ def setup_motion_config_page(sender, **kwargs):
choices=[(workflow.pk, ugettext_lazy(workflow.name)) for workflow in Workflow.objects.all()])) choices=[(workflow.pk, ugettext_lazy(workflow.name)) for workflow in Workflow.objects.all()]))
motion_identifier = ConfigVariable( motion_identifier = ConfigVariable(
name='motion_identifier', name='motion_identifier',
default_value='manually', default_value='serially_numbered',
form_field=forms.ChoiceField( form_field=forms.ChoiceField(
widget=forms.Select(), widget=forms.Select(),
required=False, required=True,
label=ugettext_lazy('Identifier'), label=ugettext_lazy('Identifier'),
choices=[ choices=[
('manually', ugettext_lazy('Set it manually')), ('serially_numbered', ugettext_lazy('Serially numbered')),
('per_category', ugettext_lazy('Numbered per category')), ('per_category', ugettext_lazy('Numbered per category')),
('serially_numbered', ugettext_lazy('Serially numbered'))])) ('manually', ugettext_lazy('Set it manually'))]))
return ConfigPage(title=ugettext_noop('Motion'), return ConfigPage(title=ugettext_noop('Motion'),
url='motion', url='motion',

View File

@ -40,11 +40,6 @@ urlpatterns = patterns('openslides.motion.views',
name='motion_delete', name='motion_delete',
), ),
url(r'^(?P<pk>\d+)/set_identifier/',
'set_identifier',
name='motion_set_identifier',
),
url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/$', url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/$',
'motion_detail', 'motion_detail',
name='motion_version_detail', name='motion_version_detail',

View File

@ -99,8 +99,16 @@ class MotionMixin(object):
for attr in ['title', 'text', 'reason']: for attr in ['title', 'text', 'reason']:
setattr(self.object, attr, form.cleaned_data[attr]) setattr(self.object, attr, form.cleaned_data[attr])
if type(self) != MotionCreateView: if type(self) == MotionCreateView:
if self.object.state.versioning and form.cleaned_data.get('new_version', True): self.object.new_version
else:
for attr in ['title', 'text', 'reason']:
if getattr(self.object, attr) != getattr(self.object.last_version, attr):
new_data = True
break
else:
new_data = False
if new_data and self.object.state.versioning and not form.cleaned_data.get('disable_versioning', False):
self.object.new_version self.object.new_version
try: try:
@ -114,7 +122,9 @@ class MotionMixin(object):
pass pass
def post_save(self, form): def post_save(self, form):
"""Save the submitter an the supporter so the motion.""" """
Save the submitter an the supporter so the motion.
"""
super(MotionMixin, self).post_save(form) super(MotionMixin, self).post_save(form)
# TODO: only delete and save neccessary submitters and supporter # TODO: only delete and save neccessary submitters and supporter
if 'submitter' in form.cleaned_data: if 'submitter' in form.cleaned_data:
@ -129,7 +139,8 @@ class MotionMixin(object):
for person in form.cleaned_data['supporter']]) for person in form.cleaned_data['supporter']])
def get_form_class(self): def get_form_class(self):
"""Return the FormClass to Create or Update the Motion. """
Return the FormClass to Create or Update the Motion.
forms.BaseMotionForm is the base for the Class, and some FormMixins forms.BaseMotionForm is the base for the Class, and some FormMixins
will be mixed in dependence of some config values. See motion.forms will be mixed in dependence of some config values. See motion.forms
@ -138,7 +149,7 @@ class MotionMixin(object):
form_classes = [] form_classes = []
if (self.request.user.has_perm('motion.can_manage_motion') and if (self.request.user.has_perm('motion.can_manage_motion') and
config['motion_identifier'] == 'manually'): (config['motion_identifier'] == 'manually' or type(self) == MotionUpdateView)):
form_classes.append(MotionIdentifierMixin) form_classes.append(MotionIdentifierMixin)
form_classes.append(BaseMotionForm) form_classes.append(BaseMotionForm)
@ -247,33 +258,42 @@ class VersionPermitView(GetVersionMixin, SingleObjectMixin, QuestionMixin, Redir
Activate the version, if the user chooses 'yes'. Activate the version, if the user chooses 'yes'.
""" """
self.object.set_active_version(self.object.version) # TODO: Write log message self.object.set_active_version(self.object.version) # TODO: Write log message
self.object.save(no_new_version=True) self.object.save(ignore_version_data=True)
version_permit = VersionPermitView.as_view() version_permit = VersionPermitView.as_view()
class VersionRejectView(GetVersionMixin, SingleObjectMixin, QuestionMixin, RedirectView): class VersionRejectView(GetVersionMixin, SingleObjectMixin, QuestionMixin, RedirectView):
"""View to reject a version.""" """
View to reject a version.
"""
model = Motion model = Motion
question_url_name = 'motion_version_detail' question_url_name = 'motion_version_detail'
success_url_name = 'motion_version_detail' success_url_name = 'motion_version_detail'
def get(self, *args, **kwargs): def get(self, *args, **kwargs):
"""Set self.object to a motion.""" """
Set self.object to a motion.
"""
self.object = self.get_object() self.object = self.get_object()
return super(VersionRejectView, self).get(*args, **kwargs) return super(VersionRejectView, self).get(*args, **kwargs)
def get_url_name_args(self): def get_url_name_args(self):
"""Return a list with arguments to create the success- and question_url.""" """
Return a list with arguments to create the success- and question_url.
"""
return [self.object.pk, self.object.version.version_number] return [self.object.pk, self.object.version.version_number]
def get_question(self): def get_question(self):
return _('Are you sure you want reject Version %s?') % self.object.version.version_number return _('Are you sure you want reject Version %s?') % self.object.version.version_number
def case_yes(self): def case_yes(self):
"""Reject the version, if the user chooses 'yes'.""" """
Reject the version, if the user chooses 'yes'.
"""
self.object.reject_version(self.object.version) # TODO: Write log message self.object.reject_version(self.object.version) # TODO: Write log message
self.object.save() self.object.save(ignore_version_data=True)
version_reject = VersionRejectView.as_view() version_reject = VersionRejectView.as_view()
@ -311,30 +331,6 @@ class VersionDiffView(DetailView):
version_diff = VersionDiffView.as_view() version_diff = VersionDiffView.as_view()
class SetIdentifierView(SingleObjectMixin, RedirectView):
"""Set the identifier of the motion.
See motion.set_identifier for more informations
"""
permission_required = 'motion.can_manage_motion'
model = Motion
url_name = 'motion_detail'
def get(self, request, *args, **kwargs):
"""Set self.object to a motion."""
self.object = self.get_object()
return super(SetIdentifierView, self).get(request, *args, **kwargs)
def pre_redirect(self, request, *args, **kwargs):
"""Set the identifier."""
self.object.set_identifier()
def get_url_name_args(self):
return [self.object.id]
set_identifier = SetIdentifierView.as_view()
class SupportView(SingleObjectMixin, QuestionMixin, RedirectView): class SupportView(SingleObjectMixin, QuestionMixin, RedirectView):
"""View to support or unsupport a motion. """View to support or unsupport a motion.
@ -544,10 +540,10 @@ poll_pdf = PollPDFView.as_view()
class MotionSetStateView(SingleObjectMixin, RedirectView): class MotionSetStateView(SingleObjectMixin, RedirectView):
"""View to set the state of a motion. """
View to set the state of a motion.
If self.reset is False, the new state is taken from url. If self.reset is False, the new state is taken from url.
If self.reset is True, the default state is taken. If self.reset is True, the default state is taken.
""" """
permission_required = 'motion.can_manage_motion' permission_required = 'motion.can_manage_motion'
@ -556,7 +552,9 @@ class MotionSetStateView(SingleObjectMixin, RedirectView):
reset = False reset = False
def pre_redirect(self, request, *args, **kwargs): def pre_redirect(self, request, *args, **kwargs):
"""Save the new state and write a log message.""" """
Save the new state and write a log message.
"""
self.object = self.get_object() self.object = self.get_object()
try: try:
if self.reset: if self.reset:
@ -566,17 +564,13 @@ class MotionSetStateView(SingleObjectMixin, RedirectView):
except WorkflowError, e: # TODO: Is a WorkflowError still possible here? except WorkflowError, e: # TODO: Is a WorkflowError still possible here?
messages.error(request, e) messages.error(request, e)
else: else:
self.object.save() self.object.save(ignore_version_data=True)
# TODO: the state is not translated # TODO: the state is not translated
self.object.write_log(ugettext_noop('State changed to %s') % self.object.write_log(ugettext_noop('State changed to %s') %
self.object.state.name, self.request.user) self.object.state.name, self.request.user)
messages.success(request, _('Motion status was set to: %s.' messages.success(request, _('Motion status was set to: %s.'
% html_strong(self.object.state))) % html_strong(self.object.state)))
def get_url_name_args(self):
"""Return the arguments to generate the redirect_url."""
return [self.object.pk]
set_state = MotionSetStateView.as_view() set_state = MotionSetStateView.as_view()
reset_state = MotionSetStateView.as_view(reset=True) reset_state = MotionSetStateView.as_view(reset=True)

View File

@ -36,8 +36,8 @@ class ModelTest(TestCase):
motion.save() motion.save()
self.assertEqual(motion.versions.count(), 2) self.assertEqual(motion.versions.count(), 2)
motion.state = State.objects.create(name='automatic_versioning', workflow=self.workflow, versioning=True)
motion.text = 'new text' motion.text = 'new text'
motion.new_version
motion.save() motion.save()
self.assertEqual(motion.versions.count(), 3) self.assertEqual(motion.versions.count(), 3)
@ -58,11 +58,13 @@ class ModelTest(TestCase):
def test_version(self): def test_version(self):
motion = Motion.objects.create(title='v1') motion = Motion.objects.create(title='v1')
motion.state = State.objects.create(name='automatic_versioning', workflow=self.workflow, versioning=True)
motion.title = 'v2' motion.title = 'v2'
motion.new_version
motion.save() motion.save()
v2_version = motion.version v2_version = motion.version
motion.title = 'v3' motion.title = 'v3'
motion.new_version
motion.save() motion.save()
with self.assertRaises(AttributeError): with self.assertRaises(AttributeError):
self._title self._title
@ -146,9 +148,6 @@ class ModelTest(TestCase):
motion.title = 'foo' motion.title = 'foo'
motion.text = 'bar' motion.text = 'bar'
first_version = motion.version first_version = motion.version
my_state = State.objects.create(name='automatic_versioning', workflow=self.workflow,
versioning=True, leave_old_version_active=True)
motion.state = my_state
motion.save() motion.save()
motion = Motion.objects.get(pk=motion.pk) motion = Motion.objects.get(pk=motion.pk)
@ -164,7 +163,7 @@ class ModelTest(TestCase):
motion.set_active_version(first_version) motion.set_active_version(first_version)
motion.version = first_version motion.version = first_version
motion.save(no_new_version=True) motion.save(ignore_version_data=True)
self.assertEqual(motion.versions.count(), 2) self.assertEqual(motion.versions.count(), 2)

View File

@ -13,7 +13,7 @@ from django.test.client import Client
from openslides.config.api import config from openslides.config.api import config
from openslides.utils.test import TestCase from openslides.utils.test import TestCase
from openslides.participant.models import User, Group from openslides.participant.models import User, Group
from openslides.motion.models import Motion from openslides.motion.models import Motion, State
class MotionViewTestCase(TestCase): class MotionViewTestCase(TestCase):
@ -120,12 +120,13 @@ class TestMotionCreateView(MotionViewTestCase):
self.assertContains(response, 'href="/motion/new/"', status_code=200) self.assertContains(response, 'href="/motion/new/"', status_code=200)
def test_identifier_not_unique(self): def test_identifier_not_unique(self):
Motion.objects.create(identifier='foo') Motion.objects.create(title='Another motion 3', identifier='uufag5faoX0thahBi8Fo')
response = self.admin_client.post(self.url, {'title': 'foo', config['motion_identifier'] = 'manually'
response = self.admin_client.post(self.url, {'title': 'something',
'text': 'bar', 'text': 'bar',
'submitter': self.admin, 'submitter': self.admin,
'identifier': 'foo'}) 'identifier': 'uufag5faoX0thahBi8Fo'})
self.assertFormError(response, 'form', 'identifier', 'The Identifier is not unique.') self.assertFormError(response, 'form', 'identifier', 'Motion with this Identifier already exists.')
def test_empty_text_field(self): def test_empty_text_field(self):
response = self.admin_client.post(self.url, {'title': 'foo', response = self.admin_client.post(self.url, {'title': 'foo',
@ -162,6 +163,62 @@ class TestMotionUpdateView(MotionViewTestCase):
motion = Motion.objects.get(pk=1) motion = Motion.objects.get(pk=1)
self.assertEqual(motion.title, 'my title') self.assertEqual(motion.title, 'my title')
def test_versioning(self):
self.assertFalse(self.motion1.state.versioning)
versioning_state = State.objects.create(name='automatic_versioning', workflow=self.motion1.state.workflow, versioning=True)
self.motion1.state = versioning_state
self.motion1.save()
motion = Motion.objects.get(pk=self.motion1.pk)
self.assertTrue(self.motion1.state.versioning)
self.assertEqual(motion.versions.count(), 1)
response = self.admin_client.post(self.url, {'title': 'another new motion_title',
'text': 'another motion text',
'reason': 'another motion reason',
'submitter': self.admin})
self.assertRedirects(response, '/motion/1/')
motion = Motion.objects.get(pk=self.motion1.pk)
self.assertEqual(motion.versions.count(), 2)
def test_disable_versioning(self):
self.assertFalse(self.motion1.state.versioning)
versioning_state = State.objects.create(name='automatic_versioning', workflow=self.motion1.state.workflow, versioning=True)
self.motion1.state = versioning_state
self.motion1.save()
motion = Motion.objects.get(pk=self.motion1.pk)
self.assertTrue(self.motion1.state.versioning)
config['motion_allow_disable_versioning'] = True
self.assertEqual(motion.versions.count(), 1)
response = self.admin_client.post(self.url, {'title': 'another new motion_title',
'text': 'another motion text',
'reason': 'another motion reason',
'submitter': self.admin,
'disable_versioning': 'true'})
self.assertRedirects(response, '/motion/1/')
motion = Motion.objects.get(pk=self.motion1.pk)
self.assertEqual(motion.versions.count(), 1)
def test_no_versioning_without_new_data(self):
self.assertFalse(self.motion1.state.versioning)
versioning_state = State.objects.create(name='automatic_versioning', workflow=self.motion1.state.workflow, versioning=True)
self.motion1.state = versioning_state
self.motion1.title = 'Chah4kaaKasiVuishi5x'
self.motion1.text = 'eedieFoothae2iethuo3'
self.motion1.reason = 'ier2laiy1veeGoo0mau2'
self.motion1.save()
motion = Motion.objects.get(pk=self.motion1.pk)
self.assertTrue(self.motion1.state.versioning)
self.assertEqual(motion.versions.count(), 1)
response = self.admin_client.post(self.url, {'title': 'Chah4kaaKasiVuishi5x',
'text': 'eedieFoothae2iethuo3',
'reason': 'ier2laiy1veeGoo0mau2',
'submitter': self.admin})
self.assertRedirects(response, '/motion/1/')
motion = Motion.objects.get(pk=self.motion1.pk)
self.assertEqual(motion.versions.count(), 1)
class TestMotionDeleteView(MotionViewTestCase): class TestMotionDeleteView(MotionViewTestCase):
def test_get(self): def test_get(self):