diff --git a/frontends/api/src/generated/v0/api.ts b/frontends/api/src/generated/v0/api.ts index 4a1bc3f9..e9fdeb1f 100644 --- a/frontends/api/src/generated/v0/api.ts +++ b/frontends/api/src/generated/v0/api.ts @@ -4108,6 +4108,74 @@ export const PaymentsApiAxiosParamCreator = function ( configuration?: Configuration, ) { return { + /** + * Creates or updates a basket for the current user, adding the selected product and discount. + * @param {string} discount_code + * @param {string} sku + * @param {string} system_slug + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createBasketFromProductWithDiscount: async ( + discount_code: string, + sku: string, + system_slug: string, + options: RawAxiosRequestConfig = {}, + ): Promise => { + // verify required parameter 'discount_code' is not null or undefined + assertParamExists( + "createBasketFromProductWithDiscount", + "discount_code", + discount_code, + ) + // verify required parameter 'sku' is not null or undefined + assertParamExists("createBasketFromProductWithDiscount", "sku", sku) + // verify required parameter 'system_slug' is not null or undefined + assertParamExists( + "createBasketFromProductWithDiscount", + "system_slug", + system_slug, + ) + const localVarPath = + `/api/v0/payments/baskets/create_from_product/{system_slug}/{sku}/{discount_code}/` + .replace( + `{${"discount_code"}}`, + encodeURIComponent(String(discount_code)), + ) + .replace(`{${"sku"}}`, encodeURIComponent(String(sku))) + .replace( + `{${"system_slug"}}`, + encodeURIComponent(String(system_slug)), + ) + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL) + let baseOptions + if (configuration) { + baseOptions = configuration.baseOptions + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + } + const localVarHeaderParameter = {} as any + const localVarQueryParameter = {} as any + + setSearchParams(localVarUrlObj, localVarQueryParameter) + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {} + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + } + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + } + }, /** * Creates or updates a basket for the current user, adding the discount if valid. * @param {string} discount_code @@ -4668,6 +4736,45 @@ export const PaymentsApiAxiosParamCreator = function ( export const PaymentsApiFp = function (configuration?: Configuration) { const localVarAxiosParamCreator = PaymentsApiAxiosParamCreator(configuration) return { + /** + * Creates or updates a basket for the current user, adding the selected product and discount. + * @param {string} discount_code + * @param {string} sku + * @param {string} system_slug + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createBasketFromProductWithDiscount( + discount_code: string, + sku: string, + system_slug: string, + options?: RawAxiosRequestConfig, + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string, + ) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.createBasketFromProductWithDiscount( + discount_code, + sku, + system_slug, + options, + ) + const index = configuration?.serverIndex ?? 0 + const operationBasePath = + operationServerMap["PaymentsApi.createBasketFromProductWithDiscount"]?.[ + index + ]?.url + return (axios, basePath) => + createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration, + )(axios, operationBasePath || basePath) + }, /** * Creates or updates a basket for the current user, adding the discount if valid. * @param {string} discount_code @@ -5032,6 +5139,25 @@ export const PaymentsApiFactory = function ( ) { const localVarFp = PaymentsApiFp(configuration) return { + /** + * Creates or updates a basket for the current user, adding the selected product and discount. + * @param {PaymentsApiCreateBasketFromProductWithDiscountRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createBasketFromProductWithDiscount( + requestParameters: PaymentsApiCreateBasketFromProductWithDiscountRequest, + options?: RawAxiosRequestConfig, + ): AxiosPromise { + return localVarFp + .createBasketFromProductWithDiscount( + requestParameters.discount_code, + requestParameters.sku, + requestParameters.system_slug, + options, + ) + .then((request) => request(axios, basePath)) + }, /** * Creates or updates a basket for the current user, adding the discount if valid. * @param {PaymentsApiPaymentsBasketsAddDiscountCreateRequest} requestParameters Request parameters. @@ -5210,6 +5336,34 @@ export const PaymentsApiFactory = function ( } } +/** + * Request parameters for createBasketFromProductWithDiscount operation in PaymentsApi. + * @export + * @interface PaymentsApiCreateBasketFromProductWithDiscountRequest + */ +export interface PaymentsApiCreateBasketFromProductWithDiscountRequest { + /** + * + * @type {string} + * @memberof PaymentsApiCreateBasketFromProductWithDiscount + */ + readonly discount_code: string + + /** + * + * @type {string} + * @memberof PaymentsApiCreateBasketFromProductWithDiscount + */ + readonly sku: string + + /** + * + * @type {string} + * @memberof PaymentsApiCreateBasketFromProductWithDiscount + */ + readonly system_slug: string +} + /** * Request parameters for paymentsBasketsAddDiscountCreate operation in PaymentsApi. * @export @@ -5392,6 +5546,27 @@ export interface PaymentsApiPaymentsOrdersHistoryRetrieveRequest { * @extends {BaseAPI} */ export class PaymentsApi extends BaseAPI { + /** + * Creates or updates a basket for the current user, adding the selected product and discount. + * @param {PaymentsApiCreateBasketFromProductWithDiscountRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof PaymentsApi + */ + public createBasketFromProductWithDiscount( + requestParameters: PaymentsApiCreateBasketFromProductWithDiscountRequest, + options?: RawAxiosRequestConfig, + ) { + return PaymentsApiFp(this.configuration) + .createBasketFromProductWithDiscount( + requestParameters.discount_code, + requestParameters.sku, + requestParameters.system_slug, + options, + ) + .then((request) => request(this.axios, this.basePath)) + } + /** * Creates or updates a basket for the current user, adding the discount if valid. * @param {PaymentsApiPaymentsBasketsAddDiscountCreateRequest} requestParameters Request parameters. diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index f1fe07f0..aec9176f 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -445,6 +445,36 @@ paths: schema: $ref: '#/components/schemas/BasketWithProduct' description: '' + /api/v0/payments/baskets/create_from_product/{system_slug}/{sku}/{discount_code}/: + post: + operationId: create_basket_from_product_with_discount + description: Creates or updates a basket for the current user, adding the selected + product and discount. + parameters: + - in: path + name: discount_code + schema: + type: string + required: true + - in: path + name: sku + schema: + type: string + required: true + - in: path + name: system_slug + schema: + type: string + required: true + tags: + - payments + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/BasketWithProduct' + description: '' /api/v0/payments/baskets/create_with_products/: post: operationId: payments_baskets_create_with_products_create diff --git a/payments/views/v0/__init__.py b/payments/views/v0/__init__.py index 1fbca921..03105bbc 100644 --- a/payments/views/v0/__init__.py +++ b/payments/views/v0/__init__.py @@ -1,6 +1,7 @@ """Views for the REST API for payments.""" import logging +from typing import Optional from django.core.exceptions import ObjectDoesNotExist from django.db import transaction @@ -124,18 +125,9 @@ def get_user_basket_for_system(request, system_slug: str): ) -@extend_schema( - description=( - "Creates or updates a basket for the current user, " - "adding the selected product." - ), - methods=["POST"], - request=None, - responses=BasketWithProductSerializer, -) -@api_view(["POST"]) -@permission_classes((IsAuthenticated,)) -def create_basket_from_product(request, system_slug: str, sku: str): +def _create_basket_from_product( + request, system_slug: str, sku: str, discount_code: Optional[str] = None +): """ Create a new basket item from a product for the currently logged in user. Reuse the existing basket object if it exists. @@ -144,10 +136,14 @@ def create_basket_from_product(request, system_slug: str, sku: str): basket, then immediately flip the user to the checkout interstitial (which then redirects to the payment gateway). + If the discount code is provided, then it will be applied to the basket. If + the discount isn't found or doesn't apply, then it will be ignored. + Args: + request (Request): The request object. system_slug (str): system slug sku (str): product slug - + discount_code (str): discount code POST Args: quantity (int): quantity of the product to add to the basket (defaults to 1) checkout (bool): redirect to checkout interstitial (defaults to False) @@ -181,6 +177,14 @@ def create_basket_from_product(request, system_slug: str, sku: str): auto_apply_discount_discounts = api.get_auto_apply_discounts_for_basket(basket.id) for discount in auto_apply_discount_discounts: basket.apply_discount_to_basket(discount) + + if discount_code: + try: + discount = Discount.objects.get(discount_code=discount_code) + basket.apply_discount_to_basket(discount) + except Discount.DoesNotExist: + pass + basket.refresh_from_db() if checkout: @@ -192,6 +196,58 @@ def create_basket_from_product(request, system_slug: str, sku: str): ) +@extend_schema( + description=( + "Creates or updates a basket for the current user, " + "adding the selected product." + ), + methods=["POST"], + request=None, + responses=BasketWithProductSerializer, + parameters=[ + OpenApiParameter( + "system_slug", OpenApiTypes.STR, OpenApiParameter.PATH, required=True + ), + OpenApiParameter("sku", OpenApiTypes.STR, OpenApiParameter.PATH, required=True), + ], +) +@api_view(["POST"]) +@permission_classes((IsAuthenticated,)) +def create_basket_from_product(request, system_slug: str, sku: str): + """Run _create_basket_from_product.""" + + return _create_basket_from_product(request, system_slug, sku) + + +@extend_schema( + operation_id="create_basket_from_product_with_discount", + description=( + "Creates or updates a basket for the current user, " + "adding the selected product and discount." + ), + methods=["POST"], + request=None, + responses=BasketWithProductSerializer, + parameters=[ + OpenApiParameter( + "system_slug", OpenApiTypes.STR, OpenApiParameter.PATH, required=True + ), + OpenApiParameter("sku", OpenApiTypes.STR, OpenApiParameter.PATH, required=True), + OpenApiParameter( + "discount_code", OpenApiTypes.STR, OpenApiParameter.PATH, required=True + ), + ], +) +@api_view(["POST"]) +@permission_classes((IsAuthenticated,)) +def create_basket_from_product_with_discount( + request, system_slug: str, sku: str, discount_code: Optional[str] = None +): + """Run _create_basket_from_product with the discount code.""" + + return _create_basket_from_product(request, system_slug, sku, discount_code) + + @extend_schema( description=( "Creates or updates a basket for the current user, " diff --git a/payments/views/v0/urls.py b/payments/views/v0/urls.py index ebea7e6d..a4759940 100644 --- a/payments/views/v0/urls.py +++ b/payments/views/v0/urls.py @@ -11,6 +11,7 @@ add_discount_to_basket, clear_basket, create_basket_from_product, + create_basket_from_product_with_discount, create_basket_with_products, get_user_basket_for_system, start_checkout, @@ -34,6 +35,11 @@ create_basket_from_product, name="create_from_product", ), + path( + "baskets/create_from_product////", + create_basket_from_product_with_discount, + name="create_from_product_with_discount", + ), path( "baskets/create_with_products/", create_basket_with_products, diff --git a/payments/views/v0/v0_test.py b/payments/views/v0/v0_test.py index 75961d1e..8ea5915e 100644 --- a/payments/views/v0/v0_test.py +++ b/payments/views/v0/v0_test.py @@ -1,6 +1,7 @@ """View tests for the v0 API.""" import pytest +from django.urls import reverse from payments.factories import BasketFactory, DiscountFactory, ProductFactory from payments.models import Basket @@ -29,7 +30,7 @@ def test_create_basket_with_products( BasketFactory(user=user, integrated_system=system) if existing_basket else None ) - url = "/api/v0/payments/baskets/create_with_products/" + url = reverse("v0:create_with_products") payload = { "system_slug": system.slug, "skus": [{"sku": product.sku, "quantity": 1} for product in products], @@ -58,3 +59,76 @@ def test_create_basket_with_products( if add_discount: assert discount in Basket.objects.get(id=basket_id).discounts.all() + + +@pytest.mark.parametrize( + ("existing_basket", "add_discount", "bad_product", "bad_discount"), + [ + (True, False, False, False), + (False, True, False, False), + (False, False, True, False), + (False, True, False, True), + ], +) +def test_create_basket_with_product( + mocker, user, user_client, existing_basket, add_discount, bad_product, bad_discount +): + """Test creating a basket with a single product, and/or a discount.""" + + mocker.patch("payments.api.send_pre_sale_webhook") + system = ActiveIntegratedSystemFactory() + + if bad_product: + product = ProductFactory.create() + else: + product = ProductFactory.create(system=system) + + basket = ( + BasketFactory(user=user, integrated_system=system) if existing_basket else None + ) + + url = reverse( + "v0:create_from_product", + kwargs={"system_slug": system.slug, "sku": product.sku}, + ) + + if add_discount: + if bad_discount: + discount = DiscountFactory( + discount_type="fixed-price", + amount=100, + integrated_system=ActiveIntegratedSystemFactory(), + ) + else: + discount = DiscountFactory(discount_type="fixed-price", amount=100) + + url = reverse( + "v0:create_from_product_with_discount", + kwargs={ + "system_slug": system.slug, + "sku": product.sku, + "discount_code": discount.discount_code, + }, + ) + + response = user_client.post(url) + + if bad_product: + assert response.status_code == 404 + return + + # This returns a 201 if we created the _basket line item_. + assert response.status_code >= 200 + assert response.status_code < 300 + + basket_id = response.data["id"] + assert Basket.objects.get(id=basket_id).basket_items.count() == 1 + + if existing_basket: + assert basket_id == basket.id + + if add_discount: + if bad_discount: + assert discount not in Basket.objects.get(id=basket_id).discounts.all() + else: + assert discount in Basket.objects.get(id=basket_id).discounts.all()