Skip to content

Commit 2756160

Browse files
authored
B2B Provisioning: Create enrollment codes when contracts are in place (#2654)
1 parent ddfb856 commit 2756160

File tree

10 files changed

+716
-32
lines changed

10 files changed

+716
-32
lines changed

b2b/api.py

Lines changed: 247 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""API functions for B2B operations."""
22

33
import logging
4+
from uuid import uuid4
45

56
import reversion
67
from django.conf import settings
@@ -12,8 +13,13 @@
1213
from b2b.models import ContractPage, OrganizationIndexPage, OrganizationPage
1314
from cms.api import get_home_page
1415
from courses.models import Course, CourseRun
15-
from ecommerce.api import establish_basket
16-
from ecommerce.models import Product
16+
from ecommerce.constants import (
17+
DISCOUNT_TYPE_FIXED_PRICE,
18+
PAYMENT_TYPE_SALES,
19+
REDEMPTION_TYPE_ONE_TIME,
20+
REDEMPTION_TYPE_UNLIMITED,
21+
)
22+
from ecommerce.models import Discount, DiscountProduct, Product
1723
from main.utils import date_to_datetime
1824

1925
log = logging.getLogger(__name__)
@@ -57,9 +63,7 @@ def create_contract_run(
5763
This won't create pages for either the run or the products, since they're
5864
not supposed to be accessed by the public.
5965
60-
- For now this won't check the contract for pricing, since we're not doing
61-
that yet.
62-
- This should also create the run in edX, but we also don't do that yet.
66+
- This should also create the run in edX, but we don't do that yet.
6367
When we add that, we should backfill the URL into the course run.
6468
6569
Args:
@@ -133,6 +137,8 @@ def create_contract_run(
133137
contract,
134138
)
135139

140+
contract.save()
141+
136142
return course_run, course_run_product
137143

138144

@@ -153,6 +159,7 @@ def validate_basket_for_b2b_purchase(request) -> bool:
153159
154160
Returns: bool
155161
"""
162+
from ecommerce.api import establish_basket
156163

157164
basket = establish_basket(request)
158165
if not basket:
@@ -210,3 +217,238 @@ def validate_basket_for_b2b_purchase(request) -> bool:
210217
return True
211218

212219
return False
220+
221+
222+
def ensure_enrollment_codes_exist(contract: ContractPage): # noqa: C901, PLR0915
223+
"""
224+
Ensure that enrollment codes exist for the given contract.
225+
226+
If the contract is non-SSO or if it specifies a price, we need to create
227+
enrollment codes so the learners can enroll in the attached resources.
228+
229+
Enrollment codes are discounts. When the contract is seat-limited, we create
230+
one discount code per learner per product. When the contract is unlimited,
231+
we create one discount code per product, and set it to unlimited redemptions.
232+
233+
When the contract is created or modified, we'll need to shore up the discount
234+
codes appropriately:
235+
- If there's no discounts for any of the products, create them.
236+
- If there are discounts, make sure they apply to all the products that
237+
we've created, and create new ones if necessary.
238+
- If there are too many discounts, log a warning message.
239+
- If the contract is SSO and unlimited, but there are discounts for the
240+
products, clear those products out. (Also remove the discounts if there
241+
was only one product in the discount.)
242+
243+
Note about SSO contracts: if there's no price, we don't create enrollment
244+
codes regardless of whether there's a learner cap or not. We'll limit the
245+
attachment when we log the user in.
246+
247+
Returns:
248+
tuple: A tuple containing the number of created codes, updated codes,
249+
and codes with errors.
250+
"""
251+
252+
created = updated = errors = 0
253+
254+
log.info("Checking enrollment codes for contract %s", contract)
255+
256+
if contract.integration_type == "sso" and not contract.enrollment_fixed_price:
257+
# SSO contracts w/out price don't need discounts.
258+
discounts = contract.get_discounts()
259+
products = contract.get_products()
260+
261+
log.info("Removing any existing discounts for SSO/free contract %s", contract)
262+
263+
for discount in discounts:
264+
discount.products.filter(product__in=products).delete()
265+
discount.refresh_from_db()
266+
267+
created += 1
268+
269+
# Only delete the discount if there's no more products.
270+
# Otherwise, we might delete one that's shared for some reason.
271+
if discount.products.count() == 0:
272+
log.info(
273+
"Contract %s: Existing discount %s no longer has products, removing",
274+
contract,
275+
discount,
276+
)
277+
discount.delete()
278+
updated += 1
279+
280+
return (created, updated, errors)
281+
282+
products = contract.get_products()
283+
284+
log.info("Checking %s products for contract %s", len(products), contract)
285+
286+
for product in products:
287+
# Check these things:
288+
# - Are there any discount codes?
289+
# - Are there enough discount codes (total, including redeemed ones)?
290+
291+
discount_amount = contract.enrollment_fixed_price or 0
292+
product_discounts = (
293+
Discount.objects.filter(products__product=product).distinct().all()
294+
)
295+
296+
if not contract.max_learners:
297+
# If we're doing unlimited seats, then we just need one discount per
298+
# product, set to Unlimited.
299+
300+
if len(product_discounts) == 0:
301+
# Quick note: these are unlimited and not one-time-per-user because
302+
# there may be any number of courses the learner will want to
303+
# enroll in. That does mean that these codes need to be
304+
# protected. It's unlikely we'll have many unlimited-seat but
305+
# also not-SSO contracts, though.
306+
discount = Discount(
307+
discount_code=uuid4(),
308+
amount=discount_amount,
309+
redemption_type=REDEMPTION_TYPE_UNLIMITED,
310+
discount_type=DISCOUNT_TYPE_FIXED_PRICE,
311+
payment_type=PAYMENT_TYPE_SALES,
312+
is_bulk=True,
313+
)
314+
discount.save()
315+
316+
discount_product = DiscountProduct(
317+
discount=discount,
318+
product=product,
319+
)
320+
discount_product.save()
321+
322+
log.info(
323+
"Contract %s: Created unlimited discount %s for product %s",
324+
contract,
325+
discount,
326+
product,
327+
)
328+
329+
created += 1
330+
elif len(product_discounts) == 1:
331+
Discount.objects.filter(pk=product_discounts[0].id).update(
332+
**{
333+
"amount": discount_amount,
334+
"redemption_type": REDEMPTION_TYPE_UNLIMITED,
335+
"discount_type": DISCOUNT_TYPE_FIXED_PRICE,
336+
"payment_type": PAYMENT_TYPE_SALES,
337+
"is_bulk": True,
338+
}
339+
)
340+
341+
product_discounts[0].refresh_from_db()
342+
343+
log.info(
344+
"Contract %s: updated discount %s for product %s",
345+
contract,
346+
product_discounts[0],
347+
product,
348+
)
349+
350+
if not product_discounts[0].products.filter(product=product).exists():
351+
discount_product = DiscountProduct(
352+
discount=product_discounts[0],
353+
product=product,
354+
)
355+
discount_product.save()
356+
log.debug(
357+
"Contract %s: Added product %s to discount %s",
358+
contract,
359+
product,
360+
product_discounts[0],
361+
)
362+
363+
updated += 1
364+
else:
365+
log.warning(
366+
"ensure_enrollment_codes_exist: Unlimited-seat contract %s product %s has too many discount codes: %s - skipping validation.",
367+
contract,
368+
product,
369+
len(product_discounts),
370+
)
371+
372+
errors += 1
373+
374+
continue
375+
376+
log.info(
377+
"Updating %s discount codes for product %s", len(product_discounts), product
378+
)
379+
380+
for discount in product_discounts:
381+
Discount.objects.filter(pk=discount.id).update(
382+
**{
383+
"amount": discount_amount,
384+
"redemption_type": REDEMPTION_TYPE_ONE_TIME,
385+
"discount_type": DISCOUNT_TYPE_FIXED_PRICE,
386+
"payment_type": PAYMENT_TYPE_SALES,
387+
"is_bulk": True,
388+
}
389+
)
390+
391+
discount.refresh_from_db()
392+
393+
log.info(
394+
"Contract %s: updated discount %s for product %s",
395+
contract,
396+
discount,
397+
product,
398+
)
399+
400+
if not discount.products.filter(product=product).exists():
401+
discount_product = DiscountProduct(
402+
discount=discount,
403+
product=product,
404+
)
405+
discount_product.save()
406+
log.debug(
407+
"Contract %s: Added product %s to discount %s",
408+
contract,
409+
product,
410+
discount,
411+
)
412+
413+
updated += 1
414+
415+
create_count = contract.max_learners - len(product_discounts)
416+
417+
log.info("Creating %s new discount codes for product %s", create_count, product)
418+
419+
if create_count < 0:
420+
log.warning(
421+
"ensure_enrollment_codes_exist: Seat limited contract %s product %s has too many discount codes: %s - skipping create",
422+
contract,
423+
product,
424+
len(product_discounts),
425+
)
426+
continue
427+
428+
for _ in range(create_count):
429+
discount = Discount(
430+
discount_code=uuid4(),
431+
amount=discount_amount,
432+
redemption_type=REDEMPTION_TYPE_ONE_TIME,
433+
discount_type=DISCOUNT_TYPE_FIXED_PRICE,
434+
payment_type=PAYMENT_TYPE_SALES,
435+
is_bulk=True,
436+
)
437+
discount.save()
438+
439+
discount_product = DiscountProduct(
440+
discount=discount,
441+
product=product,
442+
)
443+
discount_product.save()
444+
445+
created += 1
446+
447+
log.info(
448+
"Contract %s: Created discount %s for product %s",
449+
contract,
450+
discount,
451+
product,
452+
)
453+
454+
return (created, updated, errors)

0 commit comments

Comments
 (0)