Merge pull request #1506 from normanjaeckel/DjangoRESTFrameworkProjector

Added REST API for projector. Introduced new projector API.
This commit is contained in:
Norman Jäckel 2015-05-29 12:50:39 +02:00
commit 80588610f0
20 changed files with 785 additions and 213 deletions

View File

@ -11,33 +11,41 @@ Version 2.0.0 (unreleased)
Agenda:
- Updated the tests and changed only small internal parts of method of the
agenda model. No API changes.
- Deprecated mptt.
Assignments:
- Massive refactoring and cleanup of assignments app.
- Renamed app from assignment to assignments
- Renamed app from assignment to assignments.
- Massive refactoring and cleanup of the app.
Motions:
- Renamed app from motion to motions
- Renamed app from motion to motions.
Mediafiles:
- Renamed app from mediafile to mediafiles
- Renamed app from mediafile to mediafiles.
Users:
- Massive refactoring of the participant app. Now called 'users'.
- Used new anonymous user object instead of an authentification backend.
- Used new anonymous user object instead of an authentification backend. Used
special authentication class for REST requests.
- Used authentication frontend via AngularJS.
Other:
- New OpenSlides logo.
- Changed supported Python version to >= 3.3.
- Used Django 1.7 as lowest requirement.
- Added Django's application configuration. Refactored loading of signals,
template signals and slides.
- Added API using Django REST Framework 3.x. Added several views and mixins for
generic views in OpenSlides apps.
template signals and projector elements/slides.
- Setup migrations.
- Added API using Django REST Framework 3.x. Added several views and mixins
for generic views in OpenSlides apps.
- Refactored projector API using metaclasses now.
- Renamed SignalConnectMetaClass classmethod get_all_objects to get_all
(private API).
- Used AngularJS with additional libraries for single page frontend.
- Removed use of 'django.views.i18n.javascript_catalog'.
- Removed use of 'django.views.i18n.javascript_catalog'. Used angular-gettext
now.
- Updated to Bootstrap 3.
- Used SockJS for automatic update of AngularJS driven single page frontend.
- Refactored start script and management commands.
- Refactored tests.
- Used Bower and gulp to manage third party JavaScript and Cascading Style
Sheets libraries.
- Used setup.cfg for development tools.
- Fixed bug in LocalizedModelMultipleChoiceField.

View File

@ -6,27 +6,23 @@ class CoreAppConfig(AppConfig):
verbose_name = 'OpenSlides Core'
def ready(self):
# Load main menu entry and widgets.
# Load main menu entry, projector elements and widgets.
# Do this by just importing all from these files.
from . import main_menu, widgets # noqa
from . import main_menu, projector, widgets # noqa
# Import all required stuff.
from django.db.models import signals
from openslides.config.signals import config_signal
from openslides.projector.api import register_slide_model
from openslides.utils.autoupdate import inform_changed_data_receiver
from openslides.utils.rest_api import router
from .signals import setup_general_config
from .views import CustomSlideViewSet, TagViewSet
from .views import CustomSlideViewSet, ProjectorViewSet, TagViewSet
# Connect signals.
config_signal.connect(setup_general_config, dispatch_uid='setup_general_config')
# Register slides.
CustomSlide = self.get_model('CustomSlide')
register_slide_model(CustomSlide, 'core/customslide_slide.html')
# Register viewsets.
router.register('core/projector', ProjectorViewSet)
router.register('core/customslide', CustomSlideViewSet)
router.register('core/tag', TagViewSet)

View File

@ -1,5 +1,9 @@
from openslides.utils.exceptions import OpenSlidesError
class ProjectorException(OpenSlidesError):
pass
class TagException(OpenSlidesError):
pass

View File

@ -0,0 +1,75 @@
import jsonfield.fields
from django.db import migrations, models
import openslides.utils.models
import openslides.utils.rest_api
def add_default_projector(apps, schema_editor):
"""
Adds default projector, welcome slide and activates clock and welcome
slide.
"""
# We get the model from the versioned app registry;
# if we directly import it, it will be the wrong version.
CustomSlide = apps.get_model('core', 'CustomSlide')
custom_slide = CustomSlide.objects.create(
title='Welcome to OpenSlides',
weight=-500)
Projector = apps.get_model('core', 'Projector')
projector_config = [
{'name': 'core/clock'},
{'name': 'core/customslide', 'id': custom_slide.id}]
Projector.objects.create(config=projector_config)
class Migration(migrations.Migration):
dependencies = [
]
operations = [
migrations.CreateModel(
name='CustomSlide',
fields=[
('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')),
('title', models.CharField(verbose_name='Title', max_length=256)),
('text', models.TextField(verbose_name='Text', blank=True)),
('weight', models.IntegerField(verbose_name='Weight', default=0)),
],
options={
'ordering': ('weight', 'title'),
},
bases=(openslides.utils.rest_api.RESTModelMixin, openslides.utils.models.AbsoluteUrlMixin, models.Model),
),
migrations.CreateModel(
name='Projector',
fields=[
('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')),
('config', jsonfield.fields.JSONField()),
],
options={
'permissions': (
('can_see_projector', 'Can see the projector'),
('can_manage_projector', 'Can manage the projector'),
('can_see_dashboard', 'Can see the dashboard'),
('can_use_chat', 'Can use the chat')),
},
bases=(openslides.utils.rest_api.RESTModelMixin, models.Model),
),
migrations.CreateModel(
name='Tag',
fields=[
('id', models.AutoField(serialize=False, primary_key=True, auto_created=True, verbose_name='ID')),
('name', models.CharField(verbose_name='Tag', unique=True, max_length=255)),
],
options={
'permissions': (('can_manage_tags', 'Can manage tags'),),
'ordering': ('name',),
},
bases=(openslides.utils.rest_api.RESTModelMixin, openslides.utils.models.AbsoluteUrlMixin, models.Model),
),
migrations.RunPython(
add_default_projector,
),
]

View File

View File

@ -1,62 +1,105 @@
from django.core.urlresolvers import reverse
from django.db import models
from django.utils.translation import ugettext as _
from django.utils.translation import ugettext_lazy, ugettext_noop
# TODO: activate the following line after using the apploader
# from django.contrib.auth import get_user_model
from jsonfield import JSONField
from openslides.projector.models import SlideMixin
from openslides.utils.models import AbsoluteUrlMixin
from openslides.utils.projector import ProjectorElement
from openslides.utils.rest_api import RESTModelMixin
# Imports the default user so that other apps can import it from here.
# TODO: activate this with the new apploader
# User = get_user_model()
from .exceptions import ProjectorException
class CustomSlide(RESTModelMixin, SlideMixin, AbsoluteUrlMixin, models.Model):
class Projector(RESTModelMixin, models.Model):
"""
Model for Slides, only for the projector.
"""
slide_callback_name = 'customslide'
Model for all projectors. At the moment we support only one projector,
the default projector (pk=1).
title = models.CharField(max_length=256, verbose_name=ugettext_lazy('Title'))
text = models.TextField(null=True, blank=True, verbose_name=ugettext_lazy('Text'))
weight = models.IntegerField(default=0, verbose_name=ugettext_lazy('Weight'))
If the config field is empty or invalid the projector shows a default
slide. To activate a slide and extra projector elements, save valid
JSON to the config field.
Example: [{"name": "core/customslide", "id": 2},
{"name": "core/countdown", "countdown_time": 20, "status": "stop"},
{"name": "core/clock", "stable": true}]
This can be done using the REST API with POST requests on e. g. the URL
/rest/core/projector/1/activate_projector_elements/. The data have to be
a list of dictionaries. Every dictionary must have at least the
property "name". The property "stable" is to set whether this element
should disappear on prune or clear requests.
"""
config = JSONField()
class Meta:
"""
General permissions that can not be placed at a specific app.
Contains general permissions that can not be placed in a specific app.
"""
permissions = (
('can_manage_projector', ugettext_noop('Can manage the projector')),
('can_see_projector', ugettext_noop('Can see the projector')),
('can_manage_projector', ugettext_noop('Can manage the projector')),
('can_see_dashboard', ugettext_noop('Can see the dashboard')),
('can_use_chat', ugettext_noop('Can use the chat')),
)
('can_use_chat', ugettext_noop('Can use the chat')))
@property
def projector_elements(self):
"""
A generator to retrieve all projector elements given in the config
field. For every element the method get_data() is called and its
result returned.
"""
elements = {}
for element in ProjectorElement.get_all():
elements[element.name] = element
for config_entry in self.config:
name = config_entry.get('name')
element = elements.get(name)
data = {'name': name}
if element is None:
data['error'] = _('Projector element does not exist.')
else:
try:
data.update(element.get_data(
projector_object=self,
config_entry=config_entry))
except ProjectorException as e:
data['error'] = str(e)
yield data
class CustomSlide(RESTModelMixin, AbsoluteUrlMixin, models.Model):
"""
Model for slides with custom content.
"""
title = models.CharField(
verbose_name=ugettext_lazy('Title'),
max_length=256)
text = models.TextField(
verbose_name=ugettext_lazy('Text'),
blank=True)
weight = models.IntegerField(
verbose_name=ugettext_lazy('Weight'),
default=0)
class Meta:
ordering = ('weight', 'title', )
def __str__(self):
return self.title
def get_absolute_url(self, link='update'):
if link == 'update':
url = reverse('customslide_update', args=[str(self.pk)])
elif link == 'delete':
url = reverse('customslide_delete', args=[str(self.pk)])
else:
url = super().get_absolute_url(link)
return url
class Tag(RESTModelMixin, AbsoluteUrlMixin, models.Model):
"""
Model to save tags.
Model for tags. This tags can be used for other models like agenda items,
motions or assignments.
"""
name = models.CharField(max_length=255, unique=True,
verbose_name=ugettext_lazy('Tag'))
name = models.CharField(
verbose_name=ugettext_lazy('Tag'),
max_length=255,
unique=True)
class Meta:
ordering = ['name']
ordering = ('name',)
permissions = (
('can_manage_tags', ugettext_noop('Can manage tags')), )

View File

@ -0,0 +1,81 @@
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from openslides.utils.projector import ProjectorElement
from .exceptions import ProjectorException
from .models import CustomSlide
class CustomSlideSlide(ProjectorElement):
"""
Slide definitions for custom slide model.
"""
name = 'core/customslide'
scripts = 'core/customslide_slide.js'
def get_context(self):
pk = self.config_entry.get('id')
if not CustomSlide.objects.filter(pk=pk).exists():
raise ProjectorException(_('Custom slide does not exist.'))
return [{
'collection': 'core/customslide',
'id': pk}]
class Clock(ProjectorElement):
"""
Clock on the projector.
"""
name = 'core/clock'
scripts = 'core/clock.js'
def get_context(self):
return {'server_time': now().timestamp()}
class Countdown(ProjectorElement):
"""
Countdown on the projector.
To start the countdown write into the config field:
{
"countdown_time": <timestamp>,
"status": "go"
}
The timestamp is a POSIX timestamp (seconds) calculated from server
time, server time offset and countdown duration (countdown_time = now -
serverTimeOffset + duration).
To stop the countdown set the countdown time to the actual value of the
countdown (countdown_time = countdown_time - now + serverTimeOffset)
and set status to "stop".
To reset the countdown (it is not a reset in a functional way) just
change the countdown_time. The status value remain 'stop'.
To hide a running countdown add {"hidden": true}.
"""
name = 'core/countdown'
scripts = 'core/countdown.js'
def get_context(self):
if self.config_entry.get('countdown_time') is None:
raise ProjectorException(_('No countdown time given.'))
if self.config_entry.get('status') is None:
raise ProjectorException(_('No status given.'))
return {'server_time': now().timestamp()}
class Message(ProjectorElement):
"""
Short message on the projector. Rendered as overlay.
"""
name = 'core/message'
scripts = 'core/message.js'
def get_context(self):
if self.config_entry.get('message') is None:
raise ProjectorException(_('No message given.'))

View File

@ -1,6 +1,39 @@
from openslides.utils.rest_api import ModelSerializer
from openslides.utils.rest_api import Field, ModelSerializer, ValidationError
from .models import CustomSlide, Tag
from .models import CustomSlide, Projector, Tag
class JSONSerializerField(Field):
"""
Serializer for projector's JSONField.
"""
def to_internal_value(self, data):
"""
Checks that data is a list of dictionaries. Every dictionary must have
a key 'name'.
"""
if type(data) is not list:
raise ValidationError('Data must be a list of dictionaries.')
for element in data:
if type(element) is not dict:
raise ValidationError('Data must be a list of dictionaries.')
elif element.get('name') is None:
raise ValidationError("Every dictionary must have a key 'name'.")
return data
def to_representation(self, value):
return value
class ProjectorSerializer(ModelSerializer):
"""
Serializer for core.models.Projector objects.
"""
config = JSONSerializerField()
class Meta:
model = Projector
fields = ('config', 'projector_elements', )
class CustomSlideSerializer(ModelSerializer):

View File

@ -21,14 +21,24 @@ from openslides.utils.plugins import (
get_plugin_verbose_name,
get_plugin_version,
)
from openslides.utils.rest_api import ModelViewSet
from openslides.utils.rest_api import (
ModelViewSet,
ReadOnlyModelViewSet,
Response,
ValidationError,
detail_route
)
from openslides.utils.signals import template_manipulation
from openslides.utils.widgets import Widget
from .exceptions import TagException
from .forms import SelectWidgetsForm
from .models import CustomSlide, Tag
from .serializers import CustomSlideSerializer, TagSerializer
from .models import CustomSlide, Projector, Tag
from .serializers import (
CustomSlideSerializer,
ProjectorSerializer,
TagSerializer,
)
class IndexView(utils_views.CSRFMixin, utils_views.View):
@ -46,6 +56,192 @@ class IndexView(utils_views.CSRFMixin, utils_views.View):
return HttpResponse(content)
class ProjectorViewSet(ReadOnlyModelViewSet):
"""
API endpoint to list, retrieve and update the projector slide info.
"""
queryset = Projector.objects.all()
serializer_class = ProjectorSerializer
def check_permissions(self, request):
"""
Calls self.permission_denied() if the requesting user has not the
permission to see the projector and in case of an update request the
permission to manage the projector.
"""
manage_methods = (
'activate_elements',
'prune_elements',
'deactivate_elements',
'clear_elements')
if (not request.user.has_perm('core.can_see_projector') or
(self.action in manage_methods and
not request.user.has_perm('core.can_manage_projector'))):
self.permission_denied(request)
@detail_route(methods=['post'])
def activate_elements(self, request, pk):
"""
REST API operation to activate projector elements. It expects a POST
request to /rest/core/projector/<pk>/activate_elements/ with a list
of dictionaries to append to the projector config entry.
"""
# Get config entry from projector model, add new elements and try to
# serialize. This raises ValidationErrors if the data is invalid.
projector_instance = self.get_object()
projector_config = projector_instance.config
for projector_element in request.data:
projector_config.append(projector_element)
serializer = self.get_serializer(projector_instance, data={'config': projector_config}, partial=False)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@detail_route(methods=['post'])
def prune_elements(self, request, pk):
"""
REST API operation to activate projector elements. It expects a POST
request to /rest/core/projector/<pk>/prune_elements/ with a list of
dictionaries to write them to the projector config entry. All old
entries are deleted but not entries with stable == True.
"""
# Get config entry from projector model, delete old and add new
# elements and try to serialize. This raises ValidationErrors if the
# data is invalid. Do not filter 'stable' elements.
projector_instance = self.get_object()
projector_config = [element for element in projector_instance.config if element.get('stable')]
projector_config.extend(request.data)
serializer = self.get_serializer(projector_instance, data={'config': projector_config}, partial=False)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@detail_route(methods=['post'])
def deactivate_elements(self, request, pk):
"""
REST API operation to deactivate projector elements. It expects a
POST request to /rest/core/projector/<pk>/deactivate_elements/ with
a list of dictionaries. These are exactly the projector_elements in
the config that should be deleted.
"""
# Check the data. It must be a list of dictionaries. Get config
# entry from projector model. Pop out the entries that should be
# deleted and try to serialize. This raises ValidationErrors if the
# data is invalid.
if not isinstance(request.data, list) or list(filter(lambda item: not isinstance(item, dict), request.data)):
raise ValidationError({'config': ['Data must be a list of dictionaries.']})
projector_instance = self.get_object()
projector_config = projector_instance.config
for entry_to_be_deleted in request.data:
try:
projector_config.remove(entry_to_be_deleted)
except ValueError:
# The entry that should be deleted is not on the projector.
pass
serializer = self.get_serializer(projector_instance, data={'config': projector_config}, partial=False)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
@detail_route(methods=['post'])
def clear_elements(self, request, pk):
"""
REST API operation to deactivate all projector elements but not
entries with stable == True. It expects a POST request to
/rest/core/projector/<pk>/clear_elements/.
"""
# Get config entry from projector model. Then clear the config field
# and try to serialize. Do not remove 'stable' elements.
projector_instance = self.get_object()
projector_config = [element for element in projector_instance.config if element.get('stable')]
serializer = self.get_serializer(projector_instance, data={'config': projector_config}, partial=False)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data)
class CustomSlideViewSet(ModelViewSet):
"""
API endpoint to list, retrieve, create, update and destroy custom slides.
"""
queryset = CustomSlide.objects.all()
serializer_class = CustomSlideSerializer
def check_permissions(self, request):
"""
Calls self.permission_denied() if the requesting user has not the
permission to manage projector.
"""
if not request.user.has_perm('core.can_manage_projector'):
self.permission_denied(request)
class TagViewSet(ModelViewSet):
"""
API endpoint to list, retrieve, create, update and destroy tags.
"""
queryset = Tag.objects.all()
serializer_class = TagSerializer
def check_permissions(self, request):
"""
Calls self.permission_denied() if the requesting user has not the
permission to manage tags and it is a create, update or detroy request.
Users without permissions are able to list and retrieve tags.
"""
if (self.action in ('create', 'update', 'destroy') and
not request.user.has_perm('core.can_manage_tags')):
self.permission_denied(request)
class UrlPatternsView(utils_views.APIView):
"""
Returns a dictionary with all url patterns as json. The patterns kwargs
are transformed using a colon.
"""
URL_KWARGS_REGEX = re.compile(r'%\((\w*)\)s')
http_method_names = ['get']
def get_context_data(self, **context):
result = {}
url_dict = get_resolver(None).reverse_dict
for pattern_name in filter(lambda key: isinstance(key, str), url_dict.keys()):
normalized_regex_bits, p_pattern, pattern_default_args = url_dict[pattern_name]
url, url_kwargs = normalized_regex_bits[0]
result[pattern_name] = self.URL_KWARGS_REGEX.sub(r':\1', url)
return result
class ErrorView(utils_views.View):
"""
View for Http 403, 404 and 500 error pages.
"""
status_code = None
def dispatch(self, request, *args, **kwargs):
http_error_strings = {
403: {'name': _('Forbidden'),
'description': _('Sorry, you have no permission to see this page.'),
'status_code': '403'},
404: {'name': _('Not Found'),
'description': _('Sorry, the requested page could not be found.'),
'status_code': '404'},
500: {'name': _('Internal Server Error'),
'description': _('Sorry, there was an unknown error. Please contact the event manager.'),
'status_code': '500'}}
context = {}
context['http_error'] = http_error_strings[self.status_code]
template_manipulation.send(sender=self.__class__, request=request, context=context)
response = render_to_response(
'core/error.html',
context_instance=RequestContext(request, context))
response.status_code = self.status_code
return response
# TODO: Remove the following classes one by one.
class DashboardView(utils_views.AjaxMixin, utils_views.TemplateView):
"""
Overview over all possible slides, the overlays and a live view: the
@ -175,33 +371,6 @@ class SearchView(_SearchView):
return models
class ErrorView(utils_views.View):
"""
View for Http 403, 404 and 500 error pages.
"""
status_code = None
def dispatch(self, request, *args, **kwargs):
http_error_strings = {
403: {'name': _('Forbidden'),
'description': _('Sorry, you have no permission to see this page.'),
'status_code': '403'},
404: {'name': _('Not Found'),
'description': _('Sorry, the requested page could not be found.'),
'status_code': '404'},
500: {'name': _('Internal Server Error'),
'description': _('Sorry, there was an unknown error. Please contact the event manager.'),
'status_code': '500'}}
context = {}
context['http_error'] = http_error_strings[self.status_code]
template_manipulation.send(sender=self.__class__, request=request, context=context)
response = render_to_response(
'core/error.html',
context_instance=RequestContext(request, context))
response.status_code = self.status_code
return response
class CustomSlideViewMixin(object):
"""
Mixin for for CustomSlide Views.
@ -235,22 +404,6 @@ class CustomSlideDeleteView(CustomSlideViewMixin, utils_views.DeleteView):
pass
class CustomSlideViewSet(ModelViewSet):
"""
API endpoint to list, retrieve, create, update and destroy custom slides.
"""
queryset = CustomSlide.objects.all()
serializer_class = CustomSlideSerializer
def check_permissions(self, request):
"""
Calls self.permission_denied() if the requesting user has not the
permission to manage projector.
"""
if not request.user.has_perm('core.can_manage_projector'):
self.permission_denied(request)
class TagListView(utils_views.AjaxMixin, utils_views.ListView):
"""
View to list and manipulate tags.
@ -327,37 +480,3 @@ class TagListView(utils_views.AjaxMixin, utils_views.ListView):
action=getattr(self, 'action', None),
error=getattr(self, 'error', None),
**context)
class TagViewSet(ModelViewSet):
"""
API endpoint to list, retrieve, create, update and destroy tags.
"""
queryset = Tag.objects.all()
serializer_class = TagSerializer
def check_permissions(self, request):
"""
Calls self.permission_denied() if the requesting user has not the
permission to manage tags and it is a create, update or detroy request.
"""
if (self.action in ('create', 'update', 'destroy') and
not request.user.has_perm('core.can_manage_tags')):
self.permission_denied(request)
class UrlPatternsView(utils_views.APIView):
"""
Returns a dictonary with all url patterns as json.
"""
URL_KWARGS_REGEX = re.compile(r'%\((\w*)\)s')
http_method_names = ['get']
def get_context_data(self, **context):
result = {}
url_dict = get_resolver(None).reverse_dict
for pattern_name in filter(lambda key: isinstance(key, str), url_dict.keys()):
url = url_dict[pattern_name][0][0][0]
result[pattern_name] = self.URL_KWARGS_REGEX.sub(r':\1', url)
return result

View File

@ -8,28 +8,23 @@ class UsersAppConfig(AppConfig):
def ready(self):
# Load main menu entry and widgets.
# Do this by just importing all from these files.
from . import main_menu, widgets # noqa
from . import main_menu, projector, widgets # noqa
# Import all required stuff.
from openslides.config.signals import config_signal
from openslides.core.signals import post_permission_creation
from openslides.projector.api import register_slide_model
from openslides.utils.rest_api import router
from .signals import create_builtin_groups_and_admin, setup_users_config
from .views import GroupViewSet, UserViewSet
# Load User model.
User = self.get_model('User')
# Connect signals.
config_signal.connect(setup_users_config, dispatch_uid='setup_users_config')
config_signal.connect(
setup_users_config,
dispatch_uid='setup_users_config')
post_permission_creation.connect(
create_builtin_groups_and_admin,
dispatch_uid='create_builtin_groups_and_admin')
# Register slides.
register_slide_model(User, 'participant/user_slide.html')
# Register viewsets.
router.register('users/user', UserViewSet)
router.register('users/group', GroupViewSet)

View File

@ -0,0 +1,29 @@
from django.utils.translation import ugettext as _
from openslides.core.exceptions import ProjectorException
from openslides.utils.projector import ProjectorElement
from .models import User
class UserSlide(ProjectorElement):
"""
Slide definitions for user model.
"""
name = 'users/user'
scripts = 'users/user_slide.js'
def get_context(self):
pk = self.config_entry.get('id')
try:
user = User.objects.get(pk=pk)
except User.DoesNotExist:
raise ProjectorException(_('User does not exist.'))
result = [{
'collection': 'users/user',
'id': pk}]
for group in user.groups.all():
result.append({
'collection': 'users/group',
'id': group.pk})
return result

View File

@ -8,10 +8,9 @@ class SignalConnectMetaClass(type):
value for each child class and None for base classes because they will
not be connected to the signal.
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, one instance for each
child class.
The classmethod get_all is added to every class using this metaclass.
Calling this on a base class or on child classes will retrieve all
connected children, one 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
@ -29,13 +28,16 @@ class SignalConnectMetaClass(type):
class Base(object, metaclass=SignalConnectMetaClass):
signal = django.dispatch.Signal()
def __init__(self, **kwargs):
pass
@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]
@ -46,7 +48,7 @@ class SignalConnectMetaClass(type):
Creates the class and connects it to the signal if so. Adds all
default attributes and methods.
"""
class_attributes['get_all'] = get_all_objects
class_attributes['get_all'] = get_all
new_class = super(SignalConnectMetaClass, metaclass).__new__(
metaclass, class_name, class_parents, class_attributes)
try:
@ -70,18 +72,21 @@ class SignalConnectMetaClass(type):
@classmethod
def get_all_objects(cls, request):
def get_all(cls, request=None):
"""
Collects all objects of the class created by the SignalConnectMetaClass
from all apps via signal. They are sorted using the get_default_weight
method. Does not return objects where check_permission returns False.
Expects a django.http.HttpRequest object.
A django.http.HttpRequest object can optionally be given.
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()]
kwargs = {'sender': cls}
if request is not None:
kwargs['request'] = request
all_objects = [obj for __, obj in cls.signal.send(**kwargs) if obj.check_permission()]
all_objects.sort(key=lambda obj: obj.get_default_weight())
return all_objects

View File

@ -0,0 +1,66 @@
from django.dispatch import Signal
from .dispatch import SignalConnectMetaClass
class ProjectorElement(object, metaclass=SignalConnectMetaClass):
"""
Base class for an element on the projector.
Every app which wants to add projector elements has to create classes
subclassing from this base class with different names. The name and
scripts attributes have to be set. The metaclass
(SignalConnectMetaClass) does the rest of the magic.
"""
signal = Signal()
name = None
scripts = None
def __init__(self, **kwargs):
"""
Initializes the projector element instance. This is done when the
signal is sent.
Because of Django's signal API, we have to take wildcard keyword
arguments. But they are not used here.
"""
pass
@classmethod
def get_dispatch_uid(cls):
"""
Returns the classname as a unique string for each class. Returns None
for the base class so it will not be connected to the signal.
"""
if not cls.__name__ == 'ProjectorElement':
return cls.__name__
def get_data(self, projector_object, config_entry):
"""
Returns all data to be sent to the client. The projector object and
the config entry have to be given.
"""
self.projector_object = projector_object
self.config_entry = config_entry
assert self.config_entry.get('name') == self.name, (
'To get data of a projector element, the correct config entry has to be given.')
return {
'scripts': self.get_scripts(),
'context': self.get_context()}
def get_scripts(self):
"""
Returns ...?
"""
# TODO: Write docstring
if self.scripts is None:
raise NotImplementedError(
'A projector element class must define either a '
'get_scripts method or have a scripts argument.')
return self.scripts
def get_context(self):
"""
Returns the context of the projector element.
"""
return None

View File

@ -6,6 +6,7 @@ from django.core.urlresolvers import reverse
from rest_framework.decorators import detail_route # noqa
from rest_framework.serializers import ( # noqa
CharField,
Field,
IntegerField,
ListSerializer,
ModelSerializer,
@ -15,7 +16,7 @@ from rest_framework.serializers import ( # noqa
ValidationError)
from rest_framework.response import Response # noqa
from rest_framework.routers import DefaultRouter
from rest_framework.viewsets import ModelViewSet, ViewSet # noqa
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet # noqa
from rest_framework.decorators import list_route # noqa
from .exceptions import OpenSlidesError

View File

View File

@ -0,0 +1,46 @@
import json
from django.core.urlresolvers import reverse
from rest_framework import status
from openslides.utils.test import TestCase
from openslides.core.models import CustomSlide, Projector
class ProjectorAPI(TestCase):
"""
Tests requests from the anonymous user.
"""
def test_slide_on_default_projector(self):
self.client.login(username='admin', password='admin')
customslide = CustomSlide.objects.create(title='title_que1olaish5Wei7que6i', text='text_aishah8Eh7eQuie5ooji')
default_projector = Projector.objects.get(pk=1)
default_projector.config = [{'name': 'core/customslide', 'id': customslide.id}]
default_projector.save()
response = self.client.get(reverse('projector-detail', args=['1']))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content.decode()), {
'config': [{'name': 'core/customslide', 'id': customslide.id}],
'projector_elements': [
{'name': 'core/customslide',
'scripts': 'core/customslide_slide.js',
'context': [
{'collection': 'core/customslide',
'id': customslide.id}]}]})
def test_invalid_slide_on_default_projector(self):
self.client.login(username='admin', password='admin')
default_projector = Projector.objects.get(pk=1)
default_projector.config = [{'name': 'invalid_slide'}]
default_projector.save()
response = self.client.get(reverse('projector-detail', args=['1']))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(json.loads(response.content.decode()), {
'config': [{'name': 'invalid_slide'}],
'projector_elements': [
{'name': 'invalid_slide',
'error': 'Projector element does not exist.'}]})

View File

@ -1,12 +0,0 @@
from django.db import models
class TestModel(models.Model):
name = models.CharField(max_length='255')
def get_absolute_url(self, link='detail'):
if link == 'detail':
return 'detail-url-here'
if link == 'delete':
return 'delete-url-here'
raise ValueError('No URL for %s' % link)

View File

@ -3,8 +3,6 @@ from django.template import Context, Template
from openslides.config.api import config
from openslides.utils.test import TestCase
from .models import TestModel
class ConfigTagAndFilter(TestCase):
def test_config_tag(self):
@ -29,32 +27,3 @@ class ConfigTagAndFilter(TestCase):
template = Template(template_code)
self.assertTrue('FdgfkR04jtg9f8bq' in template.render(Context({})))
self.assertFalse('bad_e0fvkfHFD' in template.render(Context({})))
class AbsoluteUrlFilter(TestCase):
def setUp(self):
self.model = TestModel.objects.create(name='test_model')
def test_default_argument(self):
"""
Test to call absolute_url without an argument.
"""
t = Template("{% load tags %}URL: {{ model|absolute_url }}")
html = t.render(Context({'model': self.model}))
self.assertEqual(html, 'URL: detail-url-here')
def test_with_argument(self):
"""
Test to call absolute_url with an argument.
"""
t = Template("{% load tags %}URL: {{ model|absolute_url:'delete' }}")
html = t.render(Context({'model': self.model}))
self.assertEqual(html, 'URL: delete-url-here')
def test_wrong_argument(self):
"""
Test to call absolute_url with a non existing argument.
"""
t = Template("{% load tags %}URL: {{ model|absolute_url:'wrong' }}")
html = t.render(Context({'model': self.model}))
self.assertEqual(html, 'URL: ')

View File

@ -121,8 +121,8 @@ class CustomSlidesTest(TestCase):
def test_update(self):
# Setup
url = '/customslide/1/edit/'
CustomSlide.objects.create(title='test_title_jeeDeB3aedei8ahceeso')
custom_slide = CustomSlide.objects.create(title='test_title_jeeDeB3aedei8ahceeso')
url = '/customslide/%d/edit/' % custom_slide.pk
# Test
response = self.admin_client.get(url)
self.assertTemplateUsed(response, 'core/customslide_update.html')
@ -131,20 +131,9 @@ class CustomSlidesTest(TestCase):
url,
{'title': 'test_title_ai8Ooboh5bahr6Ee7goo', 'weight': '0'})
self.assertRedirects(response, '/dashboard/')
self.assertEqual(CustomSlide.objects.get(pk=1).title,
self.assertEqual(CustomSlide.objects.get(pk=custom_slide.pk).title,
'test_title_ai8Ooboh5bahr6Ee7goo')
def test_delete(self):
# Setup
url = '/customslide/1/del/'
CustomSlide.objects.create(title='test_title_oyie0em1chieM7YohX4H')
# Test
response = self.admin_client.get(url)
self.assertRedirects(response, '/customslide/1/edit/')
response = self.admin_client.post(url, {'yes': 'true'})
self.assertRedirects(response, '/dashboard/')
self.assertFalse(CustomSlide.objects.exists())
class TagListViewTest(TestCase):
def test_get_tag_queryset(self):

View File

@ -1,15 +1,16 @@
from unittest import TestCase
from unittest.mock import patch
from unittest.mock import patch, MagicMock
from openslides.core import views
from openslides.utils.rest_api import ValidationError
class TestUrlPatternsView(TestCase):
@patch('openslides.core.views.get_resolver')
def test_get_context_data(self, mock_resolver):
mock_resolver().reverse_dict = {
'url_pattern1': [[['my_url1']]],
'url_pattern2': [[['my_url2/%(kwarg)s/']]],
'url_pattern1': ([['my_url1', [None]]], None, None),
'url_pattern2': ([['my_url2/%(kwarg)s/', ['kwargs']]], None, None),
('not_a_str', ): [[['not_a_str']]]}
view = views.UrlPatternsView()
@ -19,3 +20,127 @@ class TestUrlPatternsView(TestCase):
context,
{'url_pattern1': 'my_url1',
'url_pattern2': 'my_url2/:kwarg/'})
@patch('openslides.core.views.ProjectorViewSet.get_object')
class ProjectorAPI(TestCase):
def setUp(self):
self.viewset = views.ProjectorViewSet()
self.viewset.format_kwarg = None
def test_activate_elements(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_Du4tie7foosahnoofahg',
'test_key_Eek8eipeingulah3aech': 'test_value_quuupaephuY7eoLohbee'}]
request = MagicMock()
request.data = [{'name': 'new_test_projector_element_el9UbeeT9quucesoyusu'}]
self.viewset.request = request
self.viewset.activate_elements(request=request, pk=MagicMock())
self.assertEqual(len(mock_object.return_value.config), 2)
def test_activate_elements_no_list(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_ahshaiTie8xie3eeThu9',
'test_key_ohwa7ooze2angoogieM9': 'test_value_raiL2ohsheij1seiqua5'}]
request = MagicMock()
request.data = {'name': 'new_test_projector_element_buuDohphahWeeR2eeQu0'}
self.viewset.request = request
with self.assertRaises(ValidationError):
self.viewset.activate_elements(request=request, pk=MagicMock())
def test_activate_elements_bad_element(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_ieroa7eu3aechaip3eeD',
'test_key_mie3Eeroh9rooKeinga6': 'test_value_gee1Uitae6aithaiphoo'}]
request = MagicMock()
request.data = [{'bad_quangah1ahoo6oKaeBai': 'value_doh8ahwe0Zooc1eefu0o'}]
self.viewset.request = request
with self.assertRaises(ValidationError):
self.viewset.activate_elements(request=request, pk=MagicMock())
def test_prune_elements(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_Oc7OhXeeg0poThoh8boo',
'test_key_ahNei1ke4uCio6uareef': 'test_value_xieSh4yeemaen9oot6ki'}]
request = MagicMock()
request.data = [{
'name': 'test_projector_element_bohb1phiebah5TeCei1N',
'test_key_gahSh9otu6aeghaiquie': 'test_value_aeNgee2Yeeph4Ohru2Oo'}]
self.viewset.request = request
self.viewset.prune_elements(request=request, pk=MagicMock())
self.assertEqual(len(mock_object.return_value.config), 1)
def test_prune_elements_with_stable(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_aegh2aichee9nooWohRu',
'test_key_wahlaelahwaeNg6fooH7': 'test_value_taePie9Ohxohja4ugisa',
'stable': True}]
request = MagicMock()
request.data = [{
'name': 'test_projector_element_yei1Aim6Aed1po8eegh2',
'test_key_mud1shoo8moh6eiXoong': 'test_value_shugieJier6agh1Ehie3'}]
self.viewset.request = request
self.viewset.prune_elements(request=request, pk=MagicMock())
self.assertEqual(len(mock_object.return_value.config), 2)
def test_deactivate_elements(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_c6oohooxugiphuuM6Wee',
'test_key_eehiloh7mibi7ur1UoB1': 'test_value_o8eig1AeSajieTh6aiwo'}]
request = MagicMock()
request.data = [{
'name': 'test_projector_element_c6oohooxugiphuuM6Wee',
'test_key_eehiloh7mibi7ur1UoB1': 'test_value_o8eig1AeSajieTh6aiwo'}]
self.viewset.request = request
self.viewset.deactivate_elements(request=request, pk=MagicMock())
self.assertEqual(len(mock_object.return_value.config), 0)
def test_deactivate_elements_wrong_element(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_c6oohooxugiphuuM6Wee',
'test_key_eehiloh7mibi7ur1UoB1': 'test_value_o8eig1AeSajieTh6aiwo'}]
request = MagicMock()
request.data = [{'name': 'wrong name'}]
self.viewset.request = request
self.viewset.deactivate_elements(request=request, pk=MagicMock())
self.assertEqual(len(mock_object.return_value.config), 1)
def test_deactivate_elements_no_list(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_Au1ce9nevaeX7zo4ye2w',
'test_key_we9biiZ7bah4Sha2haS5': 'test_value_eehoipheik6aiNgeegor'}]
request = MagicMock()
request.data = 'bad_value_no_list_ohchohWee1fie0SieTha'
self.viewset.request = request
with self.assertRaises(ValidationError):
self.viewset.deactivate_elements(request=request, pk=MagicMock())
def test_deactivate_elements_bad_list(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_teibaeRaim1heiCh6Ohv',
'test_key_uk7wai7eiZieQu0ief3': 'test_value_eeghisei3ieGh3ieb6ae'}]
request = MagicMock()
# Value 1 is not an dictionary so we expect ValidationError.
request.data = [1]
self.viewset.request = request
with self.assertRaises(ValidationError):
self.viewset.deactivate_elements(request=request, pk=MagicMock())
def test_clear_elements(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_iphuuM6Weec6oohooxug',
'test_key_bi7ur1UoB1eehiloh7mi': 'test_value_jieTh6aiwoo8eig1AeSa'}]
request = MagicMock()
self.viewset.request = request
self.viewset.clear_elements(request=request, pk=MagicMock())
self.assertEqual(len(mock_object.return_value.config), 0)
def test_clear_elements_with_stable(self, mock_object):
mock_object.return_value.config = [{
'name': 'test_projector_element_6oohooxugiphuuM6Weec',
'test_key_bi7B1eehiloh7miur1Uo': 'test_value_jiSaeTh6aiwoo8eig1Ae',
'stable': True}]
request = MagicMock()
self.viewset.request = request
self.viewset.clear_elements(request=request, pk=MagicMock())
self.assertEqual(len(mock_object.return_value.config), 1)