diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 635b354f..0d982d9d 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -413,6 +413,19 @@ paths: schema: $ref: '#/components/schemas/BasketWithProduct' description: '' + /api/v0/payments/discounts/: + post: + operationId: payments_discounts_create + description: Create a discount. + tags: + - payments + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Discount' + description: '' /api/v0/payments/orders/history/: get: operationId: payments_orders_history_list @@ -541,6 +554,73 @@ components: - tax - total_price - user + Company: + type: object + description: Serializer for companies. + properties: + id: + type: integer + readOnly: true + name: + type: string + maxLength: 255 + required: + - id + - name + Discount: + type: object + description: Serializer for discounts. + properties: + id: + type: integer + readOnly: true + discount_code: + type: string + maxLength: 100 + amount: + type: string + format: decimal + pattern: ^-?\d{0,18}(?:\.\d{0,2})?$ + payment_type: + nullable: true + oneOf: + - $ref: '#/components/schemas/PaymentTypeEnum' + - $ref: '#/components/schemas/NullEnum' + max_redemptions: + type: integer + maximum: 2147483647 + minimum: 0 + nullable: true + activation_date: + type: string + format: date-time + nullable: true + description: If set, this discount code will not be redeemable before this + date. + expiration_date: + type: string + format: date-time + nullable: true + description: If set, this discount code will not be redeemable after this + date. + integrated_system: + $ref: '#/components/schemas/IntegratedSystem' + product: + $ref: '#/components/schemas/Product' + assigned_users: + type: array + items: + $ref: '#/components/schemas/User' + company: + $ref: '#/components/schemas/Company' + required: + - amount + - assigned_users + - company + - discount_code + - id + - integrated_system + - product IntegratedSystem: type: object description: Serializer for IntegratedSystem model. @@ -608,6 +688,9 @@ components: - quantity - total_price - unit_price + NullEnum: + enum: + - null OrderHistory: type: object description: Serializer for order history. @@ -780,6 +863,35 @@ components: format: decimal pattern: ^-?\d{0,5}(?:\.\d{0,2})?$ description: Price (decimal to two places) + PaymentTypeEnum: + enum: + - marketing + - sales + - financial-assistance + - customer-support + - staff + - legacy + - credit_card + - purchase_order + type: string + description: |- + * `marketing` - marketing + * `sales` - sales + * `financial-assistance` - financial-assistance + * `customer-support` - customer-support + * `staff` - staff + * `legacy` - legacy + * `credit_card` - credit_card + * `purchase_order` - purchase_order + x-enum-descriptions: + - marketing + - sales + - financial-assistance + - customer-support + - staff + - legacy + - credit_card + - purchase_order Product: type: object description: Serializer for Product model. diff --git a/payments/api.py b/payments/api.py index 7689ff38..e15610ae 100644 --- a/payments/api.py +++ b/payments/api.py @@ -26,6 +26,7 @@ Basket, BlockedCountry, BulkDiscountCollection, + Company, Discount, FulfilledOrder, Order, @@ -513,6 +514,8 @@ def generate_discount_code(**kwargs): # noqa: C901, PLR0912, PLR0915 * expires - date to expire the code * count - number of codes to create (requires prefix) * prefix - prefix to append to the codes (max 63 characters) + * company - ID of the company to associate with the discount + * transaction_number - transaction number to associate with the discount Returns: * List of generated codes, with the following fields: @@ -641,6 +644,20 @@ def generate_discount_code(**kwargs): # noqa: C901, PLR0912, PLR0915 else: users = None + if "company" in kwargs and kwargs["company"] is not None: + try: + company = Company.objects.get(pk=kwargs["company"]) + except Company.DoesNotExist: + error_message = f"Company {kwargs['company']} does not exist." + raise ValueError(error_message) from None + else: + company = None + + if "transaction_number" in kwargs and kwargs["transaction_number"] is not None: + transaction_number = kwargs["transaction_number"] + else: + transaction_number = None + generated_codes = [] for code_to_generate in codes_to_generate: @@ -657,6 +674,8 @@ def generate_discount_code(**kwargs): # noqa: C901, PLR0912, PLR0915 integrated_system=integrated_system, product=product, bulk_discount_collection=bulk_discount_collection, + company=company, + transaction_number=transaction_number, ) if users: discount.assigned_users.set(users) diff --git a/payments/management/commands/generate_discount_code.py b/payments/management/commands/generate_discount_code.py index aae959b3..532ae942 100644 --- a/payments/management/commands/generate_discount_code.py +++ b/payments/management/commands/generate_discount_code.py @@ -124,6 +124,11 @@ def add_arguments(self, parser) -> None: help="List of user IDs or emails to associate with the discount.", ) + parser.add_argument( + "--company", + help="Company ID to associate with the discount.", + ) + def handle(self, *args, **kwargs): # pylint: disable=unused-argument # noqa: ARG002 """ Handle the generation of discount codes based on the provided arguments. diff --git a/payments/permissions.py b/payments/permissions.py new file mode 100644 index 00000000..1b6c8a4b --- /dev/null +++ b/payments/permissions.py @@ -0,0 +1,11 @@ +from rest_framework_api_key.permissions import BaseHasAPIKey + +from system_meta.models import IntegratedSystemAPIKey + + +class HasIntegratedSystemAPIKey(BaseHasAPIKey): + """ + Permission class to check for Integrated System API Key. + """ + + model = IntegratedSystemAPIKey diff --git a/payments/serializers/v0/__init__.py b/payments/serializers/v0/__init__.py index 362a00f4..346cc5e0 100644 --- a/payments/serializers/v0/__init__.py +++ b/payments/serializers/v0/__init__.py @@ -11,7 +11,7 @@ PAYMENT_HOOK_ACTION_POST_SALE, PAYMENT_HOOK_ACTIONS, ) -from payments.models import Basket, BasketItem, Line, Order +from payments.models import Basket, BasketItem, Company, Discount, Line, Order from system_meta.models import Product from system_meta.serializers import IntegratedSystemSerializer, ProductSerializer from unified_ecommerce.serializers import UserSerializer @@ -217,3 +217,40 @@ class Meta: "updated_on", ] model = Order + + +class CompanySerializer(serializers.ModelSerializer): + """Serializer for companies.""" + + class Meta: + """Meta options for CompanySerializer""" + + model = Company + fields = ["id", "name"] + + +class DiscountSerializer(serializers.ModelSerializer): + """Serializer for discounts.""" + + assigned_users = UserSerializer(many=True) + integrated_system = IntegratedSystemSerializer() + product = ProductSerializer() + company = CompanySerializer() + + class Meta: + """Meta options for DiscountSerializer""" + + fields = [ + "id", + "discount_code", + "amount", + "payment_type", + "max_redemptions", + "activation_date", + "expiration_date", + "integrated_system", + "product", + "assigned_users", + "company", + ] + model = Discount diff --git a/payments/views/v0/__init__.py b/payments/views/v0/__init__.py index 2ce3f46d..8b1b0d52 100644 --- a/payments/views/v0/__init__.py +++ b/payments/views/v0/__init__.py @@ -31,11 +31,13 @@ from payments import api from payments.exceptions import ProductBlockedError from payments.models import Basket, BasketItem, Discount, Order +from payments.permissions import HasIntegratedSystemAPIKey from payments.serializers.v0 import ( BasketWithProductSerializer, + DiscountSerializer, OrderHistorySerializer, ) -from system_meta.models import IntegratedSystem, Product +from system_meta.models import IntegratedSystem, IntegratedSystemAPIKey, Product from unified_ecommerce import settings from unified_ecommerce.constants import ( POST_SALE_SOURCE_BACKOFFICE, @@ -471,3 +473,45 @@ def add_discount_to_basket(request, system_slug: str): BasketWithProductSerializer(basket).data, status=status.HTTP_200_OK, ) + + +class DiscountAPIViewSet(APIView): + """ + Provides API for creating Discount objects. + Discounts created through this API will be associated + with the integrated system that is linked to the api key. + + Responds with a 201 status code if the discount is created successfully. + """ + + permission_classes = [HasIntegratedSystemAPIKey] + authentication_classes = [] # disables authentication + + @extend_schema( + description="Create a discount.", + methods=["POST"], + request=None, + responses=DiscountSerializer, + ) + def post(self, request): + """ + Create discounts. + + Args: + request: The request object. + + Returns: + Response: The response object. + """ + key = request.META["HTTP_AUTHORIZATION"].split()[1] + api_key = IntegratedSystemAPIKey.objects.get_from_key(key) + discount_dictionary = request.data + discount_dictionary["integrated_system"] = str(api_key.integrated_system.id) + discount_codes = api.generate_discount_code( + **discount_dictionary, + ) + + return Response( + {"discounts_created": DiscountSerializer(discount_codes, many=True).data}, + status=status.HTTP_201_CREATED, + ) diff --git a/payments/views/v0/urls.py b/payments/views/v0/urls.py index 17fea9a6..dd2a5768 100644 --- a/payments/views/v0/urls.py +++ b/payments/views/v0/urls.py @@ -7,6 +7,7 @@ BasketViewSet, CheckoutApiViewSet, CheckoutCallbackView, + DiscountAPIViewSet, OrderHistoryViewSet, add_discount_to_basket, clear_basket, @@ -43,6 +44,11 @@ BackofficeCallbackView.as_view(), name="checkout-callback", ), + path( + "discounts/", + DiscountAPIViewSet.as_view(), + name="discount-api", + ), re_path( r"^", include( diff --git a/poetry.lock b/poetry.lock index ffee2288..e2ace57a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand. [[package]] name = "amqp" @@ -1373,6 +1373,20 @@ files = [ [package.dependencies] django = ">=4.2" +[[package]] +name = "djangorestframework-api-key" +version = "3.0.0" +description = "API key permissions for the Django REST Framework" +optional = false +python-versions = ">=3.8" +files = [ + {file = "djangorestframework-api-key-3.0.0.tar.gz", hash = "sha256:f18cdfa45aaea10fd4daaebffa60481ce4002c9b9ef6c551ef1fc21dadf28845"}, + {file = "djangorestframework_api_key-3.0.0-py3-none-any.whl", hash = "sha256:b9443cd864e43caebdd330224f9309957b38128267fbc9dc1ba2f3fa1c8414d0"}, +] + +[package.dependencies] +packaging = "*" + [[package]] name = "djangorestframework-dataclasses" version = "1.3.1" @@ -1602,8 +1616,8 @@ files = [ google-auth = ">=2.14.1,<3.0.dev0" googleapis-common-protos = ">=1.56.2,<2.0.dev0" proto-plus = [ - {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, {version = ">=1.22.3,<2.0.0dev", markers = "python_version < \"3.13\""}, + {version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""}, ] protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" requests = ">=2.18.0,<3.0.0.dev0" @@ -4327,4 +4341,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" python-versions = "^3.11.0" -content-hash = "e5620c30ca7cd18cd7d873c50725a83719fefb76d1edb4b22949fe7ba67f8bbd" +content-hash = "06cae292b45df0e7590b969713ea3a78d176e2630486f9ddb76a96e3d07383f5" diff --git a/pyproject.toml b/pyproject.toml index 6323a8f3..05655b9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,7 @@ django-countries = "^7.6.1" mitol-django-geoip = ">=2024.11.05" py-moneyed = "^3.0" django-extensions = "^3.2.3" +djangorestframework-api-key = "^3.0.0" [tool.poetry.group.dev.dependencies] bpython = "^0.24" diff --git a/system_meta/admin.py b/system_meta/admin.py index 6e5f24ea..84570513 100644 --- a/system_meta/admin.py +++ b/system_meta/admin.py @@ -1,10 +1,11 @@ """Django Admin for system_meta app""" from django.contrib import admin +from rest_framework_api_key.admin import APIKeyModelAdmin from reversion.admin import VersionAdmin from safedelete.admin import SafeDeleteAdmin, SafeDeleteAdminFilter, highlight_deleted -from system_meta.models import IntegratedSystem, Product +from system_meta.models import IntegratedSystem, IntegratedSystemAPIKey, Product class IntegratedSystemAdmin(SafeDeleteAdmin): @@ -26,6 +27,11 @@ class IntegratedSystemAdmin(SafeDeleteAdmin): ) +@admin.register(IntegratedSystemAPIKey) +class IntegratedSystemAPIKeyAdmin(APIKeyModelAdmin): + pass + + class ProductAdmin(SafeDeleteAdmin, VersionAdmin): """Admin for Product model""" diff --git a/system_meta/migrations/0006_integratedsystemapikey.py b/system_meta/migrations/0006_integratedsystemapikey.py new file mode 100644 index 00000000..254f3b6f --- /dev/null +++ b/system_meta/migrations/0006_integratedsystemapikey.py @@ -0,0 +1,63 @@ +# Generated by Django 4.2.16 on 2024-12-02 19:27 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("system_meta", "0005_integratedsystem_payment_process_redirect_url"), + ] + + operations = [ + migrations.CreateModel( + name="IntegratedSystemAPIKey", + fields=[ + ( + "id", + models.CharField( + editable=False, + max_length=150, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ("prefix", models.CharField(editable=False, max_length=8, unique=True)), + ("hashed_key", models.CharField(editable=False, max_length=150)), + ("created", models.DateTimeField(auto_now_add=True, db_index=True)), + ( + "revoked", + models.BooleanField( + blank=True, + default=False, + help_text="If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)", + ), + ), + ( + "expiry_date", + models.DateTimeField( + blank=True, + help_text="Once API key expires, clients cannot use it anymore.", + null=True, + verbose_name="Expires", + ), + ), + ("name", models.CharField(max_length=100, unique=True)), + ( + "integrated_system", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_keys", + to="system_meta.integratedsystem", + ), + ), + ], + options={ + "verbose_name": "Integrated System API Key", + "verbose_name_plural": "Integrated System API Keys", + "ordering": ("-created",), + "abstract": False, + }, + ), + ] diff --git a/system_meta/models.py b/system_meta/models.py index 1f0c7122..99ff7541 100644 --- a/system_meta/models.py +++ b/system_meta/models.py @@ -8,6 +8,7 @@ from django.utils.functional import cached_property from mitol.common.models import TimestampedModel from mitol.payment_gateway.payment_utils import quantize_decimal +from rest_framework_api_key.models import AbstractAPIKey from safedelete.managers import SafeDeleteManager from safedelete.models import SafeDeleteModel from slugify import slugify @@ -136,3 +137,16 @@ def price_money(self): """Return the item price as a quantized decimal.""" return quantize_decimal(self.price) + + +class IntegratedSystemAPIKey(AbstractAPIKey): + """API key for an integrated system""" + + name = models.CharField(max_length=100, unique=True) + integrated_system = models.ForeignKey( + "IntegratedSystem", on_delete=models.CASCADE, related_name="api_keys" + ) + + class Meta(AbstractAPIKey.Meta): + verbose_name = "Integrated System API Key" + verbose_name_plural = "Integrated System API Keys" diff --git a/system_meta/urls.py b/system_meta/urls.py index ff9c8e39..dac3f399 100644 --- a/system_meta/urls.py +++ b/system_meta/urls.py @@ -1,6 +1,7 @@ """Routes for the system_meta app.""" -from django.urls import include, re_path +from django.contrib import admin +from django.urls import include, path, re_path from rest_framework import routers from system_meta.views import ( @@ -18,5 +19,6 @@ v0_router.register(r"^meta/product", ProductViewSet, basename="meta_products_api") urlpatterns = [ + path("admin/", admin.site.urls), re_path("^api/v0/", include((v0_router.urls, "v0"))), ] diff --git a/unified_ecommerce/settings.py b/unified_ecommerce/settings.py index 38889a5b..02394961 100644 --- a/unified_ecommerce/settings.py +++ b/unified_ecommerce/settings.py @@ -78,6 +78,7 @@ "django.contrib.staticfiles", "server_status", "rest_framework", + "rest_framework_api_key", "corsheaders", # "webpack_loader", "anymail",