Skip to content

Commit

Permalink
Don't allow duplicate coupon codes (#2888)
Browse files Browse the repository at this point in the history
  • Loading branch information
mudassir-hafeez authored Apr 1, 2024
1 parent 51bc4a7 commit af63e53
Show file tree
Hide file tree
Showing 9 changed files with 119 additions and 11 deletions.
23 changes: 23 additions & 0 deletions b2b_ecommerce/migrations/0011_alter_b2bcoupon_coupon_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.23 on 2024-03-04 12:48

from django.db import migrations, models
import ecommerce.utils


class Migration(migrations.Migration):

dependencies = [
("b2b_ecommerce", "0010_b2bline"),
]

operations = [
migrations.AlterField(
model_name="b2bcoupon",
name="coupon_code",
field=models.CharField(
max_length=50,
unique=True,
validators=[ecommerce.utils.CouponUtils.validate_unique_coupon_code],
),
),
]
7 changes: 6 additions & 1 deletion b2b_ecommerce/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
Product,
ProductVersion,
)
from ecommerce.utils import CouponUtils
from mitxpro.models import AuditableModel, AuditModel, TimestampedModel
from mitxpro.utils import serialize_model_object

Expand Down Expand Up @@ -66,7 +67,11 @@ class B2BCoupon(TimestampedModel, AuditableModel):
"""

name = models.TextField()
coupon_code = models.CharField(max_length=50)
coupon_code = models.CharField(
max_length=50,
unique=True,
validators=[CouponUtils.validate_unique_coupon_code],
)
discount_percent = models.DecimalField(
decimal_places=5,
max_digits=20,
Expand Down
24 changes: 22 additions & 2 deletions ecommerce/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from urllib.parse import quote_plus, urljoin

from django.conf import settings
from django.db import transaction
from django.db import IntegrityError, transaction
from django.db.models import Count, F, Max, Prefetch, Q, Subquery
from django.http import HttpRequest
from django.urls import reverse
Expand Down Expand Up @@ -1322,7 +1322,27 @@ def create_coupons(
)
for _ in range(num_coupon_codes)
]
coupon_objs = Coupon.objects.bulk_create(coupons)

try:
with transaction.atomic():
coupon_objs = Coupon.objects.bulk_create(coupons)
except IntegrityError:
log.warning(
"Falling back to create Coupons for coupon payment {} and company {}".format(
name, company_id
)
)

new_coupon_codes = [coupon.coupon_code for coupon in coupons]
existing_coupon_codes = Coupon.objects.filter(
coupon_code__in=new_coupon_codes
).values_list("coupon_code", flat=True)
if existing_coupon_codes:
for coupon in coupons:
if coupon.coupon_code in existing_coupon_codes:
coupon.coupon_code = uuid.uuid4().hex
coupon_objs = Coupon.objects.bulk_create(coupons)

versions = [
CouponVersion(coupon=obj, payment_version=payment_version)
for obj in coupon_objs
Expand Down
11 changes: 8 additions & 3 deletions ecommerce/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1473,14 +1473,19 @@ def test_fetch_and_serialize_unused_coupons_for_all_inactive_products(user):
assert unused_coupons == []


@pytest.mark.parametrize("use_defaults", [True, False])
def test_create_coupons(use_defaults):
@pytest.mark.parametrize(
"use_defaults,num_coupon_codes",
(
(True, 12),
(False, 1),
),
)
def test_create_coupons(use_defaults, num_coupon_codes):
"""create_coupons should fill in good default parameters where necessary"""
product = ProductVersionFactory.create().product
name = "n a m e"
coupon_type = CouponPaymentVersion.SINGLE_USE
amount = Decimal("123")
num_coupon_codes = 12

optional = (
{}
Expand Down
23 changes: 23 additions & 0 deletions ecommerce/migrations/0041_alter_coupon_coupon_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 3.2.23 on 2024-03-04 12:48

from django.db import migrations, models
import ecommerce.utils


class Migration(migrations.Migration):

dependencies = [
("ecommerce", "0040_alter_taxrate_tax_rate"),
]

operations = [
migrations.AlterField(
model_name="coupon",
name="coupon_code",
field=models.CharField(
max_length=50,
unique=True,
validators=[ecommerce.utils.CouponUtils.validate_unique_coupon_code],
),
),
]
12 changes: 10 additions & 2 deletions ecommerce/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@
ORDERED_VERSIONS_QSET_ATTR,
REFERENCE_NUMBER_PREFIX,
)
from ecommerce.utils import get_order_id_by_reference_number, validate_amount
from ecommerce.utils import (
get_order_id_by_reference_number,
validate_amount,
CouponUtils,
)
from mail.constants import MAILGUN_EVENT_CHOICES
from mitxpro.models import (
AuditableModel,
Expand Down Expand Up @@ -636,7 +640,11 @@ class Coupon(TimestampedModel):
coupon information. Since the coupon_code is the identifier for the coupon, this should never be changed.
"""

coupon_code = models.CharField(max_length=50)
coupon_code = models.CharField(
max_length=50,
unique=True,
validators=[CouponUtils.validate_unique_coupon_code],
)
payment = models.ForeignKey(CouponPayment, on_delete=models.PROTECT)
is_global = models.BooleanField(default=False)
enabled = models.BooleanField(default=True)
Expand Down
4 changes: 2 additions & 2 deletions ecommerce/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
)
from ecommerce.constants import CYBERSOURCE_CARD_TYPES, DISCOUNT_TYPES
from ecommerce.models import Basket, TaxRate
from ecommerce.utils import validate_amount
from ecommerce.utils import validate_amount, CouponUtils
from mitxpro.serializers import WriteableSerializerMethodField
from mitxpro.utils import now_in_utc
from users.serializers import ExtendedLegalAddressSerializer
Expand Down Expand Up @@ -875,7 +875,7 @@ class PromoCouponSerializer(BaseCouponSerializer):
num_coupon_codes = serializers.IntegerField(default=1, required=False)
coupon_code = serializers.CharField(
max_length=50,
validators=[UniqueValidator(queryset=models.Coupon.objects.all())],
validators=[CouponUtils.validate_unique_coupon_code],
)
payment_transaction = serializers.CharField(
max_length=256, allow_null=True, required=False, allow_blank=True
Expand Down
24 changes: 24 additions & 0 deletions ecommerce/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from urllib.parse import urljoin, urlencode

from django.conf import settings
from django.core.exceptions import ValidationError
from django.urls import reverse

from courses.constants import ENROLLABLE_ITEM_ID_SEPARATOR
Expand Down Expand Up @@ -108,3 +109,26 @@ def validate_amount(discount_type, amount):
def positive_or_zero(number):
"""Return 0 if a number is negative otherwise return number"""
return 0 if number < 0 else number


class CouponUtils:
@staticmethod
def validate_unique_coupon_code(value):
"""
Validate the uniqueness of coupon codes in Coupon and B2BCoupon models.
"""
if CouponUtils.is_existing_coupon_code(value):
raise ValidationError("Coupon code already exists in the platform.")

@staticmethod
def is_existing_coupon_code(value):
"""
Check if the coupon code exists in either Coupon or B2BCoupon models.
"""
from b2b_ecommerce.models import B2BCoupon
from ecommerce.models import Coupon

return (
Coupon.objects.filter(coupon_code=value).exists()
or B2BCoupon.objects.filter(coupon_code=value).exists()
)
2 changes: 1 addition & 1 deletion ecommerce/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1150,7 +1150,7 @@ def test_post_global_promo_coupon(admin_drf_client, promo_coupon_json):
"At least one product must be selected or coupon should be global.",
],
["name", "AlreadyExists", "This field must be unique."],
["coupon_code", "AlreadyExists", "This field must be unique."],
["coupon_code", "AlreadyExists", "Coupon code already exists in the platform."],
],
)
@pytest.mark.parametrize(
Expand Down

0 comments on commit af63e53

Please sign in to comment.