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)