Skip to content

Add send-to-Google-Sheets support for B2B enrollment codes#3363

Open
jkachel wants to merge 20 commits intomainfrom
jkachel/7931-add-b2b-gsheets-output
Open

Add send-to-Google-Sheets support for B2B enrollment codes#3363
jkachel wants to merge 20 commits intomainfrom
jkachel/7931-add-b2b-gsheets-output

Conversation

@jkachel
Copy link
Contributor

@jkachel jkachel commented Mar 8, 2026

What are the relevant tickets?

Closes mitodl/hq#7931

Description (What does it do?)

Adds the ability to send a contract's enrollment codes to a Google Sheet.

This PR adds a couple of new fields to the ContractPage model. These fields allow the user to specify the URL to a Google Sheet that we can throw codes into, and what worksheet (tab) within the document to use for this purpose. When set, the system will write enrollment codes to the sheet, and update the status of the individual codes as they're used.

Each ContractPage can have its own individual Google Sheet to work in. Or, it can share a Sheet with other contracts and use a specified tab within the sheet. The integration doesn't really care as long as the sheet and tab are accessible, but some care should be taken to make sure you're not re-using a sheet/tab combination. The tab field defaults to "Sheet1", which is the name of the first tab that is created by default in an empty Google Sheet. Be sure you change this if you've changed the tab name.

You can map a single sheet to a contract. To make things easier for folks managing the contract, the integration takes a URL to the sheet rather than a bare sheet ID (like the deferrals and refunds integrations do). The sheet must be writable by the account that was used to authorize MITx Online's access to the Google Drive APIs.

Codes are written to the configured sheet via a Celery task:

  • When the ContractPage is saved.
  • When ensure_enrollment_codes_exist is called.
  • According to the B2B_GSHEETS_UPDATE_FREQUENCY and B2B_GSHEETS_UPDATE_OFFSET settings (default is to run every hour).

The task will check to see if either the target Google sheet or tab field has changed in the ContractPage. If it has, then the task will assume this is a new sheet, and write out the codes along with a header, overwriting anything that may be in the sheet starting at A1. If those fields haven't changed, then it updates the codes in the sheet nondestructively instead.

Nondestructive updates allow the customer (contract owner) to modify the sheet without worry of data loss, within reason. For updates, the system checks the stored row position for each discount code to see if the code in the sheet matches the discount, and updates that row if it does. If it doesn't, then it scans for the code in the sheet starting from the header row, and updates the row if it finds the code. It writes the code data to a blank row if it can't find the code in the sheet at all. In the latter two cases, it will update the discount with the new stored row.

Updates will overwrite the 5 columns that aren't the discount code itself, so those columns are off-limits for editing. Any column beyond F is OK to edit. Deleting a code from the sheet, or modifying a code in the sheet, will cause it to be re-written.

How can this be tested?

You'll need the Google Sheets integration set up. There are some docs on this in the ol-django repo: https://github.com/mitodl/ol-django/blob/main/src/google_sheets/README.md#developer-setup The main steps are under the "Developer Setup" section. You can ignore the third part (xPRO Drive folder) but the other 3 steps are essential if you've not set this up before.

Note

Getting this set up with the current state of the art for local development was sort of involved. In my case, it involved getting a paid ngrok account set up so I could reserve a hostname for the app and one subdomain of it for local Keycloak, and involved some further changes to my APISIX and Keycloak setup so that they would understand the ngrok hostnames.

There are some docs in the code that indicate that you can use a locally-generated token: https://github.com/mitodl/ol-django/blob/7207b8da0cba0f995fb1c5cf12fb9a814b0b2383/src/google_sheets/mitol/google_sheets/api.py#L108 I have not tried this but it might be easier to deal with than trying to get the full OAuth flow going.

Additionally the template doesn't seem to have a place for the Google verification tag anymore so I just added it manually to the base template.

You will need at least one contract that requires enrollment code (so, type "non-sso" or "code"). Ideally, test with a contract that has courses in it (and thus codes) and one that's brand new.

Testing should involve triggering all the events listed above. (For testing, it would be ideal to also set the B2B_GSHEETS_UPDATE_FREQUENCY to a low number - maybe 60s or so.)

Test the following scenarios:

  • Writing the codes to an empty sheet
  • Changing the sheet and/or tab for a contract
  • Writing the codes to a non-empty sheet (this should overwrite the first 6 columns in the target sheet)
  • Attaching to the contract, which should (eventually) be reflected in the sheet
  • Adding courseware to the contract (which should generate new codes, which should be written to the sheet)

You can trigger the tasks manually, or use the ContractEnrollmentCodesSheetHandler object directly as well.

Additional Context

The system doesn't update the sheet when attachments happen - instead, the default is to update all the sheets roughly every hour. It seemed like doing the updates immediately could cause some issues with using Google's APIs, especially as we add more and more contracts, so I opted to do a bulk update instead.

The integration uses the same base stuff that the deferrals and refunds processing code uses, but works a good bit differently. The refund/deferral code expects to only ever read a single sheet and this expects to work on any number of sheets. So, it's using the ol-django google_sheets code but really only to bootstrap pygsheets.

The codes that are written are a sufficient number to fill the contract. In other words, if the contract is set for 100 seats, you'll get 100 codes written out, even if there's 9 courses in it (and thus 900 codes). This is similar to how the b2b_codes command works. However, the sheets integration will also purposefully find the codes that have been redeemed already and count them as part of the output set. (b2b_codes defaults to just delivering usable codes.) The sheet will only display codes that have been redeemed for attachment to the contract.

A field was also added to the Discount model to keep track of where the code lands in the sheet, so it doesn't have to trawl through the entire sheet to update a code. (There can be potentially thousands of codes.) It will check to make sure the code is actually there; if it isn't, then it'll default to iterating through the sheet to find it.

There's options in here to set the start row but we're not using the at the moment. The field in the Discount model that stores the row position is a character field so we can support cell indexes (i.e. A1, C9, etc.) in the future.

@github-actions
Copy link

github-actions bot commented Mar 8, 2026

OpenAPI Changes

Show/hide ## Changes for v0.yaml:
## Changes for v0.yaml:
6 changes: 0 error, 0 warning, 6 info
info	[response-optional-property-added] at head/openapi/specs/v0.yaml	
	in API GET /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/
		added the optional property 'results/items/redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v0.yaml	
	in API POST /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '201' status

info	[response-optional-property-added] at head/openapi/specs/v0.yaml	
	in API GET /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/{id}/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v0.yaml	
	in API PATCH /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/{id}/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v0.yaml	
	in API PUT /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/{id}/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v0.yaml	
	in API GET /api/v0/orders/receipt/{id}/
		added the optional property 'discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status



## Changes for v1.yaml:
6 changes: 0 error, 0 warning, 6 info
info	[response-optional-property-added] at head/openapi/specs/v1.yaml	
	in API GET /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/
		added the optional property 'results/items/redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v1.yaml	
	in API POST /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '201' status

info	[response-optional-property-added] at head/openapi/specs/v1.yaml	
	in API GET /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/{id}/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v1.yaml	
	in API PATCH /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/{id}/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v1.yaml	
	in API PUT /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/{id}/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v1.yaml	
	in API GET /api/v0/orders/receipt/{id}/
		added the optional property 'discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status



## Changes for v2.yaml:
6 changes: 0 error, 0 warning, 6 info
info	[response-optional-property-added] at head/openapi/specs/v2.yaml	
	in API GET /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/
		added the optional property 'results/items/redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v2.yaml	
	in API POST /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '201' status

info	[response-optional-property-added] at head/openapi/specs/v2.yaml	
	in API GET /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/{id}/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v2.yaml	
	in API PATCH /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/{id}/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v2.yaml	
	in API PUT /api/v0/discounts/{parent_lookup_redeemed_discount}/redemptions/{id}/
		added the optional property 'redeemed_order/discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status

info	[response-optional-property-added] at head/openapi/specs/v2.yaml	
	in API GET /api/v0/orders/receipt/{id}/
		added the optional property 'discounts/items/redeemed_discount/allOf[#/components/schemas/Nested]/b2b_sheet_location' to the response with the '200' status



Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

@jkachel jkachel force-pushed the jkachel/7931-add-b2b-gsheets-output branch from 4428dc9 to d23141c Compare March 11, 2026 21:28
@jkachel jkachel force-pushed the jkachel/7931-add-b2b-gsheets-output branch from 00d45b6 to 910a0ad Compare March 13, 2026 20:10
@jkachel jkachel marked this pull request as ready for review March 13, 2026 20:18
total_updated += updated
total_errors += errors

queue_contract_sheet_update_post_save.delay(contract.id)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Saving a ContractPage incorrectly queues the same Google Sheet update task twice, causing redundant processing and wasted resources.
Severity: MEDIUM

Suggested Fix

Remove the redundant call to queue_contract_sheet_update_post_save.delay(contract.id) from the end of the ensure_enrollment_codes_exist function in b2b/api.py. The task is already correctly queued by the ContractPage.save() method, so this second call is unnecessary.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: b2b/api.py#L937

Potential issue: Saving a `ContractPage` object triggers two separate
`queue_contract_sheet_update_post_save` tasks for the same contract. One task is queued
directly by the `ContractPage.save()` method. A second, redundant task is queued
indirectly via a call chain: `save()` queues `queue_enrollment_code_check`, which calls
`ensure_enrollment_codes_exist`, which then queues the same update task again. This
duplication leads to wasted Celery worker resources, unnecessary Google Sheets API
calls, and could introduce race conditions.

@annagav annagav self-requested a review March 18, 2026 00:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant