25
25
User = get_user_model ()
26
26
27
27
28
+ class TerminalCheckoutIntentConflict (Exception ):
29
+ pass
30
+
31
+
32
+ class SlugReservationConflict (Exception ):
33
+ pass
34
+
35
+
28
36
class CheckoutIntent (TimeStampedModel ):
29
37
"""
30
38
Tracks the complete lifecycle of a self-service checkout process:
@@ -101,10 +109,14 @@ class StateChoices(models.TextChoices):
101
109
max_length = 255 ,
102
110
)
103
111
enterprise_name = models .CharField (
112
+ null = True ,
113
+ blank = True ,
104
114
max_length = 255 ,
105
115
help_text = "Checkout intent enterprise customer name" ,
106
116
)
107
117
enterprise_slug = models .SlugField (
118
+ null = True ,
119
+ blank = True ,
108
120
max_length = 255 ,
109
121
validators = [validate_slug ],
110
122
help_text = "Checkout intent enterprise customer slug"
@@ -386,9 +398,9 @@ def can_reserve(cls, slug=None, name=None, exclude_user=None):
386
398
def create_intent (
387
399
cls ,
388
400
user : AbstractUser ,
389
- slug : str ,
390
- name : str ,
391
401
quantity : int ,
402
+ slug : str | None = None ,
403
+ name : str | None = None ,
392
404
country : str | None = None ,
393
405
terms_metadata : dict | None = None
394
406
) -> Self :
@@ -399,22 +411,23 @@ def create_intent(
399
411
not already in use by another checkout flow. If the user already has an intent:
400
412
- If it's in a success state (PAID, FULFILLED), the existing intent is returned unchanged
401
413
- If it's in a failure state (ERRORED_*), a ValueError is raised
402
- - If it's in CREATED state, it's updated with the new details
414
+ - If it's in CREATED state, it's updated with the new details (more like PATCH, not PUT).
403
415
404
416
The method also cleans up any expired intents first to free up reserved slugs/names.
405
417
406
418
Args:
407
419
user (User): The Django User who will own this intent
408
- slug (str): The enterprise slug to reserve
409
- name (str): The enterprise name to reserve
410
420
quantity (int): Number of licenses to create
421
+ slug (str, Optional): The enterprise slug to reserve
422
+ name (str, Optional): The enterprise name to reserve
411
423
412
424
Returns:
413
425
CheckoutIntent: The created or updated intent object
414
426
415
427
Raises:
416
- ValueError: If the slug or name is already reserved by another user
417
- ValueError: If the user already has an intent that failed
428
+ ValueError: If only one of [slug, name] were given, but not the other.
429
+ SlugReservationConflict: If the slug or name is already reserved by another user
430
+ TerminalCheckoutIntentConflict: If the user already has an intent that failed
418
431
419
432
Note:
420
433
This operation is atomic - either the entire reservation succeeds or fails.
@@ -425,28 +438,70 @@ def create_intent(
425
438
with transaction .atomic ():
426
439
cls .cleanup_expired ()
427
440
428
- if not cls . can_reserve (slug , name , exclude_user = user ):
429
- raise ValueError (f"Slug ' { slug } ' or name ' { name } ' is already reserved " )
441
+ if bool (slug ) != bool ( name ):
442
+ raise ValueError (" slug and name must either both be given or neither be given. " )
430
443
431
444
existing_intent = cls .objects .filter (user = user ).first ()
432
445
433
- expires_at = timezone .now () + timedelta (minutes = cls .get_reservation_duration ())
434
-
446
+ # If an existing intent has already reached a terminal state, exit fast.
435
447
if existing_intent :
436
448
if existing_intent .state in cls .SUCCESS_STATES :
437
449
return existing_intent
438
450
439
451
if existing_intent .state in cls .FAILURE_STATES :
440
- raise ValueError ("Failed checkout record already exists" )
452
+ raise TerminalCheckoutIntentConflict ("Failed checkout record already exists" )
453
+
454
+ # Establish whether or not a new slug needs to be reserved. This logic is really only an
455
+ # optimization to avoid unnecessary DB lookups to search for reservation conflicts (via
456
+ # can_reserve()) in cases where we know the reservation is not changing.
457
+ #
458
+ # | Slug | Intent | A Slug | Requested Slug | Requested Slug
459
+ # Case | Requested? | Existed? | Already Reserved? | Is Different? | Needs To Be Reserved
460
+ # ------+------------+----------+-------------------+----------------+---------------------
461
+ # 1 | no | no | N/A | N/A | no
462
+ # 2 | no | yes | no | N/A | no
463
+ # 3 | no | yes | yes | N/A | no
464
+ # 4 | yes | no | N/A | N/A | yes
465
+ # 5 | yes | yes | no | N/A | yes
466
+ # 6 | yes | yes | yes | no | no
467
+ # 7 | yes | yes | yes | yes | yes
468
+ if slug :
469
+ if existing_intent :
470
+ slug_changing = slug != existing_intent .enterprise_slug
471
+ name_changing = name != existing_intent .enterprise_name
472
+ should_reserve_new_slug = slug_changing or name_changing # Cases 5, 6, 7
473
+ else :
474
+ should_reserve_new_slug = True # Case 4
475
+ else :
476
+ should_reserve_new_slug = False # Cases 1, 2, 3
477
+
478
+ # If we are reserving a new slug, then gate this whole view on it not already being reserved.
479
+ if should_reserve_new_slug :
480
+ if not cls .can_reserve (slug , name , exclude_user = user ):
481
+ raise SlugReservationConflict (f"Slug '{ slug } ' or name '{ name } ' is already reserved" )
482
+
483
+ expires_at = timezone .now () + timedelta (minutes = cls .get_reservation_duration ())
484
+
485
+ # The remaining code essentially performs an update_or_create(), but we're not
486
+ # using update_or_create() because we already have the existing intent and
487
+ # don't need to spend a DB query looking it up again. Also, wouldn't be able
488
+ # to do the terms_metadata merging logic which could come in handy in case
489
+ # this ever gets called multiple times and we have terms on multiple pages.
441
490
442
- # Update the existing CREATED or EXPIRED intent
491
+ if existing_intent :
492
+ # Found an existing CREATED or EXPIRED intent, so update it.
493
+
494
+ # Force update certain fields.
443
495
existing_intent .state = CheckoutIntentState .CREATED
444
- existing_intent .enterprise_slug = slug
445
- existing_intent .enterprise_name = name
446
496
existing_intent .quantity = quantity
447
497
existing_intent .expires_at = expires_at
448
- existing_intent .country = country
449
- existing_intent .terms_metadata = terms_metadata
498
+
499
+ # Any of the following could be None since they're optional, so lets only update them if supplied.
500
+ existing_intent .enterprise_slug = slug or existing_intent .enterprise_slug
501
+ existing_intent .enterprise_name = name or existing_intent .enterprise_name
502
+ existing_intent .country = country or existing_intent .country
503
+ existing_intent .terms_metadata = (existing_intent .terms_metadata or {}) | (terms_metadata or {})
504
+
450
505
existing_intent .save ()
451
506
return existing_intent
452
507
0 commit comments