diff --git a/RELEASE.rst b/RELEASE.rst index cb62970b20..a179aaa459 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,13 @@ Release Notes ============= +Version 1.143.4 +--------------- + +- add certificate_available field to programs that mirrors courses (#3421) +- Add send-to-Google-Sheets support for B2B enrollment codes (#3363) +- Remove department as required field (#3407) + Version 1.143.2 (Released March 25, 2026) --------------- diff --git a/b2b/api.py b/b2b/api.py index 93d83aa237..789d1540a8 100644 --- a/b2b/api.py +++ b/b2b/api.py @@ -31,6 +31,7 @@ OrganizationPage, UserOrganization, ) +from b2b.tasks import queue_contract_sheet_update_post_save from cms.api import get_home_page from courses.constants import UAI_COURSEWARE_ID_PREFIX from courses.models import Course, CourseRun, Department, EnrollmentMode @@ -933,6 +934,8 @@ def ensure_enrollment_codes_exist(contract: ContractPage): total_updated += updated total_errors += errors + queue_contract_sheet_update_post_save.delay(contract.id) + return (total_created, total_updated, total_errors) diff --git a/b2b/migrations/0020_add_google_sheets_fields.py b/b2b/migrations/0020_add_google_sheets_fields.py new file mode 100644 index 0000000000..4448bde762 --- /dev/null +++ b/b2b/migrations/0020_add_google_sheets_fields.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.15 on 2026-03-13 20:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("b2b", "0019_backfill_org_key_prefixes"), + ] + + operations = [ + migrations.AddField( + model_name="contractpage", + name="google_sheet_target", + field=models.CharField( + blank=True, + default="", + help_text="The URL for the Google Sheet to use to send codes and updates.", + max_length=1024, + null=True, + ), + ), + migrations.AddField( + model_name="contractpage", + name="google_sheet_target_tab", + field=models.CharField( + blank=True, + default="Sheet1", + help_text="The index or title of the worksheet in the Google Sheet to put the codes.", + max_length=100, + ), + ), + ] diff --git a/b2b/models.py b/b2b/models.py index 5f63905c71..19bb5ce0bf 100644 --- a/b2b/models.py +++ b/b2b/models.py @@ -317,6 +317,19 @@ class ContractPage(Page, ClusterableModel): null=True, help_text="The fixed price for enrollment under this contract. (Set to zero or leave blank for free.)", ) + google_sheet_target = models.CharField( # noqa: DJ001 + blank=True, + null=True, + default="", + max_length=1024, + help_text="The URL for the Google Sheet to use to send codes and updates.", + ) + google_sheet_target_tab = models.CharField( + blank=True, + default="Sheet1", + max_length=100, + help_text="The index or title of the worksheet in the Google Sheet to put the codes.", + ) @property def programs(self): @@ -346,6 +359,8 @@ def programs(self): FieldPanel("membership_type"), FieldPanel("max_learners"), FieldPanel("enrollment_fixed_price"), + FieldPanel("google_sheet_target"), + FieldPanel("google_sheet_target_tab"), ], heading="Learner Provisioning", icon="user", @@ -468,19 +483,26 @@ def get_products(self): object_id__in=self.get_course_runs().values_list("id", flat=True), ).all() - def get_discounts(self): - """Get the discounts associated with the contract.""" + @property + def discounts_qs(self): + """Get the discount queryset associated with the contract.""" from ecommerce.models import Discount # noqa: PLC0415 - return Discount.objects.filter(products__product__in=self.get_products()).all() + return Discount.objects.filter(products__product__in=self.get_products()) + + def get_discounts(self): + """Get the discounts associated with the contract.""" + + return self.discounts_qs.all() def get_unused_discounts(self): """Get discounts that haven't been used yet.""" return ( - self.get_discounts() - .annotate(order_redemptions_count=models.Count("order_redemptions")) + self.discounts_qs.annotate( + order_redemptions_count=models.Count("order_redemptions") + ) .annotate(contract_redemptions_count=models.Count("contract_redemptions")) .exclude( models.Q(order_redemptions_count__gt=0) diff --git a/b2b/sheets.py b/b2b/sheets.py new file mode 100644 index 0000000000..5175aecd20 --- /dev/null +++ b/b2b/sheets.py @@ -0,0 +1,291 @@ +"""Google Sheets integration code for B2B.""" + +import logging + +from django.db.models import Count +from django.utils.functional import cached_property +from mitol.google_sheets.api import get_authorized_pygsheets_client +from mitol.google_sheets.constants import GOOGLE_SHEET_FIRST_ROW +from mitol.google_sheets.sheet_handler_api import SheetHandler + +from b2b.constants import CONTRACT_MEMBERSHIP_AUTOS +from b2b.models import ContractPage +from ecommerce.constants import REDEMPTION_TYPE_UNLIMITED +from ecommerce.models import Discount + +log = logging.getLogger(__name__) + + +class ContractEnrollmentCodesSheetHandler(SheetHandler): + """Sends enrollment code data to a specified Google Sheet""" + + default_columns = [ + "Enrollment Code", + "Use Type", + "Total Redemptions", + "Invalidated On", + "Redeemed By", + "Redeemed On", + ] + + def __init__( + self, + contract_page: ContractPage, + ): + """ + Initialize the class. + + This uses the same base as the deferrals and refunds handling code, + but it splits from it a lot. This expects a GSheets URL rather than an + ID, and it expects those to come from the ContractPage, because we allow + for each contract to have its own page. Same with the sheet tab. + + This will also raise a ValueError if you pass in a ContractPage that does + not have enrollment codes. + + Args: + contract_page (ContractPage): the contract to work with + """ + + if not contract_page.google_sheet_target: + msg = f"Contract {contract_page} has no linked Google Sheet, can't continue" + raise ValueError(msg) + + if contract_page.membership_type in CONTRACT_MEMBERSHIP_AUTOS: + msg = f"Membership for contract {contract_page} is managed; no enrollment codes, so can't continue" + raise ValueError(msg) + + self.contract_page = contract_page + self.pygsheets_client = get_authorized_pygsheets_client() + self.spreadsheet = self.pygsheets_client.open_by_url( + contract_page.google_sheet_target + ) + self.worksheet_name = contract_page.google_sheet_target_tab + self.start_row = 0 # unimplemented at this point + + @cached_property + def worksheet(self): + """Return the specified worksheet""" + + return self.spreadsheet.worksheet( + property="index" if isinstance(self.worksheet_name, int) else "title", + value=self.worksheet_name, + ) + + @property + def row_zero(self) -> int: + """Gets the first row we're supposed to use.""" + + return GOOGLE_SHEET_FIRST_ROW + self.start_row + + @property + def row_one(self) -> int: + """Gets the first row for data, based on row_zero.""" + + return self.row_zero + 1 + + def _get_sorted_codes(self): + """ + Return the applicable discount codes for the contract. + + Like the b2b_codes command, this limits output to the codes necessary to + fill the contract. Unlike the b2b_codes command, we also want to get the + codes that have been used, so this adds an annotation so we can sort by + contract redemption. + """ + + return ( + self.contract_page.discounts_qs.prefetch_related( + "contract_redemptions", "contract_redemptions__user" + ) + .annotate(attach_redemption_count=Count("contract_redemptions")) + .all() + .order_by("-attach_redemption_count", "id")[ + : self.contract_page.max_learners + ] + ) + + def _write_row(self, row: int, columns: list) -> int: + """ + Write the row to the current worksheet at the specified location. + + If row is set to a negative, then we will use the first blank line in + the sheet that we can find. This will cache the last blank row found, so + future calls should start from there rather than having to scan the + entire sheet each time. + """ + + if row < 0: + # Now we have to figure out where the next blank column is. + found_blank = False + search_idx = 0 + empty_cols = ["" for _ in self.default_columns] + while not found_blank: + cells = self.worksheet.get_row(self.row_one + search_idx) + + if len(cells) == 0 or cells[: len(self.default_columns)] == empty_cols: + found_blank = True + break + + search_idx += 1 + + row = search_idx + self.row_one + + self.worksheet.update_row(row, columns) + + return row + + def _write_header(self): + """Write/overwrite the header row.""" + + self._write_row(self.row_zero, self.default_columns) + + def _get_discount_cells(self, discount: Discount) -> list: + """Format the discount for the sheet""" + + redemption_date = "" + redeemed_by = "" + redeemed_on = "" + + if ( + discount.redemption_type != REDEMPTION_TYPE_UNLIMITED + and discount.contract_redemptions.count() > 0 + ): + redemption_date = str(discount.contract_redemptions.last().created_on) + + if discount.contract_redemptions.exists(): + redeemed_by = discount.contract_redemptions.last().user.email + redeemed_on = str(discount.contract_redemptions.last().created_on) + + return [ + discount.discount_code, + discount.redemption_type, + discount.contract_redemptions.count(), + str(discount.expiration_date) + if discount.expiration_date + else redemption_date, + redeemed_by, + redeemed_on, + ] + + def ensure_header(self): + """Ensure there's a header row in the worksheet.""" + + header_row_values = self.worksheet.get_row(self.row_zero) + + if len(header_row_values) != 0: + log.warning( + "ContractEnrollmentCodeSheetHandler.ensure_header: header row for sheet %s in contract %s has data in it that we're now going to overwrite.", + self.worksheet_name, + self.contract_page, + ) + + self._write_header() + + def write_codes(self) -> int: + """ + Write out all the enrollment codes for the contract. + + See notes in _get_sorted_codes - but this will write out all the used + and unused codes for the contract, until it has written enough codes to + complete the contract (hits max_learners). + + This function is destructive so use "update_codes" if the sheet has been + modified. + """ + + self.ensure_header() + + row_idx = self.row_one + codes = [] + + for code in self._get_sorted_codes(): + col = self._get_discount_cells(code) + + code.b2b_sheet_location = row_idx + codes.append(code) + + self._write_row(row_idx, col) + + row_idx += 1 + + Discount.objects.bulk_update(codes, {"b2b_sheet_location"}) + + return row_idx - self.row_one + + def check_code(self, discount: Discount, *, no_update: bool = False) -> int: + """Check for the given enrollment code in the sheet.""" + + if discount.b2b_sheet_location and int(discount.b2b_sheet_location) > 0: + cached_row = self.worksheet.get_row( + int(discount.b2b_sheet_location), + include_tailing_empty=False, + returnas="matrix", + ) + + if cached_row and cached_row[0] == discount.discount_code: + return int(discount.b2b_sheet_location) + + found_cell = self.worksheet.find( + discount.discount_code, + matchEntireCell=True, + rows=(self.row_one, None), + cols=(1, 1), + ) + + if len(found_cell) == 0: + return -1 + + if len(found_cell) > 1: + found_locs = ",".join([f"({cell.row},{cell.col})" for cell in found_cell]) + msg = f"Code {discount.discount_code} seems to be in the sheet more than once: {found_locs}" + + raise ValueError(msg) + + if not no_update: + discount.b2b_sheet_location = found_cell[0].row + discount.save() + + return found_cell[0].row + + def update_code(self, discount: Discount, *, no_update: bool = False) -> int: + """ + Update the enrollment code in the sheet. + + There are some status columns in the worksheet - Invalidated On, Total + Redemptions, Redeemed By, and Redeemed On. This fills those values for + the code if the code is in the sheet already. If it isn't, then it'll + be appended. + """ + + row = self.check_code(discount, no_update=no_update) + + update_col = self._get_discount_cells(discount) + + return self._write_row(row, update_col) + + def update_sheet(self) -> int: + """ + Update the entire sheet, preserving codes in their locations. + + The write_codes function will destructively write and prepare the sheet. + This isn't good if we've given the customer the sheet, because they will + probably edit it. So, when we need to refresh the entire sheet, we need + to take care to not move the codes around so we don't muck up any of the + data the customer's put into the sheet. + """ + + row_idx = self.row_one + codes = [] + + for code in self._get_sorted_codes(): + row = self.update_code(code, no_update=True) + + code.b2b_sheet_location = row + codes.append(code) + + row_idx += 1 + + Discount.objects.bulk_update(codes, {"b2b_sheet_location"}) + + return row_idx - self.row_one diff --git a/b2b/sheets_test.py b/b2b/sheets_test.py new file mode 100644 index 0000000000..e38077cb58 --- /dev/null +++ b/b2b/sheets_test.py @@ -0,0 +1,385 @@ +# ruff: noqa: SLF001 +"""Tests for the Google Shees enrollment code integration.""" + +import logging +from unittest.mock import ANY, MagicMock + +import faker +import pytest +from mitol.google_sheets.constants import GOOGLE_SHEET_FIRST_ROW + +from b2b import factories +from b2b.api import ensure_contract_run_products, ensure_enrollment_codes_exist +from b2b.constants import CONTRACT_MEMBERSHIP_CODE, CONTRACT_MEMBERSHIP_MANAGED +from b2b.models import DiscountContractAttachmentRedemption +from b2b.sheets import ContractEnrollmentCodesSheetHandler +from courses.factories import CourseRunFactory +from ecommerce.factories import OneTimeDiscountFactory +from users.factories import UserFactory + +FAKE = faker.Faker() +pytestmark = [ + pytest.mark.django_db, +] + + +@pytest.fixture(autouse=True) +def mocked_pygsheets(mocker, settings): + """Mock the pygsheets client.""" + + settings.MITOL_GOOGLE_SHEETS_DRIVE_SHARED_ID = False + + class FakePygsheetsClient: + """A faked pygsheets client with some faked methods.""" + + open_by_url = MagicMock() + + mocker.patch("mitol.google_sheets.api.get_credentials") + + return mocker.patch( + "pygsheets.authorize", + side_effect=lambda *args, **kwargs: FakePygsheetsClient(), # noqa: ARG005 + ) + + +@pytest.fixture +def contract_with_sheet(): + """Return a ContractPage with the fields set up right for the integration.""" + + return factories.ContractPageFactory.create( + membership_type=CONTRACT_MEMBERSHIP_CODE, + integration_type=CONTRACT_MEMBERSHIP_CODE, + max_learners=FAKE.random_int(10, 20), + google_sheet_target=FAKE.url(), + google_sheet_target_tab="Sheet1", + ) + + +@pytest.fixture +def contract_with_sheet_courseruns(contract_with_sheet, mocker): + """Expand on contract_with_sheet and add course runs/enrollment codes.""" + + runs = CourseRunFactory.create_batch(3, b2b_contract=contract_with_sheet) + + mocker.patch("b2b.tasks.queue_contract_sheet_update_post_save.delay") + ensure_contract_run_products(contract_with_sheet) + ensure_enrollment_codes_exist(contract_with_sheet) + + return ( + contract_with_sheet, + runs, + ) + + +@pytest.fixture +def contract_with_sheet_courseruns_handler_mocks(contract_with_sheet_courseruns): + """Expand on the contract_with_sheet_courserun and add a handler and mocks.""" + + contract_with_sheet, runs = contract_with_sheet_courseruns + handler = ContractEnrollmentCodesSheetHandler(contract_with_sheet) + + handler._write_header = MagicMock() + handler.worksheet.get_row = MagicMock(return_value=[]) + handler.worksheet.update_row = MagicMock() + + return (contract_with_sheet, runs, handler) + + +@pytest.mark.parametrize( + "valid_sheet", + [ + True, + False, + ], +) +@pytest.mark.parametrize( + "valid_contract", + [ + True, + False, + ], +) +def test_handler_init(mocked_pygsheets, valid_sheet, valid_contract): + """Test that the class initializes properly.""" + + test_contract = factories.ContractPageFactory.create( + membership_type=CONTRACT_MEMBERSHIP_CODE + if valid_contract + else CONTRACT_MEMBERSHIP_MANAGED, + integration_type=CONTRACT_MEMBERSHIP_CODE + if valid_contract + else CONTRACT_MEMBERSHIP_MANAGED, + ) + + if valid_sheet: + test_contract.google_sheet_target = FAKE.url() + test_contract.save() + + if not valid_sheet or not valid_contract: + with pytest.raises(ValueError, match=r"can't continue") as exc: + ContractEnrollmentCodesSheetHandler(test_contract) + + if not valid_sheet: + assert "no linked Google Sheet" in str(exc) + return + + if not valid_contract: + assert "no enrollment codes" in str(exc) + + return + + handler = ContractEnrollmentCodesSheetHandler(test_contract) + + assert handler + mocked_pygsheets.assert_called() + # because I will forget this - the mocked_pygsheets returns a custom class + # with mocked pygsheets Client methods in it. + handler.pygsheets_client.open_by_url.assert_called_with( + test_contract.google_sheet_target + ) + + +def test_row_helpers(contract_with_sheet): + """Make sure the row helper methods work.""" + + handler = ContractEnrollmentCodesSheetHandler(contract_with_sheet) + + assert handler.row_zero == GOOGLE_SHEET_FIRST_ROW + assert handler.row_one == GOOGLE_SHEET_FIRST_ROW + 1 + + +@pytest.mark.parametrize( + "row_zero_length", + [ + 0, + 3, + ], +) +def test_ensure_header(caplog, contract_with_sheet, row_zero_length): + """Test that the header gets written as expected.""" + + handler = ContractEnrollmentCodesSheetHandler(contract_with_sheet) + + row_zero = [] if row_zero_length == 0 else FAKE.random_letters(row_zero_length) + + handler._write_header = MagicMock() + handler.worksheet.get_row = MagicMock(return_value=row_zero) + + if row_zero_length > 0: + with caplog.at_level(logging.WARNING): + handler.ensure_header() + + assert "overwrite" in caplog.text + else: + handler.ensure_header() + handler._write_header.assert_called() + + +def test_sorted_codes(contract_with_sheet_courseruns): + """Test that the handler's code retrieval works as expected.""" + + contract, _ = contract_with_sheet_courseruns + users = UserFactory.create_batch(2) + + assert contract.get_discounts().count() == 3 * contract.max_learners + + # Grab a code from the top and bottom of the list, so we can make sure that + # _all_ redeemed codes make it into the result set. + + first_code = contract.get_discounts().first() + last_code = contract.get_discounts().last() + + DiscountContractAttachmentRedemption.objects.create( + contract=contract, + user=users[0], + discount=first_code, + ) + DiscountContractAttachmentRedemption.objects.create( + contract=contract, + user=users[0], + discount=last_code, + ) + + handler = ContractEnrollmentCodesSheetHandler(contract) + sorted_codes = handler._get_sorted_codes() + + assert sorted_codes.count() == contract.max_learners + assert first_code.id in [code.id for code in sorted_codes] + assert last_code.id in [code.id for code in sorted_codes] + + +def test_formatted_codes(contract_with_sheet_courseruns): + """Test that the proto-serializer for the sheet works.""" + + contract, _ = contract_with_sheet_courseruns + handler = ContractEnrollmentCodesSheetHandler(contract) + sorted_codes = handler._get_sorted_codes() + + formatted_code = handler._get_discount_cells(sorted_codes[0]) + + assert formatted_code == [ + sorted_codes[0].discount_code, + sorted_codes[0].redemption_type, + sorted_codes[0].contract_redemptions.count(), + "", + "", + "", + ] + + +@pytest.mark.parametrize( + "write_to_blank_line", + [ + True, + False, + ], +) +@pytest.mark.parametrize( + "test_with_explicit_blanks", + [ + True, + False, + ], +) +def test_write_row( + mocker, + contract_with_sheet_courseruns_handler_mocks, + write_to_blank_line, + test_with_explicit_blanks, +): + """Test that the internal write_row method works.""" + + sample_doc = [ + FAKE.random_letters(6), + FAKE.random_letters(15), + ["", "", "", "", "", ""] if test_with_explicit_blanks else [], + ] + + test_row = FAKE.random_letters( + len(ContractEnrollmentCodesSheetHandler.default_columns) + ) + + _, _, handler = contract_with_sheet_courseruns_handler_mocks + + # Replace the return - each call should be a new row of the sample doc, so + # we can make sure this is selecting a row and not just doing the first one. + handler.worksheet.get_row.return_value = None + handler.worksheet.get_row.side_effect = sample_doc + + write_idx = -1 if write_to_blank_line else 1 + + handler._write_row(write_idx, test_row) + + if write_to_blank_line: + handler.worksheet.update_row.assert_called_with(4, test_row) + else: + handler.worksheet.update_row.assert_called_with(write_idx, test_row) + + +def test_write_codes(contract_with_sheet_courseruns_handler_mocks): + """Test that destructively writing the codes works as expected.""" + + _, _, handler = contract_with_sheet_courseruns_handler_mocks + + handler.write_codes() + + test_cells = handler._get_discount_cells(handler._get_sorted_codes()[4]) + handler.worksheet.update_row.assert_any_call(ANY, test_cells) + + +class FakeCell: + """A simple fake pygsheets Cell for use in the test below.""" + + row = 0 + col = 0 + + def __init__(self, row, col): + """Create the cell""" + + self.row = row + self.col = col + + +def test_check_codes_expected_loc(contract_with_sheet_courseruns_handler_mocks): + """Test that the code check method finds codes as we expect.""" + + _, _, handler = contract_with_sheet_courseruns_handler_mocks + + sample_sheet = [ + ContractEnrollmentCodesSheetHandler.default_columns, + *[handler._get_discount_cells(code) for code in handler._get_sorted_codes()], + ] + + def _return_row(row_idx, sample_sheet=sample_sheet, **kwargs): + """Mock get_row but return from our sample sheet.""" + + if row_idx > len(sample_sheet) or row_idx < 0: + return [] + + return sample_sheet[row_idx] + + handler.worksheet.get_row.side_effect = _return_row + handler.worksheet.find = MagicMock() + + # Test when a discount is at the location we expect. + discount = handler._get_sorted_codes()[3] + discount.b2b_sheet_location = "4" + discount.save() + + assert handler.check_code(discount) == 4 + handler.worksheet.find.assert_not_called() + + # Test when the discount is at a different location. + discount.b2b_sheet_location = 1 + discount.save() + handler.worksheet.find.return_value = [FakeCell(4, 1)] + + assert handler.check_code(discount) == 4 + handler.worksheet.find.assert_called_with( + discount.discount_code, matchEntireCell=True, rows=ANY, cols=ANY + ) + + discount.refresh_from_db() + assert discount.b2b_sheet_location == "4" + + # Test when the discount is not in the sheet. + random_discount = OneTimeDiscountFactory.create() + handler.worksheet.find.return_value = [] + + assert handler.check_code(random_discount) == -1 + handler.worksheet.find.assert_called_with( + random_discount.discount_code, matchEntireCell=True, rows=ANY, cols=ANY + ) + + # Test when too many discounts are found. + + discount.b2b_sheet_location = "" + discount.save() + + handler.worksheet.find.return_value = [FakeCell(4, 1), FakeCell(1900, 872)] + + with pytest.raises(ValueError, match="more than once") as exc: + handler.check_code(discount) + + assert "1900" in str(exc) + handler.worksheet.find.assert_called_with( + discount.discount_code, matchEntireCell=True, rows=ANY, cols=ANY + ) + + +def test_update_sheet(contract_with_sheet_courseruns_handler_mocks): + """ + Test that update_sheet works as expected. + + The internals of this get exercised more in earlier tests, so this is pretty + simple. + """ + + contract, _, handler = contract_with_sheet_courseruns_handler_mocks + + handler.update_code = MagicMock( + side_effect=list(range(1, contract.max_learners + 1)) + ) + + handler.update_sheet() + assert handler.update_code.call_count == contract.max_learners diff --git a/b2b/tasks.py b/b2b/tasks.py index 370d6141d0..afceb340fe 100644 --- a/b2b/tasks.py +++ b/b2b/tasks.py @@ -4,6 +4,7 @@ import logging from django.core.cache import cache +from django.db.models import Q from main.celery import app @@ -121,3 +122,85 @@ def create_program_contract_runs( finally: # Always release the lock when done, even if an exception occurred cache.delete(lock_key) + + +@app.task(acks_late=True) +def queue_contract_sheet_update_post_save( + contract_id: int, *, only_update: bool = False +): + """ + Take an appropriate action on post-save for the contract. + + If the prior revision to the current has a different tab or sheet URL + specified, then run write_codes, which will set up the (presumably blank) + sheet. Otherwise, use update_sheet, which is non-destructive. + """ + + from b2b.models import ContractPage + from b2b.sheets import ContractEnrollmentCodesSheetHandler + + contract = ContractPage.objects.get(pk=contract_id) + + try: + handler = ContractEnrollmentCodesSheetHandler(contract) + except ValueError as exc: + if "Google Sheet" in str(exc): + log.info( + "Contract %s has no linked Google Sheet or tab set, skipping", contract + ) + elif "managed" in str(exc): + log.info("Contract %s is managed (no enrollment codes), skipping", contract) + return + + if not only_update: + has_revs = contract.revisions.count() > 1 + + if has_revs: + # We have page revisions so check to see if the sheet or the tab changed + # in between. If they did, then we start over. + # Explicitly set this sort even though the Wagtail model seems to do this anyway. + revs = contract.revisions.order_by("-created_at").all()[:2] + if ( + revs[0].as_object().google_sheet_target + != revs[1].as_object().google_sheet_target + ) or ( + revs[0].as_object().google_sheet_target_tab + != revs[1].as_object().google_sheet_target_tab + ): + has_revs = False + + if not only_update and not has_revs: + log.info("Setting up Google Sheet for %s", contract) + + codes_written = handler.write_codes() + else: + log.info("Updating Google Sheet for %s", contract) + + codes_written = handler.update_sheet() + + log.info("Wrote %s codes for %s", codes_written, contract) + + +@app.task(acks_late=True) +def queue_update_all_contract_enrollment_sheets(): + """ + Update all of the configured enrollment code sheets in the system. + + This fires off a bunch of calls to the above post-save task rather than rolling + through the sequentially. May need to revisit this (add batching, etc) as we + add more contracts. + """ + + from b2b.constants import CONTRACT_MEMBERSHIP_AUTOS + from b2b.models import ContractPage + + updateable_contracts = ( + ContractPage.objects.exclude( + Q(membership_type__in=CONTRACT_MEMBERSHIP_AUTOS) | Q(google_sheet_target="") + ) + .only("id") + .all() + ) + + for contract in updateable_contracts: + queue_contract_sheet_update_post_save.delay(contract.id, only_update=True) diff --git a/courses/api.py b/courses/api.py index 96c28a417c..15c0f5fd9c 100644 --- a/courses/api.py +++ b/courses/api.py @@ -1333,24 +1333,23 @@ def import_courserun_from_edx( # noqa: C901, PLR0913 root_course = Course.objects.filter(readable_id=root_course_id).first() if not root_course: - if not departments or len(departments) == 0: - msg = f"Course {root_course_id} would be created, so departments are required." - raise AttributeError(msg) - + # Allow creating a course without departments root_course = Course.objects.create( readable_id=root_course_id, title=edx_course_run.name, live=live, ) - for department in departments: - if isinstance(department, str) and create_depts: - dept, _ = Department.objects.get_or_create(name=department) - dept.save() - elif isinstance(department, Department): - dept = department - - root_course.departments.add(dept.id) + if departments: + dept = None + for department in departments: + if isinstance(department, str) and create_depts: + dept, _ = Department.objects.get_or_create(name=department) + dept.save() + elif isinstance(department, Department): + dept = department + if dept: + root_course.departments.add(dept.id) new_run = CourseRun.objects.create( course=root_course, diff --git a/courses/management/commands/create_courseware.py b/courses/management/commands/create_courseware.py index 4f51b4b9eb..c2d0c7dbbe 100644 --- a/courses/management/commands/create_courseware.py +++ b/courses/management/commands/create_courseware.py @@ -302,7 +302,8 @@ def _handle_program(self, add_depts: models.QuerySet, **kwargs): live=kwargs["live"], ) - self._add_departments_to_courseware_object(new_program, add_depts) + if add_depts: + self._add_departments_to_courseware_object(new_program, add_depts) self._successfully_created_courseware_object_message(new_program) @@ -347,8 +348,8 @@ def _handle_course(self, add_depts: models.QuerySet, **kwargs): readable_id=kwargs["courseware_id"], live=kwargs["live"], ) - - self._add_departments_to_courseware_object(new_course, add_depts) + if add_depts: + self._add_departments_to_courseware_object(new_course, add_depts) self._successfully_created_courseware_object_message(new_course) @@ -371,8 +372,6 @@ def _handle_course(self, add_depts: models.QuerySet, **kwargs): except: # noqa: E722 program = Program.objects.filter(readable_id=kwargs["program"]).first() - self._add_departments_to_courseware_object(program, add_depts) - if program is not None and kwargs["required"]: new_req = program.add_requirement(new_course) elif program is not None and kwargs["elective"]: @@ -578,8 +577,9 @@ def handle(self, *_args, **kwargs): ) exit(-1) # noqa: PLR1722 - # Validate/create departments for programs and courses (not needed for courseruns) - if kwargs["type"] in ["program", "course"]: + # Create or get departments if specified. + add_depts = None + if kwargs.get("depts"): add_depts = self._get_or_create_departments( kwargs["depts"], create_if_missing=kwargs["create_depts"] ) diff --git a/courses/management/commands/import_courserun.py b/courses/management/commands/import_courserun.py index 9f86dc7dd2..a0437f7903 100644 --- a/courses/management/commands/import_courserun.py +++ b/courses/management/commands/import_courserun.py @@ -101,9 +101,10 @@ def add_arguments(self, parser) -> None: "-d", "--dept", "--department", - help="Specify department(s) assigned to the course object. If program is specified, all courses associated with the program and imported will have the same department.", + help="Specify department(s) assigned to the course object. If not specified, the course will be created without departments.", action="append", dest="depts", + required=False, ) parser.add_argument( diff --git a/courses/migrations/0089_alter_course_departments.py b/courses/migrations/0089_alter_course_departments.py new file mode 100644 index 0000000000..2f4aa79655 --- /dev/null +++ b/courses/migrations/0089_alter_course_departments.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.15 on 2026-03-20 18:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("courses", "0088_alter_program_display_mode"), + ] + + operations = [ + migrations.AlterField( + model_name="course", + name="departments", + field=models.ManyToManyField( + blank=True, related_name="courses", to="courses.department" + ), + ), + ] diff --git a/courses/migrations/0090_alter_program_departments.py b/courses/migrations/0090_alter_program_departments.py new file mode 100644 index 0000000000..03b5705b9b --- /dev/null +++ b/courses/migrations/0090_alter_program_departments.py @@ -0,0 +1,19 @@ +# Generated by Django 5.1.15 on 2026-03-25 12:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("courses", "0089_alter_course_departments"), + ] + + operations = [ + migrations.AlterField( + model_name="program", + name="departments", + field=models.ManyToManyField( + blank=True, related_name="programs", to="courses.department" + ), + ), + ] diff --git a/courses/models.py b/courses/models.py index 6b74d89ffa..0abfe50a86 100644 --- a/courses/models.py +++ b/courses/models.py @@ -296,7 +296,7 @@ class Meta: null=True, ) departments = models.ManyToManyField( - Department, blank=False, related_name="programs" + Department, blank=True, related_name="programs" ) availability = models.CharField( choices=AVAILABILITY_CHOICES, default=AVAILABILITY_ANYTIME, max_length=255 @@ -938,9 +938,7 @@ class Meta: max_length=255, unique=True, validators=[validate_url_path_field] ) live = models.BooleanField(default=False, db_index=True) - departments = models.ManyToManyField( - Department, blank=False, related_name="courses" - ) + departments = models.ManyToManyField(Department, blank=True, related_name="courses") flexible_prices = GenericRelation( "flexiblepricing.FlexiblePrice", object_id_field="courseware_object_id", diff --git a/courses/models_test.py b/courses/models_test.py index 7580f37384..23f35a388e 100644 --- a/courses/models_test.py +++ b/courses/models_test.py @@ -1163,3 +1163,28 @@ def test_courserun_qs_b2b_flags(): def test_program_requirements_root_node_collation(): """Ensure we can create a bunch of programs ()""" ProgramFactory.create_batch(100) + + +def test_course_creation_without_department(): + """ + Test that a Course can be created without any departments + """ + course = Course.objects.create( + title="No Dept Course", readable_id="course-v1:PyT+NoDept+1", live=True + ) + assert course.departments.count() == 0 + # Should be retrievable and have no departments + retrieved = Course.objects.get(pk=course.pk) + assert retrieved.departments.count() == 0 + + +def test_create_program_without_department(): + """Test that a Program can be created without any departments.""" + + program = Program.objects.create( + title="Test Program", readable_id="program-v1:TEST+P1", live=True + ) + assert program.departments.count() == 0 + # Fetch from DB to ensure no departments are attached + program_from_db = Program.objects.get(pk=program.pk) + assert program_from_db.departments.count() == 0 diff --git a/courses/serializers/v2/programs.py b/courses/serializers/v2/programs.py index adea705d90..78d503889b 100644 --- a/courses/serializers/v2/programs.py +++ b/courses/serializers/v2/programs.py @@ -143,6 +143,7 @@ class ProgramSerializer(serializers.ModelSerializer): departments = DepartmentSerializer(many=True, read_only=True) topics = serializers.SerializerMethodField() certificate_type = serializers.SerializerMethodField() + certificate_available = serializers.SerializerMethodField() required_prerequisites = serializers.SerializerMethodField() duration = serializers.SerializerMethodField() min_weeks = serializers.SerializerMethodField() @@ -491,6 +492,10 @@ def get_certificate_type(self, instance): return "MicroMasters Credential" return "Certificate of Completion" + @extend_schema_field(bool) + def get_certificate_available(self, instance) -> bool: + return any(mode.requires_payment for mode in instance.enrollment_modes.all()) + def get_min_weekly_hours(self, instance) -> str | None: """ Get the min weekly hours of the course from the course page CMS. @@ -568,6 +573,7 @@ class Meta: "page", "program_type", "certificate_type", + "certificate_available", "departments", "live", "display_mode", diff --git a/courses/serializers/v2/programs_test.py b/courses/serializers/v2/programs_test.py index 25e4049ecc..2d292bf63a 100644 --- a/courses/serializers/v2/programs_test.py +++ b/courses/serializers/v2/programs_test.py @@ -9,6 +9,7 @@ from courses.factories import ( # noqa: F401 CourseFactory, CourseRunFactory, + EnrollmentModeFactory, ProgramCollectionFactory, ProgramFactory, program_with_empty_requirements, @@ -153,12 +154,40 @@ def test_serialize_program( "min_weekly_hours": program_with_empty_requirements.page.min_weekly_hours, "min_price": program_with_empty_requirements.page.min_price, "max_price": program_with_empty_requirements.page.max_price, + "certificate_available": False, "enrollment_modes": [], "display_mode": None, }, ) +@pytest.mark.parametrize( + ("has_paid_mode", "expected"), + [ + (True, True), + (False, False), + ], +) +def test_serialize_program_certificate_available( + mock_context, + program_with_empty_requirements, # noqa: F811 + has_paid_mode, + expected, +): + """Test that certificate_available reflects whether any enrollment mode requires payment.""" + mode = EnrollmentModeFactory.create( + mode_slug="verified" if has_paid_mode else "audit", + requires_payment=has_paid_mode, + ) + program_with_empty_requirements.enrollment_modes.add(mode) + + data = ProgramSerializer( + instance=program_with_empty_requirements, context=mock_context + ).data + + assert data["certificate_available"] is expected + + def test_serialize_program_with_products( mock_context, program_with_empty_requirements, # noqa: F811 diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py index aee52f306c..af179dfa58 100644 --- a/courses/views/v2/__init__.py +++ b/courses/views/v2/__init__.py @@ -963,6 +963,7 @@ def list(self, request): "program", "program__page", ) + .prefetch_related("program__enrollment_modes") .filter(user=request.user) .filter(~Q(change_status=ENROLL_CHANGE_STATUS_UNENROLLED)) .order_by("-id") diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index 10e928ccaa..a6db3db419 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -1553,6 +1553,7 @@ def _get_page_prop(program_enrollment, prop, default=None): "collections": [], "availability": "anytime", "certificate_type": "Certificate of Completion", + "certificate_available": False, "required_prerequisites": _get_page_prop( program_enrollment, "prerequisites", "" ) diff --git a/ecommerce/migrations/0039_add_b2b_gsheet_index_to_discount.py b/ecommerce/migrations/0039_add_b2b_gsheet_index_to_discount.py new file mode 100644 index 0000000000..0f796bb67b --- /dev/null +++ b/ecommerce/migrations/0039_add_b2b_gsheet_index_to_discount.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.15 on 2026-03-13 20:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("ecommerce", "0038_add_program_discount_flag"), + ] + + operations = [ + migrations.AddField( + model_name="discount", + name="b2b_sheet_location", + field=models.CharField( + blank=True, + default="", + help_text="The location of this code in the B2B contract's code sheet.", + max_length=10, + null=True, + ), + ), + ] diff --git a/ecommerce/models.py b/ecommerce/models.py index 0c831fd496..adfebf69d0 100644 --- a/ecommerce/models.py +++ b/ecommerce/models.py @@ -269,6 +269,15 @@ class Discount(TimestampedModel): default=False, help_text="Discount is only for creating verified course run enrollments for a program.", ) + # Only for B2B enrollment codes where the contract has a Google Sheet configured. + # This is just to save time/energy when we want to update the sheet later. + b2b_sheet_location = models.CharField( # noqa: DJ001 + blank=True, + null=True, + default="", + max_length=10, + help_text="The location of this code in the B2B contract's code sheet.", + ) def __str__(self): return f"{self.amount} {self.discount_type} {self.redemption_type} - {self.discount_code}" diff --git a/main/settings.py b/main/settings.py index fdb3ee3104..89ee2fe56a 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.143.2" +VERSION = "1.143.4" log = logging.getLogger() @@ -973,6 +973,18 @@ description="Offset for the Keycloak org sync", ) +B2B_GSHEETS_UPDATE_FREQUENCY = get_int( + name="B2B_GSHEETS_UPDATE_FREQUENCY", + default=3600, + description="How many seconds to wait between updating the enrollment code Google Sheets for B2B contracts", +) + +B2B_GSHEETS_UPDATE_OFFSET = get_int( + name="B2B_GSHEETS_UPDATE_OFFSET", + default=int(B2B_GSHEETS_UPDATE_FREQUENCY / 2), + description="Offset for the B2B enrollment code sheet updates", +) + CELERY_BEAT_SCHEDULE = { "retry-failed-edx-enrollments": { "task": "openedx.tasks.retry_failed_edx_enrollments", @@ -1031,6 +1043,13 @@ "task": "main.tasks.run_clear_tokens", "schedule": crontab(minute=0, hour=9, day_of_week=1), # every week }, + "update-b2b-enrollment-code-sheets": { + "task": "b2b.tasks.queue_update_all_contract_enrollment_sheets", + "schedule": OffsettingSchedule( + run_every=timedelta(seconds=B2B_GSHEETS_UPDATE_FREQUENCY), + offset=timedelta(seconds=B2B_GSHEETS_UPDATE_OFFSET), + ), + }, } # Hijack diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 40e9fc5ca0..80ca5aac65 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -5417,6 +5417,11 @@ components: nullable: true description: Discount is only for creating verified course run enrollments for a program. + b2b_sheet_location: + type: string + nullable: true + description: The location of this code in the B2B contract's code sheet. + maxLength: 10 required: - amount - created_on @@ -8262,6 +8267,9 @@ components: certificate_type: type: string readOnly: true + certificate_available: + type: boolean + readOnly: true departments: type: array items: @@ -8361,6 +8369,7 @@ components: $ref: '#/components/schemas/EnrollmentMode' readOnly: true required: + - certificate_available - certificate_type - collections - courses @@ -8554,6 +8563,9 @@ components: certificate_type: type: string readOnly: true + certificate_available: + type: boolean + readOnly: true departments: type: array items: @@ -8659,6 +8671,7 @@ components: - $ref: '#/components/schemas/BaseProduct' readOnly: true required: + - certificate_available - certificate_type - collections - courses diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 6457d9a807..4e4439ed4c 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -5417,6 +5417,11 @@ components: nullable: true description: Discount is only for creating verified course run enrollments for a program. + b2b_sheet_location: + type: string + nullable: true + description: The location of this code in the B2B contract's code sheet. + maxLength: 10 required: - amount - created_on @@ -8262,6 +8267,9 @@ components: certificate_type: type: string readOnly: true + certificate_available: + type: boolean + readOnly: true departments: type: array items: @@ -8361,6 +8369,7 @@ components: $ref: '#/components/schemas/EnrollmentMode' readOnly: true required: + - certificate_available - certificate_type - collections - courses @@ -8554,6 +8563,9 @@ components: certificate_type: type: string readOnly: true + certificate_available: + type: boolean + readOnly: true departments: type: array items: @@ -8659,6 +8671,7 @@ components: - $ref: '#/components/schemas/BaseProduct' readOnly: true required: + - certificate_available - certificate_type - collections - courses diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index cbfe267897..803ab3c727 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -5417,6 +5417,11 @@ components: nullable: true description: Discount is only for creating verified course run enrollments for a program. + b2b_sheet_location: + type: string + nullable: true + description: The location of this code in the B2B contract's code sheet. + maxLength: 10 required: - amount - created_on @@ -8262,6 +8267,9 @@ components: certificate_type: type: string readOnly: true + certificate_available: + type: boolean + readOnly: true departments: type: array items: @@ -8361,6 +8369,7 @@ components: $ref: '#/components/schemas/EnrollmentMode' readOnly: true required: + - certificate_available - certificate_type - collections - courses @@ -8554,6 +8563,9 @@ components: certificate_type: type: string readOnly: true + certificate_available: + type: boolean + readOnly: true departments: type: array items: @@ -8659,6 +8671,7 @@ components: - $ref: '#/components/schemas/BaseProduct' readOnly: true required: + - certificate_available - certificate_type - collections - courses