Skip to content

Commit 3257153

Browse files
authored
Update attach user endpoint to check redemptions more closely (#3066)
1 parent fd7969e commit 3257153

File tree

5 files changed

+102
-13
lines changed

5 files changed

+102
-13
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Generated by Django 4.2.25 on 2025-11-03 21:18
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
dependencies = [
10+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
11+
("ecommerce", "0036_alter_order_state"),
12+
("b2b", "0011_sync_contract_type_fields"),
13+
]
14+
15+
operations = [
16+
migrations.AlterField(
17+
model_name="discountcontractattachmentredemption",
18+
name="contract",
19+
field=models.ForeignKey(
20+
help_text="The contract that the user was attached to.",
21+
on_delete=django.db.models.deletion.DO_NOTHING,
22+
related_name="code_redemptions",
23+
to="b2b.contractpage",
24+
),
25+
),
26+
migrations.AlterField(
27+
model_name="discountcontractattachmentredemption",
28+
name="discount",
29+
field=models.ForeignKey(
30+
help_text="The discount that was redemeed.",
31+
on_delete=django.db.models.deletion.DO_NOTHING,
32+
related_name="contract_redemptions",
33+
to="ecommerce.discount",
34+
),
35+
),
36+
migrations.AlterField(
37+
model_name="discountcontractattachmentredemption",
38+
name="user",
39+
field=models.ForeignKey(
40+
help_text="The user that redeemed the discount.",
41+
on_delete=django.db.models.deletion.DO_NOTHING,
42+
related_name="+",
43+
to=settings.AUTH_USER_MODEL,
44+
),
45+
),
46+
]

b2b/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,16 +432,19 @@ class DiscountContractAttachmentRedemption(TimestampedModel):
432432
"ecommerce.Discount",
433433
on_delete=models.DO_NOTHING,
434434
help_text="The discount that was redemeed.",
435+
related_name="contract_redemptions",
435436
)
436437
user = models.ForeignKey(
437438
settings.AUTH_USER_MODEL,
438439
on_delete=models.DO_NOTHING,
439440
help_text="The user that redeemed the discount.",
441+
related_name="+",
440442
)
441443
contract = models.ForeignKey(
442444
ContractPage,
443445
on_delete=models.DO_NOTHING,
444446
help_text="The contract that the user was attached to.",
447+
related_name="code_redemptions",
445448
)
446449

447450

b2b/views/v0/__init__.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Views for the B2B API (v0)."""
22

33
from django.contrib.contenttypes.models import ContentType
4-
from django.db.models import Q
4+
from django.db.models import Count, Q
55
from django.views.decorators.csrf import csrf_exempt
66
from drf_spectacular.utils import extend_schema
77
from mitol.common.utils.datetime import now_in_utc
@@ -23,6 +23,7 @@
2323
OrganizationPageSerializer,
2424
)
2525
from courses.models import CourseRun
26+
from ecommerce.constants import REDEMPTION_TYPE_UNLIMITED
2627
from ecommerce.models import Discount, Product
2728
from main.authentication import CsrfExemptSessionAuthentication
2829
from main.constants import USER_MSG_TYPE_B2B_ENROLL_SUCCESS
@@ -107,7 +108,8 @@ def post(self, request, enrollment_code: str, format=None): # noqa: A002, ARG00
107108
108109
This will respect the activation and expiration dates (of both the contract
109110
and the discount), and will make sure there's sufficient available seats
110-
in the contract.
111+
in the contract. It will also make sure the code hasn't been used for
112+
attachment purposes before.
111113
112114
If the user is already in the contract, then we skip it.
113115
@@ -118,10 +120,13 @@ def post(self, request, enrollment_code: str, format=None): # noqa: A002, ARG00
118120
now = now_in_utc()
119121
try:
120122
code = (
121-
Discount.objects.filter(
122-
Q(activation_date__isnull=True) | Q(activation_date__lte=now)
123-
)
123+
Discount.objects.annotate(Count("contract_redemptions"))
124+
.filter(Q(activation_date__isnull=True) | Q(activation_date__lte=now))
124125
.filter(Q(expiration_date__isnull=True) | Q(expiration_date__gte=now))
126+
.filter(
127+
Q(redemption_type=REDEMPTION_TYPE_UNLIMITED)
128+
| Q(contract_redemptions__count__lt=1)
129+
)
125130
.get(discount_code=enrollment_code)
126131
)
127132
except Discount.DoesNotExist:

b2b/views/v0/views_test.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,33 @@ def test_b2b_contract_attachment_bad_code(user):
3939
assert user.b2b_contracts.count() == 0
4040

4141

42-
def test_b2b_contract_attachment(mocker, user):
42+
@pytest.mark.parametrize(
43+
"max_learners",
44+
[
45+
10,
46+
None,
47+
],
48+
)
49+
@pytest.mark.parametrize(
50+
"code_used",
51+
[
52+
True,
53+
False,
54+
],
55+
)
56+
def test_b2b_contract_attachment(mocker, max_learners, code_used):
4357
"""Ensure a supplied code results in attachment for the user."""
4458

4559
mocked_attach_user = mocker.patch(
4660
"b2b.models.OrganizationPage.attach_user", return_value=True
4761
)
4862

63+
user = UserFactory.create()
64+
4965
contract = ContractPageFactory.create(
5066
membership_type=CONTRACT_MEMBERSHIP_NONSSO,
5167
integration_type=CONTRACT_MEMBERSHIP_NONSSO,
52-
max_learners=10,
68+
max_learners=max_learners,
5369
)
5470

5571
courserun = CourseRunFactory.create(b2b_contract=contract)
@@ -58,6 +74,14 @@ def test_b2b_contract_attachment(mocker, user):
5874
ensure_enrollment_codes_exist(contract)
5975
contract_codes = contract.get_discounts().all()
6076

77+
if code_used:
78+
other_user = UserFactory.create()
79+
DiscountContractAttachmentRedemption.objects.create(
80+
discount=contract_codes[0],
81+
user=other_user,
82+
contract=contract,
83+
)
84+
6185
client = APIClient()
6286
client.force_login(user)
6387

@@ -67,15 +91,25 @@ def test_b2b_contract_attachment(mocker, user):
6791
resp = client.post(url)
6892

6993
assert resp.status_code == 200
70-
mocked_attach_user.assert_called()
7194

7295
user.refresh_from_db()
73-
assert user.b2b_organizations.filter(pk=contract.organization.id).exists()
74-
assert user.b2b_contracts.filter(pk=contract.id).exists()
7596

76-
assert DiscountContractAttachmentRedemption.objects.filter(
77-
contract=contract, user=user, discount=contract_codes[0]
78-
).exists()
97+
if code_used and max_learners:
98+
assert not user.b2b_organizations.filter(pk=contract.organization.id).exists()
99+
assert not user.b2b_contracts.filter(pk=contract.id).exists()
100+
101+
assert not DiscountContractAttachmentRedemption.objects.filter(
102+
contract=contract, user=user, discount=contract_codes[0]
103+
).exists()
104+
else:
105+
mocked_attach_user.assert_called()
106+
107+
assert user.b2b_organizations.filter(pk=contract.organization.id).exists()
108+
assert user.b2b_contracts.filter(pk=contract.id).exists()
109+
110+
assert DiscountContractAttachmentRedemption.objects.filter(
111+
contract=contract, user=user, discount=contract_codes[0]
112+
).exists()
79113

80114

81115
@pytest.mark.parametrize(

courses/admin.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ class CourseRunAdmin(TimestampedModelAdmin):
129129
)
130130
list_filter = [
131131
"live",
132+
"is_source_run",
132133
"course",
133134
"b2b_contract",
134135
]

0 commit comments

Comments
 (0)