diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 0ac952416a..fd23eefc71 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -23,6 +23,7 @@ Resources and endpoints waitinglist giftcards carts + teams webhooks seatingplans billing_invoices diff --git a/doc/api/resources/teams.rst b/doc/api/resources/teams.rst new file mode 100644 index 0000000000..0186f8b9a6 --- /dev/null +++ b/doc/api/resources/teams.rst @@ -0,0 +1,671 @@ +.. spelling:: fullname + +.. _`rest-teams`: + +Teams +===== + +.. warning:: Unlike our user interface, the team API **does** allow you to lock yourself out by deleting or modifying + the team your user or API key belongs to. Be careful around here! + +Team resource +------------- + +The team resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the team +name string Team name +all_events boolean Whether this team has access to all events +limit_events list List of event slugs this team has access to +can_create_events boolean +can_change_teams boolean +can_change_organizer_settings boolean +can_manage_gift_cards boolean +can_change_event_settings boolean +can_change_items boolean +can_view_orders boolean +can_change_orders boolean +can_view_vouchers boolean +can_change_vouchers boolean +===================================== ========================== ======================================================= + +Team member resource +-------------------- + +The team member resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the user +email string The user's email address +fullname string The user's full name (or ``null``) +require_2fa boolean Whether this user uses two-factor-authentication +===================================== ========================== ======================================================= + +Team invite resource +-------------------- + +The team invite resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the invite +email string The invitee's email address +===================================== ========================== ======================================================= + +Team API token resource +----------------------- + +The team API token resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the invite +name string Name of this API token +active boolean Whether this API token is active (can never be set to + ``true`` again once ``false``) +token string The actual API token. Will only be sent back during + token creation. +===================================== ========================== ======================================================= + +Team endpoints +-------------- + +.. http:get:: /api/v1/organizers/(organizer)/teams/ + + Returns a list of all teams within a given organizer. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/teams/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": "Admin team", + "all_events": true, + "limit_events": [], + "can_create_events": true, + ... + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/teams/(id)/ + + Returns information on one team, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/teams/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "name": "Admin team", + "all_events": true, + "limit_events": [], + "can_create_events": true, + ... + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param id: The ``id`` field of the team to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/teams/ + + Creates a new team + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/teams/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "name": "Admin team", + "all_events": true, + "limit_events": [], + "can_create_events": true, + ... + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 2, + "name": "Admin team", + "all_events": true, + "limit_events": [], + "can_create_events": true, + ... + } + + :param organizer: The ``slug`` field of the organizer to create a team for + :statuscode 201: no error + :statuscode 400: The team 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)/teams/(id)/ + + Update a team. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/teams/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "can_create_events": true + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "name": "Admin team", + "all_events": true, + "limit_events": [], + "can_create_events": true, + ... + } + + :param organizer: The ``slug`` field of the organizer to modify + :param id: The ``id`` field of the team to modify + :statuscode 200: no error + :statuscode 400: The team could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource. + +.. http:delete:: /api/v1/organizers/(organizer)/teams/(id)/ + + Deletes a team. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/teams/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + + :param organizer: The ``slug`` field of the organizer to modify + :param id: The ``id`` field of the team to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource. + +Team member endpoints +--------------------- + +.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/members/ + + Returns a list of all members of a team. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/teams/1/members/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "fullname": "John Doe", + "email": "john@example.com", + "require_2fa": true + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :param team: The ``id`` field of the team to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested team does not exist + +.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/members/(id)/ + + Returns information on one team member, identified by their ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/teams/1/members/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "fullname": "John Doe", + "email": "john@example.com", + "require_2fa": true + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param team: The ``id`` field of the team to fetch + :param id: The ``id`` field of the member to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested team or member does not exist + +.. http:delete:: /api/v1/organizers/(organizer)/teams/(team)/members/(id)/ + + Removes a member from the team. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/teams/1/members/1/ HTTP/1.1 + Host: pretix.eu + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + + :param organizer: The ``slug`` field of the organizer to modify + :param team: The ``id`` field of the team to modify + :param id: The ``id`` field of the member to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + :statuscode 404: The requested team or member does not exist + +Team invite endpoints +--------------------- + +.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/invites/ + + Returns a list of all invitations to a team. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/teams/1/invites/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "email": "john@example.com" + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :param team: The ``id`` field of the team to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested team does not exist + +.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/invites/(id)/ + + Returns information on one invite, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/teams/1/invites/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "email": "john@example.org" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param team: The ``id`` field of the team to fetch + :param id: The ``id`` field of the invite to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested team or invite does not exist + +.. http:post:: /api/v1/organizers/(organizer)/teams/(team)/invites/ + + Invites someone into the team. Note that if the user already has a pretix account, you will receive a response without + an ``id`` and instead of an invite being created, the user will be directly added to the team. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/teams/1/invites/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "email": "mark@example.org" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": "1", + "email": "mark@example.org" + } + + :param organizer: The ``slug`` field of the organizer to modify + :param team: The ``id`` field of the team to modify + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + :statuscode 404: The requested team does not exist + +.. http:delete:: /api/v1/organizers/(organizer)/teams/(team)/invites/(id)/ + + Revokes an invite. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/teams/1/invites/1/ HTTP/1.1 + Host: pretix.eu + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + + :param organizer: The ``slug`` field of the organizer to modify + :param team: The ``id`` field of the team to modify + :param id: The ``id`` field of the invite to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + :statuscode 404: The requested team or invite does not exist + +Team API token endpoints +------------------------ + +.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/tokens/ + + Returns a list of all API tokens of a team. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/teams/1/tokens/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "active": true, + "name": "Test token" + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :param team: The ``id`` field of the team to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested team does not exist + +.. http:get:: /api/v1/organizers/(organizer)/teams/(team)/tokens/(id)/ + + Returns information on one token, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/teams/1/tokens/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "active": true, + "name": "Test token" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param team: The ``id`` field of the team to fetch + :param id: The ``id`` field of the token to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested team or token does not exist + +.. http:post:: /api/v1/organizers/(organizer)/teams/(team)/tokens/ + + Creates a new token. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/teams/1/tokens/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "name": "New token" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 2, + "name": "New token", + "active": true, + "token": "", + } + + :param organizer: The ``slug`` field of the organizer to modify + :param team: The ``id`` field of the team to create a token for + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + :statuscode 404: The requested team does not exist + +.. http:delete:: /api/v1/organizers/(organizer)/teams/(team)/tokens/(id)/ + + Disables a token. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/teams/1/tokens/1/ HTTP/1.1 + Host: pretix.eu + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "name": "My token", + "active": false + } + + :param organizer: The ``slug`` field of the organizer to modify + :param team: The ``id`` field of the team to modify + :param id: The ``id`` field of the token to delete + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + :statuscode 404: The requested team or token does not exist diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 01485e58cb..3e520c4d7d 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -1,14 +1,19 @@ from decimal import Decimal from django.db.models import Q -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import get_language, ugettext_lazy as _ from rest_framework import serializers from rest_framework.exceptions import ValidationError from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import CompatibleJSONField -from pretix.base.models import GiftCard, Organizer, SeatingPlan +from pretix.base.auth import get_auth_backends +from pretix.base.models import ( + GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User, +) from pretix.base.models.seating import SeatingPlanLayoutValidator +from pretix.base.services.mail import SendMailException, mail +from pretix.helpers.urls import build_absolute_uri class OrganizerSerializer(I18nAwareModelSerializer): @@ -36,16 +41,129 @@ class GiftCardSerializer(I18nAwareModelSerializer): qs = GiftCard.objects.filter( secret=s ).filter( - Q(issuer=self.context["organizer"]) | Q(issuer__gift_card_collector_acceptance__collector=self.context["organizer"]) + Q(issuer=self.context["organizer"]) | Q( + issuer__gift_card_collector_acceptance__collector=self.context["organizer"]) ) if self.instance: qs = qs.exclude(pk=self.instance.pk) if qs.exists(): raise ValidationError( - {'secret': _('A gift card with the same secret already exists in your or an affiliated organizer account.')} + {'secret': _( + 'A gift card with the same secret already exists in your or an affiliated organizer account.')} ) return data class Meta: model = GiftCard fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode') + + +class EventSlugField(serializers.SlugRelatedField): + def get_queryset(self): + return self.context['organizer'].events.all() + + +class TeamSerializer(serializers.ModelSerializer): + limit_events = EventSlugField(slug_field='slug', many=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + class Meta: + model = Team + fields = ( + 'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams', + 'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings', + 'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers', + 'can_change_vouchers' + ) + + def validate(self, data): + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + if full_data.get('limit_events') and full_data.get('all_events'): + raise ValidationError('Do not set both limit_events and all_events.') + return data + + +class TeamInviteSerializer(serializers.ModelSerializer): + class Meta: + model = TeamInvite + fields = ( + 'id', 'email' + ) + + def _send_invite(self, instance): + try: + mail( + instance.email, + _('pretix account invitation'), + 'pretixcontrol/email/invitation.txt', + { + 'user': self, + 'organizer': self.context['organizer'].name, + 'team': instance.team.name, + 'url': build_absolute_uri('control:auth.invite', kwargs={ + 'token': instance.token + }) + }, + event=None, + locale=get_language() # TODO: expose? + ) + except SendMailException: + pass # Already logged + + def create(self, validated_data): + if 'email' in validated_data: + try: + user = User.objects.get(email__iexact=validated_data['email']) + except User.DoesNotExist: + if self.context['team'].invites.filter(email__iexact=validated_data['email']).exists(): + raise ValidationError(_('This user already has been invited for this team.')) + if 'native' not in get_auth_backends(): + raise ValidationError('Users need to have a pretix account before they can be invited.') + + invite = self.context['team'].invites.create(email=validated_data['email']) + self._send_invite(invite) + invite.team.log_action( + 'pretix.team.invite.created', + data={ + 'email': validated_data['email'] + }, + **self.context['log_kwargs'] + ) + return invite + else: + if self.context['team'].members.filter(pk=user.pk).exists(): + raise ValidationError(_('This user already has permissions for this team.')) + + self.context['team'].members.add(user) + self.context['team'].log_action( + 'pretix.team.member.added', + data={ + 'email': user.email, + 'user': user.pk, + }, + **self.context['log_kwargs'] + ) + return TeamInvite(email=user.email) + else: + raise ValidationError('No email address given.') + + +class TeamAPITokenSerializer(serializers.ModelSerializer): + active = serializers.BooleanField(default=True, read_only=True) + + class Meta: + model = TeamAPIToken + fields = ( + 'id', 'name', 'active' + ) + + +class TeamMemberSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ( + 'id', 'email', 'fullname', 'require_2fa' + ) diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 917b2fe973..2ea1dcd018 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -20,6 +20,12 @@ orga_router.register(r'subevents', event.SubEventViewSet) orga_router.register(r'webhooks', webhooks.WebHookViewSet) orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet) orga_router.register(r'giftcards', organizer.GiftCardViewSet) +orga_router.register(r'teams', organizer.TeamViewSet) + +team_router = routers.DefaultRouter() +team_router.register(r'members', organizer.TeamMemberViewSet) +team_router.register(r'invites', organizer.TeamInviteViewSet) +team_router.register(r'tokens', organizer.TeamAPITokenViewSet) event_router = routers.DefaultRouter() event_router.register(r'subevents', event.SubEventViewSet) @@ -62,6 +68,7 @@ urlpatterns = [ url(r'^', include(router.urls)), url(r'^organizers/(?P[^/]+)/', include(orga_router.urls)), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/', include(event_router.urls)), + url(r'^organizers/(?P[^/]+)/teams/(?P[^/]+)/', include(team_router.urls)), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/items/(?P[^/]+)/', include(item_router.urls)), url(r'^organizers/(?P[^/]+)/events/(?P[^/]+)/questions/(?P[^/]+)/', include(question_router.urls)), diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index cba3e1aae3..ea3fa1d79f 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -1,16 +1,23 @@ from decimal import Decimal from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils.functional import cached_property from rest_framework import filters, serializers, status, viewsets from rest_framework.decorators import action from rest_framework.exceptions import MethodNotAllowed, PermissionDenied +from rest_framework.mixins import CreateModelMixin, DestroyModelMixin from rest_framework.response import Response from pretix.api.models import OAuthAccessToken from pretix.api.serializers.organizer import ( GiftCardSerializer, OrganizerSerializer, SeatingPlanSerializer, + TeamAPITokenSerializer, TeamInviteSerializer, TeamMemberSerializer, + TeamSerializer, +) +from pretix.base.models import ( + GiftCard, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, User, ) -from pretix.base.models import GiftCard, Organizer, SeatingPlan from pretix.helpers.dicts import merge_dicts @@ -55,6 +62,7 @@ class SeatingPlanViewSet(viewsets.ModelViewSet): ctx['organizer'] = self.request.organizer return ctx + @transaction.atomic() def perform_create(self, serializer): inst = serializer.save(organizer=self.request.organizer) self.request.organizer.log_action( @@ -64,6 +72,7 @@ class SeatingPlanViewSet(viewsets.ModelViewSet): data=merge_dicts(self.request.data, {'id': inst.pk}) ) + @transaction.atomic() def perform_update(self, serializer): if serializer.instance.events.exists() or serializer.instance.subevents.exists(): raise PermissionDenied('This plan can not be changed while it is in use for an event.') @@ -76,6 +85,7 @@ class SeatingPlanViewSet(viewsets.ModelViewSet): ) return inst + @transaction.atomic() def perform_destroy(self, instance): if instance.events.exists() or instance.subevents.exists(): raise PermissionDenied('This plan can not be deleted while it is in use for an event.') @@ -153,3 +163,169 @@ class GiftCardViewSet(viewsets.ModelViewSet): def perform_destroy(self, instance): raise MethodNotAllowed("Gift cards cannot be deleted.") + + +class TeamViewSet(viewsets.ModelViewSet): + serializer_class = TeamSerializer + queryset = Team.objects.none() + permission = 'can_change_teams' + write_permission = 'can_change_teams' + + def get_queryset(self): + return self.request.organizer.teams.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + + @transaction.atomic() + def perform_create(self, serializer): + inst = serializer.save(organizer=self.request.organizer) + inst.log_action( + 'pretix.team.created', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': inst.pk}) + ) + + @transaction.atomic() + def perform_update(self, serializer): + inst = serializer.save() + inst.log_action( + 'pretix.team.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data + ) + return inst + + def perform_destroy(self, instance): + instance.log_action('pretix.team.deleted', user=self.request.user, auth=self.request.auth) + instance.delete() + + +class TeamMemberViewSet(DestroyModelMixin, viewsets.ReadOnlyModelViewSet): + serializer_class = TeamMemberSerializer + queryset = User.objects.none() + permission = 'can_change_teams' + write_permission = 'can_change_teams' + + @cached_property + def team(self): + return get_object_or_404(self.request.organizer.teams, pk=self.kwargs.get('team')) + + def get_queryset(self): + return self.team.members.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + + @transaction.atomic() + def perform_destroy(self, instance): + self.team.members.remove(instance) + self.team.log_action( + 'pretix.team.member.removed', user=self.request.user, auth=self.request.auth, data={ + 'email': instance.email, + 'user': instance.pk + } + ) + + +class TeamInviteViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet): + serializer_class = TeamInviteSerializer + queryset = TeamInvite.objects.none() + permission = 'can_change_teams' + write_permission = 'can_change_teams' + + @cached_property + def team(self): + return get_object_or_404(self.request.organizer.teams, pk=self.kwargs.get('team')) + + def get_queryset(self): + return self.team.invites.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + ctx['team'] = self.team + ctx['log_kwargs'] = { + 'user': self.request.user, + 'auth': self.request.auth, + } + return ctx + + @transaction.atomic() + def perform_destroy(self, instance): + self.team.log_action( + 'pretix.team.invite.deleted', user=self.request.user, auth=self.request.auth, data={ + 'email': instance.email, + } + ) + instance.delete() + + @transaction.atomic() + def perform_create(self, serializer): + serializer.save(team=self.team) + + +class TeamAPITokenViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet): + serializer_class = TeamAPITokenSerializer + queryset = TeamAPIToken.objects.none() + permission = 'can_change_teams' + write_permission = 'can_change_teams' + + @cached_property + def team(self): + return get_object_or_404(self.request.organizer.teams, pk=self.kwargs.get('team')) + + def get_queryset(self): + return self.team.tokens.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + ctx['team'] = self.team + ctx['log_kwargs'] = { + 'user': self.request.user, + 'auth': self.request.auth, + } + return ctx + + @transaction.atomic() + def perform_destroy(self, instance): + instance.active = False + instance.save() + self.team.log_action( + 'pretix.team.token.deleted', user=self.request.user, auth=self.request.auth, data={ + 'name': instance.name, + } + ) + + @transaction.atomic() + def perform_create(self, serializer): + instance = serializer.save(team=self.team) + self.team.log_action( + 'pretix.team.token.created', auth=self.request.auth, user=self.request.user, data={ + 'name': instance.name, + 'id': instance.pk + } + ) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + d = serializer.data + d['token'] = serializer.instance.token + return Response(d, status=status.HTTP_201_CREATED, headers=headers) + + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + self.perform_destroy(instance) + serializer = self.get_serializer_class()(instance) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_200_OK, headers=headers) diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 9ff37af9fa..7129cf1a8a 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -71,6 +71,8 @@ def event3(organizer, meta_prop): def team(organizer): return Team.objects.create( organizer=organizer, + name="Test-Team", + can_change_teams=True, can_manage_gift_cards=True, can_change_items=True, can_create_events=True, diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 08f51885f1..721b7b6b96 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -142,6 +142,21 @@ org_permission_sub_urls = [ ('get', 'can_manage_gift_cards', 'giftcards/1/', 404), ('put', 'can_manage_gift_cards', 'giftcards/1/', 404), ('patch', 'can_manage_gift_cards', 'giftcards/1/', 404), + ('get', 'can_change_teams', 'teams/', 200), + ('post', 'can_change_teams', 'teams/', 400), + ('get', 'can_change_teams', 'teams/{team_id}/', 200), + ('put', 'can_change_teams', 'teams/{team_id}/', 400), + ('patch', 'can_change_teams', 'teams/{team_id}/', 200), + ('get', 'can_change_teams', 'teams/{team_id}/members/', 200), + ('delete', 'can_change_teams', 'teams/{team_id}/members/2/', 404), + ('get', 'can_change_teams', 'teams/{team_id}/invites/', 200), + ('get', 'can_change_teams', 'teams/{team_id}/invites/2/', 404), + ('delete', 'can_change_teams', 'teams/{team_id}/invites/2/', 404), + ('post', 'can_change_teams', 'teams/{team_id}/invites/', 400), + ('get', 'can_change_teams', 'teams/{team_id}/tokens/', 200), + ('get', 'can_change_teams', 'teams/{team_id}/tokens/0/', 404), + ('delete', 'can_change_teams', 'teams/{team_id}/tokens/0/', 404), + ('post', 'can_change_teams', 'teams/{team_id}/tokens/', 400), ] @@ -430,7 +445,7 @@ def test_token_org_subresources_permission_allowed(token_client, team, organizer setattr(team, urlset[1], True) team.save() resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/{}'.format( - organizer.slug, urlset[2])) + organizer.slug, urlset[2].format(team_id=team.pk))) assert resp.status_code == urlset[3] @@ -444,7 +459,7 @@ def test_token_org_subresources_permission_not_allowed(token_client, team, organ setattr(team, urlset[1], False) team.save() resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/{}'.format( - organizer.slug, urlset[2])) + organizer.slug, urlset[2].format(team_id=team.pk))) if urlset[3] == 404: assert resp.status_code == 403 else: diff --git a/src/tests/api/test_teams.py b/src/tests/api/test_teams.py new file mode 100644 index 0000000000..25d2f63a2d --- /dev/null +++ b/src/tests/api/test_teams.py @@ -0,0 +1,260 @@ +import pytest +from django.core import mail +from django_scopes import scopes_disabled + +from pretix.base.models import Team, User + + +@pytest.fixture +def second_team(organizer, event): + t = organizer.teams.create( + name='User team', + all_events=False, + ) + t.limit_events.add(event) + return t + + +TEST_TEAM_RES = { + 'id': 1, 'name': 'Test-Team', 'all_events': True, 'limit_events': [], 'can_create_events': True, + 'can_change_teams': True, 'can_change_organizer_settings': True, 'can_manage_gift_cards': True, + 'can_change_event_settings': True, 'can_change_items': True, 'can_view_orders': True, 'can_change_orders': True, + 'can_view_vouchers': True, 'can_change_vouchers': True +} + +SECOND_TEAM_RES = { + 'id': 1, 'name': 'User team', 'all_events': False, 'limit_events': ['dummy'], + 'can_create_events': False, + 'can_change_teams': False, 'can_change_organizer_settings': False, 'can_manage_gift_cards': False, + 'can_change_event_settings': False, 'can_change_items': False, 'can_view_orders': False, 'can_change_orders': False, + 'can_view_vouchers': False, 'can_change_vouchers': False +} + + +@pytest.mark.django_db +def test_team_list(token_client, organizer, event, team): + res = dict(TEST_TEAM_RES) + res["id"] = team.pk + + resp = token_client.get('/api/v1/organizers/{}/teams/'.format(organizer.slug)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_team_detail(token_client, organizer, event, second_team): + res = dict(SECOND_TEAM_RES) + res["id"] = second_team.pk + resp = token_client.get('/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +TEST_TEAM_CREATE_PAYLOAD = { + "name": "Foobar", + "limit_events": ["dummy"], +} + + +@pytest.mark.django_db +def test_team_create(token_client, organizer, event): + resp = token_client.post( + '/api/v1/organizers/{}/teams/'.format(organizer.slug), + TEST_TEAM_CREATE_PAYLOAD, + format='json' + ) + assert resp.status_code == 201 + with scopes_disabled(): + team = Team.objects.get(pk=resp.data['id']) + assert list(team.limit_events.all()) == [event] + + +@pytest.mark.django_db +def test_team_update(token_client, organizer, event, second_team): + assert not second_team.can_change_event_settings + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + { + 'can_change_event_settings': True, + }, + format='json' + ) + assert resp.status_code == 200 + second_team.refresh_from_db() + assert second_team.can_change_event_settings + + resp = token_client.patch( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + { + 'all_events': True, + }, + format='json' + ) + print(resp.data) + assert resp.status_code == 400 + + +@pytest.mark.django_db +def test_team_delete(token_client, organizer, event, second_team): + resp = token_client.delete( + '/api/v1/organizers/{}/teams/{}/'.format(organizer.slug, second_team.pk), + format='json' + ) + assert resp.status_code == 204 + assert organizer.teams.count() == 1 + + +TEST_TEAM_MEMBER_RES = { + 'email': 'dummy@dummy.dummy', + 'fullname': None, + 'require_2fa': False +} + + +@pytest.mark.django_db +def test_team_members_list(token_client, organizer, event, user, team): + team.members.add(user) + res = dict(TEST_TEAM_MEMBER_RES) + res["id"] = user.pk + + resp = token_client.get('/api/v1/organizers/{}/teams/{}/members/'.format(organizer.slug, team.pk)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_team_members_detail(token_client, organizer, event, team, user): + team.members.add(user) + res = dict(TEST_TEAM_MEMBER_RES) + res["id"] = user.pk + resp = token_client.get('/api/v1/organizers/{}/teams/{}/members/{}/'.format(organizer.slug, team.pk, user.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_team_members_delete(token_client, organizer, event, team, user): + team.members.add(user) + resp = token_client.delete('/api/v1/organizers/{}/teams/{}/members/{}/'.format(organizer.slug, team.pk, user.pk)) + assert resp.status_code == 204 + assert team.members.count() == 0 + assert User.objects.filter(pk=user.pk).exists() + + +@pytest.fixture +def invite(team): + return team.invites.create(email='foo@bar.com') + + +TEST_TEAM_INVITE_RES = { + 'email': 'foo@bar.com', +} + + +@pytest.mark.django_db +def test_team_invites_list(token_client, organizer, event, user, team, invite): + res = dict(TEST_TEAM_INVITE_RES) + res["id"] = invite.pk + + resp = token_client.get('/api/v1/organizers/{}/teams/{}/invites/'.format(organizer.slug, team.pk)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_team_invites_detail(token_client, organizer, event, team, user, invite): + res = dict(TEST_TEAM_INVITE_RES) + res["id"] = invite.pk + resp = token_client.get('/api/v1/organizers/{}/teams/{}/invites/{}/'.format(organizer.slug, team.pk, invite.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_team_invites_delete(token_client, organizer, event, team, user, invite): + resp = token_client.delete('/api/v1/organizers/{}/teams/{}/invites/{}/'.format(organizer.slug, team.pk, invite.pk)) + assert resp.status_code == 204 + assert team.invites.count() == 0 + + +@pytest.mark.django_db +def test_team_invites_create(token_client, organizer, event, team, user): + resp = token_client.post('/api/v1/organizers/{}/teams/{}/invites/'.format(organizer.slug, team.pk), { + 'email': 'newmail@dummy.dummy' + }) + assert resp.status_code == 201 + assert team.invites.get().email == 'newmail@dummy.dummy' + assert len(mail.outbox) == 1 + + resp = token_client.post('/api/v1/organizers/{}/teams/{}/invites/'.format(organizer.slug, team.pk), { + 'email': 'newmail@dummy.dummy' + }) + assert resp.status_code == 400 + assert resp.content.decode() == '["This user already has been invited for this team."]' + + resp = token_client.post('/api/v1/organizers/{}/teams/{}/invites/'.format(organizer.slug, team.pk), { + 'email': user.email + }) + assert resp.status_code == 201 + assert not resp.data.get('id') + assert team.invites.count() == 1 + assert user in team.members.all() + + resp = token_client.post('/api/v1/organizers/{}/teams/{}/invites/'.format(organizer.slug, team.pk), { + 'email': user.email + }) + assert resp.status_code == 400 + assert resp.content.decode() == '["This user already has permissions for this team."]' + + +TEST_TEAM_TOKEN_RES = { + 'name': 'Testtoken', + 'active': True, +} + + +@pytest.fixture +def token(second_team): + t = second_team.tokens.create(name='Testtoken') + return t + + +@pytest.mark.django_db +def test_team_tokens_list(token_client, organizer, event, user, second_team, token): + res = dict(TEST_TEAM_TOKEN_RES) + res["id"] = token.pk + + resp = token_client.get('/api/v1/organizers/{}/teams/{}/tokens/'.format(organizer.slug, second_team.pk)) + assert resp.status_code == 200 + assert [res] == resp.data['results'] + + +@pytest.mark.django_db +def test_team_tokens_detail(token_client, organizer, event, second_team, token): + res = dict(TEST_TEAM_TOKEN_RES) + res["id"] = token.pk + resp = token_client.get( + '/api/v1/organizers/{}/teams/{}/tokens/{}/'.format(organizer.slug, second_team.pk, token.pk)) + assert resp.status_code == 200 + assert res == resp.data + + +@pytest.mark.django_db +def test_team_tokens_delete(token_client, organizer, event, second_team, token): + resp = token_client.delete( + '/api/v1/organizers/{}/teams/{}/tokens/{}/'.format(organizer.slug, second_team.pk, token.pk)) + assert resp.status_code == 200 + token.refresh_from_db() + assert not token.active + + +@pytest.mark.django_db +def test_team_token_create(token_client, organizer, event, second_team): + resp = token_client.post('/api/v1/organizers/{}/teams/{}/tokens/'.format(organizer.slug, second_team.pk), { + 'name': 'New token' + }) + assert resp.status_code == 201 + t = second_team.tokens.get() + assert t.name == 'New token' + assert t.active + assert resp.data['token'] == t.token