Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT

- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ repos:
- "config/keycloak/*"
additional_dependencies: ["gibberish-detector"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.15.8"
rev: "v0.15.9"
hooks:
- id: ruff-format
- id: ruff
Expand Down
9 changes: 9 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
Release Notes
=============

Version 1.146.2
---------------

- Add admin search by edx_username (#3482)
- Update discount code redemption to create codes if necessary (#3483)
- Adds action to populate empty verifiable creds by courserun or program (#3473)
- chore(deps): update actions/cache digest to 6682284 (#3432)
- [pre-commit.ci] pre-commit autoupdate (#3461)

Version 1.146.1 (Released April 09, 2026)
---------------

Expand Down
53 changes: 43 additions & 10 deletions b2b/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -986,7 +986,7 @@ def _validate_b2b_enrollment_prerequisites(user, product: Product) -> Union[dict

if (
isinstance(purchasable_object, CourseRun)
and not purchasable_object.is_enrollable
and not purchasable_object.is_enrollable_for_b2b
):
return {"result": main_constants.USER_MSG_TYPE_B2B_ERROR_NOT_ENROLLABLE}

Expand Down Expand Up @@ -1019,20 +1019,53 @@ def _prepare_basket_for_b2b_enrollment(request, product: Product) -> Basket:

def _apply_available_discount(request, product: Product, basket: Basket) -> None:
"""Apply available discount to the basket if one exists."""
applicable_discounts_qs = product.discounts.annotate(
redemptions=Count("discount__order_redemptions")
).filter(discount__is_bulk=True, redemptions=0, discount__products__product=product)

# Changed to only check redemption count if the discount isn't unlimited -
# which it will be if the contract has unlimited seats - and order by ID
# so it matches what we send out to people.
applicable_discounts_qs = (
product.discounts.annotate(redemptions=Count("discount__order_redemptions"))
.filter(discount__is_bulk=True, discount__products__product=product)
.filter(
Q(redemptions=0) | Q(discount__redemption_type=REDEMPTION_TYPE_UNLIMITED)
)
.order_by("id")
)

if applicable_discounts_qs.exists():
# We have unused codes for this product, so we should apply one.
discount = applicable_discounts_qs.first().discount
basket_discount = BasketDiscount.objects.create(
redemption_date=now_in_utc(),
redeemed_by=request.user,
redeemed_discount=discount,
redeemed_basket=basket,
else:
# At this point we've checked for available seats, and we've checked for
# an appropriate discount, and we couldn't find one, so now we need to
# make one for the learner (or for the contract).

if (
not product.purchasable_object
or not product.purchasable_object.b2b_contract
):
msg = f"Product {product} has no purchasable object or the purchasable object has no B2B contract"
raise ValueError(msg)

discount_amount = product.purchasable_object.b2b_contract.enrollment_fixed_price
redemption_type = (
REDEMPTION_TYPE_ONE_TIME
if product.purchasable_object.b2b_contract.max_learners
and product.purchasable_object.b2b_contract.max_learners > 0
else REDEMPTION_TYPE_UNLIMITED
)
basket_discount.save()

discount = _create_discount_with_product(
product, discount_amount if discount_amount else Decimal(0), redemption_type
)

basket_discount = BasketDiscount.objects.create(
redemption_date=now_in_utc(),
redeemed_by=request.user,
redeemed_discount=discount,
redeemed_basket=basket,
)
basket_discount.save()


def create_b2b_enrollment(request, product: Product):
Expand Down
165 changes: 164 additions & 1 deletion b2b/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@

from b2b import factories
from b2b.api import (
_apply_available_discount,
_handle_extra_enrollment_codes,
_validate_b2b_enrollment_prerequisites,
create_b2b_enrollment,
create_contract_run,
create_contract_run_key,
Expand Down Expand Up @@ -58,15 +60,23 @@
BasketFactory,
BasketItemFactory,
OneTimeDiscountFactory,
OrderFactory,
ProductFactory,
UnlimitedUseDiscountFactory,
)
from ecommerce.models import Basket, BasketDiscount, DiscountProduct
from ecommerce.models import (
Basket,
BasketDiscount,
DiscountProduct,
DiscountRedemption,
OrderStatus,
)
from main.constants import (
USER_MSG_TYPE_B2B_DISALLOWED,
USER_MSG_TYPE_B2B_ENROLL_SUCCESS,
USER_MSG_TYPE_B2B_ERROR_NO_CONTRACT,
USER_MSG_TYPE_B2B_ERROR_NO_PRODUCT,
USER_MSG_TYPE_B2B_ERROR_NOT_ENROLLABLE,
USER_MSG_TYPE_B2B_ERROR_REQUIRES_CHECKOUT,
)
from main.utils import date_to_datetime
Expand Down Expand Up @@ -1464,3 +1474,156 @@ def test_ensure_enrollment_codes_courseware_changes(
contract.get_discounts().filter(products__product=product_2).count()
== max_learners
)


def test_apply_available_discount_seat_limit():
"""
Test that the internal _apply_available_discount function works as expected.

This should find an applicable discount to apply to the order. For a
seat-limited contract, this should be an unused discount linked to the
product in the cart. For an unlimited seat contract, this is the discount
linked to the product (ignoring use). If there's not a discount, then we
should make one if there's sufficient seats available.
"""

contract = factories.ContractPageFactory.create(
max_learners=2,
membership_type=CONTRACT_MEMBERSHIP_NONSSO,
integration_type=CONTRACT_MEMBERSHIP_NONSSO,
)
CourseRunFactory.create_batch(2, b2b_contract=contract)
user_orgs = factories.UserOrganizationFactory.create_batch(
3, organization=contract.organization
)

products = ensure_contract_run_products(contract)
ensure_enrollment_codes_exist(contract)

assert contract.get_discounts().count() == 4

# For this we care about redemptions for enrollment, so we need to create
# some orders and throw some discounts on them.

for uo in user_orgs[:2]:
user = uo.user

for product in products:
order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED)
discount = (
contract.get_unused_discounts()
.filter(products__product=product)
.first()
)
DiscountRedemption.objects.create(
redemption_date=now_in_utc(),
redeemed_by=user,
redeemed_discount=discount,
redeemed_order=order,
)
CourseRunEnrollment.objects.create(
run=product.purchasable_object, active=True, user=user
)

assert contract.get_unused_discounts().count() == 0

# The _apply_available_discount function doesn't check for seats - space is
# expected to be checked before this gets called. So we should have just one
# new discount.

basket = Basket.objects.create(user=user_orgs[2].user)
request = RequestFactory()
request.user = user_orgs[2].user

# Test the validate step - this gets called before the apply call and should
# fail. (So, in real life, trying to add this third user should not work.)

user_orgs[2].user.b2b_contracts.add(contract)
user_orgs[2].user.save()

result = _validate_b2b_enrollment_prerequisites(user_orgs[2].user, products[0])

assert result == {"result": USER_MSG_TYPE_B2B_ERROR_NOT_ENROLLABLE}

# Calling this directly should result in a new discount being created.

_apply_available_discount(request, products[0], basket)

assert contract.get_discounts().count() == 5


@pytest.mark.parametrize(
"existing_discounts",
[
True,
False,
],
)
def test_apply_available_discount_unlimited_seats(existing_discounts):
"""
Test that the internal _apply_available_discount function works as expected.

The other half of the above test - this checks for proper operation when the
contract has unlimited seats.
"""

contract = factories.ContractPageFactory.create(
max_learners=0,
membership_type=CONTRACT_MEMBERSHIP_NONSSO,
integration_type=CONTRACT_MEMBERSHIP_NONSSO,
)
CourseRunFactory.create_batch(2, b2b_contract=contract)
user_orgs = factories.UserOrganizationFactory.create_batch(
3, organization=contract.organization
)

products = ensure_contract_run_products(contract)
if existing_discounts:
# Testing for existing discounts means we should create some orders where
# the discount is used, to make sure we don't end up with extras.

ensure_enrollment_codes_exist(contract)

assert contract.get_discounts().count() == 2

for uo in user_orgs[:2]:
user = uo.user

for product in products:
order = OrderFactory.create(purchaser=user, state=OrderStatus.FULFILLED)
discount = (
contract.get_discounts().filter(products__product=product).first()
)
DiscountRedemption.objects.create(
redemption_date=now_in_utc(),
redeemed_by=user,
redeemed_discount=discount,
redeemed_order=order,
)
CourseRunEnrollment.objects.create(
run=product.purchasable_object, active=True, user=user
)

assert contract.get_unused_discounts().count() == 0

# The _apply_available_discount function doesn't check for seats - space is
# expected to be checked before this gets called. So we should have just one
# new discount.

basket = Basket.objects.create(user=user_orgs[2].user)
request = RequestFactory()
request.user = user_orgs[2].user

# Test the validate step - this gets called before the apply call and should
# fail. (So, in real life, trying to add this third user should not work.)

user_orgs[2].user.b2b_contracts.add(contract)
user_orgs[2].user.save()

result = _validate_b2b_enrollment_prerequisites(user_orgs[2].user, products[0])

assert not result

_apply_available_discount(request, products[0], basket)

assert contract.get_discounts().count() == (2 if existing_discounts else 1)
25 changes: 24 additions & 1 deletion b2b/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,23 @@
import faker
import wagtail_factories
from factory import Factory, LazyAttribute, LazyFunction, SubFactory
from factory.django import DjangoModelFactory

from b2b.constants import CONTRACT_MEMBERSHIP_NONSSO, CONTRACT_MEMBERSHIP_SSO
from b2b.keycloak_admin_dataclasses import (
OrganizationRepresentation,
RealmRepresentation,
)
from b2b.models import ContractPage, OrganizationIndexPage, OrganizationPage
from b2b.models import (
ContractPage,
OrganizationIndexPage,
OrganizationPage,
UserOrganization,
)
from cms.factories import HomePageFactory
from cms.models import HomePage
from courses.constants import UAI_COURSEWARE_ID_PREFIX
from users.factories import UserFactory

FAKE = faker.Faker()

Expand Down Expand Up @@ -94,3 +101,19 @@ class Meta:
alias = LazyAttribute(lambda _: FAKE.unique.word())
description = LazyAttribute(lambda _: FAKE.text())
enabled = True


class UserOrganizationFactory(DjangoModelFactory):
"""Factory for UserOrganizations"""

class Meta:
model = UserOrganization
django_get_or_create = (
"user",
"organization",
)

user = SubFactory(UserFactory)
organization = SubFactory(OrganizationPageFactory)
keep_until_seen = True
is_manager = False
Loading
Loading