Merge pull request #1380 from ostcar/amendments

Feature: amendments
This commit is contained in:
Oskar Hahn 2015-01-03 15:22:10 +01:00
commit e42345b3d1
11 changed files with 310 additions and 89 deletions

View File

@ -4,8 +4,16 @@
http://openslides.org
Version 1.6.2 (unreleased)
==========================
Version 1.7 (unreleased)
========================
[https://github.com/OpenSlides/OpenSlides/milestones/1.7]
motion:
- New Feature to create amendments, which are related to a parent motion.
Version 1.6.2
=============
[https://github.com/OpenSlides/OpenSlides/milestones/1.6.2]
Motions:
- Added possibility to hide motions from non staff users in some states.

View File

@ -3,7 +3,6 @@
from django import forms
from django.utils.translation import ugettext_lazy
from openslides.config.api import config
from openslides.mediafile.models import Mediafile
from openslides.utils.forms import (CleanHtmlFormMixin, CssClassMixin,
CSVImportForm, LocalizedModelChoiceField)
@ -66,8 +65,6 @@ class BaseMotionForm(CleanHtmlFormMixin, CssClassMixin, forms.ModelForm):
self.initial['text'] = last_version.text
self.initial['reason'] = last_version.reason
self.initial['attachments'] = self.motion.attachments.all()
else:
self.initial['text'] = config['motion_preamble']
super(BaseMotionForm, self).__init__(*args, **kwargs)

View File

@ -71,8 +71,12 @@ class Motion(SlideMixin, AbsoluteUrlMixin, models.Model):
Many to many relation to mediafile objects.
"""
# TODO: proposal
# master = models.ForeignKey('self', null=True, blank=True)
parent = models.ForeignKey('self', null=True, blank=True, related_name='amendments')
"""
Field for amendments to reference to the motion that should be altered.
Null if the motion is not an amendment.
"""
class Meta:
permissions = (
@ -206,19 +210,31 @@ class Motion(SlideMixin, AbsoluteUrlMixin, models.Model):
def set_identifier(self):
"""
Sets the motion identifier automaticly according to the config
value if it is not set yet.
Sets the motion identifier automaticly according to the config value if
it is not set yet.
"""
# The identifier is already set or should be set manually
if config['motion_identifier'] == 'manually' or self.identifier:
# Do not set an identifier.
return
# The motion is an amendment
elif self.is_amendment():
motions = self.parent.amendments.all()
# The motions should be counted per category
elif config['motion_identifier'] == 'per_category':
motions = Motion.objects.filter(category=self.category)
else: # That means: config['motion_identifier'] == 'serially_numbered'
# The motions should be counted over all.
else:
motions = Motion.objects.all()
number = motions.aggregate(Max('identifier_number'))['identifier_number__max'] or 0
if self.category is None or not self.category.prefix:
if self.is_amendment():
parent_identifier = self.parent.identifier or ''
prefix = '%s %s ' % (parent_identifier, config['motion_amendments_prefix'])
elif self.category is None or not self.category.prefix:
prefix = ''
else:
prefix = '%s ' % self.category.prefix
@ -524,6 +540,15 @@ class Motion(SlideMixin, AbsoluteUrlMixin, models.Model):
"""
MotionLog.objects.create(motion=self, message_list=message_list, person=person)
def is_amendment(self):
"""
Returns True if the motion is an amendment.
A motion is a amendment if amendments are activated in the config and
the motion has a parent.
"""
return config['motion_amendments_enabled'] and self.parent is not None
class MotionVersion(AbsoluteUrlMixin, models.Model):
"""
@ -819,10 +844,12 @@ class State(models.Model):
"""If true, new versions are not automaticly set active."""
dont_set_identifier = models.BooleanField(default=False)
"""Decides if the motion gets an identifier.
"""
Decides if the motion gets an identifier.
If true, the motion does not get an identifier if the state change to
this one, else it does."""
this one, else it does.
"""
def __unicode__(self):
"""Returns the name of the state."""

View File

@ -3,7 +3,7 @@
from django import forms
from django.dispatch import receiver
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy, ugettext_noop
from django.utils.translation import ugettext_lazy, ugettext_noop, pgettext
from openslides.config.api import ConfigGroup, ConfigGroupedCollection, ConfigVariable
from openslides.config.signals import config_signal
@ -67,6 +67,25 @@ def setup_motion_config(sender, **kwargs):
motion_stop_submitting,
motion_allow_disable_versioning))
# Amendments
motion_amendments_enabled = ConfigVariable(
name='motion_amendments_enabled',
default_value=False,
form_field=forms.BooleanField(
label=ugettext_lazy('Activate amendments'),
required=False))
motion_amendments_prefix = ConfigVariable(
name='motion_amendments_prefix',
default_value=pgettext('Prefix for amendment', 'A'),
form_field=forms.CharField(
required=False,
label=ugettext_lazy('Prefix for the identifier for amendments')))
group_amendments = ConfigGroup(
title=ugettext_lazy('Amendments'),
variables=(motion_amendments_enabled, motion_amendments_prefix))
# Supporters
motion_min_supporters = ConfigVariable(
name='motion_min_supporters',
@ -148,7 +167,8 @@ def setup_motion_config(sender, **kwargs):
title=ugettext_noop('Motion'),
url='motion',
weight=30,
groups=(group_general, group_supporters, group_ballot_papers, group_pdf))
groups=(group_general, group_amendments, group_supporters,
group_ballot_papers, group_pdf))
@receiver(post_database_setup, dispatch_uid='motion_create_builtin_workflows')

View File

@ -29,6 +29,9 @@
</span>
{% endif %}
{% endif %}
{% if motion.is_amendment %}
(Amendment of <a href="{{ motion.parent|absolute_url }}">{{ motion.parent.identifier|default:motion.parent }}</a>)
{% endif %}
</small>
<small class="pull-right">
<a href="{% url 'motion_list' %}" class="btn btn-mini">
@ -280,6 +283,23 @@
</h5>
{{ version.creation_time }}
{% if 'motion_amendments_enabled'|get_config %}
<h5>{% trans 'Amendments' %}:</h5>
{% with amendments=motion.amendments.all %}
{% if amendments %}
<ul>
{% for amendment in amendments %}
<li><a href="{{ amendment|absolute_url }}">{{ amendment }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endwith %}
<a class="btn btn-mini btn-primary" href="{% url 'motion_create_amendment' motion.pk %}">
<i class="icon-plus icon-white"></i>
{% trans 'New amendment' %}
</a>
{% endif %}
<!-- Support/Unsupport button -->
{% if perms.motion.can_support_motion and 'motion_min_supporters'|get_config > 0 %}
{% if allowed_actions.unsupport %}

View File

@ -33,7 +33,7 @@
<small class="pull-right">
{% if perms.motion.can_create_motion %}
{% if not 'motion_stop_submitting'|get_config or perms.motion.can_manage_motion %}
<a href="{% url 'motion_new' %}" class="btn btn-mini btn-primary" rel="tooltip" data-original-title="{% trans 'New motion' %}"><i class="icon-plus icon-white"></i> {% trans 'New' %}</a>
<a href="{% url 'motion_create' %}" class="btn btn-mini btn-primary" rel="tooltip" data-original-title="{% trans 'New motion' %}"><i class="icon-plus icon-white"></i> {% trans 'New' %}</a>
{% endif %}
{% endif %}
{% if perms.motion.can_manage_motion %}
@ -64,7 +64,14 @@
{% for motion in motion_list %}
<tr class="{% if motion.is_active_slide %}activeline{% endif %}">
<td class="nobr">{{ motion.identifier|default:'' }}</td>
<td><a href="{{ motion|absolute_url }}">{{ motion.title }}</a></td>
<td>
<a href="{{ motion|absolute_url }}">{{ motion.title }}</a>
{% if motion.is_amendment %}
<a class="label label-success" data-original-title="Amendment" rel="tooltip">
{{ 'motion_amendments_prefix'|get_config }}
</a>
{% endif %}
</td>
<td class="optional">{% if motion.category %}{{ motion.category }}{% else %}{% endif %}</td>
<td class="optional-small"><span class="label label-info">{% trans motion.state.name %}</span></td>
<td class="optional">

View File

@ -77,6 +77,9 @@
{{ motion.active_version.title }}
<small>
{% trans "Motion" %} {{ motion.identifier|default:'' }}
{% if motion.is_amendment %}
(Amendment of {{ motion.parent.identifier|default:motion.parent }})
{% endif %}
{% if motion.get_active_version.version_number > 1 %} | {% trans 'Version' %} {{ motion.active_version.version_number }}{% endif %}
</small>
</h1>

View File

@ -2,107 +2,112 @@
from django.conf.urls import patterns, url
from . import views
# TODO: define the Views inhere
urlpatterns = patterns(
'openslides.motion.views',
url(r'^$',
'motion_list',
views.MotionListView.as_view(),
name='motion_list'),
url(r'^new/$',
'motion_create',
# TODO: rename to motion_create
name='motion_new'),
views.MotionCreateView.as_view(),
name='motion_create'),
url(r'^(?P<pk>\d+)/$',
'motion_detail',
views.MotionDetailView.as_view(),
name='motion_detail'),
url(r'^(?P<pk>\d+)/edit/$',
'motion_update',
views.MotionUpdateView.as_view(),
name='motion_update'),
url(r'^(?P<pk>\d+)/del/$',
'motion_delete',
views.MotionDeleteView.as_view(),
name='motion_delete'),
url(r'^(?P<pk>\d+)/new_amendment/$',
views.MotionCreateAmendmentView.as_view(),
name='motion_create_amendment'),
url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/$',
'motion_detail',
views.MotionDetailView.as_view(),
name='motion_version_detail'),
url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/permit/$',
'version_permit',
views.VersionPermitView.as_view(),
name='motion_version_permit'),
url(r'^(?P<pk>\d+)/version/(?P<version_number>\d+)/del/$',
'version_delete',
views.VersionDeleteView.as_view(),
name='motion_version_delete'),
url(r'^(?P<pk>\d+)/diff/$',
'version_diff',
views.VersionDiffView.as_view(),
name='motion_version_diff'),
url(r'^(?P<pk>\d+)/support/$',
'motion_support',
views.SupportView.as_view(support=True),
name='motion_support'),
url(r'^(?P<pk>\d+)/unsupport/$',
'motion_unsupport',
views.SupportView.as_view(support=False),
name='motion_unsupport'),
url(r'^(?P<pk>\d+)/create_poll/$',
'poll_create',
views.PollCreateView.as_view(),
name='motionpoll_create'),
url(r'^(?P<pk>\d+)/poll/(?P<poll_number>\d+)/edit/$',
'poll_update',
views.PollUpdateView.as_view(),
name='motionpoll_update'),
url(r'^(?P<pk>\d+)/poll/(?P<poll_number>\d+)/del/$',
'poll_delete',
views.PollDeleteView.as_view(),
name='motionpoll_delete'),
url(r'^(?P<pk>\d+)/poll/(?P<poll_number>\d+)/pdf/$',
'poll_pdf',
views.PollPDFView.as_view(),
name='motionpoll_pdf'),
url(r'^(?P<pk>\d+)/set_state/(?P<state>\d+)/$',
'set_state',
views.MotionSetStateView.as_view(),
name='motion_set_state'),
url(r'^(?P<pk>\d+)/reset_state/$',
'reset_state',
views.MotionSetStateView.as_view(reset=True),
name='motion_reset_state'),
url(r'^(?P<pk>\d+)/agenda/$',
'create_agenda_item',
views.CreateRelatedAgendaItemView.as_view(),
name='motion_create_agenda'),
url(r'^pdf/$',
'motion_list_pdf',
views.MotionPDFView.as_view(print_all_motions=True),
name='motion_list_pdf'),
url(r'^(?P<pk>\d+)/pdf/$',
'motion_detail_pdf',
views.MotionPDFView.as_view(print_all_motions=False),
name='motion_detail_pdf'),
url(r'^category/$',
'category_list',
views.CategoryListView.as_view(),
name='motion_category_list'),
url(r'^category/new/$',
'category_create',
views.CategoryCreateView.as_view(),
name='motion_category_create'),
url(r'^category/(?P<pk>\d+)/edit/$',
'category_update',
views.CategoryUpdateView.as_view(),
name='motion_category_update'),
url(r'^category/(?P<pk>\d+)/del/$',
'category_delete',
views.CategoryDeleteView.as_view(),
name='motion_category_delete'),
url(r'^csv_import/$',
'motion_csv_import',
views.MotionCSVImportView.as_view(),
name='motion_csv_import'),
)

View File

@ -57,8 +57,6 @@ class MotionListView(ListView):
motions.append(motion)
return motions
motion_list = MotionListView.as_view()
class MotionDetailView(DetailView):
"""
@ -96,8 +94,6 @@ class MotionDetailView(DetailView):
'reason': version.reason})
return super(MotionDetailView, self).get_context_data(**kwargs)
motion_detail = MotionDetailView.as_view()
class MotionEditMixin(object):
"""
@ -205,17 +201,26 @@ class MotionCreateView(MotionEditMixin, CreateView):
def form_valid(self, form):
"""
Write a log message if the form is valid.
Write a log message and set the submitter if necessary.
"""
# First, validate and process the form and create the motion
response = super(MotionCreateView, self).form_valid(form)
# Write the log message
self.object.write_log([ugettext_noop('Motion created')], self.request.user)
# Set submitter to request.user if no submitter is set yet
if ('submitter' not in form.cleaned_data or
not form.cleaned_data['submitter']):
self.object.add_submitter(self.request.user)
return response
def get_initial(self):
"""
Sets the initial data for the MotionCreateForm.
"""
initial = super(MotionCreateView, self).get_initial()
initial['text'] = config['motion_preamble']
if self.request.user.has_perm('motion.can_manage_motion'):
initial['workflow'] = config['motion_workflow']
return initial
@ -229,7 +234,53 @@ class MotionCreateView(MotionEditMixin, CreateView):
self.object.reset_state(workflow)
self.version = self.object.get_new_version()
motion_create = MotionCreateView.as_view()
class MotionCreateAmendmentView(MotionCreateView):
"""
Create an amendment.
"""
def dispatch(self, *args, **kwargs):
if not config['motion_amendments_enabled']:
raise Http404('Amendments are disabled in the config.')
return super(MotionCreateAmendmentView, self).dispatch(*args, **kwargs)
def get_parent_motion(self):
"""
Gets the parent motion from the url.
Caches the value.
"""
try:
parent = self._object_parent
except AttributeError:
# self.get_object() is the django method, which does not cache the
# object. For now this is not a problem, because get_object() is only
# called once.
parent = self._object_parent = self.get_object()
return parent
def manipulate_object(self, form):
"""
Sets the parent to the motion to which this amendment refers.
"""
self.object.parent = self.get_parent_motion()
super(MotionCreateAmendmentView, self).manipulate_object(form)
def get_initial(self):
"""
Sets the initial values to the form.
This are the values for title, text and reason which are set to the
values from the parent motion.
"""
initial = super(MotionCreateAmendmentView, self).get_initial()
parent = self.get_parent_motion()
initial['title'] = parent.title
initial['text'] = parent.text
initial['reason'] = parent.reason
initial['category'] = parent.category
return initial
class MotionUpdateView(MotionEditMixin, UpdateView):
@ -297,8 +348,6 @@ class MotionUpdateView(MotionEditMixin, UpdateView):
self.version = self.object.get_last_version()
self.used_new_version = False
motion_update = MotionUpdateView.as_view()
class MotionDeleteView(DeleteView):
"""
@ -316,8 +365,6 @@ class MotionDeleteView(DeleteView):
def get_final_message(self):
return _('%s was successfully deleted.') % _('Motion')
motion_delete = MotionDeleteView.as_view()
class VersionDeleteView(DeleteView):
"""
@ -349,8 +396,6 @@ class VersionDeleteView(DeleteView):
def get_url_name_args(self):
return (self.get_object().motion_id, )
version_delete = VersionDeleteView.as_view()
class VersionPermitView(SingleObjectMixin, QuestionView):
"""
@ -396,8 +441,6 @@ class VersionPermitView(SingleObjectMixin, QuestionView):
ugettext_noop('permitted')],
person=self.request.user)
version_permit = VersionPermitView.as_view()
class VersionDiffView(DetailView):
"""
@ -438,8 +481,6 @@ class VersionDiffView(DetailView):
})
return context
version_diff = VersionDiffView.as_view()
class SupportView(SingleObjectMixin, QuestionView):
"""
@ -502,9 +543,6 @@ class SupportView(SingleObjectMixin, QuestionView):
else:
return _("You have unsupported this motion successfully.")
motion_support = SupportView.as_view(support=True)
motion_unsupport = SupportView.as_view(support=False)
class PollCreateView(SingleObjectMixin, RedirectView):
"""
@ -528,8 +566,6 @@ class PollCreateView(SingleObjectMixin, RedirectView):
"""
return reverse('motionpoll_update', args=[self.get_object().pk, self.poll.poll_number])
poll_create = PollCreateView.as_view()
class PollMixin(object):
"""
@ -595,8 +631,6 @@ class PollUpdateView(PollMixin, PollFormView):
self.get_object().write_log([ugettext_noop('Poll updated')], self.request.user)
return value
poll_update = PollUpdateView.as_view()
class PollDeleteView(PollMixin, DeleteView):
"""
@ -618,8 +652,6 @@ class PollDeleteView(PollMixin, DeleteView):
"""
return reverse('motion_detail', args=[self.get_object().motion.pk])
poll_delete = PollDeleteView.as_view()
class PollPDFView(PollMixin, PDFView):
"""
@ -649,8 +681,6 @@ class PollPDFView(PollMixin, PDFView):
"""
motion_poll_to_pdf(pdf, self.get_object())
poll_pdf = PollPDFView.as_view()
class MotionSetStateView(SingleObjectMixin, RedirectView):
"""
@ -688,9 +718,6 @@ class MotionSetStateView(SingleObjectMixin, RedirectView):
_('The state of the motion was set to %s.')
% html_strong(_(self.get_object().state.name)))
set_state = MotionSetStateView.as_view()
reset_state = MotionSetStateView.as_view(reset=True)
class CreateRelatedAgendaItemView(_CreateRelatedAgendaItemView):
"""
@ -705,8 +732,6 @@ class CreateRelatedAgendaItemView(_CreateRelatedAgendaItemView):
super(CreateRelatedAgendaItemView, self).pre_redirect(request, *args, **kwargs)
self.get_object().write_log([ugettext_noop('Agenda item created')], self.request.user)
create_agenda_item = CreateRelatedAgendaItemView.as_view()
class MotionPDFView(SingleObjectMixin, PDFView):
"""
@ -767,16 +792,11 @@ class MotionPDFView(SingleObjectMixin, PDFView):
else:
motion_to_pdf(pdf, self.get_object())
motion_list_pdf = MotionPDFView.as_view(print_all_motions=True)
motion_detail_pdf = MotionPDFView.as_view(print_all_motions=False)
class CategoryListView(ListView):
required_permission = 'motion.can_manage_motion'
model = Category
category_list = CategoryListView.as_view()
class CategoryCreateView(CreateView):
required_permission = 'motion.can_manage_motion'
@ -784,8 +804,6 @@ class CategoryCreateView(CreateView):
success_url_name = 'motion_category_list'
url_name_args = []
category_create = CategoryCreateView.as_view()
class CategoryUpdateView(UpdateView):
required_permission = 'motion.can_manage_motion'
@ -793,8 +811,6 @@ class CategoryUpdateView(UpdateView):
success_url_name = 'motion_category_list'
url_name_args = []
category_update = CategoryUpdateView.as_view()
class CategoryDeleteView(DeleteView):
required_permission = 'motion.can_manage_motion'
@ -803,8 +819,6 @@ class CategoryDeleteView(DeleteView):
url_name_args = []
success_url_name = 'motion_category_list'
category_delete = CategoryDeleteView.as_view()
class MotionCSVImportView(CSVImportView):
"""
@ -830,5 +844,3 @@ class MotionCSVImportView(CSVImportView):
messages.error(self.request, error)
# Overleap method of CSVImportView
return super(CSVImportView, self).form_valid(form)
motion_csv_import = MotionCSVImportView.as_view()

View File

@ -147,6 +147,64 @@ class ModelTest(TestCase):
# motion.__unicode__() raised an AttributeError
self.assertEqual(str(motion), 'test_identifier_VohT1hu9uhiSh6ooVBFS | test_title_Koowoh1ISheemeey1air')
def test_is_amendment(self):
config['motion_amendments_enabled'] = True
amendment = Motion.objects.create(title='amendment', parent=self.motion)
self.assertTrue(amendment.is_amendment())
self.assertFalse(self.motion.is_amendment())
def test_set_identifier_allready_set(self):
"""
If the motion already has a identifier, the method does nothing.
"""
motion = Motion(identifier='My test identifier')
motion.set_identifier()
self.assertEqual(motion.identifier, 'My test identifier')
def test_set_identifier_manually(self):
"""
If the config is set to manually, the method does nothing.
"""
config['motion_identifier'] = 'manually'
motion = Motion()
motion.set_identifier()
# If the identifier should be set manually, the method does nothing
self.assertIsNone(motion.identifier)
def test_set_identifier_amendment(self):
"""
If the motion is an amendment, the identifier is the identifier from the
parent + a suffix.
"""
config['motion_amendments_enabled'] = True
self.motion.identifier = 'Parent identifier'
self.motion.save()
motion = Motion(parent=self.motion)
motion.set_identifier()
self.assertEqual(motion.identifier, 'Parent identifier A 1')
def test_set_identifier_second_amendment(self):
"""
If a motion has already an amendment, the second motion gets another
identifier.
"""
config['motion_amendments_enabled'] = True
self.motion.identifier = 'Parent identifier'
self.motion.save()
Motion.objects.create(title='Amendment1', parent=self.motion)
motion = Motion(parent=self.motion)
motion.set_identifier()
self.assertEqual(motion.identifier, 'Parent identifier A 2')
class ConfigTest(TestCase):
def test_stop_submitting(self):

View File

@ -2,12 +2,15 @@
import os
import tempfile
from mock import MagicMock
from django.conf import settings
from django.test import RequestFactory
from django.test.client import Client
from openslides.config.api import config
from openslides.mediafile.models import Mediafile
from openslides.motion import views
from openslides.motion.models import Category, Motion, MotionLog, State
from openslides.participant.models import Group, User
from openslides.utils.test import TestCase
@ -244,6 +247,67 @@ class TestMotionCreateView(MotionViewTestCase):
self.assertEqual(MotionLog.objects.get(pk=1).message_list, ['Motion created'])
class TestMotionCreateAmendmentView(MotionViewTestCase):
url = '/motion/1/new_amendment/'
def test_get_amendment_active(self):
config['motion_amendments_enabled'] = True
self.check_url(self.url, self.admin_client, 200)
def test_get_amendment_inactive(self):
config['motion_amendments_enabled'] = False
self.check_url(self.url, self.admin_client, 404)
def test_get_parent_motion(self):
motion = Motion.objects.create(title='Test Motion')
view = views.MotionCreateAmendmentView()
view.request = RequestFactory().get(self.url)
view.kwargs = {'pk': motion.pk}
self.assertEqual(view.get_parent_motion(), motion)
def test_manipulate_object(self):
motion = Motion.objects.create(title='Test Motion')
view = views.MotionCreateAmendmentView()
view.request = RequestFactory().get(self.url)
view.kwargs = {'pk': motion.pk}
view.object = MagicMock()
view.manipulate_object(MagicMock())
self.assertEqual(view.object.parent, motion)
def test_get_initial(self):
motion = Motion.objects.create(
title='Test Motion', text='Parent Motion text', reason='test reason')
view = views.MotionCreateAmendmentView()
view.request = MagicMock()
view.kwargs = {'pk': motion.pk}
self.assertEqual(view.get_initial(), {
'reason': u'test reason',
'text': u'Parent Motion text',
'title': u'Test Motion',
'category': None,
'workflow': '1'})
def test_get_initial_with_category(self):
category = Category.objects.create(name='test category')
motion = Motion.objects.create(
title='Test Motion', text='Parent Motion text', reason='test reason',
category=category)
view = views.MotionCreateAmendmentView()
view.request = MagicMock()
view.kwargs = {'pk': motion.pk}
self.assertEqual(view.get_initial(), {
'reason': u'test reason',
'text': u'Parent Motion text',
'title': u'Test Motion',
'category': category,
'workflow': '1'})
class TestMotionUpdateView(MotionViewTestCase):
url = '/motion/1/edit/'