From dc7d27a985c0be9543976a3ba9a54bb9657a9a57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Norman=20J=C3=A4ckel?= Date: Wed, 18 Feb 2015 01:45:39 +0100 Subject: [PATCH] Added REST API for projector. Introduced new projector API. Added custom slide projector element class. Added welcome slide as custom slide. Added user slide projector element class. Added clock, countdown ans message projector elements. Renamed SignalConnectMetaClass classmethod get_all_objects to get_all (private API). Added migrations to core app. Fixed and wrote tests. Updated CHANGELOG. --- CHANGELOG | 26 +- openslides/core/apps.py | 12 +- openslides/core/exceptions.py | 4 + openslides/core/migrations/0001_initial.py | 75 +++++ openslides/core/migrations/__init__.py | 0 openslides/core/models.py | 107 ++++--- openslides/core/projector.py | 81 ++++++ openslides/core/serializers.py | 41 ++- openslides/core/views.py | 279 +++++++++++++------ openslides/users/apps.py | 13 +- openslides/users/projector.py | 29 ++ openslides/utils/dispatch.py | 25 +- openslides/utils/projector.py | 66 +++++ openslides/utils/rest_api.py | 3 +- tests/integration/core/__init__.py | 0 tests/integration/core/test_views.py | 46 +++ tests/old/core/models.py | 12 - tests/old/core/test_template_tags_filters.py | 31 --- tests/old/core/test_views.py | 17 +- tests/unit/core/test_views.py | 131 ++++++++- 20 files changed, 785 insertions(+), 213 deletions(-) create mode 100644 openslides/core/migrations/0001_initial.py create mode 100644 openslides/core/migrations/__init__.py create mode 100644 openslides/core/projector.py create mode 100644 openslides/users/projector.py create mode 100644 openslides/utils/projector.py create mode 100644 tests/integration/core/__init__.py create mode 100644 tests/integration/core/test_views.py delete mode 100644 tests/old/core/models.py diff --git a/CHANGELOG b/CHANGELOG index c35498cde..d965a89cd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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. diff --git a/openslides/core/apps.py b/openslides/core/apps.py index a458ba4c7..ca50324a6 100644 --- a/openslides/core/apps.py +++ b/openslides/core/apps.py @@ -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) diff --git a/openslides/core/exceptions.py b/openslides/core/exceptions.py index 08bd4f270..01660ab6f 100644 --- a/openslides/core/exceptions.py +++ b/openslides/core/exceptions.py @@ -1,5 +1,9 @@ from openslides.utils.exceptions import OpenSlidesError +class ProjectorException(OpenSlidesError): + pass + + class TagException(OpenSlidesError): pass diff --git a/openslides/core/migrations/0001_initial.py b/openslides/core/migrations/0001_initial.py new file mode 100644 index 000000000..c1a8f29be --- /dev/null +++ b/openslides/core/migrations/0001_initial.py @@ -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, + ), + ] diff --git a/openslides/core/migrations/__init__.py b/openslides/core/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openslides/core/models.py b/openslides/core/models.py index 0749f7c6e..991840a71 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -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')), ) diff --git a/openslides/core/projector.py b/openslides/core/projector.py new file mode 100644 index 000000000..9c7c0315e --- /dev/null +++ b/openslides/core/projector.py @@ -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": , + "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.')) diff --git a/openslides/core/serializers.py b/openslides/core/serializers.py index 30e0ae83d..bd3eef7fe 100644 --- a/openslides/core/serializers.py +++ b/openslides/core/serializers.py @@ -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): @@ -9,7 +42,7 @@ class CustomSlideSerializer(ModelSerializer): """ class Meta: model = CustomSlide - fields = ('id', 'title', 'text', 'weight',) + fields = ('id', 'title', 'text', 'weight', ) class TagSerializer(ModelSerializer): @@ -18,4 +51,4 @@ class TagSerializer(ModelSerializer): """ class Meta: model = Tag - fields = ('id', 'name',) + fields = ('id', 'name', ) diff --git a/openslides/core/views.py b/openslides/core/views.py index e9175476a..935d1e132 100644 --- a/openslides/core/views.py +++ b/openslides/core/views.py @@ -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//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//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//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//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 diff --git a/openslides/users/apps.py b/openslides/users/apps.py index 20d20d2bd..c04af0627 100644 --- a/openslides/users/apps.py +++ b/openslides/users/apps.py @@ -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) diff --git a/openslides/users/projector.py b/openslides/users/projector.py new file mode 100644 index 000000000..a35ecacaf --- /dev/null +++ b/openslides/users/projector.py @@ -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 diff --git a/openslides/utils/dispatch.py b/openslides/utils/dispatch.py index df74630bf..09bb228ae 100644 --- a/openslides/utils/dispatch.py +++ b/openslides/utils/dispatch.py @@ -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,14 +28,17 @@ 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 + pass child = Base.get_all(request)[0] assert Child == type(child) @@ -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 diff --git a/openslides/utils/projector.py b/openslides/utils/projector.py new file mode 100644 index 000000000..629abe9b4 --- /dev/null +++ b/openslides/utils/projector.py @@ -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 diff --git a/openslides/utils/rest_api.py b/openslides/utils/rest_api.py index cb24813c0..19783bd50 100644 --- a/openslides/utils/rest_api.py +++ b/openslides/utils/rest_api.py @@ -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 diff --git a/tests/integration/core/__init__.py b/tests/integration/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/core/test_views.py b/tests/integration/core/test_views.py new file mode 100644 index 000000000..671627d0f --- /dev/null +++ b/tests/integration/core/test_views.py @@ -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.'}]}) diff --git a/tests/old/core/models.py b/tests/old/core/models.py deleted file mode 100644 index 421d4f5b2..000000000 --- a/tests/old/core/models.py +++ /dev/null @@ -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) diff --git a/tests/old/core/test_template_tags_filters.py b/tests/old/core/test_template_tags_filters.py index e8eec4787..71e695316 100644 --- a/tests/old/core/test_template_tags_filters.py +++ b/tests/old/core/test_template_tags_filters.py @@ -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: ') diff --git a/tests/old/core/test_views.py b/tests/old/core/test_views.py index 54d709de9..a08953537 100644 --- a/tests/old/core/test_views.py +++ b/tests/old/core/test_views.py @@ -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): diff --git a/tests/unit/core/test_views.py b/tests/unit/core/test_views.py index 36f1df6b3..f146ad8dc 100644 --- a/tests/unit/core/test_views.py +++ b/tests/unit/core/test_views.py @@ -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)