Skip to content

Commit 02511ab

Browse files
authored
Add API for redeeming enrollment codes for org membership (#2866)
1 parent a513e3f commit 02511ab

File tree

10 files changed

+561
-5
lines changed

10 files changed

+561
-5
lines changed

b2b/admin.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,32 @@
22

33
from django.contrib import admin
44

5-
from b2b.models import ContractPage, OrganizationPage
5+
from b2b.models import (
6+
ContractPage,
7+
DiscountContractAttachmentRedemption,
8+
OrganizationPage,
9+
)
10+
11+
12+
@admin.register(DiscountContractAttachmentRedemption)
13+
class DiscountContractAttachmentRedemptionAdmin(admin.ModelAdmin):
14+
"""Admin for discount attachments."""
15+
16+
list_display = ["user", "contract", "discount", "created_on"]
17+
date_hierarchy = "created_on"
18+
fields = ["user", "contract", "discount", "created_on"]
19+
readonly_fields = ["user", "contract", "discount", "created_on"]
20+
21+
def has_add_permission(self, request): # noqa: ARG002
22+
"""Disable create."""
23+
24+
return False
25+
26+
def has_delete_permission(self, request, obj=None): # noqa: ARG002
27+
"""Disable deletions."""
28+
29+
return False
30+
631

732
admin.site.register(OrganizationPage)
833
admin.site.register(ContractPage)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Generated by Django 4.2.22 on 2025-08-06 20:13
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", "0004_add_sso_org_id_field"),
13+
]
14+
15+
operations = [
16+
migrations.CreateModel(
17+
name="DiscountContractAttachmentRedemption",
18+
fields=[
19+
(
20+
"id",
21+
models.BigAutoField(
22+
auto_created=True,
23+
primary_key=True,
24+
serialize=False,
25+
verbose_name="ID",
26+
),
27+
),
28+
("created_on", models.DateTimeField(auto_now_add=True)),
29+
("updated_on", models.DateTimeField(auto_now=True)),
30+
(
31+
"contract",
32+
models.ForeignKey(
33+
help_text="The contract that the user was attached to.",
34+
on_delete=django.db.models.deletion.DO_NOTHING,
35+
to="b2b.contractpage",
36+
),
37+
),
38+
(
39+
"discount",
40+
models.ForeignKey(
41+
help_text="The discount that was redemeed.",
42+
on_delete=django.db.models.deletion.DO_NOTHING,
43+
to="ecommerce.discount",
44+
),
45+
),
46+
(
47+
"user",
48+
models.ForeignKey(
49+
help_text="The user that redeemed the discount.",
50+
on_delete=django.db.models.deletion.DO_NOTHING,
51+
to=settings.AUTH_USER_MODEL,
52+
),
53+
),
54+
],
55+
options={
56+
"abstract": False,
57+
},
58+
),
59+
]

b2b/models.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Models for B2B data."""
22

3+
from django.conf import settings
34
from django.contrib.auth import get_user_model
45
from django.contrib.contenttypes.models import ContentType
56
from django.db import models
67
from django.http import Http404
78
from django.utils.text import slugify
9+
from mitol.common.models import TimestampedModel
810
from mitol.common.utils import now_in_utc
911
from wagtail.admin.panels import FieldPanel, MultiFieldPanel
1012
from wagtail.fields import RichTextField
@@ -221,6 +223,24 @@ def get_learners(self):
221223
.distinct()
222224
)
223225

226+
def is_full(self):
227+
"""Determine if the contract is full or not."""
228+
229+
return (
230+
self.get_learners().count() >= self.max_learners
231+
if self.max_learners
232+
else False
233+
)
234+
235+
def is_overfull(self):
236+
"""Determine if the contract is overcommitted."""
237+
238+
return (
239+
self.get_learners().count() > self.max_learners
240+
if self.max_learners
241+
else False
242+
)
243+
224244
def get_course_runs(self):
225245
"""Get the runs associated with the contract."""
226246

@@ -250,3 +270,23 @@ def get_discounts(self):
250270
from ecommerce.models import Discount
251271

252272
return Discount.objects.filter(products__product__in=self.get_products()).all()
273+
274+
275+
class DiscountContractAttachmentRedemption(TimestampedModel):
276+
"""Records when a discount was used to attach the user to a contract."""
277+
278+
discount = models.ForeignKey(
279+
"ecommerce.Discount",
280+
on_delete=models.DO_NOTHING,
281+
help_text="The discount that was redemeed.",
282+
)
283+
user = models.ForeignKey(
284+
settings.AUTH_USER_MODEL,
285+
on_delete=models.DO_NOTHING,
286+
help_text="The user that redeemed the discount.",
287+
)
288+
contract = models.ForeignKey(
289+
ContractPage,
290+
on_delete=models.DO_NOTHING,
291+
help_text="The contract that the user was attached to.",
292+
)

b2b/views/v0/__init__.py

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

33
from django.contrib.contenttypes.models import ContentType
4+
from django.db.models import Q
45
from django.views.decorators.csrf import csrf_exempt
56
from drf_spectacular.utils import extend_schema
7+
from mitol.common.utils.datetime import now_in_utc
68
from rest_framework import status, viewsets
79
from rest_framework.permissions import IsAdminUser, IsAuthenticated
810
from rest_framework.response import Response
911
from rest_framework.views import APIView
1012
from rest_framework_api_key.permissions import HasAPIKey
1113

1214
from b2b.api import create_b2b_enrollment
13-
from b2b.models import ContractPage, OrganizationPage
15+
from b2b.models import (
16+
ContractPage,
17+
DiscountContractAttachmentRedemption,
18+
OrganizationPage,
19+
)
1420
from b2b.serializers.v0 import (
1521
ContractPageSerializer,
1622
CreateB2BEnrollmentSerializer,
1723
OrganizationPageSerializer,
1824
)
1925
from courses.models import CourseRun
20-
from ecommerce.models import Product
26+
from ecommerce.models import Discount, Product
2127
from main.constants import USER_MSG_TYPE_B2B_ENROLL_SUCCESS
2228

2329

@@ -74,3 +80,70 @@ def post(self, request, readable_id: str, format=None): # noqa: A002, ARG002
7480
if response["result"] == USER_MSG_TYPE_B2B_ENROLL_SUCCESS
7581
else status.HTTP_406_NOT_ACCEPTABLE,
7682
)
83+
84+
85+
class AttachContractApi(APIView):
86+
"""View for attaching a user to a B2B contract."""
87+
88+
permission_classes = [IsAuthenticated]
89+
90+
@extend_schema(
91+
request=None,
92+
responses=ContractPageSerializer(many=True),
93+
)
94+
@csrf_exempt
95+
def post(self, request, enrollment_code: str, format=None): # noqa: A002, ARG002
96+
"""
97+
Use the provided enrollment code to attach the user to a B2B contract.
98+
99+
This will not create an order, nor will it enroll the user. It will
100+
attach the user to the contract and log that the code was used for this
101+
purpose (but will _not_ invalidate the code, since we're not actually
102+
using it at this point).
103+
104+
This will respect the activation and expiration dates (of both the contract
105+
and the discount), and will make sure there's sufficient available seats
106+
in the contract.
107+
108+
If the user is already in the contract, then we skip it.
109+
110+
Returns:
111+
- list of ContractPageSerializer - the contracts for the user
112+
"""
113+
114+
now = now_in_utc()
115+
try:
116+
code = (
117+
Discount.objects.filter(
118+
Q(activation_date__isnull=True) | Q(activation_date__lte=now)
119+
)
120+
.filter(Q(expiration_date__isnull=True) | Q(expiration_date__gte=now))
121+
.get(discount_code=enrollment_code)
122+
)
123+
except Discount.DoesNotExist:
124+
return Response(
125+
ContractPageSerializer(request.user.b2b_contracts.all(), many=True).data
126+
)
127+
128+
contract_ids = list(code.b2b_contracts().values_list("id", flat=True))
129+
contracts = (
130+
ContractPage.objects.filter(pk__in=contract_ids)
131+
.exclude(pk__in=request.user.b2b_contracts.all())
132+
.exclude(Q(contract_end__lt=now) | Q(contract_start__gt=now))
133+
.all()
134+
)
135+
136+
for contract in contracts:
137+
if contract.is_full():
138+
continue
139+
140+
request.user.b2b_contracts.add(contract)
141+
DiscountContractAttachmentRedemption.objects.create(
142+
user=request.user, discount=code, contract=contract
143+
)
144+
145+
request.user.save()
146+
147+
return Response(
148+
ContractPageSerializer(request.user.b2b_contracts.all(), many=True).data
149+
)

b2b/views/v0/urls.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33
from django.urls import include, path, re_path
44
from rest_framework.routers import SimpleRouter
55

6-
from b2b.views.v0 import ContractPageViewSet, Enroll, OrganizationPageViewSet
6+
from b2b.views.v0 import (
7+
AttachContractApi,
8+
ContractPageViewSet,
9+
Enroll,
10+
OrganizationPageViewSet,
11+
)
712

813
app_name = "b2b"
914

@@ -22,4 +27,9 @@
2227
urlpatterns = [
2328
re_path(r"^", include(v0_router.urls)),
2429
path(r"enroll/<str:readable_id>/", Enroll.as_view()),
30+
path(
31+
r"attach/<str:enrollment_code>/",
32+
AttachContractApi.as_view(),
33+
name="attach-user",
34+
),
2535
]

0 commit comments

Comments
 (0)