diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1680c12168..d3a4fa2cf2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b23b92e593..4ccc946998 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/RELEASE.rst b/RELEASE.rst index b76e18c645..cf8a0b2fdb 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -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) --------------- diff --git a/b2b/api.py b/b2b/api.py index b82aa82fbc..eb80f658ca 100644 --- a/b2b/api.py +++ b/b2b/api.py @@ -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} @@ -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): diff --git a/b2b/api_test.py b/b2b/api_test.py index 50fa15fd35..b6100f7519 100644 --- a/b2b/api_test.py +++ b/b2b/api_test.py @@ -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, @@ -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 @@ -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) diff --git a/b2b/factories.py b/b2b/factories.py index e963720c70..8e293713e7 100644 --- a/b2b/factories.py +++ b/b2b/factories.py @@ -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() @@ -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 diff --git a/courses/admin.py b/courses/admin.py index db96deeb05..d03c9af0cb 100644 --- a/courses/admin.py +++ b/courses/admin.py @@ -2,7 +2,7 @@ Admin site bindings for profiles """ -from django.contrib import admin +from django.contrib import admin, messages from django.contrib.admin.decorators import display from django.db import models from django.forms import TextInput @@ -10,7 +10,7 @@ from mitol.common.admin import TimestampedModelAdmin import cms.admin # noqa: F401 -from courses.api import downgrade_learner +from courses.api import create_verifiable_credential, downgrade_learner from courses.forms import ProgramAdminForm from courses.models import ( BlockedCountry, @@ -34,12 +34,33 @@ ProgramEnrollmentAudit, ProgramRun, RelatedProgram, + VerifiableCredential, ) from main.admin import AuditableModelAdmin, ModelAdminRunActionsForAllMixin from main.utils import get_field_names from openedx.tasks import retry_failed_edx_enrollments +class VerifiableCredentialBackfillAdminMixin: + def populate_verifiable_credentials_for_certificate(self, request, certificates): + """Helper method to create and associate a verifiable credential for a given certificate""" + failed_certificates = [] + for certificate in certificates: + try: + create_verifiable_credential(certificate, raise_on_error=True) + except Exception: # noqa: PERF203, BLE001 + failed_certificates.append(certificate) + + message = f"Successfully requested verifiable credential backfill for {len(certificates)} {self.model._meta.model_name} certificates." # noqa: SLF001 + level = messages.INFO + if failed_certificates: + # We indicate IDs, but errors should also be logged to sentry from within create_verifiable_credential + level = messages.WARNING + message = f"Successfully requested verifiable credential backfill for {len(certificates)} {self.model._meta.model_name} certificates, but encountered errors for certificates with IDs: {[cert.id for cert in failed_certificates]}" # noqa: SLF001 + + self.message_user(request, message, level=level) + + class ProgramContractPageInline(admin.TabularInline): """Inline for contract pages""" @@ -51,7 +72,7 @@ class ProgramContractPageInline(admin.TabularInline): @admin.register(Program) -class ProgramAdmin(admin.ModelAdmin): +class ProgramAdmin(VerifiableCredentialBackfillAdminMixin, admin.ModelAdmin): """Admin for Program""" model = Program @@ -68,6 +89,21 @@ class ProgramAdmin(admin.ModelAdmin): ) list_filter = ["live", "b2b_only", "program_type", "display_mode", "departments"] inlines = [ProgramContractPageInline] + actions = ["populate_verifiable_credentials_for_program"] + + @admin.action( + description="Backfill verifiable credentials for program certificates" + ) + def populate_verifiable_credentials_for_program(self, request, queryset): + """Admin action to regenerate verifiable credentials for a program""" + program_ids = queryset.values_list("id", flat=True) + # If a cert already has a cred, leave it alone for now. + certificates = list( + ProgramCertificate.objects.filter( + program_id__in=program_ids, verifiable_credential__isnull=True + ) + ) + self.populate_verifiable_credentials_for_certificate(request, certificates) @admin.register(ProgramRun) @@ -126,7 +162,7 @@ def get_form(self, request, obj=None, change=False, **kwargs): # noqa: FBT002 @admin.register(CourseRun) -class CourseRunAdmin(TimestampedModelAdmin): +class CourseRunAdmin(VerifiableCredentialBackfillAdminMixin, TimestampedModelAdmin): """Admin for CourseRun""" model = CourseRun @@ -154,6 +190,22 @@ class CourseRunAdmin(TimestampedModelAdmin): models.TextField: {"widget": TextInput(attrs={"size": "100"})}, } + actions = ["populate_verifiable_credentials_for_courserun"] + + @admin.action( + description="Backfill verifiable credentials for course run certificates" + ) + def populate_verifiable_credentials_for_courserun(self, request, queryset): + """Admin action to regenerate verifiable credentials for a course run""" + course_run_ids = queryset.values_list("id", flat=True) + # If a cert already has a cred, leave it alone for now. + certificates = list( + CourseRunCertificate.objects.filter( + course_run_id__in=course_run_ids, verifiable_credential__isnull=True + ) + ) + self.populate_verifiable_credentials_for_certificate(request, certificates) + @admin.register(ProgramEnrollment) class ProgramEnrollmentAdmin(AuditableModelAdmin): @@ -635,6 +687,22 @@ def get_queryset(self, request): # noqa: ARG002 return self.model.all_objects.get_queryset().select_related("user", "program") +@admin.register(VerifiableCredential) +class VerifiableCredentialAdmin(TimestampedModelAdmin): + """Admin for VerifiableCredential""" + + model = VerifiableCredential + include_timestamps_in_list = True + list_display = ["uuid", "programcertificate", "courseruncertificate"] + + def has_add_permission(self, request, obj=None): # noqa: ARG002 + return False + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.select_related("programcertificate", "courseruncertificate") + + @admin.register(PartnerSchool) class PartnerSchoolAdmin(TimestampedModelAdmin): """Admin for PartnerSchool""" diff --git a/courses/management/commands/backfill_verifiable_credentials.py b/courses/management/commands/backfill_verifiable_credentials.py index cb4c2f4e6c..1843d1cb92 100644 --- a/courses/management/commands/backfill_verifiable_credentials.py +++ b/courses/management/commands/backfill_verifiable_credentials.py @@ -114,7 +114,7 @@ def handle(self, *args, **options): # noqa: ARG002 program_ids = Program.objects.filter(readable_id__in=ids).values_list( "id", flat=True ) - certificates = ProgramCertificate.objects.filter(id__in=program_ids) + certificates = ProgramCertificate.objects.filter(program_id__in=program_ids) elif courseware_type == "course": course_run_ids = CourseRun.objects.filter( courseware_id__in=ids diff --git a/courses/models.py b/courses/models.py index bf8da8445f..f1bfe03107 100644 --- a/courses/models.py +++ b/courses/models.py @@ -1287,6 +1287,26 @@ def is_enrollable(self): and self.start_date is not None ) + @cached_property + def is_enrollable_for_b2b(self): + """Determine if the run is enrollable for B2B purchases.""" + + if not self.b2b_contract: + return False + + if not self.b2b_contract.max_learners: + return self.is_enrollable + + contract_enrollments = self.enrollments.filter( + active=True, change_status=None + ).count() + + return ( + self.b2b_contract.max_learners > 0 + and self.b2b_contract.max_learners > contract_enrollments + and self.is_enrollable + ) + @property def is_fake_course_run(self): """ diff --git a/main/settings.py b/main/settings.py index 6e51df9c8f..f2347f3f9a 100644 --- a/main/settings.py +++ b/main/settings.py @@ -37,7 +37,7 @@ from main.sentry import init_sentry from openapi.settings_spectacular import open_spectacular_settings -VERSION = "1.146.1" +VERSION = "1.146.2" log = logging.getLogger() diff --git a/openedx/admin.py b/openedx/admin.py index ce526123d9..31f738de06 100644 --- a/openedx/admin.py +++ b/openedx/admin.py @@ -14,8 +14,14 @@ class OpenEdxUserAdmin(ModelAdminRunActionsForAllMixin, admin.ModelAdmin): """Admin for OpenEdxUser""" model = OpenEdxUser - search_fields = ["user__username", "user__email", "user__name", "platform"] - list_display = ["id", "user", "has_been_synced", "platform"] + search_fields = [ + "user__username", + "user__email", + "user__name", + "edx_username", + "platform", + ] + list_display = ["id", "user", "edx_username", "has_been_synced", "platform"] list_filter = ["has_been_synced", "platform"] raw_id_fields = ["user"] actions = ["repair_all_faulty_openedx_users"]