11"""API functions for B2B operations."""
22
33import logging
4+ from uuid import uuid4
45
56import reversion
67from django .conf import settings
1213from b2b .models import ContractPage , OrganizationIndexPage , OrganizationPage
1314from cms .api import get_home_page
1415from 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
1723from main .utils import date_to_datetime
1824
1925log = 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