diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 8423daa1e6..fd62404c70 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -83,9 +83,9 @@ downloads list of objects List of ticket .. versionchanged:: 1.9 + First write operations (``…/mark_paid/``, ``…/mark_pending/``, ``…/mark_canceled/``, ``…/mark_expired/``) have been added. The attribute ``invoice_address.internal_reference`` has been added. - Order position resource ----------------------- @@ -336,6 +336,7 @@ Order 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 this resource. + :statuscode 404: The requested order does not exist. .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/download/(output)/ @@ -374,9 +375,206 @@ Order endpoints :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource **or** downlodas are not available for this order at this time. The response content will contain more details. + :statuscode 404: The requested order or output provider does not exist. :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting vor a few seconds. +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/ + + Marks a pending or expired order as successfully paid. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_paid/ 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 + + { + "code": "ABC12", + "status": "p", + ... + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param code: The ``code`` field of the order to modify + :statuscode 200: no error + :statuscode 400: The order cannot be marked as paid, either because the current order status does not allow it or because no quota is left to perform the operation. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order does not exist. + :statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_canceled/ + + Marks a pending order as canceled. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_canceled/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: text/json + + { + "send_email": true + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "code": "ABC12", + "status": "c", + ... + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param code: The ``code`` field of the order to modify + :statuscode 200: no error + :statuscode 400: The order cannot be marked as canceled since the current order status does not allow it. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order does not exist. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_pending/ + + Marks a paid order as unpaid. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_pending/ 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 + + { + "code": "ABC12", + "status": "n", + ... + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param code: The ``code`` field of the order to modify + :statuscode 200: no error + :statuscode 400: The order cannot be marked as unpaid since the current order status does not allow it. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order does not exist. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_expired/ + + Marks a unpaid order as expired. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/mark_expired/ 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 + + { + "code": "ABC12", + "status": "e", + ... + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param code: The ``code`` field of the order to modify + :statuscode 200: no error + :statuscode 400: The order cannot be marked as expired since the current order status does not allow it. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order does not exist. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/extend/ + + Extends the payment deadline of a pending order. If the order is already expired and quota is still + available, its state will be changed to pending. + + The only required parameter of this operation is ``expires``, which should contain a date in the future. + Note that only a date is expected, not a datetime, since pretix will always set the deadline to the end of the + day in the event's timezone. + + You can pass the optional parameter ``force``. If it is set to ``true``, the operation will be performed even if + it leads to an overbooked quota because the order was expired and the tickets have been sold again. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/extend/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: text/json + + { + "expires": "2017-10-28", + "force": false + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "code": "ABC12", + "status": "n", + "expires": "2017-10-28T23:59:59Z", + ... + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param code: The ``code`` field of the order to modify + :statuscode 200: no error + :statuscode 400: The order cannot be extended since the current order status does not allow it or no quota is available or the submitted date is invalid. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested order does not exist. + Order position endpoints ------------------------ @@ -527,6 +725,7 @@ Order position 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 this resource. + :statuscode 404: The requested order position does not exist. .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/download/(output)/ @@ -566,5 +765,6 @@ Order position endpoints :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource **or** downlodas are not available for this order position at this time. The response content will contain more details. + :statuscode 404: The requested order position or download provider does not exist. :statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting vor a few seconds. diff --git a/doc/api/resources/taxrules.rst b/doc/api/resources/taxrules.rst index 0008a0f7c0..bf49c4e1e3 100644 --- a/doc/api/resources/taxrules.rst +++ b/doc/api/resources/taxrules.rst @@ -231,5 +231,4 @@ Endpoints :param id: The ``id`` field of the tax rule to delete :statuscode 204: no error :statuscode 401: Authentication failure - :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it - **or** this tax rule cannot be deleted since it is currently in use. + :statuscode 403: The requested organizer/event/rule does not exist **or** you have no permission to change it **or** this tax rule cannot be deleted since it is currently in use. diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst index dd5f20d736..85d5a0bd8c 100644 --- a/doc/api/resources/vouchers.rst +++ b/doc/api/resources/vouchers.rst @@ -44,6 +44,10 @@ subevent integer ID of the date ===================================== ========================== ======================================================= +.. versionchanged:: 1.9 + + The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. + Endpoints --------- @@ -224,7 +228,8 @@ Endpoints :statuscode 201: no error :statuscode 400: The voucher 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 view this resource. + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource. + :statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period. .. http:patch:: /api/v1/organizers/(organizer)/events/(event)/vouchers/(id)/ @@ -282,7 +287,8 @@ her. :statuscode 200: no error :statuscode 400: The voucher could not be modified due to invalid submitted data :statuscode 401: Authentication failure - :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. + :statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period. .. http:delete:: /api/v1/organizers/(organizer)/events/(event)/vouchers/(id)/ @@ -308,4 +314,4 @@ her. :param id: The ``id`` field of the tax rule 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 view this resource. + :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 279d57de4d..0549b6862c 100644 --- a/src/pretix/api/auth/permission.py +++ b/src/pretix/api/auth/permission.py @@ -1,3 +1,4 @@ +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import SAFE_METHODS, BasePermission from pretix.base.models import Event @@ -46,3 +47,18 @@ class EventPermission(BasePermission): if required_permission and required_permission not in request.orgapermset: return False return True + + +def permission_required(required_permission): + def decorator(function): + def wrapper(self, request, *args, **kw): + if 'event' in request.resolver_match.kwargs and 'organizer' in request.resolver_match.kwargs: + if required_permission and required_permission not in request.eventpermset: + raise PermissionDenied('You do not have permission to perform this operation.') + elif 'organizer' in request.resolver_match.kwargs: + if required_permission and required_permission not in request.orgapermset: + raise PermissionDenied('You do not have permission to perform this operation.') + + return function(self, request, *args, **kw) + return wrapper + return decorator diff --git a/src/pretix/api/exception.py b/src/pretix/api/exception.py new file mode 100644 index 0000000000..47869e7f4b --- /dev/null +++ b/src/pretix/api/exception.py @@ -0,0 +1,16 @@ +from rest_framework.response import Response +from rest_framework.views import exception_handler, status + +from pretix.base.services.locking import LockTimeoutException + + +def custom_exception_handler(exc, context): + response = exception_handler(exc, context) + + if isinstance(exc, LockTimeoutException): + response = Response( + {'detail': 'The server was too busy to process your request. Please try again.'}, + status=status.HTTP_409_CONFLICT + ) + + return response diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index bf778f1adb..32aa1a2545 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -1,18 +1,28 @@ +import datetime + import django_filters +import pytz from django.db.models import Q from django.db.models.functions import Concat from django.http import FileResponse +from django.utils.timezone import make_aware from django_filters.rest_framework import DjangoFilterBackend, FilterSet -from rest_framework import viewsets +from rest_framework import serializers, status, viewsets from rest_framework.decorators import detail_route from rest_framework.exceptions import APIException, NotFound, PermissionDenied from rest_framework.filters import OrderingFilter +from rest_framework.response import Response from pretix.api.serializers.order import ( InvoiceSerializer, OrderPositionSerializer, OrderSerializer, ) -from pretix.base.models import Invoice, Order, OrderPosition +from pretix.base.models import Invoice, Order, OrderPosition, Quota +from pretix.base.models.organizer import TeamAPIToken from pretix.base.services.invoices import invoice_pdf +from pretix.base.services.mail import SendMailException +from pretix.base.services.orders import ( + OrderError, cancel_order, extend_order, mark_order_paid, +) from pretix.base.services.tickets import ( get_cachedticket_for_order, get_cachedticket_for_position, ) @@ -34,6 +44,7 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): filter_class = OrderFilter lookup_field = 'code' permission = 'can_view_orders' + write_permission = 'can_change_orders' def get_queryset(self): return self.request.event.orders.prefetch_related( @@ -71,6 +82,130 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): ) return resp + @detail_route(methods=['POST']) + def mark_paid(self, request, **kwargs): + order = self.get_object() + + if order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED): + try: + mark_order_paid( + order, manual=True, + user=request.user if request.user.is_authenticated else None, + api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + ) + except Quota.QuotaExceededException as e: + return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) + except SendMailException: + pass + + return self.retrieve(request, [], **kwargs) + return Response( + {'detail': 'The order is not pending or expired.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + @detail_route(methods=['POST']) + def mark_canceled(self, request, **kwargs): + send_mail = request.data.get('send_email', True) + + order = self.get_object() + if order.status != Order.STATUS_PENDING: + return Response( + {'detail': 'The order is not pending.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + cancel_order( + order, + user=request.user if request.user.is_authenticated else None, + api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + send_mail=send_mail + ) + return self.retrieve(request, [], **kwargs) + + @detail_route(methods=['POST']) + def mark_pending(self, request, **kwargs): + order = self.get_object() + + if order.status != Order.STATUS_PAID: + return Response( + {'detail': 'The order is not paid.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + order.status = Order.STATUS_PENDING + order.payment_manual = True + order.save() + order.log_action( + 'pretix.event.order.unpaid', + user=request.user if request.user.is_authenticated else None, + api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + ) + return self.retrieve(request, [], **kwargs) + + @detail_route(methods=['POST']) + def mark_expired(self, request, **kwargs): + order = self.get_object() + + if order.status != Order.STATUS_PENDING: + return Response( + {'detail': 'The order is not pending.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + order.status = Order.STATUS_EXPIRED + order.save() + order.log_action( + 'pretix.event.order.expired', + user=request.user if request.user.is_authenticated else None, + api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + ) + return self.retrieve(request, [], **kwargs) + + # TODO: Find a way to implement mark_refunded + + @detail_route(methods=['POST']) + def extend(self, request, **kwargs): + new_date = request.data.get('expires', None) + force = request.data.get('force', False) + if not new_date: + return Response( + {'detail': 'New date is missing.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + df = serializers.DateField() + try: + new_date = df.to_internal_value(new_date) + except: + return Response( + {'detail': 'New date is invalid.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + tz = pytz.timezone(self.request.event.settings.timezone) + new_date = make_aware(datetime.datetime.combine( + new_date, + datetime.time(hour=23, minute=59, second=59) + ), tz) + + order = self.get_object() + + try: + extend_order( + order, + new_date=new_date, + force=force, + user=request.user if request.user.is_authenticated else None, + api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None), + ) + return self.retrieve(request, [], **kwargs) + except OrderError as e: + return Response( + {'detail': str(e)}, + status=status.HTTP_400_BAD_REQUEST + ) + class OrderPositionFilter(FilterSet): order = django_filters.CharFilter(name='order', lookup_expr='code') diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 41b9bab155..e3185d2176 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -24,6 +24,7 @@ from pretix.base.models import ( ) from pretix.base.models.event import SubEvent from pretix.base.models.orders import CachedTicket, InvoiceAddress, OrderFee +from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.tax import TaxedPrice from pretix.base.payment import BasePaymentProvider from pretix.base.reldate import RelativeDateWrapper @@ -76,7 +77,7 @@ logger = logging.getLogger(__name__) def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None, force: bool=False, send_mail: bool=True, user: User=None, mail_text='', - count_waitinglist=True) -> Order: + count_waitinglist=True, api_token=None) -> Order: """ Marks an order as paid. This sets the payment provider, info and date and returns the order object. @@ -119,7 +120,7 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date 'date': date or now_dt, 'manual': manual, 'force': force - }, user=user) + }, user=user, api_token=api_token) order_paid.send(order.event, order=order) if order.event.settings.get('invoice_generate') in ('True', 'paid') and invoice_qualified(order): @@ -158,6 +159,46 @@ def mark_order_paid(order: Order, provider: str=None, info: str=None, date: date return order +def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, api_token=None): + """ + Extends the deadline of an order. If the order is already expired, the quota will be checked to + see if this is actually still possible. If ``force`` is set to ``True``, the result of this check + will be ignored. + """ + if new_date < now(): + raise OrderError(_('The new expiry date needs to be in the future.')) + if order.status == Order.STATUS_PENDING: + order.expires = new_date + order.save() + order.log_action( + 'pretix.event.order.expirychanged', + user=user, + api_token=api_token, + data={ + 'expires': order.expires, + 'state_change': False + } + ) + else: + with order.event.lock() as now_dt: + is_available = order._is_still_available(now_dt, count_waitinglist=False) + if is_available is True or force is True: + order.expires = new_date + order.status = Order.STATUS_PENDING + order.save() + order.log_action( + 'pretix.event.order.expirychanged', + user=user, + api_token=api_token, + data={ + 'expires': order.expires, + 'state_change': True + } + ) + else: + raise OrderError(is_available) + + @transaction.atomic def mark_order_refunded(order, user=None): """ @@ -182,7 +223,7 @@ def mark_order_refunded(order, user=None): @transaction.atomic -def _cancel_order(order, user=None, send_mail: bool=True): +def _cancel_order(order, user=None, send_mail: bool=True, api_token=None): """ Mark this order as canceled :param order: The order to change @@ -192,13 +233,15 @@ def _cancel_order(order, user=None, send_mail: bool=True): order = Order.objects.get(pk=order) if isinstance(user, int): user = User.objects.get(pk=user) + if isinstance(api_token, int): + api_token = TeamAPIToken.objects.get(pk=api_token) with order.event.lock(): if order.status != Order.STATUS_PENDING: raise OrderError(_('You cannot cancel this order.')) order.status = Order.STATUS_CANCELED order.save() - order.log_action('pretix.event.order.canceled', user=user) + order.log_action('pretix.event.order.canceled', user=user, api_token=api_token) i = order.invoices.filter(is_cancellation=False).last() if i: generate_cancellation(i) @@ -954,10 +997,10 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str], @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) -def cancel_order(self, order: int, user: int=None, send_mail: bool=True): +def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None): try: try: - return _cancel_order(order, user, send_mail) + return _cancel_order(order, user, send_mail, api_token) except LockTimeoutException: self.retry(exc=OrderError(error_messages['busy'])) except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index d7b77f90e6..44a7c547ab 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -37,7 +37,8 @@ from pretix.base.services.invoices import ( from pretix.base.services.locking import LockTimeoutException from pretix.base.services.mail import SendMailException, render_mail from pretix.base.services.orders import ( - OrderChangeManager, OrderError, cancel_order, mark_order_paid, + OrderChangeManager, OrderError, cancel_order, extend_order, + mark_order_paid, ) from pretix.base.services.stats import order_overview from pretix.base.signals import register_data_exporters @@ -453,32 +454,20 @@ class OrderExtend(OrderView): def post(self, *args, **kwargs): if self.form.is_valid(): - if self.order.status == Order.STATUS_PENDING: + try: + extend_order( + self.order, + new_date=self.form.cleaned_data.get('expires'), + force=self.form.cleaned_data.get('quota_ignore', False), + user=self.request.user + ) messages.success(self.request, _('The payment term has been changed.')) - self.order.log_action('pretix.event.order.expirychanged', user=self.request.user, data={ - 'expires': self.order.expires, - 'state_change': False - }) - self.form.save() - else: - try: - with self.order.event.lock() as now_dt: - is_available = self.order._is_still_available(now_dt, count_waitinglist=False) - if is_available is True or self.form.cleaned_data.get('quota_ignore', False) is True: - self.form.save() - self.order.status = Order.STATUS_PENDING - self.order.save() - self.order.log_action('pretix.event.order.expirychanged', user=self.request.user, data={ - 'expires': self.order.expires, - 'state_change': True - }) - messages.success(self.request, _('The payment term has been changed.')) - else: - messages.error(self.request, is_available) - return self._redirect_here() - except LockTimeoutException: - messages.error(self.request, _('We were not able to process the request completely as the ' - 'server was too busy.')) + except OrderError as e: + messages.error(self.request, str(e)) + return self._redirect_here() + except LockTimeoutException: + messages.error(self.request, _('We were not able to process the request completely as the ' + 'server was too busy.')) return self._redirect_back() else: return self.get(*args, **kwargs) diff --git a/src/pretix/settings.py b/src/pretix/settings.py index c4072d3453..60d54c2da2 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -252,6 +252,7 @@ REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.JSONRenderer', ), + 'EXCEPTION_HANDLER': 'pretix.api.exception.custom_exception_handler', 'UNICODE_JSON': False } diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 64b09d054d..c245b1c613 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -41,6 +41,7 @@ def team(organizer): can_change_event_settings=True, can_change_vouchers=True, can_view_vouchers=True, + can_change_orders=True, ) diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 9ff3006593..3003b2a50d 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -4,6 +4,8 @@ from distutils.version import LooseVersion from unittest import mock import pytest +from django.core import mail as djmail +from django.utils.timezone import now from django_countries.fields import Country from pytz import UTC @@ -25,6 +27,13 @@ def taxrule(event): return event.tax_rules.create(rate=Decimal('19.00')) +@pytest.fixture +def quota(event, item): + q = event.quotas.create(name="Budget Quota", size=200) + q.items.add(item) + return q + + @pytest.fixture def order(event, item, taxrule): testtime = datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=UTC) @@ -376,3 +385,285 @@ def test_invoice_detail(token_client, organizer, event, invoice): invoice.number)) assert resp.status_code == 200 assert res == resp.data + + +@pytest.mark.django_db +def test_order_mark_paid_pending(token_client, organizer, event, order): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_paid/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 200 + assert resp.data['status'] == Order.STATUS_PAID + + +@pytest.mark.django_db +def test_order_mark_paid_canceled(token_client, organizer, event, order): + order.status = Order.STATUS_CANCELED + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_paid/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 400 + order.refresh_from_db() + assert order.status == Order.STATUS_CANCELED + + +@pytest.mark.django_db +def test_order_mark_paid_expired_quota_free(token_client, organizer, event, order, quota): + order.status = Order.STATUS_EXPIRED + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_paid/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + +@pytest.mark.django_db +def test_order_mark_paid_expired_quota_fill(token_client, organizer, event, order, quota): + order.status = Order.STATUS_EXPIRED + order.save() + quota.size = 0 + quota.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_paid/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 400 + order.refresh_from_db() + assert order.status == Order.STATUS_EXPIRED + + +@pytest.mark.django_db +def test_order_mark_paid_locked(token_client, organizer, event, order): + order.status = Order.STATUS_EXPIRED + order.save() + with event.lock(): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_paid/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 409 + order.refresh_from_db() + assert order.status == Order.STATUS_EXPIRED + + +@pytest.mark.django_db +def test_order_mark_canceled_pending(token_client, organizer, event, order): + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 200 + assert resp.data['status'] == Order.STATUS_CANCELED + assert len(djmail.outbox) == 1 + + +@pytest.mark.django_db +def test_order_mark_canceled_pending_no_email(token_client, organizer, event, order): + djmail.outbox = [] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'send_email': False + } + ) + assert resp.status_code == 200 + assert resp.data['status'] == Order.STATUS_CANCELED + assert len(djmail.outbox) == 0 + + +@pytest.mark.django_db +def test_order_mark_canceled_paid(token_client, organizer, event, order): + order.status = Order.STATUS_PAID + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 400 + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + +@pytest.mark.django_db +def test_order_mark_paid_unpaid(token_client, organizer, event, order): + order.status = Order.STATUS_PAID + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_pending/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 200 + assert resp.data['status'] == Order.STATUS_PENDING + + +@pytest.mark.django_db +def test_order_mark_canceled_unpaid(token_client, organizer, event, order): + order.status = Order.STATUS_CANCELED + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_pending/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 400 + order.refresh_from_db() + assert order.status == Order.STATUS_CANCELED + + +@pytest.mark.django_db +def test_order_mark_pending_expired(token_client, organizer, event, order): + order.status = Order.STATUS_PENDING + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_expired/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 200 + assert resp.data['status'] == Order.STATUS_EXPIRED + + +@pytest.mark.django_db +def test_order_mark_paid_expired(token_client, organizer, event, order): + order.status = Order.STATUS_PAID + order.save() + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/mark_expired/'.format( + organizer.slug, event.slug, order.code + ) + ) + assert resp.status_code == 400 + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + +@pytest.mark.django_db +def test_order_extend_paid(token_client, organizer, event, order): + order.status = Order.STATUS_PAID + order.save() + newdate = (now() + datetime.timedelta(days=20)).strftime("%Y-%m-%d") + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/extend/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'expires': newdate + } + ) + assert resp.status_code == 400 + order.refresh_from_db() + assert order.status == Order.STATUS_PAID + + +@pytest.mark.django_db +def test_order_extend_pending(token_client, organizer, event, order): + order.status = Order.STATUS_PENDING + order.save() + newdate = (now() + datetime.timedelta(days=20)).strftime("%Y-%m-%d") + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/extend/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'expires': newdate + } + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert order.status == Order.STATUS_PENDING + assert order.expires.strftime("%Y-%m-%d %H:%M:%S") == newdate[:10] + " 23:59:59" + + +@pytest.mark.django_db +def test_order_extend_expired_quota_empty(token_client, organizer, event, order, quota): + order.status = Order.STATUS_EXPIRED + order.save() + quota.size = 0 + quota.save() + newdate = (now() + datetime.timedelta(days=20)).strftime("%Y-%m-%d") + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/extend/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'expires': newdate + } + ) + assert resp.status_code == 400 + order.refresh_from_db() + assert order.status == Order.STATUS_EXPIRED + + +@pytest.mark.django_db +def test_order_extend_expired_quota_ignore(token_client, organizer, event, order, quota): + order.status = Order.STATUS_EXPIRED + order.save() + quota.size = 0 + quota.save() + newdate = (now() + datetime.timedelta(days=20)).strftime("%Y-%m-%d") + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/extend/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'expires': newdate, + 'force': True + } + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert order.status == Order.STATUS_PENDING + assert order.expires.strftime("%Y-%m-%d %H:%M:%S") == newdate[:10] + " 23:59:59" + + +@pytest.mark.django_db +def test_order_extend_expired_quota_waiting_list(token_client, organizer, event, order, item, quota): + order.status = Order.STATUS_EXPIRED + order.save() + quota.size = 1 + quota.save() + event.waitinglistentries.create(item=item, email='foo@bar.com') + newdate = (now() + datetime.timedelta(days=20)).strftime("%Y-%m-%d") + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/extend/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'expires': newdate, + } + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert order.status == Order.STATUS_PENDING + assert order.expires.strftime("%Y-%m-%d %H:%M:%S") == newdate[:10] + " 23:59:59" + + +@pytest.mark.django_db +def test_order_extend_expired_quota_left(token_client, organizer, event, order, quota): + order.status = Order.STATUS_EXPIRED + order.save() + quota.size = 2 + quota.save() + newdate = (now() + datetime.timedelta(days=20)).strftime("%Y-%m-%d") + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/{}/extend/'.format( + organizer.slug, event.slug, order.code + ), format='json', data={ + 'expires': newdate, + } + ) + assert resp.status_code == 200 + order.refresh_from_db() + assert order.status == Order.STATUS_PENDING + assert order.expires.strftime("%Y-%m-%d %H:%M:%S") == newdate[:10] + " 23:59:59" diff --git a/src/tests/api/test_permissions.py b/src/tests/api/test_permissions.py index 582d6249d8..edd3739b6b 100644 --- a/src/tests/api/test_permissions.py +++ b/src/tests/api/test_permissions.py @@ -34,6 +34,10 @@ event_permission_urls = [ ('put', 'can_change_vouchers', 'vouchers/1/', 404), ('patch', 'can_change_vouchers', 'vouchers/1/', 404), ('delete', 'can_change_vouchers', 'vouchers/1/', 404), + ('post', 'can_change_orders', 'orders/ABC12/mark_paid/', 404), + ('post', 'can_change_orders', 'orders/ABC12/mark_pending/', 404), + ('post', 'can_change_orders', 'orders/ABC12/mark_expired/', 404), + ('post', 'can_change_orders', 'orders/ABC12/mark_canceled/', 404), ] @@ -121,4 +125,7 @@ def test_token_event_permission_not_allowed(token_client, team, organizer, event team.save() resp = getattr(token_client, urlset[0])('/api/v1/organizers/{}/events/{}/{}'.format( organizer.slug, event.slug, urlset[2])) - assert resp.status_code in (404, 403) + if urlset[3] == 404: + assert resp.status_code == 403 + else: + assert resp.status_code in (404, 403)