diff --git a/.gitignore b/.gitignore index 36d23203..fd7b9fb3 100644 --- a/.gitignore +++ b/.gitignore @@ -134,3 +134,6 @@ config/keycloak/tls/tls.key # Docker Compose override docker-compose.override.yml + +# Bulk discount csv +generated-codes.csv diff --git a/payments/admin.py b/payments/admin.py index c3d0a4c8..af26df66 100644 --- a/payments/admin.py +++ b/payments/admin.py @@ -191,9 +191,7 @@ def get_queryset(self, request): @admin.register(models.Discount) -class DiscountAdmin(admin.ModelAdmin): - """Admin for Discount""" - +class DiscountAdmin(VersionAdmin): model = models.Discount search_fields = ["discount_type", "redemption_type", "discount_code"] list_display = [ @@ -222,6 +220,18 @@ class RedeemedDiscountAdmin(admin.ModelAdmin): list_filter = ["discount", "order", "user"] +@admin.register(models.BulkDiscountCollection) +class BulkDiscountCollectionAdmin(VersionAdmin): + """Admin for BulkDiscountCollection""" + + model = models.BulkDiscountCollection + search_fields = ["prefix"] + list_display = [ + "prefix", + ] + list_filter = ["prefix"] + + @admin.register(models.BlockedCountry) class BlockedCountryAdmin(SafeDeleteAdmin): """Admin for BlockedCountry""" diff --git a/payments/api.py b/payments/api.py index 56ad2f78..7689ff38 100644 --- a/payments/api.py +++ b/payments/api.py @@ -1,8 +1,12 @@ """Ecommerce APIs""" import logging +import uuid +from decimal import Decimal +import reversion from django.conf import settings +from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist, PermissionDenied, ValidationError from django.db import transaction from django.db.models import Q, QuerySet @@ -21,6 +25,7 @@ from payments.models import ( Basket, BlockedCountry, + BulkDiscountCollection, Discount, FulfilledOrder, Order, @@ -28,15 +33,23 @@ TaxRate, ) from payments.tasks import send_post_sale_webhook -from system_meta.models import IntegratedSystem +from payments.utils import parse_supplied_date +from system_meta.models import IntegratedSystem, Product from unified_ecommerce.constants import ( + ALL_DISCOUNT_TYPES, + ALL_PAYMENT_TYPES, + ALL_REDEMPTION_TYPES, CYBERSOURCE_ACCEPT_CODES, CYBERSOURCE_ERROR_CODES, CYBERSOURCE_REASON_CODE_SUCCESS, + DISCOUNT_TYPE_PERCENT_OFF, FLAGGED_COUNTRY_BLOCKED, FLAGGED_COUNTRY_TAX, POST_SALE_SOURCE_BACKOFFICE, POST_SALE_SOURCE_REDIRECT, + REDEMPTION_TYPE_ONE_TIME, + REDEMPTION_TYPE_ONE_TIME_PER_USER, + REDEMPTION_TYPE_UNLIMITED, REFUND_SUCCESS_STATES, USER_MSG_TYPE_PAYMENT_ACCEPTED_NOVALUE, ZERO_PAYMENT_DATA, @@ -45,6 +58,7 @@ from users.api import determine_user_location, get_flagged_countries log = logging.getLogger(__name__) +User = get_user_model() def generate_checkout_payload(request, system): @@ -476,6 +490,377 @@ def get_auto_apply_discounts_for_basket(basket_id: int) -> QuerySet[Discount]: ) +def generate_discount_code(**kwargs): # noqa: C901, PLR0912, PLR0915 + """ + Generate a discount code (or a batch of discount codes) as specified by the + arguments passed. + + Note that the prefix argument will not add any characters between it and the + UUID - if you want one (the convention is a -), you need to ensure it's + there in the prefix (and that counts against the limit) + + If you specify redemption_type, specifying one_time or one_time_per_user will not be + honored. + + Keyword Args: + * discount_type - one of the valid discount types + * payment_type - one of the valid payment types + * redemption_type - one of the valid redemption types (overrules use of the flags) + * amount - the value of the discount + * one_time - boolean; discount can only be redeemed once + * one_time_per_user - boolean; discount can only be redeemed once per user + * activates - date to activate + * expires - date to expire the code + * count - number of codes to create (requires prefix) + * prefix - prefix to append to the codes (max 63 characters) + + Returns: + * List of generated codes, with the following fields: + code, type, amount, expiration_date + + """ + codes_to_generate = [] + discount_type = kwargs["discount_type"] + redemption_type = REDEMPTION_TYPE_UNLIMITED + payment_type = kwargs["payment_type"] + amount = Decimal(kwargs["amount"]) + bulk_discount_collection = None + if kwargs["discount_type"] not in ALL_DISCOUNT_TYPES: + raise ValueError(f"Invalid discount type: {kwargs['discount_type']}.") # noqa: EM102, TRY003 + + if payment_type not in ALL_PAYMENT_TYPES: + raise ValueError(f"Payment type {payment_type} is not valid.") # noqa: EM102, TRY003 + + if kwargs["discount_type"] == DISCOUNT_TYPE_PERCENT_OFF and amount > 100: # noqa: PLR2004 + message = ( + f"Discount amount {amount} not valid for discount type " + f"{DISCOUNT_TYPE_PERCENT_OFF}." + ) + raise ValueError(message) + + if kwargs["count"] > 1 and "prefix" not in kwargs: + raise ValueError("You must specify a prefix to create a batch of codes.") # noqa: EM101, TRY003 + + if kwargs["count"] > 1: + prefix = kwargs["prefix"] + if prefix: + # upped the discount code limit to 100 characters - this used to be 13 (50 - 37 for the UUID) # noqa: E501 + if len(prefix) > 63: # noqa: PLR2004 + raise ValueError( # noqa: TRY003 + f"Prefix {prefix} is {len(prefix)} - prefixes must be 63 characters or less." # noqa: E501, EM102 + ) + bulk_discount_collection, _ = BulkDiscountCollection.objects.get_or_create( + prefix=prefix + ) + + for i in range(kwargs["count"]): # noqa: B007 + generated_uuid = uuid.uuid4() + code = f"{prefix}{generated_uuid}" + + codes_to_generate.append(code) + else: + codes_to_generate = kwargs["codes"] + + if kwargs.get("one_time"): + redemption_type = REDEMPTION_TYPE_ONE_TIME + + if kwargs.get("once_per_user"): + redemption_type = REDEMPTION_TYPE_ONE_TIME_PER_USER + + if ( + "redemption_type" in kwargs + and kwargs["redemption_type"] in ALL_REDEMPTION_TYPES + ): + redemption_type = kwargs["redemption_type"] + + if "expires" in kwargs and kwargs["expires"] is not None: + expiration_date = parse_supplied_date(kwargs["expires"]) + else: + expiration_date = None + + if "activates" in kwargs and kwargs["activates"] is not None: + activation_date = parse_supplied_date(kwargs["activates"]) + else: + activation_date = None + + if "integrated_system" in kwargs and kwargs["integrated_system"] is not None: + # Try to get the integrated system via ID or slug. Raise an exception if it doesn't exist. # noqa: E501 + # check if integrated_system is an integer or a slug + integrated_system_missing_msg = ( + f"Integrated system {kwargs['integrated_system']} does not exist." + ) + if kwargs["integrated_system"].isdigit(): + try: + integrated_system = IntegratedSystem.objects.get( + pk=kwargs["integrated_system"] + ) + except IntegratedSystem.DoesNotExist: + raise ValueError(integrated_system_missing_msg) # noqa: B904 + else: + try: + integrated_system = IntegratedSystem.objects.get( + slug=kwargs["integrated_system"] + ) + except IntegratedSystem.DoesNotExist: + raise ValueError(integrated_system_missing_msg) # noqa: B904 + else: + integrated_system = None + + if "product" in kwargs and kwargs["product"] is not None: + # Try to get the product via ID or SKU. Raise an exception if it doesn't exist. + product_missing_msg = f"Product {kwargs['product']} does not exist." + if kwargs["product"].isdigit(): + try: + product = Product.objects.get(pk=kwargs["product"]) + except Product.DoesNotExist: + raise ValueError(product_missing_msg) # noqa: B904 + else: + try: + product = Product.objects.get(sku=kwargs["product"]) + except Product.DoesNotExist: + raise ValueError(product_missing_msg) # noqa: B904 + else: + product = None + + if "users" in kwargs and kwargs["users"] is not None: + # Try to get the users via ID or email. Raise an exception if it doesn't exist. + users = [] + user_missing_msg = "User %s does not exist." + for user_identifier in kwargs["users"]: + if user_identifier.isdigit(): + try: + users.append(User.objects.get(pk=user_identifier)) + except User.DoesNotExist: + raise ValueError(user_missing_msg % user_identifier) # noqa: B904 + else: + try: + user = User.objects.get(email=user_identifier) + users.append(user) + except User.DoesNotExist: + raise ValueError(user_missing_msg % user_identifier) # noqa: B904 + else: + users = None + + generated_codes = [] + + for code_to_generate in codes_to_generate: + with reversion.create_revision(): + discount = Discount.objects.create( + discount_type=discount_type, + redemption_type=redemption_type, + payment_type=payment_type, + expiration_date=expiration_date, + activation_date=activation_date, + discount_code=code_to_generate, + amount=amount, + is_bulk=True, + integrated_system=integrated_system, + product=product, + bulk_discount_collection=bulk_discount_collection, + ) + if users: + discount.assigned_users.set(users) + + generated_codes.append(discount) + + return generated_codes + + +def update_discount_codes(**kwargs): # noqa: C901, PLR0912, PLR0915 + """ + Update a discount code (or a batch of discount codes) as specified by the + arguments passed. + + Keyword Args: + * discount_codes - list of discount codes to update + * discount_type - one of the valid discount types + * payment_type - one of the valid payment types + * amount - the value of the discount + * one_time - boolean; discount can only be redeemed once + * one_time_per_user - boolean; discount can only be redeemed once per user + * activates - date to activate + * expires - date to expire the code + * integrated_system - ID or slug of the integrated system to associate + with the discount + * product - ID or SKU of the product to associate with the discount + * users - list of user IDs or emails to associate with the discount + * clear_users - boolean; clear the users associated with the discount + * clear_products - boolean; clear the products associated with the discount + * clear_integrated_systems - boolean; clear the integrated systems associated with + the discount + * prefix - prefix of the bulk discount codes to update + + Returns: + * Number of discounts updated + + """ + discount_codes_to_update = kwargs["discount_codes"] + if kwargs.get("discount_type"): + if kwargs["discount_type"] not in ALL_DISCOUNT_TYPES: + error_message = f"Discount type {kwargs['discount_type']} is not valid." + raise ValueError(error_message) + else: + discount_type = kwargs["discount_type"] + else: + discount_type = None + + if kwargs.get("payment_type"): + if kwargs["payment_type"] not in ALL_PAYMENT_TYPES: + error_message = f"Payment type {kwargs['payment_type']} is not valid." + raise ValueError(error_message) + else: + payment_type = kwargs["payment_type"] + else: + payment_type = None + + if kwargs.get("one_time"): + redemption_type = REDEMPTION_TYPE_ONE_TIME + elif kwargs.get("one_time_per_user"): + redemption_type = REDEMPTION_TYPE_ONE_TIME_PER_USER + else: + redemption_type = REDEMPTION_TYPE_UNLIMITED + + amount = Decimal(kwargs["amount"]) if kwargs.get("amount") else None + + if kwargs.get("activates"): + activation_date = parse_supplied_date(kwargs["activates"]) + else: + activation_date = None + + if kwargs.get("expires"): + expiration_date = parse_supplied_date(kwargs["expires"]) + else: + expiration_date = None + + if kwargs.get("integrated_system"): + # Try to get the integrated system via ID or slug. + # Raise an exception if it doesn't exist. + integrated_system_missing_msg = ( + f"Integrated system {kwargs['integrated_system']} does not exist." + ) + if kwargs["integrated_system"].isdigit(): + try: + integrated_system = IntegratedSystem.objects.get( + pk=kwargs["integrated_system"] + ) + except IntegratedSystem.DoesNotExist: + raise ValueError(integrated_system_missing_msg) # noqa: B904 + else: + try: + integrated_system = IntegratedSystem.objects.get( + slug=kwargs["integrated_system"] + ) + except IntegratedSystem.DoesNotExist: + raise ValueError(integrated_system_missing_msg) # noqa: B904 + else: + integrated_system = None + + if kwargs.get("product"): + if kwargs.get("clear_products"): + error_message = "Cannot clear and set products at the same time." + raise ValueError(error_message) + # Try to get the product via ID or SKU. Raise an exception if it doesn't exist. + product_missing_msg = f"Product {kwargs['product']} does not exist." + if kwargs["product"].isdigit(): + try: + product = Product.objects.get(pk=kwargs["product"]) + except Product.DoesNotExist: + raise ValueError(product_missing_msg) # noqa: B904 + else: + try: + product = Product.objects.get(sku=kwargs["product"]) + except Product.DoesNotExist: + raise ValueError(product_missing_msg) # noqa: B904 + else: + product = None + + if kwargs.get("users"): + if kwargs.get("clear_users"): + error_message = "Cannot clear and set users at the same time." + raise ValueError(error_message) + # Try to get the users via ID or email. Raise an exception if it doesn't exist. + users = [] + user_missing_msg = "User %s does not exist." + for user_identifier in kwargs["users"]: + if user_identifier.isdigit(): + try: + users.append(User.objects.get(pk=user_identifier)) + except User.DoesNotExist: + raise ValueError(user_missing_msg % user_identifier) # noqa: B904 + else: + try: + user = User.objects.get(email=user_identifier) + users.append(user) + except User.DoesNotExist: + raise ValueError(user_missing_msg % user) # noqa: B904 + else: + users = None + + if kwargs.get("prefix"): + prefix = kwargs["prefix"] + bulk_discount_collection = BulkDiscountCollection.objects.filter( + prefix=prefix + ).first() + if not bulk_discount_collection: + error_message = ( + f"Bulk discount collection with prefix {prefix} does not exist." + ) + raise ValueError(error_message) + discounts_to_update = bulk_discount_collection.discounts.all() + else: + discounts_to_update = Discount.objects.filter( + discount_code__in=discount_codes_to_update + ) + + # Don't include any discounts with one time or one time per user redemption types + # if there is a matching RedeemedDiscount, or if the max_redemptions + # has been reached. + for discount in discounts_to_update: + if discount.redemption_type in [ + REDEMPTION_TYPE_ONE_TIME, + REDEMPTION_TYPE_ONE_TIME_PER_USER, + ]: + if discount.redeemed_discounts.exists(): + discounts_to_update = discounts_to_update.exclude(pk=discount.pk) + elif ( + discount.max_redemptions + and discount.redeemed_discounts.count() == discount.max_redemptions + ): + discounts_to_update = discounts_to_update.exclude(pk=discount.pk) + + discount_attributes_dict = { + "discount_type": discount_type, + "redemption_type": redemption_type, + "payment_type": payment_type, + "expiration_date": expiration_date, + "activation_date": activation_date, + "amount": amount, + "integrated_system": integrated_system, + "product": product, + } + discount_values_to_update = { + key: value + for key, value in discount_attributes_dict.items() + if value is not None + } + with reversion.create_revision(): + number_of_discounts_updated = discounts_to_update.update( + **discount_values_to_update, + ) + if kwargs.get("clear_products"): + discounts_to_update.update(product=None) + if kwargs.get("clear_integrated_systems"): + discounts_to_update.update(integrated_system=None) + if kwargs.get("clear_users"): + for discount in discounts_to_update: + discount.assigned_users.clear() + elif users: + for discount in discounts_to_update: + discount.assigned_users.set(users) + + return number_of_discounts_updated + + def locate_customer_for_basket(request, basket, basket_item): """ Locate the customer. diff --git a/payments/api_test.py b/payments/api_test.py index a0858cf3..6cbbcebd 100644 --- a/payments/api_test.py +++ b/payments/api_test.py @@ -25,6 +25,7 @@ from payments.factories import ( BasketFactory, BasketItemFactory, + DiscountFactory, LineFactory, OrderFactory, TransactionFactory, @@ -726,23 +727,26 @@ def test_get_auto_apply_discount_for_basket_multiple_auto_discount_exists_for_us when they exist for the basket's - basket item - product, basket's user, and basket's integrated system. """ basket_item = BasketItemFactory.create() - user_discount = Discount.objects.create( + user_discount = DiscountFactory.create( automatic=True, amount=10, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + discount_code=uuid.uuid4(), ) basket_item.basket.user.discounts.add(user_discount) - Discount.objects.create( + DiscountFactory.create( automatic=True, amount=10, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, integrated_system=basket_item.basket.integrated_system, + discount_code=uuid.uuid4(), ) - Discount.objects.create( + DiscountFactory.create( automatic=True, amount=10, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, product=basket_item.product, + discount_code=uuid.uuid4(), ) discount = get_auto_apply_discounts_for_basket(basket_item.basket.id) @@ -755,23 +759,26 @@ def test_get_auto_apply_discount_for_basket_no_auto_discount_exists(): when no auto discount exists for the basket. """ basket_item = BasketItemFactory.create() - user_discount = Discount.objects.create( + user_discount = DiscountFactory.create( automatic=False, amount=10, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + discount_code=uuid.uuid4(), ) basket_item.basket.user.discounts.add(user_discount) - Discount.objects.create( + DiscountFactory.create( automatic=False, amount=10, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, integrated_system=basket_item.basket.integrated_system, + discount_code=uuid.uuid4(), ) - Discount.objects.create( + DiscountFactory.create( automatic=False, amount=10, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, product=basket_item.product, + discount_code=uuid.uuid4(), ) discount = get_auto_apply_discounts_for_basket(basket_item.basket.id) diff --git a/payments/factories.py b/payments/factories.py index 5f7ac706..eafd8b23 100644 --- a/payments/factories.py +++ b/payments/factories.py @@ -92,3 +92,25 @@ class Meta: """Meta options for BlockedCountryFactory""" model = models.TaxRate + + +class DiscountFactory(DjangoModelFactory): + """Factory for Discount""" + + amount = fuzzy.FuzzyDecimal(low=0, high=99, precision=4) + payment_type = fuzzy.FuzzyChoice( + [ + "marketing", + "sales", + "financial-assistance", + "customer-support", + "staff", + ] + ) + discount_type = fuzzy.FuzzyChoice(["dollars-off", "percent-off", "fixed-price"]) + discount_code = FAKE.unique.word() + + class Meta: + """Meta options for DiscountFactory""" + + model = models.Discount diff --git a/payments/management/commands/__init__.py b/payments/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/management/commands/delete_discount_code.py b/payments/management/commands/delete_discount_code.py new file mode 100644 index 00000000..226aa709 --- /dev/null +++ b/payments/management/commands/delete_discount_code.py @@ -0,0 +1,61 @@ +from datetime import datetime + +import pytz +from django.core.management import BaseCommand + +from payments.models import BulkDiscountCollection, Discount +from unified_ecommerce import settings + + +class Command(BaseCommand): + """ + Deactivates multiple discounts using the Discount codes. + An example usage of this command: + python manage.py delete_discount_code --discount_codes 1 2 3 + """ + + help = "Deactivate multiple discounts using the Discount codes." + + def add_arguments(self, parser) -> None: + """ + Add arguments to the command parser. + """ + parser.add_argument( + "--discount-codes", + type=str, + nargs="+", + help="The codes of the discounts to deactivate.", + ) + parser.add_argument( + "--prefix", + type=str, + help="The prefix of the codes to deactivate.", + ) + + def handle(self, **options) -> None: + """ + Handle the deactivation of discounts based on provided discount codes. + """ + if options["prefix"]: + prefix = options["prefix"] + bulk_discount_collection = BulkDiscountCollection.objects.filter( + prefix=prefix + ).first() + if not bulk_discount_collection: + error_message = ( + f"Bulk discount collection with prefix {prefix} does not exist." + ) + raise ValueError(error_message) + discounts = bulk_discount_collection.discounts.all() + else: + discount_codes = options["discount_codes"] + discounts = Discount.objects.filter(discount_code__in=discount_codes) + # set the expiration date to the current date + for discount in discounts: + discount.expiration_date = datetime.now( + tz=pytz.timezone(settings.TIME_ZONE) + ).strftime("%Y-%m-%d") + discount.save() + self.stderr.write( + self.style.SUCCESS("Discounts have been successfully deactivated.") + ) diff --git a/payments/management/commands/generate_discount_code.py b/payments/management/commands/generate_discount_code.py new file mode 100644 index 00000000..aae959b3 --- /dev/null +++ b/payments/management/commands/generate_discount_code.py @@ -0,0 +1,174 @@ +""" +This module contains the management command to generate discount codes. +""" + +import csv + +from django.core.management import BaseCommand + +from payments.api import generate_discount_code + + +class Command(BaseCommand): + """ + Generates discount codes. + An example usage of this command: + python manage.py generate_discount_code --payment-type marketing \ + --amount 10 --count 5 --one-time + """ + + help = "Generates discount codes." + + def add_arguments(self, parser) -> None: + """ + Add command line arguments to the parser. + """ + parser.add_argument( + "--prefix", + type=str, + help="The prefix to use for the codes. (Maximum length 13 characters)", + ) + + parser.add_argument( + "--expires", + type=str, + help=( + "Optional expiration date for the code, " + "in ISO-8601 (YYYY-MM-DD) format." + ), + ) + + parser.add_argument( + "--activates", + type=str, + help=( + "Optional activation date for the code, " + "in ISO-8601 (YYYY-MM-DD) format." + ), + ) + + parser.add_argument( + "--discount-type", + type=str, + help=( + "Sets the discount type (dollars-off, percent-off, fixed-price; " + "default percent-off)" + ), + default="dollars-off", + ) + + parser.add_argument( + "--payment-type", + type=str, + help=( + "Sets the payment type (marketing, sales, financial-assistance, " + "customer-support, staff)" + ), + required=True, + ) + + parser.add_argument( + "--amount", + type=str, + nargs="?", + help="Sets the discount amount", + required=True, + ) + + parser.add_argument( + "--count", + type=int, + nargs="?", + help="Number of codes to produce. Not required if codes are provided.", + default=1, + ) + + parser.add_argument( + "--one-time", + help=( + "Make the resulting code(s) one-time redemptions " + "(otherwise, default to unlimited)" + ), + action="store_true", + ) + + parser.add_argument( + "--once-per-user", + help=( + "Make the resulting code(s) one-time per user redemptions " + "(otherwise, default to unlimited)" + ), + action="store_true", + ) + + parser.add_argument( + "codes", + nargs="*", + type=str, + help="Discount codes to generate (ignored if --count is specified)", + ) + + parser.add_argument( + "--integrated-system", + help="Integrated system ID or slug to associate with the discount.", + ) + + parser.add_argument( + "--product", + help="Product ID or SKU to associate with the discount.", + ) + + parser.add_argument( + "--users", + nargs="*", + help="List of user IDs or emails 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. + """ + # Don't allow the creation of bulk unlimited discounts. + if not kwargs.get("one_time") and kwargs.get("bulk"): + self.stderr.write( + self.style.ERROR( + "Bulk discounts must be one-time redemptions. " + "Please specify the --one-time flag." + ) + ) + return + + # count is required if codes are not provided + if not kwargs.get("codes") and not kwargs.get("count"): + self.stderr.write( + self.style.ERROR( + "Number of codes to produce is required. " + "Please specify the --count flag." + ) + ) + return + + generated_codes = [] + try: + generated_codes = generate_discount_code(**kwargs) + except (ValueError, TypeError) as e: + self.stderr.write(self.style.ERROR(e)) + + with open("generated-codes.csv", mode="w") as output_file: # noqa: PTH123 + writer = csv.DictWriter( + output_file, ["code", "type", "amount", "expiration_date"] + ) + + writer.writeheader() + + for code in generated_codes: + writer.writerow( + { + "code": code.discount_code, + "type": code.discount_type, + "amount": code.amount, + "expiration_date": code.expiration_date, + } + ) + + self.stdout.write(self.style.SUCCESS(f"{len(generated_codes)} created.")) diff --git a/payments/management/commands/update_discount_code.py b/payments/management/commands/update_discount_code.py new file mode 100644 index 00000000..6d77a12e --- /dev/null +++ b/payments/management/commands/update_discount_code.py @@ -0,0 +1,155 @@ +from datetime import datetime + +import pytz +from django.core.management import BaseCommand + +from payments.api import update_discount_codes +from unified_ecommerce import settings + + +class Command(BaseCommand): + """ + Updates one or multiple discount codes using the Discount codes. + example usage of this command: + python manage.py update_discount_code --discount_codes 1 2 3 --expires 2021-12-31 + --amount 10 + """ + + help = "Updates one or multiple discount codes using the Discount codes." + + def add_arguments(self, parser) -> None: + parser.add_argument( + "--discount-codes", + type=str, + nargs="+", + help="The codes of the discounts to update.", + ) + + parser.add_argument( + "--expires", + type=str, + help=( + "Optional expiration date for the code, " + "in ISO-8601 (YYYY-MM-DD) format." + ), + ) + + parser.add_argument( + "--activates", + type=str, + help=( + "Optional activation date for the code, " + "in ISO-8601 (YYYY-MM-DD) format." + ), + ) + + parser.add_argument( + "--discount-type", + type=str, + help=( + "Sets the discount type (dollars-off, percent-off, fixed-price; " + "default percent-off)" + ), + ) + + parser.add_argument( + "--payment-type", + type=str, + help=( + "Sets the payment type (marketing, sales, financial-assistance, " + "customer-support, staff)" + ), + ) + + parser.add_argument( + "--amount", + type=str, + nargs="?", + help="Sets the discount amount", + ) + + parser.add_argument( + "--one-time", + help=( + "Make the resulting code(s) one-time redemptions " + "(otherwise, default to unlimited)" + ), + action="store_true", + ) + + parser.add_argument( + "--one_time_per_user", + help=( + "Make the resulting code(s) one-time per user redemptions " + "(otherwise, default to unlimited)" + ), + action="store_true", + ) + + parser.add_argument( + "--integrated-system", + help="Integrated system ID or slug to associate with the discount.", + ) + + parser.add_argument( + "--product", + help="Product ID or SKU to associate with the discount.", + ) + + parser.add_argument( + "--users", + nargs="*", + help="List of user IDs or emails to associate with the discount.", + ) + + parser.add_argument( + "--expire-now", + help="Expire the discount code(s) immediately.", + action="store_true", + ) + + parser.add_argument( + "--clear-users", + help="Clear the users associated with the discount code(s).", + action="store_true", + ) + + parser.add_argument( + "--clear-products", + help="Clear the products associated with the discount code(s).", + action="store_true", + ) + + parser.add_argument( + "--clear-integrated-systems", + help="Clear the integrated systems associated with the discount code(s).", + action="store_true", + ) + + parser.add_argument( + "--prefix", + type=str, + help="The prefix of the bulk discount codes to update.", + ) + + def handle(self, **options) -> None: + """ + Handle the updating of discount codes based on the provided options. + """ + if options.get("expire_now"): + # convert date to string + options["expires"] = datetime.now( + tz=pytz.timezone(settings.TIME_ZONE) + ).strftime("%Y-%m-%d") + + number_of_updated_codes = 0 + try: + number_of_updated_codes = update_discount_codes(**options) + except (ValueError, KeyError, TypeError) as e: + self.stderr.write(self.style.ERROR(e)) + + self.stdout.write( + self.style.SUCCESS( + f"Successfully updated {number_of_updated_codes} discounts." + ) + ) diff --git a/payments/migrations/0010_bulkdiscountcollection_and_more.py b/payments/migrations/0010_bulkdiscountcollection_and_more.py new file mode 100644 index 00000000..585a58ad --- /dev/null +++ b/payments/migrations/0010_bulkdiscountcollection_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.16 on 2024-11-14 20:22 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("payments", "0009_add_blocked_country_tax_rate_update_basket_order"), + ] + + operations = [ + migrations.CreateModel( + name="BulkDiscountCollection", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_on", models.DateTimeField(auto_now_add=True)), + ("updated_on", models.DateTimeField(auto_now=True)), + ("prefix", models.CharField(max_length=100, unique=True)), + ], + options={ + "abstract": False, + }, + ), + migrations.AddField( + model_name="discount", + name="bulk_discount_collection", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="discounts", + to="payments.bulkdiscountcollection", + ), + ), + ] diff --git a/payments/migrations/0011_alter_discount_discount_code.py b/payments/migrations/0011_alter_discount_discount_code.py new file mode 100644 index 00000000..579f7efc --- /dev/null +++ b/payments/migrations/0011_alter_discount_discount_code.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.16 on 2024-11-15 16:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("payments", "0010_bulkdiscountcollection_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="discount", + name="discount_code", + field=models.CharField(max_length=100, unique=True), + ), + ] diff --git a/payments/models.py b/payments/models.py index a2f9483d..8175c32a 100644 --- a/payments/models.py +++ b/payments/models.py @@ -8,6 +8,7 @@ from decimal import Decimal import pytz +import reversion from django.conf import settings from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError @@ -40,6 +41,7 @@ pm = get_plugin_manager() +@reversion.register(exclude=("created_on", "updated_on")) class Discount(TimestampedModel): """Discount model""" @@ -52,7 +54,7 @@ class Discount(TimestampedModel): redemption_type = models.CharField(choices=REDEMPTION_TYPES, max_length=30) payment_type = models.CharField(null=True, choices=PAYMENT_TYPES, max_length=30) # noqa: DJ001 max_redemptions = models.PositiveIntegerField(null=True, default=0) - discount_code = models.CharField(max_length=100) + discount_code = models.CharField(max_length=100, unique=True) activation_date = models.DateTimeField( null=True, blank=True, @@ -84,6 +86,13 @@ class Discount(TimestampedModel): blank=True, null=True, ) + bulk_discount_collection = models.ForeignKey( + "BulkDiscountCollection", + on_delete=models.PROTECT, + related_name="discounts", + blank=True, + null=True, + ) def is_valid(self, basket) -> bool: """ @@ -181,6 +190,56 @@ def __str__(self): return f"{self.amount} {self.discount_type} {self.redemption_type} - {self.discount_code}" # noqa: E501 + @staticmethod + def resolve_discount_version(discount, discount_version=None): + """ + Resolve the specified version of the discount. Specify None to indicate the + current version. + + Returns: Discount; either the discount you passed in or the version of the + discount you requested. + """ + if discount_version is None: + return discount + + versions = reversion.models.Version.objects.get_for_object(discount) + + if versions.count() == 0: + return discount + + for test_version in versions.all(): + if test_version == discount_version: + return Discount( + id=test_version.field_dict["id"], + amount=test_version.field_dict["amount"], + automatic=test_version.field_dict["automatic"], + discount_type=test_version.field_dict["discount_type"], + redemption_type=test_version.field_dict["redemption_type"], + payment_type=test_version.field_dict["payment_type"], + max_redemptions=test_version.field_dict["max_redemptions"], + discount_code=test_version.field_dict["discount_code"], + activation_date=test_version.field_dict["activation_date"], + expiration_date=test_version.field_dict["expiration_date"], + is_bulk=test_version.field_dict["is_bulk"], + integrated_system=IntegratedSystem.objects.get( + pk=test_version.field_dict["integrated_system_id"] + ), + product=Product.objects.get( + pk=test_version.field_dict["product_id"] + ), + assigned_users=test_version.field_dict["assigned_users"], + deleted_on=test_version.field_dict["deleted_on"], + deleted_by_cascade=test_version.field_dict["deleted_by_cascade"], + ) + exception_message = "Invalid product version specified" + raise TypeError(exception_message) + + +class BulkDiscountCollection(TimestampedModel): + """Bulk Discount Collection model""" + + prefix = models.CharField(max_length=100, unique=True) + class BlockedCountry(SafeDeleteModel, SoftDeleteActiveModel, TimestampedModel): """ diff --git a/payments/models_test.py b/payments/models_test.py index e559c595..6faa835d 100644 --- a/payments/models_test.py +++ b/payments/models_test.py @@ -1,5 +1,6 @@ """Tests for payment models.""" +import uuid from datetime import datetime, timedelta from decimal import Decimal @@ -79,10 +80,12 @@ def test_unused_discounts_do_not_create_redeemed_discounts_when_creating_pending discount_used = models.Discount.objects.create( amount=10, product=basket.basket_items.first().product, + discount_code=uuid.uuid4(), ) discount_not_used = models.Discount.objects.create( amount=10, product=unused_product, + discount_code=uuid.uuid4(), ) basket.discounts.add(discount_used, discount_not_used) models.PendingOrder.create_from_basket(basket) @@ -103,11 +106,13 @@ def test_only_best_discounts_create_redeemed_discounts_when_creating_pending_ord amount=10, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, product=basket.basket_items.first().product, + discount_code=uuid.uuid4(), ) discount_not_used = models.Discount.objects.create( amount=5, product=basket.basket_items.first().product, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + discount_code=uuid.uuid4(), ) basket.discounts.add(discount_used, discount_not_used) models.PendingOrder.create_from_basket(basket) @@ -337,11 +342,13 @@ def test_discounted_price_for_multiple_discounts_for_product(): amount=10, product=basket_item.product, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + discount_code=uuid.uuid4(), ) discount_2 = models.Discount.objects.create( amount=5, product=basket_item.product, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + discount_code=uuid.uuid4(), ) basket.discounts.add(discount_1, discount_2) @@ -357,11 +364,13 @@ def test_discounted_price_for_multiple_discounts_for_integrated_system(): amount=10, integrated_system=basket.integrated_system, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + discount_code=uuid.uuid4(), ) discount_2 = models.Discount.objects.create( amount=5, integrated_system=basket.integrated_system, discount_type=DISCOUNT_TYPE_DOLLARS_OFF, + discount_code=uuid.uuid4(), ) basket.discounts.add(discount_1, discount_2) diff --git a/payments/utils.py b/payments/utils.py index 8e649694..be4b19d6 100644 --- a/payments/utils.py +++ b/payments/utils.py @@ -2,12 +2,16 @@ from decimal import Decimal +import dateutil +import pytz + from system_meta.models import Product from unified_ecommerce.constants import ( DISCOUNT_TYPE_DOLLARS_OFF, DISCOUNT_TYPE_FIXED_PRICE, DISCOUNT_TYPE_PERCENT_OFF, ) +from unified_ecommerce.settings import TIME_ZONE def product_price_with_discount(discount, product: Product) -> Decimal: @@ -28,3 +32,21 @@ def product_price_with_discount(discount, product: Product) -> Decimal: if discount.discount_type == DISCOUNT_TYPE_FIXED_PRICE: return Decimal(discount.amount) return product.price + + +def parse_supplied_date(datearg): + """ + Create a datetime with timezone from a user-supplied date. For use in + management commands. + + Args: + - datearg (string): the date supplied by the user. + Returns: + - datetime + """ + retDate = dateutil.parser.parse(datearg) + if retDate.utcoffset() is not None: + retDate = retDate - retDate.utcoffset() + + retDate = retDate.replace(tzinfo=pytz.timezone(TIME_ZONE)) + return retDate # noqa: RET504