diff --git a/doc/api/fundamentals.rst b/doc/api/fundamentals.rst index 027c60ccf0..3b668c98d4 100644 --- a/doc/api/fundamentals.rst +++ b/doc/api/fundamentals.rst @@ -44,6 +44,25 @@ like the following: adding OAuth2 support in the future for user-level authentication. If you want to use session authentication, be sure to comply with Django's `CSRF policies`_. +Permissions +----------- + +The API follows pretix team based permissions model. Each organizer can have several teams +each with it's own set of permissions. Each team can have any number of API keys attached. + +To access a given endpoint the team the API key belongs to needs to have the corresponding +permission for the organizer/event being accessed. + +Possible permissions are: + +* Can create events +* Can change event settings +* Can change product settings +* Can view orders +* Can change orders +* Can view vouchers +* Can change vouchers + Compatibility ------------- diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index fb7bf3b835..fc755dccc4 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -25,14 +25,22 @@ presale_start datetime The date at whi presale_end datetime The date at which the ticket shop closes (or ``null``) location multi-lingual string The event location (or ``null``) has_subevents boolean ``True`` if the event series feature is active for this - event + event. Cannot change after event is created. meta_data dict Values set for organizer-specific meta data parameters. +plugins list A list of package names of the enabled plugins for this + event. ===================================== ========================== ======================================================= + .. versionchanged:: 1.7 The ``meta_data`` field has been added. +.. versionchanged:: 1.15 + + The ``plugins`` field has been added. + The operations POST, PATCH, PUT and DELETE have been added. + Endpoints --------- @@ -40,6 +48,8 @@ Endpoints Returns a list of all events within a given organizer the authenticated user/token has access to. + Permission required: "Can change event settings" + **Example request**: .. sourcecode:: http @@ -74,7 +84,13 @@ Endpoints "presale_end": null, "location": null, "has_subevents": false, - "meta_data": {} + "meta_data": {}, + "plugins": [ + "pretix.plugins.banktransfer" + "pretix.plugins.stripe" + "pretix.plugins.paypal" + "pretix.plugins.ticketoutputpdf" + ] } ] } @@ -89,6 +105,8 @@ Endpoints Returns information on one event, identified by its slug. + Permission required: "Can change event settings" + **Example request**: .. sourcecode:: http @@ -118,7 +136,13 @@ Endpoints "presale_end": null, "location": null, "has_subevents": false, - "meta_data": {} + "meta_data": {}, + "plugins": [ + "pretix.plugins.banktransfer" + "pretix.plugins.stripe" + "pretix.plugins.paypal" + "pretix.plugins.ticketoutputpdf" + ] } :param organizer: The ``slug`` field of the organizer to fetch @@ -126,3 +150,242 @@ Endpoints :statuscode 200: no error :statuscode 401: Authentication failure :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it. + +.. http:post:: /api/v1/organizers/(organizer)/events/ + + Creates a new event + + Please note that events cannot be created as 'live' using this endpoint. Quotas and payment must be added to the + event before sales can go live. + + Permission required: "Can create events" + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "name": {"en": "Sample Conference"}, + "slug": "sampleconf", + "live": false, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": null, + "date_admission": null, + "is_public": false, + "presale_start": null, + "presale_end": null, + "location": null, + "has_subevents": false, + "meta_data": {}, + "plugins": [ + "pretix.plugins.stripe", + "pretix.plugins.paypal" + ] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "name": {"en": "Sample Conference"}, + "slug": "sampleconf", + "live": false, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": null, + "date_admission": null, + "is_public": false, + "presale_start": null, + "presale_end": null, + "location": null, + "has_subevents": false, + "meta_data": {}, + "plugins": [ + "pretix.plugins.stripe", + "pretix.plugins.paypal" + ] + } + + :param organizer: The ``slug`` field of the organizer of the event to create. + :statuscode 201: no error + :statuscode 400: The event could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/ + + Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public', + settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions. + + If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise + their value will be copied from the existing event. + + Please note that you can only copy from events under the same organizer. + + Permission required: "Can create events" + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/clone/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "name": {"en": "Sample Conference"}, + "slug": "sampleconf", + "live": false, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": null, + "date_admission": null, + "is_public": false, + "presale_start": null, + "presale_end": null, + "location": null, + "has_subevents": false, + "meta_data": {}, + "plugins": [ + "pretix.plugins.stripe", + "pretix.plugins.paypal" + ] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "name": {"en": "Sample Conference"}, + "slug": "sampleconf", + "live": false, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": null, + "date_admission": null, + "is_public": false, + "presale_start": null, + "presale_end": null, + "location": null, + "has_subevents": false, + "meta_data": {}, + "plugins": [ + "pretix.plugins.stripe", + "pretix.plugins.paypal" + ] + } + + :param organizer: The ``slug`` field of the organizer of the event to create. + :param event: The ``slug`` field of the event to copy settings and items from. + :statuscode 201: no error + :statuscode 400: The event could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/ + + Updates an event + + Permission required: "Can change event settings" + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "plugins": [ + "pretix.plugins.banktransfer", + "pretix.plugins.stripe", + "pretix.plugins.paypal", + "pretix.plugins.pretixdroid" + ] + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "name": {"en": "Sample Conference"}, + "slug": "sampleconf", + "live": false, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": null, + "date_admission": null, + "is_public": false, + "presale_start": null, + "presale_end": null, + "location": null, + "has_subevents": false, + "meta_data": {}, + "plugins": [ + "pretix.plugins.banktransfer", + "pretix.plugins.stripe", + "pretix.plugins.paypal", + "pretix.plugins.pretixdroid" + ] + } + + :param organizer: The ``slug`` field of the organizer of the event to update + :param event: The ``slug`` field of the event to update + :statuscode 201: no error + :statuscode 400: The event could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource. + + +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/ + + Delete an event. Note that events with orders cannot be deleted to ensure data integrity. + + Permission required: "Can change event settings" + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. diff --git a/src/pretix/api/auth/permission.py b/src/pretix/api/auth/permission.py index ab09323124..aa9e4f5174 100644 --- a/src/pretix/api/auth/permission.py +++ b/src/pretix/api/auth/permission.py @@ -56,3 +56,18 @@ class EventPermission(BasePermission): if required_permission and required_permission not in request.orgapermset: return False return True + + +class EventCRUDPermission(EventPermission): + def has_permission(self, request, view): + if not super(EventCRUDPermission, self).has_permission(request, view): + return False + elif view.action == 'create' and 'can_create_events' not in request.orgapermset: + return False + elif view.action == 'destroy' and 'can_change_event_settings' not in request.eventpermset: + return False + elif view.action in ['retrieve', 'update', 'partial_update'] \ + and 'can_change_event_settings' not in request.eventpermset: + return False + + return True diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 8225396f80..6480e7ed81 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -1,3 +1,7 @@ +from django.core.exceptions import ValidationError +from django.db import transaction +from django.utils.functional import cached_property +from django.utils.translation import ugettext as _ from django_countries.serializers import CountryFieldMixin from rest_framework.fields import Field @@ -14,15 +18,161 @@ class MetaDataField(Field): v.property.name: v.value for v in value.meta_values.all() } + def to_internal_value(self, data): + return { + 'meta_data': data + } + + +class PluginsField(Field): + + def to_representation(self, obj): + from pretix.base.plugins import get_all_plugins + + return { + p.module for p in get_all_plugins() + if not p.name.startswith('.') and getattr(p, 'visible', True) and p.module in obj.get_plugins() + } + + def to_internal_value(self, data): + return { + 'plugins': data + } + class EventSerializer(I18nAwareModelSerializer): - meta_data = MetaDataField(source='*') + meta_data = MetaDataField(required=False, source='*') + plugins = PluginsField(required=False, source='*') class Meta: model = Event fields = ('name', 'slug', 'live', 'currency', 'date_from', 'date_to', 'date_admission', 'is_public', 'presale_start', - 'presale_end', 'location', 'has_subevents', 'meta_data') + 'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins') + + def validate(self, data): + data = super().validate(data) + + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + + Event.clean_dates(data.get('date_from'), data.get('date_to')) + Event.clean_presale(data.get('presale_start'), data.get('presale_end')) + + return data + + def validate_has_subevents(self, value): + Event.clean_has_subevents(self.instance, value) + return value + + def validate_slug(self, value): + Event.clean_slug(self.context['request'].organizer, self.instance, value) + return value + + def validate_live(self, value): + if value: + if self.instance is None: + raise ValidationError(_('Events cannot be created as \'live\'. Quotas and payment must be added to the ' + 'event before sales can go live.')) + else: + self.instance.clean_live() + return value + + @cached_property + def meta_properties(self): + return { + p.name: p for p in self.context['request'].organizer.meta_properties.all() + } + + def validate_meta_data(self, value): + for key in value['meta_data'].keys(): + if key not in self.meta_properties: + raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) + return value + + def validate_plugins(self, value): + from pretix.base.plugins import get_all_plugins + + plugins_available = { + p.module for p in get_all_plugins() + if not p.name.startswith('.') and getattr(p, 'visible', True) + } + + for plugin in value.get('plugins'): + if plugin not in plugins_available: + raise ValidationError(_('Unknown plugin: \'{name}\'.').format(name=plugin)) + + return value + + @transaction.atomic + def create(self, validated_data): + meta_data = validated_data.pop('meta_data', None) + plugins = validated_data.pop('plugins', None) + event = super().create(validated_data) + + # Meta data + if meta_data is not None: + for key, value in meta_data.items(): + event.meta_values.create( + property=self.meta_properties.get(key), + value=value + ) + + # Plugins + if plugins is not None: + event.set_active_plugins(plugins) + + return event + + @transaction.atomic + def update(self, instance, validated_data): + meta_data = validated_data.pop('meta_data', None) + plugins = validated_data.pop('plugins', None) + event = super().update(instance, validated_data) + + # Meta data + if meta_data is not None: + current = {mv.property: mv for mv in event.meta_values.select_related('property')} + for key, value in meta_data.items(): + prop = self.meta_properties.get(key) + if prop in current: + current[prop].value = value + current[prop].save() + else: + event.meta_values.create( + property=self.meta_properties.get(key), + value=value + ) + + for prop, current_object in current.items(): + if prop.name not in meta_data: + current_object.delete() + + # Plugins + if plugins is not None: + event.set_active_plugins(plugins) + event.save() + + return event + + +class CloneEventSerializer(EventSerializer): + @transaction.atomic + def create(self, validated_data): + plugins = validated_data.pop('plugins', None) + is_public = validated_data.pop('is_public', None) + new_event = super().create(validated_data) + + event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first() + new_event.copy_data_from(event) + + if plugins is not None: + new_event.set_active_plugins(plugins) + if is_public is not None: + new_event.is_public = is_public + new_event.save() + + return new_event class SubEventItemSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 0aedd5fbdf..539cad2ac0 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -14,6 +14,7 @@ orga_router.register(r'events', event.EventViewSet) event_router = routers.DefaultRouter() event_router.register(r'subevents', event.SubEventViewSet) +event_router.register(r'clone', event.CloneEventViewSet) event_router.register(r'items', item.ItemViewSet) event_router.register(r'categories', item.ItemCategoryViewSet) event_router.register(r'questions', item.QuestionViewSet) diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index d8ea999ab6..989ae0fc4c 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -1,24 +1,123 @@ +from django.db import transaction +from django.db.models import ProtectedError from django_filters.rest_framework import DjangoFilterBackend, FilterSet from rest_framework import filters, viewsets from rest_framework.exceptions import PermissionDenied +from pretix.api.auth.permission import EventCRUDPermission from pretix.api.serializers.event import ( - EventSerializer, SubEventSerializer, TaxRuleSerializer, + CloneEventSerializer, EventSerializer, SubEventSerializer, + TaxRuleSerializer, ) from pretix.base.models import Event, ItemCategory, TaxRule from pretix.base.models.event import SubEvent from pretix.base.models.organizer import TeamAPIToken +from pretix.helpers.dicts import merge_dicts -class EventViewSet(viewsets.ReadOnlyModelViewSet): +class EventViewSet(viewsets.ModelViewSet): serializer_class = EventSerializer queryset = Event.objects.none() lookup_field = 'slug' lookup_url_kwarg = 'event' + permission_classes = (EventCRUDPermission,) def get_queryset(self): return self.request.organizer.events.prefetch_related('meta_values', 'meta_values__property') + def perform_update(self, serializer): + current_live_value = serializer.instance.live + updated_live_value = serializer.validated_data.get('live', None) + current_plugins_value = serializer.instance.get_plugins() + updated_plugins_value = serializer.validated_data.get('plugins', None) + + super().perform_update(serializer) + + if updated_live_value is not None and updated_live_value != current_live_value: + log_action = 'pretix.event.live.activated' if updated_live_value else 'pretix.event.live.deactivated' + serializer.instance.log_action( + log_action, + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + + if updated_plugins_value is not None and set(updated_plugins_value) != set(current_plugins_value): + enabled = {m: 'enabled' for m in updated_plugins_value if m not in current_plugins_value} + disabled = {m: 'disabled' for m in current_plugins_value if m not in updated_plugins_value} + changed = merge_dicts(enabled, disabled) + + for module, action in changed.items(): + serializer.instance.log_action( + 'pretix.event.plugins.' + action, + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data={'plugin': module} + ) + + other_keys = {k: v for k, v in serializer.validated_data.items() if k not in ['plugins', 'live']} + if other_keys: + serializer.instance.log_action( + 'pretix.event.changed', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + + def perform_create(self, serializer): + serializer.save(organizer=self.request.organizer) + serializer.instance.log_action( + 'pretix.event.added', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + + def perform_destroy(self, instance): + if not instance.allow_delete(): + raise PermissionDenied('The event can not be deleted as it already contains orders. Please set \'live\'' + ' to false to hide the event and take the shop offline instead.') + try: + with transaction.atomic(): + instance.organizer.log_action( + 'pretix.event.deleted', user=self.request.user, + data={ + 'event_id': instance.pk, + 'name': str(instance.name), + 'logentries': list(instance.logentry_set.values_list('pk', flat=True)) + } + ) + instance.delete_sub_objects() + super().perform_destroy(instance) + except ProtectedError: + raise PermissionDenied('The event could not be deleted as some constraints (e.g. data created by plug-ins) ' + 'do not allow it.') + + +class CloneEventViewSet(viewsets.ModelViewSet): + serializer_class = CloneEventSerializer + queryset = Event.objects.none() + lookup_field = 'slug' + lookup_url_kwarg = 'event' + http_method_names = ['post'] + write_permission = 'can_create_events' + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.kwargs['event'] + ctx['organizer'] = self.request.organizer + return ctx + + def perform_create(self, serializer): + serializer.save(organizer=self.request.organizer) + + serializer.instance.log_action( + 'pretix.event.added', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + class SubEventFilter(FilterSet): class Meta: diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index eba442c1e2..84e5a18126 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -525,6 +525,40 @@ class Event(EventMixin, LoggedModel): data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()}) return data + @property + def has_payment_provider(self): + result = False + for provider in self.get_payment_providers().values(): + if provider.is_enabled and provider.identifier != 'free': + result = True + break + return result + + @property + def has_paid_things(self): + from .items import Item, ItemVariation + + return Item.objects.filter(event=self, default_price__gt=0).exists()\ + or ItemVariation.objects.filter(item__event=self, default_price__gt=0).exists() + + @cached_property + def live_issues(self): + from pretix.base.signals import event_live_issues + issues = [] + + if self.has_paid_things and not self.has_payment_provider: + issues.append(_('You have configured at least one paid product but have not enabled any payment methods.')) + + if not self.quotas.exists(): + issues.append(_('You need to configure at least one quota to sell anything.')) + + responses = event_live_issues.send(self) + for receiver, response in sorted(responses, key=lambda r: str(r[0])): + if response: + issues.append(response) + + return issues + def get_users_with_any_permission(self): """ Returns a queryset of users who have any permission to this event. @@ -556,9 +590,78 @@ class Event(EventMixin, LoggedModel): return User.objects.annotate(twp=Exists(team_with_perm)).filter(twp=True) + def clean_live(self): + for issue in self.live_issues: + if issue: + raise ValidationError(issue) + def allow_delete(self): return not self.orders.exists() and not self.invoices.exists() + def delete_sub_objects(self): + self.items.all().delete() + self.subevents.all().delete() + + def set_active_plugins(self, modules, allow_restricted=False): + from pretix.base.plugins import get_all_plugins + + plugins_active = self.get_plugins() + plugins_available = { + p.module: p for p in get_all_plugins() + if not p.name.startswith('.') and getattr(p, 'visible', True) + } + + enable = [m for m in modules if m not in plugins_active and m in plugins_available] + + for module in enable: + if getattr(plugins_available[module].app, 'restricted', False) and not allow_restricted: + modules.remove(module) + elif hasattr(plugins_available[module].app, 'installed'): + getattr(plugins_available[module].app, 'installed')(self) + + self.plugins = ",".join(modules) + + def enable_plugin(self, module, allow_restricted=False): + plugins_active = self.get_plugins() + + if module not in plugins_active: + plugins_active.append(module) + self.set_active_plugins(plugins_active, allow_restricted=allow_restricted) + + def disable_plugin(self, module): + plugins_active = self.get_plugins() + + if module in plugins_active: + plugins_active.remove(module) + self.set_active_plugins(plugins_active) + + @staticmethod + def clean_has_subevents(event, has_subevents): + if event is not None and event.has_subevents is not None: + if event.has_subevents != has_subevents: + raise ValidationError(_('Once created an event cannot change between an series and a single event.')) + + @staticmethod + def clean_slug(organizer, event, slug): + if event is not None and event.slug is not None: + if event.slug != slug: + raise ValidationError(_('The event slug cannot be changed.')) + else: + if Event.objects.filter(slug=slug, organizer=organizer).exists(): + raise ValidationError(_('This slug has already been used for a different event.')) + + @staticmethod + def clean_dates(date_from, date_to): + if date_from is not None and date_to is not None: + if date_from > date_to: + raise ValidationError(_('The event cannot end before it starts.')) + + @staticmethod + def clean_presale(presale_start, presale_end): + if presale_start is not None and presale_end is not None: + if presale_start > presale_end: + raise ValidationError(_('The event\'s presale cannot end before it starts.')) + class SubEvent(EventMixin, LoggedModel): """ diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 0f90c4b3a4..68cfd0fd95 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -229,6 +229,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.plugins.disabled': _('A plugin has been disabled.'), 'pretix.event.live.activated': _('The shop has been taken live.'), 'pretix.event.live.deactivated': _('The shop has been taken offline.'), + 'pretix.event.added': _('The event has been created.'), 'pretix.event.changed': _('The event settings have been changed.'), 'pretix.event.question.option.added': _('An answer option has been added to the question.'), 'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'), diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 3031264daa..b128305316 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -30,13 +30,13 @@ from pytz import timezone from pretix.base.i18n import LazyCurrencyNumber from pretix.base.models import ( - CachedCombinedTicket, CachedTicket, Event, Item, ItemVariation, LogEntry, - Order, RequiredAction, TaxRule, Voucher, + CachedCombinedTicket, CachedTicket, Event, LogEntry, Order, RequiredAction, + TaxRule, Voucher, ) from pretix.base.models.event import EventMetaValue from pretix.base.services import tickets from pretix.base.services.invoices import build_preview_invoice_pdf -from pretix.base.signals import event_live_issues, register_ticket_outputs +from pretix.base.signals import register_ticket_outputs from pretix.base.templatetags.money import money_filter from pretix.control.forms.event import ( CommentForm, DisplaySettingsForm, EventDeleteForm, EventMetaValueForm, @@ -200,34 +200,29 @@ class EventPlugins(EventSettingsViewMixin, EventPermissionRequiredMixin, Templat self.object = self.get_object() - plugins_active = self.object.get_plugins() plugins_available = { p.module: p for p in get_all_plugins() if not p.name.startswith('.') and getattr(p, 'visible', True) } with transaction.atomic(): + allow_restricted = request.user.has_active_staff_session(request.session.session_key) + for key, value in request.POST.items(): if key.startswith("plugin:"): module = key.split(":")[1] if value == "enable" and module in plugins_available: if getattr(plugins_available[module], 'restricted', False): - if not request.user.has_active_staff_session(request.session.session_key): + if not allow_restricted: continue - if hasattr(plugins_available[module].app, 'installed'): - getattr(plugins_available[module].app, 'installed')(self.request.event) - self.request.event.log_action('pretix.event.plugins.enabled', user=self.request.user, data={'plugin': module}) - if module not in plugins_active: - plugins_active.append(module) + self.object.enable_plugin(module, allow_restricted=allow_restricted) else: self.request.event.log_action('pretix.event.plugins.disabled', user=self.request.user, data={'plugin': module}) - if module in plugins_active: - plugins_active.remove(module) - self.object.plugins = ",".join(plugins_active) + self.object.disable_plugin(module) self.object.save() messages.success(self.request, _('Your changes have been saved.')) return redirect(self.get_success_url()) @@ -749,38 +744,11 @@ class EventLive(EventPermissionRequiredMixin, TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx['issues'] = self.issues + ctx['issues'] = self.request.event.live_issues return ctx - @cached_property - def issues(self): - issues = [] - has_paid_things = ( - Item.objects.filter(event=self.request.event, default_price__gt=0).exists() - or ItemVariation.objects.filter(item__event=self.request.event, default_price__gt=0).exists() - ) - - has_payment_provider = False - for provider in self.request.event.get_payment_providers().values(): - if provider.is_enabled and provider.identifier != 'free': - has_payment_provider = True - break - - if has_paid_things and not has_payment_provider: - issues.append(_('You have configured at least one paid product but have not enabled any payment methods.')) - - if not self.request.event.quotas.exists(): - issues.append(_('You need to configure at least one quota to sell anything.')) - - responses = event_live_issues.send(self.request.event) - for receiver, response in sorted(responses, key=lambda r: str(r[0])): - if response: - issues.append(response) - - return issues - def post(self, request, *args, **kwargs): - if request.POST.get("live") == "true" and not self.issues: + if request.POST.get("live") == "true" and not self.request.event.live_issues: request.event.live = True request.event.save() self.request.event.log_action( @@ -831,8 +799,7 @@ class EventDelete(EventPermissionRequiredMixin, FormView): 'logentries': list(self.request.event.logentry_set.values_list('pk', flat=True)) } ) - self.request.event.items.all().delete() - self.request.event.subevents.all().delete() + self.request.event.delete_sub_objects() self.request.event.delete() messages.success(self.request, _('The event has been deleted.')) return redirect(self.get_success_url()) diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 5c13e58116..8d043d4e86 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -27,7 +27,8 @@ def event(organizer, meta_prop): e = Event.objects.create( organizer=organizer, name='Dummy', slug='dummy', date_from=datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC), - plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf' + plugins='pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf', + is_public=True ) e.meta_values.create(property=meta_prop, value="Conference") return e @@ -60,6 +61,7 @@ def team(organizer): return Team.objects.create( organizer=organizer, can_change_items=True, + can_create_events=True, can_change_event_settings=True, can_change_vouchers=True, can_view_vouchers=True, diff --git a/src/tests/api/test_events.py b/src/tests/api/test_events.py index a88a2b8595..c6b5aab9cb 100644 --- a/src/tests/api/test_events.py +++ b/src/tests/api/test_events.py @@ -1,4 +1,77 @@ +from datetime import datetime, timedelta +from decimal import Decimal +from unittest import mock + import pytest +from django_countries.fields import Country +from pytz import UTC + +from pretix.base.models import ( + CartPosition, Event, InvoiceAddress, Order, OrderPosition, +) +from pretix.base.models.orders import OrderFee + + +@pytest.fixture +def variations(item): + v = list() + v.append(item.variations.create(value="ChildA1")) + v.append(item.variations.create(value="ChildA2")) + return v + + +@pytest.fixture +def order(event, item, taxrule): + testtime = datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, secret="k24fiuwvu8kxz3y1", + datetime=datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC), + expires=datetime(2017, 12, 10, 10, 0, 0, tzinfo=UTC), + total=23, payment_provider='banktransfer', locale='en' + ) + o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'), + tax_value=Decimal('0.05'), tax_rule=taxrule) + InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ')) + return o + + +@pytest.fixture +def order_position(item, order, taxrule, variations): + op = OrderPosition.objects.create( + order=order, + item=item, + variation=variations[0], + tax_rule=taxrule, + tax_rate=taxrule.rate, + tax_value=Decimal("3"), + price=Decimal("23"), + attendee_name="Peter", + secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + ) + return op + + +@pytest.fixture +def cart_position(event, item, variations): + testtime = datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) + + with mock.patch('django.utils.timezone.now') as mock_now: + mock_now.return_value = testtime + c = CartPosition.objects.create( + event=event, + item=item, + datetime=datetime.now(), + expires=datetime.now() + timedelta(days=1), + variation=variations[0], + price=Decimal("23"), + cart_id="z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + ) + return c + TEST_EVENT_RES = { "name": {"en": "Dummy"}, @@ -7,22 +80,473 @@ TEST_EVENT_RES = { "date_from": "2017-12-27T10:00:00Z", "date_to": None, "date_admission": None, - "is_public": False, + "is_public": True, "presale_start": None, "presale_end": None, "location": None, "slug": "dummy", "has_subevents": False, - "meta_data": {"type": "Conference"} + "meta_data": {"type": "Conference"}, + 'plugins': { + 'pretix.plugins.banktransfer', + 'pretix.plugins.ticketoutputpdf' + } } +@pytest.fixture +def item(event): + return event.items.create(name="Budget Ticket", default_price=23) + + +@pytest.fixture +def free_item(event): + return event.items.create(name="Free Ticket", default_price=0) + + +@pytest.fixture +def free_quota(event, free_item): + q = event.quotas.create(name="Budget Quota", size=200) + q.items.add(free_item) + return q + + @pytest.mark.django_db def test_event_list(token_client, organizer, event): resp = token_client.get('/api/v1/organizers/{}/events/'.format(organizer.slug)) assert resp.status_code == 200 print(resp.data) - assert TEST_EVENT_RES == dict(resp.data['results'][0]) + assert TEST_EVENT_RES == resp.data['results'][0] + + +@pytest.mark.django_db +def test_event_get(token_client, organizer, event): + resp = token_client.get('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug)) + assert resp.status_code == 200 + print(resp.data) + assert TEST_EVENT_RES == resp.data + + +@pytest.mark.django_db +def test_event_create(token_client, organizer, event, meta_prop): + resp = token_client.post( + '/api/v1/organizers/{}/events/'.format(organizer.slug), + { + "name": { + "de": "Demo Konference 2020 Test", + "en": "Demo Conference 2020 Test" + }, + "live": False, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": "2017-12-28T10:00:00Z", + "date_admission": None, + "is_public": False, + "presale_start": None, + "presale_end": None, + "location": None, + "slug": "2030", + "meta_data": { + meta_prop.name: "Conference" + } + }, + format='json' + ) + assert resp.status_code == 201 + assert organizer.events.get(slug="2030").meta_values.filter( + property__name=meta_prop.name, value="Conference" + ).exists() + + resp = token_client.post( + '/api/v1/organizers/{}/events/'.format(organizer.slug), + { + "name": { + "de": "Demo Konference 2020 Test", + "en": "Demo Conference 2020 Test" + }, + "live": False, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": "2017-12-28T10:00:00Z", + "date_admission": None, + "is_public": False, + "presale_start": None, + "presale_end": None, + "location": None, + "slug": "2020", + "meta_data": { + "foo": "bar" + } + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"meta_data":["Meta data property \'foo\' does not exist."]}' + + resp = token_client.post( + '/api/v1/organizers/{}/events/'.format(organizer.slug), + { + "name": { + "de": "Demo Konference 2020 Test", + "en": "Demo Conference 2020 Test" + }, + "live": False, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": "2017-12-28T10:00:00Z", + "date_admission": None, + "is_public": False, + "presale_start": None, + "presale_end": None, + "location": None, + "slug": event.slug, + "meta_data": { + "type": "Conference" + } + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"slug":["This slug has already been used for a different event."]}' + + resp = token_client.post( + '/api/v1/organizers/{}/events/'.format(organizer.slug), + { + "name": { + "de": "Demo Konference 2020 Test", + "en": "Demo Conference 2020 Test" + }, + "live": True, + "currency": "EUR", + "date_from": "2017-12-27T10:00:00Z", + "date_to": "2017-12-28T10:00:00Z", + "date_admission": None, + "is_public": False, + "presale_start": None, + "presale_end": None, + "location": None, + "slug": "2031", + "meta_data": { + "type": "Conference" + } + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"live":["Events cannot be created as \'live\'. Quotas and payment must be added ' \ + 'to the event before sales can go live."]}' + + +@pytest.mark.django_db +def test_event_create_with_clone(token_client, organizer, event, meta_prop): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug), + { + "name": { + "de": "Demo Konference 2020 Test", + "en": "Demo Conference 2020 Test" + }, + "live": False, + "currency": "EUR", + "date_from": "2018-12-27T10:00:00Z", + "date_to": "2018-12-28T10:00:00Z", + "date_admission": None, + "is_public": False, + "presale_start": None, + "presale_end": None, + "location": None, + "slug": "2030", + "meta_data": { + "type": "Conference" + }, + "plugins": [ + "pretix.plugins.ticketoutputpdf" + ] + }, + format='json' + ) + + assert resp.status_code == 201 + cloned_event = Event.objects.get(organizer=organizer.pk, slug='2030') + assert cloned_event.plugins == 'pretix.plugins.ticketoutputpdf' + assert cloned_event.is_public is False + assert organizer.events.get(slug="2030").meta_values.filter( + property__name=meta_prop.name, value="Conference" + ).exists() + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug), + { + "name": { + "de": "Demo Konference 2020 Test", + "en": "Demo Conference 2020 Test" + }, + "live": False, + "currency": "EUR", + "date_from": "2018-12-27T10:00:00Z", + "date_to": "2018-12-28T10:00:00Z", + "date_admission": None, + "presale_start": None, + "presale_end": None, + "location": None, + "slug": "2031", + "meta_data": { + "type": "Conference" + } + }, + format='json' + ) + + assert resp.status_code == 201 + cloned_event = Event.objects.get(organizer=organizer.pk, slug='2031') + assert cloned_event.plugins == "pretix.plugins.banktransfer,pretix.plugins.ticketoutputpdf" + assert cloned_event.is_public is True + assert organizer.events.get(slug="2031").meta_values.filter( + property__name=meta_prop.name, value="Conference" + ).exists() + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug), + { + "name": { + "de": "Demo Konference 2020 Test", + "en": "Demo Conference 2020 Test" + }, + "live": False, + "currency": "EUR", + "date_from": "2018-12-27T10:00:00Z", + "date_to": "2018-12-28T10:00:00Z", + "date_admission": None, + "presale_start": None, + "presale_end": None, + "location": None, + "slug": "2032", + "plugins": [] + }, + format='json' + ) + + assert resp.status_code == 201 + cloned_event = Event.objects.get(organizer=organizer.pk, slug='2032') + assert cloned_event.plugins == "" + + +@pytest.mark.django_db +def test_event_put_with_clone(token_client, organizer, event, meta_prop): + resp = token_client.put( + '/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug), + {}, + format='json' + ) + + assert resp.status_code == 405 + + +@pytest.mark.django_db +def test_event_patch_with_clone(token_client, organizer, event, meta_prop): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug), + {}, + format='json' + ) + + assert resp.status_code == 405 + + +@pytest.mark.django_db +def test_event_delete_with_clone(token_client, organizer, event, meta_prop): + resp = token_client.delete( + '/api/v1/organizers/{}/events/{}/clone/'.format(organizer.slug, event.slug), + {}, + format='json' + ) + + assert resp.status_code == 405 + + +@pytest.mark.django_db +def test_event_update(token_client, organizer, event, item, meta_prop): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "date_from": "2018-12-27T10:00:00Z", + "date_to": "2018-12-28T10:00:00Z", + "currency": "DKK", + }, + format='json' + ) + assert resp.status_code == 200 + event = Event.objects.get(organizer=organizer.pk, slug=resp.data['slug']) + assert event.currency == "DKK" + assert organizer.events.get(slug=resp.data['slug']).meta_values.filter( + property__name=meta_prop.name, value="Conference" + ).exists() + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "date_from": "2017-12-27T10:00:00Z", + "date_to": "2017-12-26T10:00:00Z" + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["The event cannot end before it starts."]}' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "presale_start": "2017-12-27T10:00:00Z", + "presale_end": "2017-12-26T10:00:00Z" + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"non_field_errors":["The event\'s presale cannot end before it starts."]}' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "slug": "testing" + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"slug":["The event slug cannot be changed."]}' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "has_subevents": True + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"has_subevents":["Once created an event cannot change between an series and a ' \ + 'single event."]}' + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "meta_data": { + meta_prop.name: "Workshop" + } + }, + format='json' + ) + assert resp.status_code == 200 + assert organizer.events.get(slug=resp.data['slug']).meta_values.filter( + property__name=meta_prop.name, value="Workshop" + ).exists() + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "meta_data": { + } + }, + format='json' + ) + assert resp.status_code == 200 + assert not organizer.events.get(slug=resp.data['slug']).meta_values.filter( + property__name=meta_prop.name + ).exists() + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "meta_data": { + "test": "test" + } + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"meta_data":["Meta data property \'test\' does not exist."]}' + + +@pytest.mark.django_db +def test_event_update_live_no_product(token_client, organizer, event): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "live": True + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"live":["You need to configure at least one quota to sell anything."]}' + + +@pytest.mark.django_db +def test_event_update_live_no_payment_method(token_client, organizer, event, item): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "live": True + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"live":["You have configured at least one paid product but have not enabled any ' \ + 'payment methods."]}' + + +@pytest.mark.django_db +def test_event_update_live_free_product(token_client, organizer, event, free_item, free_quota): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "live": True + }, + format='json' + ) + assert resp.status_code == 200 + + +@pytest.mark.django_db +def test_event_update_plugins(token_client, organizer, event, free_item, free_quota): + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "plugins": [ + "pretix.plugins.ticketoutputpdf", + "pretix.plugins.pretixdroid" + ] + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data.get('plugins') == { + "pretix.plugins.ticketoutputpdf", + "pretix.plugins.pretixdroid" + } + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "plugins": { + "pretix.plugins.banktransfer" + } + }, + format='json' + ) + assert resp.status_code == 200 + assert resp.data.get('plugins') == { + "pretix.plugins.banktransfer" + } + + resp = token_client.patch( + '/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug), + { + "plugins": { + "pretix.plugins.test" + } + }, + format='json' + ) + assert resp.status_code == 400 + assert resp.content.decode() == '{"plugins":["Unknown plugin: \'pretix.plugins.test\'."]}' @pytest.mark.django_db @@ -32,3 +556,28 @@ def test_event_detail(token_client, organizer, event, team): resp = token_client.get('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug)) assert resp.status_code == 200 assert TEST_EVENT_RES == resp.data + + +@pytest.mark.django_db +def test_event_delete(token_client, organizer, event): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug)) + assert resp.status_code == 204 + assert not organizer.events.filter(pk=event.id).exists() + + +@pytest.mark.django_db +def test_event_with_order_position_not_delete(token_client, organizer, event, item, order_position): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug)) + assert resp.status_code == 403 + assert resp.content.decode() == '{"detail":"The event can not be deleted as it already contains orders. Please ' \ + 'set \'live\' to false to hide the event and take the shop offline instead."}' + assert organizer.events.filter(pk=event.id).exists() + + +@pytest.mark.django_db +def test_event_with_cart_position_not_delete(token_client, organizer, event, item, cart_position): + resp = token_client.delete('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug)) + assert resp.status_code == 403 + assert resp.content.decode() == '{"detail":"The event could not be deleted as some constraints (e.g. data ' \ + 'created by plug-ins) do not allow it."}' + assert organizer.events.filter(pk=event.id).exists() diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index d4f3e83ca8..e3fe9fa220 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -20,7 +20,7 @@ event_urls = [ 'checkinlists/', ] -event_permission_urls = [ +event_permission_sub_urls = [ ('get', 'can_view_orders', 'orders/', 200), ('get', 'can_view_orders', 'orderpositions/', 200), ('get', 'can_view_vouchers', 'vouchers/', 200), @@ -68,6 +68,15 @@ event_permission_urls = [ ('put', 'can_change_event_settings', 'checkinlists/1/', 404), ('patch', 'can_change_event_settings', 'checkinlists/1/', 404), ('delete', 'can_change_event_settings', 'checkinlists/1/', 404), + ('post', 'can_create_events', 'clone/', 400), +] + + +event_permission_root_urls = [ + ('post', 'can_create_events', 400), + ('put', 'can_change_event_settings', 400), + ('patch', 'can_change_event_settings', 200), + ('delete', 'can_change_event_settings', 204), ] @@ -137,8 +146,8 @@ def test_event_not_existing(token_client, organizer, url, event): @pytest.mark.django_db -@pytest.mark.parametrize("urlset", event_permission_urls) -def test_token_event_permission_allowed(token_client, team, organizer, event, urlset): +@pytest.mark.parametrize("urlset", event_permission_sub_urls) +def test_token_event_subresources_permission_allowed(token_client, team, organizer, event, urlset): team.all_events = True setattr(team, urlset[1], True) team.save() @@ -148,8 +157,8 @@ def test_token_event_permission_allowed(token_client, team, organizer, event, ur @pytest.mark.django_db -@pytest.mark.parametrize("urlset", event_permission_urls) -def test_token_event_permission_not_allowed(token_client, team, organizer, event, urlset): +@pytest.mark.parametrize("urlset", event_permission_sub_urls) +def test_token_event_subresources_permission_not_allowed(token_client, team, organizer, event, urlset): team.all_events = True setattr(team, urlset[1], False) team.save() @@ -161,6 +170,32 @@ def test_token_event_permission_not_allowed(token_client, team, organizer, event assert resp.status_code in (404, 403) +@pytest.mark.django_db +@pytest.mark.parametrize("urlset", event_permission_root_urls) +def test_token_event_permission_allowed(token_client, team, organizer, event, urlset): + team.all_events = True + setattr(team, urlset[1], True) + team.save() + if urlset[0] == 'post': + resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/'.format(organizer.slug)) + else: + resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug)) + assert resp.status_code == urlset[2] + + +@pytest.mark.django_db +@pytest.mark.parametrize("urlset", event_permission_root_urls) +def test_token_event_permission_not_allowed(token_client, team, organizer, event, urlset): + team.all_events = True + setattr(team, urlset[1], False) + team.save() + if urlset[0] == 'post': + resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/'.format(organizer.slug)) + else: + resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/'.format(organizer.slug, event.slug)) + assert resp.status_code == 403 + + @pytest.mark.django_db def test_log_out_after_absolute_timeout(user_client, team, organizer, event): session = user_client.session