From 903d2a5aca8a3f0900d04bca618ccd2d384ccaf3 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah Date: Mon, 4 Aug 2025 16:34:09 +0500 Subject: [PATCH 1/2] temp: discovery to lms communication with empty SKUs POC --- course_discovery/apps/core/models.py | 13 ++ .../apps/course_metadata/utils.py | 111 ++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/course_discovery/apps/core/models.py b/course_discovery/apps/core/models.py index 2676f58c43..7a588bd760 100644 --- a/course_discovery/apps/core/models.py +++ b/course_discovery/apps/core/models.py @@ -122,6 +122,19 @@ def has_changed(self): return False return self.field_tracker.changed() + @property + def lms_credit_api_url(self): + """ + Returns the credit API URL for the partner. + """ + return self.lms_url.rstrip('/') + '/api/credit/v1' + + @property + def lms_commerce_api_url(self): + """ + Returns the credit API URL for the partner. + """ + return self.lms_url.rstrip('/') + '/api/commerce/v1' class SalesforceConfiguration(models.Model): partner = models.OneToOneField(Partner, models.CASCADE, related_name='salesforce') diff --git a/course_discovery/apps/course_metadata/utils.py b/course_discovery/apps/course_metadata/utils.py index b99b3d6e32..1791038b8b 100644 --- a/course_discovery/apps/course_metadata/utils.py +++ b/course_discovery/apps/course_metadata/utils.py @@ -4,8 +4,11 @@ import re import string import uuid +from decimal import Decimal from tempfile import NamedTemporaryFile +from urllib.error import HTTPError from urllib.parse import urljoin, urlparse +from hashlib import md5 import html2text import jsonschema @@ -493,12 +496,120 @@ def serialize_entitlement_for_ecommerce_api(entitlement): 'attribute_values': attribute_values_list, } +def push_to_lms_for_course_run(course_run): + """ + Publishes course run data directly to LMS, bypassing legacy Ecommerce. + Includes course modes (seats) and credit enablement. + """ + course = course_run.course + partner = course.partner + + print(f"[INFO] Starting LMS publication for course run: {course_run.key}") + print(f"[INFO] Partner: {partner}") + + if not partner.lms_coursemode_api_url: + print(f"[WARN] No LMS coursemode API URL configured for partner [{partner}]. Skipping.") + return False + + api = partner.oauth_api_client + entitlements = course.entitlements.all() + has_credit_seat = False + + # 1. Build (seat, track.mode) pairs + print(f"[INFO] Collecting seats and matching with track modes...") + tracks = course_run.type.tracks.all() + seats_with_modes = [] + for seat in course_run.seats.all(): + print(f" [SEAT] type={seat.type}, price={seat.price}") + for track in tracks: + print(f" [TRACK] seat_type={track.seat_type}, mode={track.mode.slug}") + if track.mode.certificate_type == 'credit': + has_credit_seat = True + print(f" [INFO] Found credit mode for seat: {seat.type}") + if track.seat_type and seat.type == track.seat_type: + seats_with_modes.append((seat, track.mode)) + print(f" [MATCH] Seat matched with mode: {track.mode.slug}") + break + + if not seats_with_modes and not entitlements: + print(f"[WARN] No seats or entitlements found to publish for [{course_run.key}].") + return False + + # Step 2: Publish CreditCourse if 'credit' mode present + if has_credit_seat: + try: + credit_data = { + "course_key": str(course_run.key), + "enabled": True + } + credit_url = urljoin(f"{partner.lms_credit_api_url}/", f"courses/{course_run.key}/") + print(f"[INFO] Publishing CreditCourse to LMS: {credit_url}") + print(" Payload:", credit_data) + + credit_response = api.put(credit_url, json=credit_data) + credit_response.raise_for_status() + + print(f"[SUCCESS] CreditCourse published for {course_run.key}") + except HTTPError as e: + print(f"[ERROR] HTTPError during CreditCourse publish: {e}") + return False + except Exception as e: + print(f"[ERROR] Unexpected error during CreditCourse publish: {e}") + return False + else: + print(f"[INFO] No credit seats found — skipping CreditCourse publish.") + + # Step 3: Construct course modes payload (seats) + print("[INFO] Building course modes payload...") + modes_payload = [] + for seat, mode in seats_with_modes: + + expires = serialize_datetime(calculated_seat_upgrade_deadline(seat)) + mode_entry = { + "name": mode.slug, + "currency": "USD", + "price": int(Decimal(seat.price)), + "sku": None, + "bulk_sku": None, + "android_sku": None, + "ios_sku": None, + "expires": expires, + } + print(f" [MODE] {mode_entry}") + modes_payload.append(mode_entry) + + course_mode_url = urljoin(partner.lms_commerce_api_url.rstrip('/') + '/', f'courses/{course_run.key}/') + payload = { + "id": course_run.key, + "name": course_run.title, + "verification_deadline": serialize_datetime(course_run.end), + "modes": modes_payload + } + + print(f"[INFO] Publishing course modes to LMS: {course_mode_url}") + print(" Payload:", payload) + + # Step 4: Publish course modes + try: + response = api.put(course_mode_url, json=payload) + response.raise_for_status() + print(f"[SUCCESS] Course modes successfully published for {course_run.key}") + except HTTPError as e: + print(f"[ERROR] HTTPError during course modes publish: {e}") + return False + except Exception as e: + print(f"[ERROR] Unexpected error during course modes publish: {e}") + return False + + return True + def push_to_ecommerce_for_course_run(course_run): """ Args: course_run: Official version of a course_metadata CourseRun """ + return push_to_lms_for_course_run(course_run) course = course_run.course if not course.partner.ecommerce_api_url: return False From 3e5dfc930e3cafa3f5c13784aa2363120e86d1bf Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah Date: Tue, 5 Aug 2025 13:53:07 +0500 Subject: [PATCH 2/2] temp: discovery to lms communication with dummy generated SKUs POC --- .../apps/course_metadata/utils.py | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/course_discovery/apps/course_metadata/utils.py b/course_discovery/apps/course_metadata/utils.py index 1791038b8b..639cd3e543 100644 --- a/course_discovery/apps/course_metadata/utils.py +++ b/course_discovery/apps/course_metadata/utils.py @@ -496,6 +496,35 @@ def serialize_entitlement_for_ecommerce_api(entitlement): 'attribute_values': attribute_values_list, } +def generate_sku(partner, product, product_type, mode=None): + """ + Generates a SKU for the given partner and and product combination. + + Example: 76E4E71 + """ + if product_type == 'Seat': + _hash = ' '.join(( + str(mode.certificate_type) or '', + str(product.course_run.course_id) or '', + str(mode.is_id_verified) or '', + str(product.credit_provider) or '', + str(product.id), + str(partner.id) + )).encode('utf-8') + elif product_type == 'Course Entitlement': + _hash = ' '.join(( + mode if isinstance(mode, str) else mode.slug, + str(product.course.uuid), + str(partner.id) + )).encode('utf-8') + else: + raise Exception('Unexpected product class') + + md5_hash = md5(_hash.lower()) + digest = md5_hash.hexdigest()[-7:] + + return digest.upper() + def push_to_lms_for_course_run(course_run): """ Publishes course run data directly to LMS, bypassing legacy Ecommerce. @@ -535,7 +564,27 @@ def push_to_lms_for_course_run(course_run): print(f"[WARN] No seats or entitlements found to publish for [{course_run.key}].") return False - # Step 2: Publish CreditCourse if 'credit' mode present + # 2. Generate and store SKU on seats/entitlements === + with transaction.atomic(): + for seat, mode in seats_with_modes: + if not seat.sku: + generated = generate_sku(partner, seat, 'Seat', mode) + seat.sku = generated + seat.save() + if seat.draft_version: + seat.draft_version.sku = generated + seat.draft_version.save() + + for entitlement in entitlements: + if not entitlement.sku: + generated = generate_sku(partner, entitlement, 'Course Entitlement', entitlement.mode) + entitlement.sku = generated + entitlement.save() + if entitlement.draft_version: + entitlement.draft_version.sku = generated + entitlement.draft_version.save() + + # Step 3: Publish CreditCourse if 'credit' mode present if has_credit_seat: try: credit_data = { @@ -559,20 +608,20 @@ def push_to_lms_for_course_run(course_run): else: print(f"[INFO] No credit seats found — skipping CreditCourse publish.") - # Step 3: Construct course modes payload (seats) + + # Step 4: Construct course modes payload (seats) print("[INFO] Building course modes payload...") modes_payload = [] for seat, mode in seats_with_modes: - expires = serialize_datetime(calculated_seat_upgrade_deadline(seat)) mode_entry = { "name": mode.slug, "currency": "USD", "price": int(Decimal(seat.price)), - "sku": None, + "sku": seat.sku, "bulk_sku": None, - "android_sku": None, - "ios_sku": None, + "android_sku": f"android.{seat.sku}" if seat.sku else None, + "ios_sku": f"ios.{seat.sku}" if seat.sku else None, "expires": expires, } print(f" [MODE] {mode_entry}")