@@ -37,3 +41,5 @@
{% trans "Scroll level" %}:
{{ 'projector_scroll'|get_config }}
{% endif %}
+
+{% endblock %}
diff --git a/openslides/projector/templates/projector/overlay_widget.html b/openslides/projector/templates/projector/widget_overlay.html
similarity index 92%
rename from openslides/projector/templates/projector/overlay_widget.html
rename to openslides/projector/templates/projector/widget_overlay.html
index e132da98c..55981c73e 100644
--- a/openslides/projector/templates/projector/overlay_widget.html
+++ b/openslides/projector/templates/projector/widget_overlay.html
@@ -1,5 +1,8 @@
+{% extends 'core/widget.html' %}
+
{% load i18n %}
+{% block content %}
{% for overlay in overlays %}
-
@@ -19,3 +22,4 @@
{% endfor %}
+{% endblock %}
diff --git a/openslides/projector/views.py b/openslides/projector/views.py
index 88e0967d2..7095efccc 100644
--- a/openslides/projector/views.py
+++ b/openslides/projector/views.py
@@ -1,39 +1,42 @@
# -*- coding: utf-8 -*-
from django.contrib import messages
-from django.core.context_processors import csrf
from django.core.urlresolvers import reverse
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from openslides.config.api import config
+from openslides.mediafile.models import Mediafile
from openslides.utils.tornado_webserver import ProjectorSocketHandler
from openslides.utils.template import Tab
from openslides.utils.views import (AjaxMixin, CreateView, DeleteView,
RedirectView, TemplateView, UpdateView)
-from openslides.mediafile.models import Mediafile
+from openslides.utils.widgets import Widget
-from .api import (call_on_projector, get_active_slide, get_all_widgets,
+from .api import (call_on_projector, get_active_slide,
get_overlays, get_projector_content, get_projector_overlays,
get_projector_overlays_js, reset_countdown, set_active_slide,
start_countdown, stop_countdown, update_projector_overlay)
from .forms import SelectWidgetsForm
from .models import ProjectorSlide
-from .projector import Widget
-from .signals import projector_overlays
class DashboardView(AjaxMixin, TemplateView):
"""
- Overview over all possible slides, the overlays and a liveview.
+ Overview over all possible slides, the overlays and a live view.
"""
template_name = 'projector/dashboard.html'
permission_required = 'projector.can_see_dashboard'
def get_context_data(self, **kwargs):
context = super(DashboardView, self).get_context_data(**kwargs)
-
- context['widgets'] = get_all_widgets(self.request, session=True)
+ widgets = []
+ for widget in Widget.get_all(self.request):
+ if widget.is_active():
+ widgets.append(widget)
+ context['extra_stylefiles'].extend(widget.get_stylesheets())
+ context['extra_javascript'].extend(widget.get_javascript_files())
+ context['widgets'] = widgets
return context
@@ -101,16 +104,16 @@ class SelectWidgetsView(TemplateView):
def get_context_data(self, **kwargs):
context = super(SelectWidgetsView, self).get_context_data(**kwargs)
- widgets = get_all_widgets(self.request)
- activated_widgets = self.request.session.get('widgets', {})
- for name, widget in widgets.items():
- initial = {'widget': activated_widgets.get(name, True)}
+
+ widgets = Widget.get_all(self.request)
+ for widget in widgets:
+ initial = {'widget': widget.is_active()}
+ prefix = widget.name
if self.request.method == 'POST':
- widget.form = SelectWidgetsForm(self.request.POST, prefix=name,
+ widget.form = SelectWidgetsForm(self.request.POST, prefix=prefix,
initial=initial)
else:
- widget.form = SelectWidgetsForm(prefix=name, initial=initial)
-
+ widget.form = SelectWidgetsForm(prefix=prefix, initial=initial)
context['widgets'] = widgets
return context
@@ -119,16 +122,15 @@ class SelectWidgetsView(TemplateView):
Activates or deactivates the widgets in a post request.
"""
context = self.get_context_data(**kwargs)
- activated_widgets = self.request.session.get('widgets', {})
-
- for name, widget in context['widgets'].items():
+ session_widgets = self.request.session.get('widgets', {})
+ for widget in context['widgets']:
if widget.form.is_valid():
- activated_widgets[name] = widget.form.cleaned_data['widget']
+ session_widgets[widget.name] = widget.form.cleaned_data['widget']
else:
messages.error(request, _('Errors in the form.'))
break
else:
- self.request.session['widgets'] = activated_widgets
+ self.request.session['widgets'] = session_widgets
return redirect(reverse('dashboard'))
@@ -291,64 +293,3 @@ def register_tab(request):
permission=request.user.has_perm('projector.can_see_dashboard'),
selected=selected,
)
-
-
-def get_widgets(request):
- """
- Return the widgets of the projector app
- """
- widgets = []
-
- # Welcome widget
- widgets.append(Widget(
- request,
- name='welcome',
- display_name=config['welcome_title'],
- template='projector/welcome_widget.html',
- context={'welcometext': config['welcome_text']},
- permission_required='projector.can_see_dashboard',
- default_column=1,
- default_weight=10))
-
- # Projector live view widget
- widgets.append(Widget(
- request,
- name='live_view',
- display_name=_('Projector live view'),
- template='projector/live_view_widget.html',
- permission_required='projector.can_see_projector',
- default_column=2,
- default_weight=10))
-
- # Overlay widget
- overlays = []
- for receiver, overlay in projector_overlays.send(sender='overlay_widget', request=request):
- if overlay.widget_html_callback is not None:
- overlays.append(overlay)
- context = {'overlays': overlays}
- context.update(csrf(request))
- widgets.append(Widget(
- request,
- name='overlays',
- display_name=_('Overlays'),
- template='projector/overlay_widget.html',
- permission_required='projector.can_manage_projector',
- default_column=2,
- default_weight=20,
- context=context))
-
- # Custom slide widget
- welcomepage_is_active = get_active_slide().get('callback', 'default') == 'default'
- widgets.append(Widget(
- request,
- name='custom_slide',
- display_name=_('Custom Slides'),
- template='projector/custom_slide_widget.html',
- context={
- 'slides': ProjectorSlide.objects.all().order_by('weight'),
- 'welcomepage_is_active': welcomepage_is_active},
- permission_required='projector.can_manage_projector',
- default_column=2,
- default_weight=30))
-
- return widgets
diff --git a/openslides/projector/widgets.py b/openslides/projector/widgets.py
new file mode 100644
index 000000000..9d089d8a2
--- /dev/null
+++ b/openslides/projector/widgets.py
@@ -0,0 +1,65 @@
+# -*- coding: utf-8 -*-
+
+from django.core.context_processors import csrf
+from django.utils.translation import ugettext_lazy
+
+from openslides.projector.api import get_active_slide
+from openslides.utils.widgets import Widget
+
+from .models import ProjectorSlide
+from .signals import projector_overlays
+
+
+class ProjectorLiveWidget(Widget):
+ """
+ Widget with a live view of the projector.
+ """
+ name = 'live_view'
+ verbose_name = ugettext_lazy('Projector live view')
+ permission_required = 'projector.can_see_projector'
+ default_column = 2
+ default_weight = 10
+ template_name = 'projector/widget_live_view.html'
+
+
+class OverlayWidget(Widget):
+ """
+ Widget to control all overlays.
+ """
+ name = 'overlays' # TODO: Use singular here
+ verbose_name = ugettext_lazy('Overlays')
+ permission_required = 'projector.can_manage_projector'
+ default_column = 2
+ default_weight = 20
+ template_name = 'projector/widget_overlay.html'
+
+ def get_context_data(self, **context):
+ """
+ Inserts all overlays into the context. The overlays are collected by
+ the projector_overlays signal.
+ """
+ overlays = [overlay for __, overlay in projector_overlays.send(sender='overlay_widget', request=self.request)
+ if overlay.widget_html_callback is not None]
+ context.update(csrf(self.request))
+ return super(OverlayWidget, self).get_context_data(
+ overlays=overlays,
+ **context)
+
+
+class CustonSlideWidget(Widget):
+ """
+ Widget to control custom slides.
+ """
+ name = 'custom_slide'
+ verbose_name = ugettext_lazy('Custom Slides')
+ permission_required = 'projector.can_manage_projector'
+ default_column = 2
+ default_weight = 30
+ template_name = 'projector/widget_custom_slide.html'
+ context = None
+
+ def get_context_data(self, **context):
+ return super(CustonSlideWidget, self).get_context_data(
+ slides=ProjectorSlide.objects.all().order_by('weight'),
+ welcomepage_is_active=get_active_slide().get('callback', 'default') == 'default',
+ **context)
diff --git a/openslides/static/styles/base.css b/openslides/static/styles/base.css
index b9f84d230..19f98338c 100644
--- a/openslides/static/styles/base.css
+++ b/openslides/static/styles/base.css
@@ -304,9 +304,6 @@ legend + .control-group {
.icon-config {
background-position: -432px 0px;
}
-.icon-welcome {
- background-position: 0 -24px;
-}
.icon-live_view {
background-position: -432px -48px;
}
diff --git a/openslides/utils/dispatch.py b/openslides/utils/dispatch.py
new file mode 100644
index 000000000..56659fddf
--- /dev/null
+++ b/openslides/utils/dispatch.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+
+
+class SignalConnectMetaClass(type):
+ """
+ Metaclass to connect the children of a base class to a Django signal.
+
+ Classes must have a signal argument and a get_dispatch_uid classmethod.
+ The signal argument must be the Django signal the class should be
+ connected to. The get_dispatch_uid classmethod must return a unique
+ value for each child class and None for base classes which will not be
+ connected.
+
+ The classmethod get_all_objects is added as get_all classmethod to every
+ class using this metaclass. Calling this on a base class or on child
+ classes will retrieve all connected children, on instance for each child
+ class. These instances will have a check_permission method which
+ returns True by default. You can override this method to return False
+ on runtime if you want to filter some children.
+
+ Example:
+
+ class Base(object):
+ __metaclass__ = SignalConnectMetaClass
+ signal = django.dispatch.Signal()
+ @classmethod
+ def get_dispatch_uid(cls):
+ if not cls.__name__ == 'Base':
+ return cls.__name__
+
+ class Child(Base):
+ def __init__(self, **kwargs):
+ pass
+
+ child = Base.get_all(request)[0]
+ assert Child == type(child)
+ """
+ def __new__(metaclass, class_name, class_parents, class_attributes):
+ """
+ Creates the class and connects it to the signal if so.
+ """
+ class_attributes['get_all'] = get_all_objects
+ new_class = super(SignalConnectMetaClass, metaclass).__new__(
+ metaclass, class_name, class_parents, class_attributes)
+ try:
+ dispatch_uid = new_class.get_dispatch_uid()
+ except AttributeError:
+ raise NotImplementedError('Your class %s must have a get_dispatch_uid classmethod.' % class_name)
+ if dispatch_uid is not None:
+ try:
+ signal = new_class.signal
+ except AttributeError:
+ raise NotImplementedError('Your class %s must have a signal argument, which must be a Django Signal instance.' % class_name)
+ else:
+ signal.connect(new_class, dispatch_uid=dispatch_uid)
+ if not hasattr(new_class, 'check_permission'):
+ setattr(new_class, 'check_permission', check_permission)
+ return new_class
+
+
+@classmethod
+def get_all_objects(cls, request):
+ """
+ Collects all objects of the class created by the SignalConnectMetaClass
+ from all apps via signal. If they have a default weight, they are sorted.
+ Does not return objects where check_permission returns False.
+
+ Expects a request object.
+
+ This classmethod is added as get_all classmethod to every class using the
+ SignalConnectMetaClass.
+ """
+ all_objects = [obj for __, obj in cls.signal.send(sender=cls, request=request) if obj.check_permission()]
+ if hasattr(cls, 'get_default_weight'):
+ all_objects.sort(key=lambda obj: obj.get_default_weight())
+ return all_objects
+
+
+def check_permission(self):
+ """
+ Returns True by default. Override this to filter some children on runtime.
+
+ This method is added to every instance of classes using the
+ SignalConnectMetaClass.
+ """
+ return True
diff --git a/openslides/utils/views.py b/openslides/utils/views.py
index 402eb7696..98ace10d4 100644
--- a/openslides/utils/views.py
+++ b/openslides/utils/views.py
@@ -54,6 +54,7 @@ class PermissionMixin(object):
"""
permission_required = NO_PERMISSION_REQUIRED
+ # TODO: Rename this to check_permission
def has_permission(self, request, *args, **kwargs):
"""
Checks if the user has the required permission.
diff --git a/openslides/utils/widgets.py b/openslides/utils/widgets.py
new file mode 100644
index 000000000..72b365e66
--- /dev/null
+++ b/openslides/utils/widgets.py
@@ -0,0 +1,139 @@
+# -*- coding: utf-8 -*-
+
+from django.core.urlresolvers import reverse
+from django.dispatch import Signal
+from django.template import RequestContext
+from django.template.loader import render_to_string
+
+from .dispatch import SignalConnectMetaClass
+
+
+class Widget(object):
+ """
+ Base class for a widget for the dashboard.
+
+ Every app which wants to add a widget to the dashboard has to create a
+ widget class subclassing from this base class. The name attribute has to
+ be set. The __metaclass__ attribute (SignalConnectMetaClass) does the
+ rest of the magic.
+
+ For the appearance of the widget there are some more attributes like
+ verbose_name, permission_required, default_column, default_weight,
+ default_active, template_name, context, icon, more_link_pattern_name,
+ stylesheets and javascript_files.
+ """
+ __metaclass__ = SignalConnectMetaClass
+ signal = Signal(providing_args=['request'])
+ name = None
+ verbose_name = None
+ permission_required = None
+ default_column = 1
+ default_weight = 0
+ default_active = True
+ template_name = None
+ context = None
+ icon_css_class = None
+ more_link_pattern_name = None
+ stylesheets = None
+ javascript_files = None
+
+ def __init__(self, sender, request, **kwargs):
+ """
+ Initialize the widget instance. This is done when the signal is sent.
+
+ Only the required request argument is used. Because of Django's signal
+ API, we have to take also a sender argument and wildcard keyword
+ arguments. But they are not used here.
+ """
+ self.request = request
+
+ def __repr__(self):
+ return repr(self.get_verbose_name())
+
+ def __unicode__(self):
+ return unicode(self.get_verbose_name())
+
+ @classmethod
+ def get_dispatch_uid(cls):
+ """
+ Returns the name as a unique string for each class. Returns None for
+ the base class so it will not be connected to the signal.
+ """
+ return cls.name
+
+ def get_verbose_name(self):
+ """
+ Returns a human readable name of the widget.
+ """
+ return self.verbose_name or self.name.capitalize()
+
+ def check_permission(self):
+ """
+ Returns True if the request user is allowed to see the widget.
+ """
+ return self.permission_required is None or self.request.user.has_perm(self.permission_required)
+
+ def get_default_weight(self):
+ """
+ Returns the default weight of the widget.
+ """
+ return self.default_weight
+
+ def is_active(self):
+ """
+ Returns True if the widget is active to be displayed.
+ """
+ session_widgets = self.request.session.get('widgets', {})
+ return session_widgets.get(self.name, self.default_active)
+
+ def get_html(self):
+ """
+ Returns the html code of the widget.
+
+ This method also adds the widget itself to the context.
+ """
+ if self.template_name is not None:
+ html = render_to_string(
+ template_name=self.template_name,
+ dictionary=self.get_context_data(widget=self),
+ context_instance=RequestContext(self.request))
+ else:
+ raise NotImplementedError('A widget class must define either a get_html '
+ 'method or have template_name argument.')
+ return html
+
+ def get_context_data(self, **context):
+ """
+ Returns the context data for the widget template.
+ """
+ return_context = self.context or {}
+ return_context.update(context)
+ return return_context
+
+ def get_icon_css_class(self):
+ """
+ Returns the css class name of the icon.
+ """
+ return self.icon_css_class or 'icon-%s' % self.name
+
+ def get_url_for_more(self):
+ """
+ Returns the url for the link 'More ...' in the base template.
+ """
+ if self.more_link_pattern_name is not None:
+ url = reverse(self.more_link_pattern_name)
+ else:
+ url = None
+ return url
+
+ def get_stylesheets(self):
+ """
+ Returns an interable of stylesheets to be loaded.
+ """
+ return iter(self.stylesheets or [])
+
+ def get_javascript_files(self):
+ """
+ Returns an interable of javascript files to be loaded.
+ """
+ return iter(self.javascript_files or [])
diff --git a/tests/core/test_widgets.py b/tests/core/test_widgets.py
new file mode 100644
index 000000000..5f0eb6130
--- /dev/null
+++ b/tests/core/test_widgets.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+
+from django.contrib.auth.models import AnonymousUser
+from django.test.client import RequestFactory
+
+from openslides.utils.test import TestCase
+from openslides.utils.widgets import Widget
+
+
+class WidgetObject(TestCase):
+ request_factory = RequestFactory()
+
+ def get_widget(self, name):
+ request = self.request_factory.get('/')
+ request.user = AnonymousUser()
+ for widget in Widget.get_all(request):
+ if widget.name == name:
+ value = widget
+ break
+ else:
+ value = False
+ return value
+
+ def test_connecting_signal(self):
+
+ class TestWidgetOne(Widget):
+ name = 'test_case_widget_begae7poh1Ahshohfi1r'
+
+ self.assertTrue(self.get_widget('test_case_widget_begae7poh1Ahshohfi1r'))
+
+ def test_not_connecting_signal(self):
+
+ class TestWidgetTwo(Widget):
+ name = 'test_case_widget_zuRietaewiCii9mahDah'
+
+ @classmethod
+ def get_dispatch_uid(cls):
+ return None
+
+ self.assertFalse(self.get_widget('test_case_widget_zuRietaewiCii9mahDah'))
+
+ def test_missing_template(self):
+
+ class TestWidgetThree(Widget):
+ name = 'test_widget_raiLaiPhahQuahngeer4'
+
+ widget = self.get_widget('test_widget_raiLaiPhahQuahngeer4')
+ self.assertRaisesMessage(
+ NotImplementedError,
+ 'A widget class must define either a get_html method or have template_name argument.',
+ widget.get_html)
diff --git a/tests/projector/test_views.py b/tests/projector/test_views.py
index 6366ca9b9..6f97e0a00 100644
--- a/tests/projector/test_views.py
+++ b/tests/projector/test_views.py
@@ -51,8 +51,8 @@ class ActivateViewTest(TestCase):
view.pre_redirect(view.request, callback='some_callback')
- mock_set_active_slide.called_with('some_callback',
- {'some_key': 'some_value'})
+ mock_set_active_slide.assert_called_with('some_callback',
+ **{'some_key': 'some_value'})
mock_config.get_default.assert_has_calls([call('projector_scroll'),
call('projector_scale')])
self.assertEqual(mock_config.__setitem__.call_count, 2)
@@ -64,30 +64,31 @@ class SelectWidgetsViewTest(TestCase):
@patch('openslides.projector.views.SelectWidgetsForm')
@patch('openslides.projector.views.TemplateView.get_context_data')
- @patch('openslides.projector.views.get_all_widgets')
- def test_get_context_data(self, mock_get_all_widgets, mock_get_context_data,
+ @patch('openslides.projector.views.Widget')
+ def test_get_context_data(self, mock_Widget, mock_get_context_data,
mock_SelectWidgetsForm):
view = views.SelectWidgetsView()
view.request = self.rf.get('/')
view.request.session = MagicMock()
widget = MagicMock()
- widget.name.return_value = 'some_widget'
- mock_get_all_widgets.return_value = {'some_widget': widget}
+ widget.name = 'some_widget_Bohsh1Pa0eeziRaihu8O'
+ widget.is_active.return_value = True
+ mock_Widget.get_all.return_value = [widget]
mock_get_context_data.return_value = {}
# Test get
context = view.get_context_data()
self.assertIn('widgets', context)
- self.assertIn('some_widget', context['widgets'])
- mock_SelectWidgetsForm.called_with(
- prefix='some_widget', initial={'widget': True})
+ self.assertIn(widget, context['widgets'])
+ mock_SelectWidgetsForm.assert_called_with(
+ prefix='some_widget_Bohsh1Pa0eeziRaihu8O', initial={'widget': True})
# Test post
view.request = self.rf.post('/')
view.request.session = MagicMock()
context = view.get_context_data()
- mock_SelectWidgetsForm.called_with(
- view.request.POST, prefix='some_widget', initial={'widget': True})
+ mock_SelectWidgetsForm.assert_called_with(
+ view.request.POST, prefix='some_widget_Bohsh1Pa0eeziRaihu8O', initial={'widget': True})
@patch('openslides.projector.views.messages')
def test_post(self, mock_messages):
@@ -95,14 +96,14 @@ class SelectWidgetsViewTest(TestCase):
view.request = self.rf.post('/')
view.request.session = {}
widget = MagicMock()
- widget.name.return_value = 'some_widget'
- context = {'widgets': {'some_widget': widget}}
+ widget.name = 'some_widget_ahgaeree8JeReichue8u'
+ context = {'widgets': [widget]}
mock_context_data = MagicMock(return_value=context)
with patch('openslides.projector.views.SelectWidgetsView.get_context_data', mock_context_data):
widget.form.is_valid.return_value = True
view.post(view.request)
- self.assertIn('some_widget', view.request.session['widgets'])
+ self.assertIn('some_widget_ahgaeree8JeReichue8u', view.request.session['widgets'])
# Test with errors in form
widget.form.is_valid.return_value = False
diff --git a/tests/projector/test_widgets.py b/tests/projector/test_widgets.py
deleted file mode 100644
index 7f2c864e1..000000000
--- a/tests/projector/test_widgets.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# -*- coding: utf-8 -*-
-
-from django.http import HttpRequest
-
-from openslides.projector.projector import Widget
-from openslides.utils.exceptions import OpenSlidesError
-from openslides.utils.test import TestCase
-
-
-class WidgetObject(TestCase):
- def test_error(self):
- with self.assertRaises(OpenSlidesError):
- Widget(HttpRequest(), name='chahghuyeim8ie0Noong')
-
- def test_repr(self):
- w = Widget(HttpRequest(), name='abcdefgäöüß', html='
html')
- self.assertEqual(repr(w), repr('Abcdefgäöüß'))
diff --git a/tests/utils/test_dispatch.py b/tests/utils/test_dispatch.py
new file mode 100644
index 000000000..a2bdb4cd8
--- /dev/null
+++ b/tests/utils/test_dispatch.py
@@ -0,0 +1,80 @@
+# -*- coding: utf-8 -*-
+
+from django.dispatch import Signal
+from django.test.client import RequestFactory
+from mock import patch
+
+from openslides.utils.dispatch import SignalConnectMetaClass
+from openslides.utils.test import TestCase
+
+
+class TestBaseOne(object):
+ __metaclass__ = SignalConnectMetaClass
+ signal = Signal()
+
+ @classmethod
+ def get_dispatch_uid(cls):
+ if not cls.__name__ == 'TestBaseOne':
+ return 'test_vieM1eingi6luish5Sei'
+
+
+class TestBaseTwo(object):
+ __metaclass__ = SignalConnectMetaClass
+ signal = Signal()
+
+ @classmethod
+ def get_dispatch_uid(cls):
+ pass
+
+
+class TestSignalConnectMetaClass(TestCase):
+ request_factory = RequestFactory()
+
+ @patch('tests.utils.test_dispatch.TestBaseOne.signal')
+ def test_call_signal_send(self, mock_signal):
+ TestBaseOne.get_all(self.request_factory.request)
+ self.assertTrue(mock_signal.send.called)
+
+ @patch('tests.utils.test_dispatch.TestBaseOne.signal')
+ def test_call_signal_connect(self, mock_signal):
+ class TestChildOne(TestBaseOne):
+ pass
+
+ self.assertTrue(mock_signal.connect.called)
+ self.assertEqual(mock_signal.connect.call_args[0][0], TestChildOne)
+ self.assertEqual(mock_signal.connect.call_args[1], dict(dispatch_uid='test_vieM1eingi6luish5Sei'))
+
+ def test_bad_base_class(self):
+ def wrapper():
+ class BadClass1(object):
+ __metaclass__ = SignalConnectMetaClass
+ self.assertRaisesMessage(
+ NotImplementedError,
+ 'Your class BadClass1 must have a get_dispatch_uid classmethod.',
+ wrapper)
+
+ def test_bad_base_class_without_signal(self):
+ def wrapper():
+ class BadClass2(object):
+ __metaclass__ = SignalConnectMetaClass
+
+ @classmethod
+ def get_dispatch_uid(cls):
+ return True
+
+ self.assertRaisesMessage(
+ NotImplementedError,
+ 'Your class BadClass2 must have a signal argument, which must be a Django Signal instance.',
+ wrapper)
+
+ def test_receive_signal(self):
+ class TestChildTwo(TestBaseTwo):
+ def __init__(self, sender, **kwargs):
+ pass
+
+ @classmethod
+ def get_dispatch_uid(self):
+ return 'test_leeve5eighahT3zooxe5'
+
+ childtwo = TestBaseTwo.get_all(self.request_factory.request)[0]
+ self.assertEqual(type(childtwo), TestChildTwo)