Repository: svthalia/concrexit

Issue:
Add sales order API
### Describe the solution you'd like
- An api endpoint `api/v2/sales/order/<uuid>/` that returns the order information that is shown on the website when you pay for the url from a qr code.
- It should be possible to pay the order through `api/v2/payments/sales/order/<uuid>`. This might already be possible, I haven't checked.

### Motivation
This way people will be able to pay from the app, instead of through the website where they may even need to log in. I think this is an obvious case where the ease of use of an app is very welcome.




Discussion:
> An api endpoint api/v2/sales/order/<uuid>/ that returns the order information that is shown on the website when you pay for the url from a qr code.

I don't think this needs to exist. The payable will contain a description of the order and thus contains all information a user needs

> It should be possible to pay the order through api/v2/payments/sales/order/<uuid>. This might already be possible, I haven't checked.

This already exists, it's the whole idea behind payments

> This way people will be able to pay from the app, instead of through the website where they may even need to log in.

They should still scan the QR code to identify themselves _at the counter_. The QR code will simply redirect them to the website. It is fine by me if they are redirected to the app to process the payment, but I don't want to introduce a flow of paying for orders. The app could just implement a tpay payment confirmation flow that works on everything (not just sales orders)

> The payable will contain a description of the order and thus contains all information a user needs. (...) This already exists

If thats the case then indeed no new endpoint is needed. The code of the payment page suggested that paying for sales is more intricate than for other payables, but if it just works through the payments api that's fine.

> They should still scan the QR code to identify themselves at the counter. The QR code will simply redirect them to the website. It is fine by me if they are redirected to the app to process the payment

That is exactly the idea.



Well there is one extra thing, namely that the payer is also set after scanning the QR. This is not yet possible via the api and will prevent users from paying, because they are not yet registered as the payer of the payable.

I guess this is possible now after #1787? I'll take a look if I can figure it out :)
I don't think it is possible yet actually. #1787 only gives access to order that are already yours, but _claiming_ an order that doesn't have a payer is not yet possible 
That seems right. Right now, `v2/sales/order/<uuid>` raises 403 for paid orders and when user orders are not allowed, and 404 if the user is not the payer. That makes sense for user orders, but doesn't let you claim orders. I'm not sure what would be the best way to make claiming possible:

- Allow (empty) PATCH on unclaimed `v2/sales/order/<uuid>` to set the payer. This adds some logic that's unrelated to user orders in the UserOrderSerializer, which sucks.
- Add a separate endpoint only for claiming (and viewing the order content) such as `v2/sales/order/<uuid>/pay`, analogous to the normal url with the same purpose. This adds an endpoint that is very similar to what exists, which sucks, but at least the endpoint is really simple and won't conflict with user order logic.

I think I'll try making a PR for the second option.

Besides, in OrderPaymentView: 
```python
if order.payer is not None and order.payer != request.member:
    messages.warning(request, _("This order is not yours."))
```
It does not check `created_by`, whereas the user order api allows both `payer` and `created_by` users to access, and sets both `payer` and `created_by` on creation. Then it's possible to later set `payer` to null (or any other user) through the user order API, after which someone else can pay for it. In general I think there are some limitations missing on what a user can change about user orders, and I'm not sure what the purpose is of having separate `created_by` and `payer`.

And: https://github.com/svthalia/concrexit/blob/aa49794e6b742186dce3543f61443c059ae79c94/website/sales/views.py#L38 I think this line should be gone

> I'm not sure what would be the best way to make claiming possible

I'm in favor of the first approach, as it is the most RESTful thing to do and leaves actual paying of the payable to the payments app and payments API. 



> I'm not sure what the purpose is of having separate created_by and payer

In the future, pizza orders will also go via this system. In that such cases, orders that are placed by the person itself have payer == created_by. Updating a payer field should only be possible if you are a shift manager.



> I think this line should be gone

No. If you remove the line, everyone trying to pay will only get the 'you successfully identified' message and be redirected to the index page, and they won't go to the page where they actually perform the payment. 


Resolving patch:
diff --git a/website/sales/api/v2/urls.py b/website/sales/api/v2/urls.py
--- a/website/sales/api/v2/urls.py
+++ b/website/sales/api/v2/urls.py
@@ -1,6 +1,7 @@
 from django.urls import path
 
 from sales.api.v2.views import (
+    OrderClaimView,
     UserShiftListView,
     UserShiftDetailView,
     UserOrderListView,
@@ -10,6 +11,7 @@
 app_name = "sales"
 
 urlpatterns = [
+    path("sales/order/<uuid:pk>/claim/", OrderClaimView.as_view(), name="order-claim"),
     path("sales/shifts/", UserShiftListView.as_view(), name="user-shift-list"),
     path(
         "sales/shifts/<int:pk>/",
diff --git a/website/sales/api/v2/views.py b/website/sales/api/v2/views.py
--- a/website/sales/api/v2/views.py
+++ b/website/sales/api/v2/views.py
@@ -1,25 +1,23 @@
 from django.db.models import Q
 from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope
 from rest_framework.exceptions import PermissionDenied
-from rest_framework.generics import (
-    ListAPIView,
-    RetrieveAPIView,
-    CreateAPIView,
-    UpdateAPIView,
-    DestroyAPIView,
-)
+from rest_framework.generics import GenericAPIView
 from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly
+from rest_framework.response import Response
+from rest_framework.schemas.openapi import AutoSchema
 
-from sales.api.v2.admin.serializers.order import OrderSerializer, OrderListSerializer
+from sales.api.v2.admin.serializers.order import OrderListSerializer
 from sales.api.v2.admin.views import (
-    OrderListView,
     OrderDetailView,
+    OrderListView,
     ShiftDetailView,
     ShiftListView,
 )
+from sales import services
 from sales.api.v2.serializers.user_order import UserOrderSerializer
 from sales.api.v2.serializers.user_shift import UserShiftSerializer
-from sales.models.shift import SelfOrderPeriod, Shift
+from sales.models.shift import Shift
+from sales.models.order import Order
 from thaliawebsite.api.v2.permissions import IsAuthenticatedOrTokenHasScopeForMethod
 
 
@@ -111,4 +109,40 @@ def destroy(self, request, *args, **kwargs):
             raise PermissionDenied
         if self.get_object().payment:
             raise PermissionDenied
-        return super(UserOrderDetailView, self).destroy(request, *args, **kwargs)
+
+
+class OrderClaimView(GenericAPIView):
+    """Claims an order to be paid by the current user."""
+
+    class OrderClaimViewSchema(AutoSchema):
+        def get_request_serializer(self, path, method):
+            # This endpoint does not expect any content in the request body.
+            return None
+
+    queryset = Order.objects.all()
+    serializer_class = UserOrderSerializer
+    schema = OrderClaimViewSchema(operation_id_base="claimOrder")
+    permission_classes = [IsAuthenticatedOrTokenHasScope]
+    required_scopes = ["sales:order"]
+
+    def patch(self, request, *args, **kwargs):
+        if request.member is None:
+            raise PermissionDenied("You need to be a member to pay for an order.")
+
+        order = self.get_object()
+        if order.payment:
+            raise PermissionDenied(detail="This order was already paid for.")
+
+        if order.payer is not None and order.payer != request.member:
+            raise PermissionDenied(detail="This order is not yours.")
+
+        order.payer = request.member
+        order.save()
+
+        if order.age_restricted and not services.is_adult(request.member):
+            raise PermissionDenied(
+                "The age restrictions on this order do not allow you to pay for this order."
+            )
+
+        serializer = self.get_serializer(order)
+        return Response(serializer.data)


Test patch:
diff --git a/website/sales/tests/test_api.py b/website/sales/tests/test_api.py
--- a/website/sales/tests/test_api.py
+++ b/website/sales/tests/test_api.py
@@ -1,19 +1,20 @@
+from unittest import mock
+
+from activemembers.models import Committee, MemberGroupMembership
 from django.contrib.auth.models import Permission
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 from django.urls import reverse
 from django.utils import timezone
 from freezegun import freeze_time
-from rest_framework.test import APIClient
-
-from activemembers.models import Committee, MemberGroupMembership
 from members.models import Member
 from payments.models import Payment
 from payments.services import create_payment
+from rest_framework.test import APIClient
 from sales import payables
 from sales.models.order import Order, OrderItem
 from sales.models.product import Product, ProductList
-from sales.models.shift import Shift, SelfOrderPeriod
+from sales.models.shift import SelfOrderPeriod, Shift
 
 
 @freeze_time("2021-01-01")
@@ -630,6 +631,75 @@ def test_user_self_order(self):
         order = Order.objects.get(pk=response.data["pk"])
         self.assertEqual(order.payer, self.member)
 
+    def test_claim_order(self):
+        with self.subTest("Claim a normal order"):
+            response = self.client.patch(
+                reverse("api:v2:sales:order-claim", kwargs={"pk": self.o3.pk})
+            )
+            self.assertEqual(200, response.status_code)
+            self.assertEqual(
+                Order.objects.get(pk=response.data["pk"]).payer, self.member
+            )
+
+        self.o3.payer = None
+        self.o3.save()
+
+        with self.subTest("Claim an order that is already yours"):
+            self.o3.payer = self.member
+            self.o3.save()
+
+            response = self.client.patch(
+                reverse("api:v2:sales:order-claim", kwargs={"pk": self.o3.pk})
+            )
+            self.assertEqual(200, response.status_code)
+            self.assertEqual(
+                Order.objects.get(pk=response.data["pk"]).payer, self.member
+            )
+
+        self.o3.payer = None
+        self.o3.save()
+
+        with self.subTest("Claim an order that is not yours"):
+            member = Member.objects.create(
+                username="test1",
+                first_name="Test1",
+                last_name="Example",
+                email="test1@example.org",
+                is_staff=False,
+                is_superuser=False,
+            )
+            self.o3.payer = member
+            self.o3.save()
+
+            response = self.client.patch(
+                reverse("api:v2:sales:order-claim", kwargs={"pk": self.o3.pk})
+            )
+            self.assertEqual(403, response.status_code)
+            self.o3.refresh_from_db()
+            self.assertEqual(self.o3.payer, member)
+
+        self.o3.payer = None
+        self.o3.save()
+
+        with self.subTest("Claim a paid order"):
+            response = self.client.patch(
+                reverse("api:v2:sales:order-claim", kwargs={"pk": self.o4.pk})
+            )
+            self.assertEqual(403, response.status_code)
+
+        self.o3.payer = None
+        self.o3.save()
+
+        with self.subTest("Claim an age-restricted order as a minor"):
+            with mock.patch("sales.services.is_adult") as is_adult:
+                is_adult.return_value = False
+                response = self.client.patch(
+                    reverse("api:v2:sales:order-claim", kwargs={"pk": self.o3.pk})
+                )
+                self.assertEqual(403, response.status_code)
+                self.o3.refresh_from_db()
+                self.assertEqual(self.o3.payer, self.member)
+
 
 class ShiftAPITest(TestCase):
     fixtures = [