diff --git a/RELEASE.rst b/RELEASE.rst index 930e7253cc..eeb65d3dff 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,19 @@ Release Notes ============= +Version 1.143.5 +--------------- + +- fix: typo fixed in enrollment_failure template (#3433) +- fix: remove locally taken usernames from suggestions (#3416) +- Remove automatic program enrollment (#3425) +- Stop checking elective enrollments when creating a verified course enrollment for a program (#3430) +- feat: migrate to mitol-django-observability plugin (#3346) +- Redirect UAI+B2C purchases to Learn dashboard (#3429) +- Add "include in learn catalog" setting for programs to mitxonline cms (#3412) +- Ensure Product admin creates reversion entries (#3423) +- chore(deps): update dependency requests to v2.33.0 [security] (#3427) + Version 1.143.4 (Released March 26, 2026) --------------- diff --git a/cms/api.py b/cms/api.py index f860f5b238..c788aef2f9 100644 --- a/cms/api.py +++ b/cms/api.py @@ -440,7 +440,7 @@ def create_default_courseware_page( "include_in_learn_catalog": include_in_learn_catalog, "ingest_content_files_for_ai": ingest_content_files_for_ai, } - program_only_kwargs = {} + program_only_kwargs = {"include_in_learn_catalog": include_in_learn_catalog} if optional_kwargs is None: optional_kwargs = {} diff --git a/cms/migrations/0058_programpage_include_in_learn_catalog.py b/cms/migrations/0058_programpage_include_in_learn_catalog.py new file mode 100644 index 0000000000..11c3e94f97 --- /dev/null +++ b/cms/migrations/0058_programpage_include_in_learn_catalog.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.9 on 2026-03-20 00:00 + +from django.db import migrations, models + + +def set_include_in_learn_catalog_for_live_program_pages(apps, schema_editor): + ProgramPage = apps.get_model("cms", "ProgramPage") + ProgramPage.objects.filter(live=True).update(include_in_learn_catalog=True) + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0057_certificatepage_should_provision_verifiable_credential_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="programpage", + name="include_in_learn_catalog", + field=models.BooleanField( + default=False, + help_text="If true, Learn should include this in its catalog.", + null=True, + ), + ), + migrations.RunPython( + set_include_in_learn_catalog_for_live_program_pages, + migrations.RunPython.noop, + ), + ] diff --git a/cms/models.py b/cms/models.py index 9576c55c82..eadffc479c 100644 --- a/cms/models.py +++ b/cms/models.py @@ -1470,6 +1470,11 @@ class ProgramPage(ProductPage): blank=True, null=True, ) + include_in_learn_catalog = models.BooleanField( + default=False, + null=True, + help_text="If true, Learn should include this in its catalog.", + ) template = "product_page.html" search_fields = Page.search_fields + [ # noqa: RUF005 @@ -1480,12 +1485,17 @@ class ProgramPage(ProductPage): ], ) ] - content_panels = [ # noqa: RUF005 - FieldPanel("program"), - ] + ProductPage.content_panels + content_panels = ( + [ # noqa: RUF005 + FieldPanel("program"), + ] + + ProductPage.content_panels + + [FieldPanel("include_in_learn_catalog")] + ) api_fields = [ *ProductPage.api_fields, APIField("program_details"), + APIField("include_in_learn_catalog"), ] @property diff --git a/cms/serializers.py b/cms/serializers.py index 6b4ca8331c..124891946e 100644 --- a/cms/serializers.py +++ b/cms/serializers.py @@ -372,6 +372,7 @@ class Meta: "financial_assistance_form_url", "description", "live", + "include_in_learn_catalog", "length", "effort", "price", diff --git a/cms/serializers_test.py b/cms/serializers_test.py index 4974244cc9..270c32cff1 100644 --- a/cms/serializers_test.py +++ b/cms/serializers_test.py @@ -411,6 +411,7 @@ def test_serialize_program_page( "length": program_page.length, "effort": program_page.effort, "price": None, + "include_in_learn_catalog": False, }, ) @@ -461,6 +462,7 @@ def test_serialize_program_page__form_child_of_course_with_program_fk( "length": program_page.length, "effort": program_page.effort, "price": None, + "include_in_learn_catalog": False, }, ) @@ -499,6 +501,7 @@ def test_serialize_program_page__with_related_financial_form( "length": program_page.length, "effort": program_page.effort, "price": None, + "include_in_learn_catalog": False, }, ) @@ -531,6 +534,7 @@ def test_serialize_program_page__no_financial_form( "length": program_page.length, "effort": program_page.effort, "price": None, + "include_in_learn_catalog": False, }, ) @@ -566,6 +570,7 @@ def test_serialize_program_page__with_related_program_no_financial_form( "length": program_page.length, "effort": program_page.effort, "price": None, + "include_in_learn_catalog": False, }, ) diff --git a/cms/wagtail_api/views.py b/cms/wagtail_api/views.py index 450aa078dc..1bb1527abd 100644 --- a/cms/wagtail_api/views.py +++ b/cms/wagtail_api/views.py @@ -80,7 +80,10 @@ def get_queryset(self): if model_type and not self.request.user.is_authenticated: if model_type == PageType.PROGRAM.value: - queryset = queryset.filter(program__b2b_only=False) + queryset = queryset.filter( + program__b2b_only=False, + include_in_learn_catalog=True, + ) elif model_type == PageType.COURSE.value: queryset = queryset.filter(include_in_learn_catalog=True) diff --git a/courses/api.py b/courses/api.py index 15c0f5fd9c..0a98c309bc 100644 --- a/courses/api.py +++ b/courses/api.py @@ -175,25 +175,6 @@ def create_run_enrollments( # noqa: C901 def send_enrollment_emails(): subscribe_edx_course_emails.delay(enrollment.id) - def _enroll_learner_into_associated_programs(): - """ - Enrolls the learner into all programs for which the course they are enrolling into - is associated as a requirement or elective. If a program enrollment already exists - then the change_status of that program_enrollment is checked to ensure it equals None. - """ - for program in run.course.programs: - if not program.live: - continue - program_enrollment, _ = ProgramEnrollment.objects.get_or_create( - user=user, - program=program, - defaults=dict( # noqa: C408 - change_status=None, - ), - ) - if program_enrollment.change_status is not None: - program_enrollment.reactivate_and_save() - edx_request_success = True if not runs[0].is_fake_course_run: # Make the API call to enroll the user in edX only if the run is not a fake course run @@ -233,8 +214,6 @@ def _enroll_learner_into_associated_programs(): ), ) - _enroll_learner_into_associated_programs() - # If the run is associated with a B2B contract, add the contract # to the user's contract list and update their org memberships if run.b2b_contract: diff --git a/courses/api_test.py b/courses/api_test.py index 73a328616b..54261406eb 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -236,8 +236,6 @@ def test_create_run_enrollments_upgrade( """ create_run_enrollments should call the edX API to create/update enrollments, and set the enrollment mode properly in the event of an upgrade e.g a user moving from Audit to Verified mode - - In addition, tests to make sure there's a ProgramEnrollment for the course. """ test_enrollment = CourseRunEnrollmentFactory.create( user=user, @@ -265,7 +263,7 @@ def test_create_run_enrollments_upgrade( assert edx_request_success is True test_enrollment.refresh_from_db() assert test_enrollment.enrollment_mode == EDX_ENROLLMENT_VERIFIED_MODE - assert ProgramEnrollment.objects.filter( + assert not ProgramEnrollment.objects.filter( user=user, program=program_with_empty_requirements ).exists() @@ -278,7 +276,7 @@ def test_create_run_enrollments_multiple_programs( """ create_run_enrollments should enroll the user into any Programs which have the CourseRun's Course defined as a requirement or elective. - In addition, tests to make sure there's a ProgramEnrollment for the course. + In addition, tests to make sure a ProgramEnrollment is created for the course. """ test_enrollment = CourseRunEnrollmentFactory.create( user=user, @@ -312,10 +310,10 @@ def test_create_run_enrollments_multiple_programs( user, runs=[test_enrollment.run], mode=EDX_ENROLLMENT_VERIFIED_MODE ) - assert ProgramEnrollment.objects.filter( + assert not ProgramEnrollment.objects.filter( user=user, program=program_with_empty_requirements ).exists() - assert ProgramEnrollment.objects.filter(user=user, program=program2).exists() + assert not ProgramEnrollment.objects.filter(user=user, program=program2).exists() @pytest.mark.parametrize( @@ -1610,35 +1608,6 @@ def test_generate_program_certificate_failure_not_all_passed_nested_elective_sti assert len(ProgramCertificate.objects.all()) == 0 -def test_program_enrollment_unenrollment_re_enrollment( - mocker, - user, - program_with_empty_requirements, # noqa: F811 -): - """ - create_run_enrollments should always enroll a learner into a program even - if the learner has previously unenrolled from the program. - """ - - # Create a program_enrollment that mocks what exists after a learner unenrolls from - # a program. - ProgramEnrollmentFactory( - user=user, - program=program_with_empty_requirements, - change_status=ENROLL_CHANGE_STATUS_UNENROLLED, - ) - course_run = CourseRunFactory.create() - program_with_empty_requirements.add_requirement(course_run.course) - mocker.patch("courses.api.enroll_in_edx_course_runs") - mocker.patch("courses.api.mail_api.send_course_run_enrollment_email") - mocker.patch("courses.tasks.subscribe_edx_course_emails.delay") - - create_run_enrollments(user, runs=[course_run], mode=EDX_ENROLLMENT_VERIFIED_MODE) - assert ProgramEnrollment.objects.filter( - user=user, program=program_with_empty_requirements, change_status=None - ).exists() - - @patch("courses.signals.upsert_custom_properties") def test_generate_program_certificate_with_subprogram_requirement( mock_upsert_custom_properties, user, mocker diff --git a/courses/templates/mail/enrollment_failure/body.html b/courses/templates/mail/enrollment_failure/body.html index 37d7f27728..958955fc88 100644 --- a/courses/templates/mail/enrollment_failure/body.html +++ b/courses/templates/mail/enrollment_failure/body.html @@ -1,4 +1,4 @@ -{% extend "mail/support" %} +{% extends "mail/support" %}

{{enrollment_type}} #{{enrollment_obj.id}} ({{enrollment_obj.title}})


diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py index af179dfa58..4144f46c80 100644 --- a/courses/views/v2/__init__.py +++ b/courses/views/v2/__init__.py @@ -680,33 +680,14 @@ def _create_course_enrollment_from_program(request, courserun_id, program_enroll # Check if: # .. the program enrollment is audit, - # .. if the run isn't in the program, - # .. if the run is an elective, and if the learner already has enough verified elective enrollments - # and create an audit enrollment if any of these are true. + # .. if the run isn't in the program - if ( - (program_enrollment.enrollment_mode == EDX_ENROLLMENT_AUDIT_MODE) - or ( - run.course - not in [ - *program_enrollment.program.required_courses, - *program_enrollment.program.elective_courses, - ] - ) - or ( - run.course not in program_enrollment.program.required_courses - and ( - CourseRunEnrollment.objects.filter( - run__course__in=program_enrollment.program.elective_courses, - user=request.user, - active=True, - enrollment_mode=EDX_ENROLLMENT_VERIFIED_MODE, - ).count() - >= ( - program_enrollment.program.minimum_elective_courses_requirement or 1 - ) - ) - ) + if (program_enrollment.enrollment_mode == EDX_ENROLLMENT_AUDIT_MODE) or ( + run.course + not in [ + *program_enrollment.program.required_courses, + *program_enrollment.program.elective_courses, + ] ): # Audit enrollments just get created, regardless of whether or not # the course is an elective. @@ -760,11 +741,7 @@ def add_verified_program_course_enrollment(request, courserun_id: str): Some special handling is needed for program-related course run enrollments when the learner has an enrollment in the program. The learner should get a course run enrollment that matches their program enrollment at no additional - charge. However, if the learner is enrolling in a course that's an elective, - and they have already enrolled in enough electives to satisfy the program's - requirements, they should then get an audit enrollment. (This won't preclude - them from getting a certificate for the course itself but they'll have to buy - the upgrade separately.) + charge. """ program_ids = request.data @@ -936,7 +913,7 @@ def get_program_certificate(request, cert_uuid): class UserProgramEnrollmentsViewSet(viewsets.ViewSet): - """ViewSet for user program enrollments with v2 serializers.""" + """ViewSet for user program and courserun enrollments with v2 serializers.""" permission_classes = [IsAuthenticated] diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py index a6db3db419..7f3ca5d2af 100644 --- a/courses/views/v2/views_test.py +++ b/courses/views/v2/views_test.py @@ -1739,7 +1739,7 @@ def test_add_verified_program_course_enrollment( if requirement_type == "elective-extra": # Add another elective, adjust the requirement to only require one, and # give the user a verified enrollment in that course. This should result - # in the learner getting an _audit_ enrollment in the course created + # in the learner getting a verified enrollment in the course created # earlier. second_elective = CourseRunFactory.create() program.add_elective(second_elective.course) @@ -1766,22 +1766,12 @@ def test_add_verified_program_course_enrollment( assert resp.status_code == status.HTTP_201_CREATED assert resp.json()["run"]["id"] == course_run.id - if ( - program_enrollment_type == EDX_ENROLLMENT_VERIFIED_MODE - and requirement_type != "elective-extra" - ): + if program_enrollment_type == EDX_ENROLLMENT_VERIFIED_MODE: order = user.orders.last() line = order.lines.last() assert course_run == line.purchased_object assert resp.json()["enrollment_mode"] == EDX_ENROLLMENT_VERIFIED_MODE - if ( - program_enrollment_type == EDX_ENROLLMENT_VERIFIED_MODE - and requirement_type == "elective-extra" - ): - # We had enough electives so we should have gotten an audit enrollment - assert resp.json()["enrollment_mode"] == EDX_ENROLLMENT_AUDIT_MODE - @pytest.mark.skip_nplusone_check @responses.activate diff --git a/ecommerce/admin.py b/ecommerce/admin.py index f2d577535d..d0e82f9a69 100644 --- a/ecommerce/admin.py +++ b/ecommerce/admin.py @@ -1,5 +1,6 @@ """Admin management for Ecommerce module""" +import reversion from django.contrib import admin, messages from django.contrib.admin.decorators import display from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin @@ -79,6 +80,12 @@ def get_queryset(self, request): # noqa: ARG002 """ return self.model.all_objects.get_queryset() + def save_model(self, request, obj, form, change): + """Ensure Product saves via admin always produce a reversion entry.""" + with reversion.create_revision(): + super().save_model(request, obj, form, change) + reversion.set_user(request.user) + @admin.register(Basket) class BasketAdmin(VersionAdmin): diff --git a/ecommerce/admin_test.py b/ecommerce/admin_test.py index 28f0c9ebb9..4945834ce5 100644 --- a/ecommerce/admin_test.py +++ b/ecommerce/admin_test.py @@ -1,11 +1,14 @@ """Tests for ecommerce admin views""" import pytest +from django.contrib.contenttypes.models import ContentType from django.contrib.messages import get_messages from django.urls import reverse +from reversion.models import Version +from courses.factories import CourseRunFactory from ecommerce.factories import OrderFactory -from ecommerce.models import OrderStatus +from ecommerce.models import OrderStatus, Product pytestmark = [pytest.mark.django_db] @@ -194,3 +197,28 @@ def test_admin_refund_order_post_order_not_found(client, admin_user): str(messages[0].message) == f"Order {missing_order_id} could not be found - is it Fulfilled?" ) + + +def test_admin_product_create_generates_reversion(client, admin_user): + """Creating a Product via admin should create an initial reversion entry.""" + + _login_admin(client, admin_user) + courserun = CourseRunFactory.create() + content_type = ContentType.objects.get_for_model(courserun) + + response = client.post( + reverse("admin:ecommerce_product_add"), + data={ + "content_type": content_type.id, + "object_id": courserun.id, + "price": "123.45", + "description": "Admin created versioned product", + "is_active": "on", + "_save": "Save", + }, + ) + + assert response.status_code == 302 + + product = Product.all_objects.get(description="Admin created versioned product") + assert Version.objects.get_for_object(product).count() == 1 diff --git a/ecommerce/views/legacy/__init__.py b/ecommerce/views/legacy/__init__.py index b07625a720..ddc8093b51 100644 --- a/ecommerce/views/legacy/__init__.py +++ b/ecommerce/views/legacy/__init__.py @@ -11,7 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Count, Q -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import render from django.urls import reverse from django.utils.decorators import method_decorator @@ -86,6 +86,16 @@ log = logging.getLogger(__name__) +def _has_uai_b2c_program_purchase(order): + """Return True if the order includes a program whose readable_id contains UAI+B2C.""" + return any( + isinstance(line.purchased_object, Program) + and line.purchased_object.readable_id + and "UAI+B2C" in line.purchased_object.readable_id + for line in order.lines.all() + ) + + class ProductsPagination(RefinePagination): default_limit = 2 @@ -756,6 +766,9 @@ def post_checkout_redirect(self, order_state, order, request): reverse("cart"), {"type": USER_MSG_TYPE_PAYMENT_DECLINED} ) elif order_state == OrderStatus.FULFILLED: + if _has_uai_b2c_program_purchase(order): + return HttpResponseRedirect(settings.MIT_LEARN_DASHBOARD_URL) + return redirect_with_user_message( reverse("user-dashboard"), { diff --git a/ecommerce/views/legacy/views_test.py b/ecommerce/views/legacy/views_test.py index fc7dc1bcad..1fd97593ae 100644 --- a/ecommerce/views/legacy/views_test.py +++ b/ecommerce/views/legacy/views_test.py @@ -652,6 +652,45 @@ def test_checkout_result( # noqa: PLR0913 assert Basket.objects.filter(id=basket.id).exists() is basket_exists +@pytest.mark.skip_nplusone_check +@pytest.mark.dont_mock_enrollments +def test_checkout_result_redirects_uai_b2c_program_to_learn_dashboard( + settings, + user, + user_client, + mocker, +): + """Accepted UAI+B2C program purchases should redirect to the MIT Learn dashboard.""" + settings.MIT_LEARN_DASHBOARD_URL = "https://learn.mit.edu/dashboard" + + mocker.patch("hubspot_sync.tasks.sync_deal_with_hubspot.apply_async") + mocker.patch( + "mitol.payment_gateway.api.PaymentGateway.validate_processor_response", + return_value=True, + ) + mocker.patch("courses.api.create_program_enrollments") + + program = ProgramFactory.create(readable_id="program-v1:MITx+UAI+B2C+2026") + with reversion.create_revision(): + product = ProductFactory.create(purchasable_object=program) + + create_basket_with_product(user, product) + + resp = user_client.post(reverse("checkout_api-start_checkout")) + + payload = resp.json()["payload"] + payload = { + **{f"req_{key}": value for key, value in payload.items()}, + "decision": "ACCEPT", + "message": "payment processor message", + "transaction_id": "12345", + } + + resp = user_client.post(reverse("checkout-result-callback"), payload) + assert resp.status_code == 302 + assert resp.url == settings.MIT_LEARN_DASHBOARD_URL + + @pytest.mark.parametrize( "cart_exists, cart_empty", # noqa: PT006 [(True, False), (True, True), (False, True)], diff --git a/frontend/public/src/components/ProgramProductDetailEnroll.js b/frontend/public/src/components/ProgramProductDetailEnroll.js index e95ece4b7b..87f4227365 100644 --- a/frontend/public/src/components/ProgramProductDetailEnroll.js +++ b/frontend/public/src/components/ProgramProductDetailEnroll.js @@ -22,6 +22,7 @@ import { programsQueryKey } from "../lib/queries/courseRuns" import { + programEnrollmentQuery, programEnrollmentsQuery, programEnrollmentsQueryKey, programEnrollmentsSelector @@ -48,6 +49,7 @@ type Props = { programStatus: ?number, upgradeEnrollmentDialogVisibility: boolean, addProductToBasket: (user: number, productId: number) => Promise, + createProgramEnrollment: (programId: number) => Promise, currentUser: User, updateAddlFields: (currentUser: User) => Promise, programEnrollments: ?Array, @@ -130,12 +132,23 @@ export class ProgramProductDetailEnroll extends React.Component< } } - toggleUpgradeDialogVisibility = () => { + toggleUpgradeDialogVisibility = async () => { const { upgradeEnrollmentDialogVisibility } = this.state + const { programs, createProgramEnrollment } = this.props this.setState({ upgradeEnrollmentDialogVisibility: !upgradeEnrollmentDialogVisibility }) + try { + //find program id + const program = programs && programs[0] + if (program) { + await createProgramEnrollment(program.id) + } + } catch (error) { + console.error("Failed to create program enrollment", error) + // Optionally, display an error message to the user. + } } setCurrentCourseRun = (runId: string) => { @@ -470,6 +483,9 @@ const updateAddlFields = (currentUser: User) => { return mutateAsync(users.editProfileMutation(updatedUser)) } +const createProgramEnrollment = (programId: number) => + mutateAsync(programEnrollmentQuery(programId)) + const mapStateToProps = createStructuredSelector({ courseRuns: courseRunsSelector, programs: programsSelector, @@ -495,7 +511,8 @@ const mapPropsToConfig = props => [ ] const mapDispatchToProps = { - updateAddlFields + updateAddlFields, + createProgramEnrollment } export default compose( diff --git a/frontend/public/src/lib/queries/enrollment.js b/frontend/public/src/lib/queries/enrollment.js index 6846450956..0f64c1d784 100644 --- a/frontend/public/src/lib/queries/enrollment.js +++ b/frontend/public/src/lib/queries/enrollment.js @@ -187,3 +187,16 @@ export const enrollmentMutation = (runId: number) => ({ }, update: {} }) + +export const programEnrollmentQuery = (programId: number) => ({ + url: `/api/v3/program_enrollments/`, + body: { + program_id: `${programId}`, + isapi: true + }, + options: { + ...getCsrfOptions(), + method: "POST" + }, + update: {} +}) diff --git a/main/apps.py b/main/apps.py index bc6f08765b..80b9cf6b3e 100644 --- a/main/apps.py +++ b/main/apps.py @@ -16,7 +16,3 @@ def ready(self): envs.validate() configure() - - from main.telemetry import configure_opentelemetry # noqa: PLC0415 - - configure_opentelemetry() diff --git a/main/settings.py b/main/settings.py index 89ee2fe56a..5daf024db5 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.4" +VERSION = "1.143.5" log = logging.getLogger() @@ -273,6 +273,7 @@ "micromasters_import", # ol-django apps, must be after this project's apps for template precedence "mitol.common.apps.CommonApp", + "mitol.observability.apps.ObservabilityConfig", "mitol.google_sheets.apps.GoogleSheetsApp", "mitol.google_sheets_refunds.apps.GoogleSheetsRefundsApp", "mitol.google_sheets_deferrals.apps.GoogleSheetsDeferralsApp", @@ -694,49 +695,26 @@ name="DJANGO_LOG_LEVEL", default="INFO", description="The log level for django" ) +# mitol-django-observability reads LOG_LEVEL directly from os.environ; bridge the +# legacy MITX_ONLINE_LOG_LEVEL env var so operators don't silently lose log control. +os.environ.setdefault("LOG_LEVEL", LOG_LEVEL) + HOSTNAME = platform.node().split(".")[0] -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, - "formatters": { - "verbose": { - "format": ( - "[%(asctime)s] %(levelname)s %(process)d [%(name)s] " - "%(filename)s:%(lineno)d - " - f"[{HOSTNAME}] - %(message)s" - ), - "datefmt": "%Y-%m-%d %H:%M:%S", - } - }, - "handlers": { - "console": { - "level": "DEBUG", - "class": "logging.StreamHandler", - "formatter": "verbose", - }, - "mail_admins": { - "level": "ERROR", - "filters": ["require_debug_false"], - "class": "django.utils.log.AdminEmailHandler", - }, - }, - "loggers": { - "django": { - "propagate": True, - "level": DJANGO_LOG_LEVEL, - "handlers": ["console"], - }, - "django.request": { - "handlers": ["mail_admins"], - "level": DJANGO_LOG_LEVEL, - "propagate": True, - }, - "zeal": {"handlers": ["console"], "level": "ERROR"}, - }, - "root": {"handlers": ["console"], "level": LOG_LEVEL}, +# LOGGING is provided by mitol-django-observability (structlog-based, JSON in prod) +from mitol.observability.settings.logging import LOGGING # noqa: E402 + +# Restore the AdminEmailHandler so Django still emails ADMINS on unhandled 500 +# errors in production (i.e. when DEBUG=False). Sentry captures errors too, but +# this provides an independent email-based safety net. +LOGGING.setdefault("filters", {}) +LOGGING["filters"]["require_debug_false"] = {"()": "django.utils.log.RequireDebugFalse"} +LOGGING["handlers"]["mail_admins"] = { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", } +LOGGING["loggers"]["django.request"]["handlers"].append("mail_admins") # server-status STATUS_TOKEN = get_string( diff --git a/main/telemetry.py b/main/telemetry.py deleted file mode 100644 index 89b2af41ce..0000000000 --- a/main/telemetry.py +++ /dev/null @@ -1,100 +0,0 @@ -"""OpenTelemetry initialization and configuration for MITxOnline.""" - -from __future__ import annotations - -import logging - -from django.conf import settings -from opentelemetry import trace -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( - OTLPSpanExporter as OTLPSpanExporterGrpc, -) -from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter -from opentelemetry.instrumentation.celery import CeleryInstrumentor -from opentelemetry.instrumentation.django import DjangoInstrumentor -from opentelemetry.instrumentation.psycopg import PsycopgInstrumentor -from opentelemetry.instrumentation.redis import RedisInstrumentor -from opentelemetry.instrumentation.requests import RequestsInstrumentor -from opentelemetry.sdk.resources import Resource -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor, ConsoleSpanExporter - -log = logging.getLogger(__name__) - - -def configure_opentelemetry() -> TracerProvider | None: - """ - Configure OpenTelemetry with appropriate instrumentations and exporters. - Returns the tracer provider if configured, None otherwise. - """ - if not getattr(settings, "OPENTELEMETRY_ENABLED", False): - log.info("OpenTelemetry is disabled") - return None - - log.info("Initializing OpenTelemetry") - - # Create a resource with service info - resource = Resource.create( - { - "service.name": getattr( - settings, "OPENTELEMETRY_SERVICE_NAME", "mitxonline" - ), - "service.version": getattr(settings, "VERSION", "unknown"), - "deployment.environment": settings.ENVIRONMENT, - } - ) - - # Configure the tracer provider - tracer_provider = TracerProvider(resource=resource) - trace.set_tracer_provider(tracer_provider) - - # Add console exporter for development/testing - if settings.DEBUG: - log.info("Adding console exporter for OpenTelemetry") - console_exporter = ConsoleSpanExporter() - tracer_provider.add_span_processor(BatchSpanProcessor(console_exporter)) - - # Add OTLP exporter if configured - otlp_endpoint = getattr(settings, "OPENTELEMETRY_ENDPOINT", None) - if otlp_endpoint: - log.info("Configuring OTLP exporter to endpoint: %s", otlp_endpoint) - - headers = {} - - try: - use_grpc = getattr(settings, "OPENTELEMETRY_USE_GRPC", False) - if use_grpc: - otlp_exporter = OTLPSpanExporterGrpc( - endpoint=otlp_endpoint, - headers=headers, - insecure=getattr(settings, "OPENTELEMETRY_INSECURE", True), - ) - else: - otlp_exporter = OTLPSpanExporter( - endpoint=otlp_endpoint, - headers=headers, - ) - - tracer_provider.add_span_processor( - BatchSpanProcessor( - otlp_exporter, - max_export_batch_size=getattr( - settings, "OPENTELEMETRY_BATCH_SIZE", 512 - ), - schedule_delay_millis=getattr( - settings, "OPENTELEMETRY_EXPORT_TIMEOUT_MS", 5000 - ), - ) - ) - except Exception: - log.exception("Failed to configure OTLP exporter") - - # Initialize instrumentations - DjangoInstrumentor().instrument() - PsycopgInstrumentor().instrument() - RedisInstrumentor().instrument() - CeleryInstrumentor().instrument() - RequestsInstrumentor().instrument() - - log.info("OpenTelemetry initialized successfully") - return tracer_provider diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 80ca5aac65..ccbc94a078 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -3045,11 +3045,7 @@ paths: Some special handling is needed for program-related course run enrollments when the learner has an enrollment in the program. The learner should get a course run enrollment that matches their program enrollment at no additional - charge. However, if the learner is enrolling in a course that's an elective, - and they have already enrolled in enough electives to satisfy the program's - requirements, they should then get an audit enrollment. (This won't preclude - them from getting a certificate for the course itself but they'll have to buy - the upgrade separately.) + charge. parameters: - in: path name: courserun_id @@ -6533,6 +6529,10 @@ components: live: type: boolean readOnly: true + include_in_learn_catalog: + type: boolean + nullable: true + description: If true, Learn should include this in its catalog. length: type: string description: A short description indicating how long it takes to complete diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index 4e4439ed4c..9e9ef080fd 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -3045,11 +3045,7 @@ paths: Some special handling is needed for program-related course run enrollments when the learner has an enrollment in the program. The learner should get a course run enrollment that matches their program enrollment at no additional - charge. However, if the learner is enrolling in a course that's an elective, - and they have already enrolled in enough electives to satisfy the program's - requirements, they should then get an audit enrollment. (This won't preclude - them from getting a certificate for the course itself but they'll have to buy - the upgrade separately.) + charge. parameters: - in: path name: courserun_id @@ -6533,6 +6529,10 @@ components: live: type: boolean readOnly: true + include_in_learn_catalog: + type: boolean + nullable: true + description: If true, Learn should include this in its catalog. length: type: string description: A short description indicating how long it takes to complete diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 803ab3c727..357f68b9e6 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -3045,11 +3045,7 @@ paths: Some special handling is needed for program-related course run enrollments when the learner has an enrollment in the program. The learner should get a course run enrollment that matches their program enrollment at no additional - charge. However, if the learner is enrolling in a course that's an elective, - and they have already enrolled in enough electives to satisfy the program's - requirements, they should then get an audit enrollment. (This won't preclude - them from getting a certificate for the course itself but they'll have to buy - the upgrade separately.) + charge. parameters: - in: path name: courserun_id @@ -6533,6 +6529,10 @@ components: live: type: boolean readOnly: true + include_in_learn_catalog: + type: boolean + nullable: true + description: If true, Learn should include this in its catalog. length: type: string description: A short description indicating how long it takes to complete diff --git a/openedx/api.py b/openedx/api.py index 52ab716d2e..73aa586124 100644 --- a/openedx/api.py +++ b/openedx/api.py @@ -247,9 +247,27 @@ def _handle_username_collision( # noqa: PLR0913 if suggestions: suggested_usernames = suggestions + # Filter out any suggestions that are already taken locally in MITx Online. + # edX does not know about our local username uniqueness constraint, so its + # suggestions may collide with existing OpenEdxUser records here. + if suggested_usernames: + locally_taken = set( + OpenEdxUser.objects.filter(edx_username__in=suggested_usernames) + .exclude(user=user) + .values_list("edx_username", flat=True) + ) + if locally_taken: + log.debug( + "Skipping OpenEdX suggestions already taken locally: %s", + locally_taken, + ) + suggested_usernames = [ + u for u in suggested_usernames if u not in locally_taken + ] + if not suggested_usernames: log.info( - "OpenEdX returned empty username suggestions, falling back to local generation" + "OpenEdX returned no locally available username suggestions, falling back to local generation" ) base_username = open_edx_user.desired_edx_username if not base_username: diff --git a/openedx/api_test.py b/openedx/api_test.py index 536816a68d..a6f709917a 100644 --- a/openedx/api_test.py +++ b/openedx/api_test.py @@ -478,6 +478,59 @@ def test_create_edx_user_conflict( # noqa: PLR0913 ) +@responses.activate +@pytest.mark.usefixtures("application") +def test_create_edx_user_conflict_suggested_username_taken_locally(settings): + """ + When edX suggests a username that is already taken locally + in MITx Online (e.g., jeff_8 exists in OpenEdxUser), the app must not blindly + use it (which would cause a UniqueViolation). Instead it should skip that + suggestion and fall back to generating a locally unique username. + """ + user = UserFactory.create( + openedx_user__has_been_synced=False, + openedx_user__desired_edx_username="jeff", + ) + # Simulate jeff_8 already existing locally in MITx Online + UserFactory.create(openedx_user__edx_username="jeff_8") + + responses.add( + responses.GET, + f"{settings.OPENEDX_API_BASE_URL}/api/mobile/v0.5/my_user_info", + json={}, + status=status.HTTP_200_OK, + ) + # edX returns a 409 with its only suggestion being jeff_8, which is already taken locally + responses.add( + responses.POST, + f"{settings.OPENEDX_API_BASE_URL}/user_api/v1/account/registration/", + json={ + "error_code": "duplicate-username", + "username_suggestions": ["jeff_8"], + }, + status=status.HTTP_409_CONFLICT, + ) + # Second registration attempt with the locally-generated username succeeds + responses.add( + responses.POST, + f"{settings.OPENEDX_API_BASE_URL}/user_api/v1/account/registration/", + json=dict(success=True), # noqa: C408 + status=status.HTTP_200_OK, + ) + edx_username_validation_response_mock(False, settings) # noqa: FBT003 + + create_edx_user(user) + + user.refresh_from_db() + edx_user = user.openedx_users.first() + + assert edx_user.has_been_synced is True + # Must not have used the locally-conflicting suggestion + assert edx_user.edx_username != "jeff_8" + # Should have generated a username based on the base username + assert edx_user.edx_username.startswith("jeff_") + + @pytest.mark.parametrize( ("base_username", "expected_prefix"), [ diff --git a/pyproject.toml b/pyproject.toml index 077f090666..a35541aa46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,14 +47,12 @@ dependencies = [ "mitol-django-openedx==2023.12.19", "mitol-django-payment-gateway==2023.12.19", "mitol-django-scim>=2025.5.23", - "opentelemetry-api>=1.31.0", - "opentelemetry-exporter-otlp>=1.31.0", + "mitol-django-observability>=2026.1.0", "opentelemetry-instrumentation-celery>=0.52b0", "opentelemetry-instrumentation-django>=0.52b0", "opentelemetry-instrumentation-psycopg>=0.52b0", "opentelemetry-instrumentation-redis>=0.52b0", "opentelemetry-instrumentation-requests>=0.52b0", - "opentelemetry-sdk>=1.31.0", "psycopg>=3.2.4,<4", "psycopg2>=2.9.5,<3", "pyOpenSSL>=26,<27", diff --git a/uv.lock b/uv.lock index 56932accec..297c35c6ff 100644 --- a/uv.lock +++ b/uv.lock @@ -1820,6 +1820,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/45/27afcb7a712c72cc3f4bbadfd696fe03c747d9fc35582b610cd0fa25b07f/mitol_django_mail-2023.12.19-py3-none-any.whl", hash = "sha256:a32853bfe7da39d4c34651d7e5bddc547678c1b5b3a54e56e492879276eab371", size = 19714, upload-time = "2023-12-19T18:14:25.682Z" }, ] +[[package]] +name = "mitol-django-observability" +version = "2026.3.11" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django" }, + { name = "mitol-django-common" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-otlp-proto-grpc" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, + { name = "pyyaml" }, + { name = "structlog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/3b/8c07a0a0feda332656213a8af4f055fa83fb9e0d6d483d77f12d8e63b47e/mitol_django_observability-2026.3.11.tar.gz", hash = "sha256:4539eff6b7da18e500fb0f23c0abf6bdba67eb65e35e890b08c338318494a1e6", size = 10093, upload-time = "2026-03-13T20:21:41.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/76/3f730dec3ff4ff6c5ab044d7b1e027b6f3214cedb66a2532301b6eb4b285/mitol_django_observability-2026.3.11-py3-none-any.whl", hash = "sha256:7523782802c09cad3003cacecdb62cc055efdbd8a80a9ea27a12cb5aaf3f59bc", size = 16052, upload-time = "2026-03-13T20:21:40.216Z" }, +] + [[package]] name = "mitol-django-olposthog" version = "2026.3.6" @@ -1933,18 +1952,16 @@ dependencies = [ { name = "mitol-django-google-sheets-refunds" }, { name = "mitol-django-hubspot-api" }, { name = "mitol-django-mail" }, + { name = "mitol-django-observability" }, { name = "mitol-django-olposthog" }, { name = "mitol-django-openedx" }, { name = "mitol-django-payment-gateway" }, { name = "mitol-django-scim" }, - { name = "opentelemetry-api" }, - { name = "opentelemetry-exporter-otlp" }, { name = "opentelemetry-instrumentation-celery" }, { name = "opentelemetry-instrumentation-django" }, { name = "opentelemetry-instrumentation-psycopg" }, { name = "opentelemetry-instrumentation-redis" }, { name = "opentelemetry-instrumentation-requests" }, - { name = "opentelemetry-sdk" }, { name = "psycopg" }, { name = "psycopg2" }, { name = "pycountry" }, @@ -2043,18 +2060,16 @@ requires-dist = [ { name = "mitol-django-google-sheets-refunds", specifier = ">=2025.6.13,<2026" }, { name = "mitol-django-hubspot-api", specifier = "==2025.12.18" }, { name = "mitol-django-mail", specifier = "==2023.12.19" }, + { name = "mitol-django-observability", specifier = ">=2026.1.0" }, { name = "mitol-django-olposthog", specifier = ">=2026.3.6,<2027" }, { name = "mitol-django-openedx", specifier = "==2023.12.19" }, { name = "mitol-django-payment-gateway", specifier = "==2023.12.19" }, { name = "mitol-django-scim", specifier = ">=2025.5.23" }, - { name = "opentelemetry-api", specifier = ">=1.31.0" }, - { name = "opentelemetry-exporter-otlp", specifier = ">=1.31.0" }, { name = "opentelemetry-instrumentation-celery", specifier = ">=0.52b0" }, { name = "opentelemetry-instrumentation-django", specifier = ">=0.52b0" }, { name = "opentelemetry-instrumentation-psycopg", specifier = ">=0.52b0" }, { name = "opentelemetry-instrumentation-redis", specifier = ">=0.52b0" }, { name = "opentelemetry-instrumentation-requests", specifier = ">=0.52b0" }, - { name = "opentelemetry-sdk", specifier = ">=1.31.0" }, { name = "psycopg", specifier = ">=3.2.4,<4" }, { name = "psycopg2", specifier = ">=2.9.5,<3" }, { name = "pycountry", specifier = ">=24.6.1,<25" }, @@ -2170,19 +2185,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cf/df/d3f1ddf4bb4cb50ed9b1139cc7b1c54c34a1e7ce8fd1b9a37c0d1551a6bd/opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950", size = 66356, upload-time = "2025-12-11T13:32:17.304Z" }, ] -[[package]] -name = "opentelemetry-exporter-otlp" -version = "1.39.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "opentelemetry-exporter-otlp-proto-grpc" }, - { name = "opentelemetry-exporter-otlp-proto-http" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/30/9c/3ab1db90f32da200dba332658f2bbe602369e3d19f6aba394031a42635be/opentelemetry_exporter_otlp-1.39.1.tar.gz", hash = "sha256:7cf7470e9fd0060c8a38a23e4f695ac686c06a48ad97f8d4867bc9b420180b9c", size = 6147, upload-time = "2025-12-11T13:32:40.309Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/00/6c/bdc82a066e6fb1dcf9e8cc8d4e026358fe0f8690700cc6369a6bf9bd17a7/opentelemetry_exporter_otlp-1.39.1-py3-none-any.whl", hash = "sha256:68ae69775291f04f000eb4b698ff16ff685fdebe5cb52871bc4e87938a7b00fe", size = 7019, upload-time = "2025-12-11T13:32:19.387Z" }, -] - [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.39.1" @@ -3095,7 +3097,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.33.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -3103,9 +3105,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] [[package]] @@ -3374,6 +3376,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, ] +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + [[package]] name = "tabulate" version = "0.9.0"