diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27d6e035d..f68f5ecad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: run: cat Aptfile | sudo xargs apt-get install - name: Install uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eacc7b7c9..fc4f3468a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - --exclude-files - "_test.js$" - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.15.11" + rev: "v0.15.12" hooks: - id: ruff-format - id: ruff diff --git a/.secrets.baseline b/.secrets.baseline index fd49503d1..1e565c0e2 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -116,7 +116,7 @@ ".yarn/", "_test.py$", "test_.*.py", - "poetry.lock", + "uv.lock", "yarn.lock", "compliance/test_data/cybersource/", "_test.js$" @@ -189,14 +189,14 @@ "filename": "pytest.ini", "hashed_secret": "c0d7ae39ad8e0e46cdbac4c3fb44a6bc1a17105d", "is_verified": false, - "line_number": 19 + "line_number": 18 }, { "type": "Secret Keyword", "filename": "pytest.ini", "hashed_secret": "b235838f76594bf21886c6eec9c06a207e9ec5ce", "is_verified": false, - "line_number": 35 + "line_number": 34 } ], "sheets/dev-setup.md": [ @@ -245,5 +245,5 @@ } ] }, - "generated_at": "2025-06-19T09:18:19Z" + "generated_at": "2026-04-13T13:24:33Z" } diff --git a/RELEASE.rst b/RELEASE.rst index 8acb1998e..12e3c4312 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,14 @@ Release Notes ============= +Version 0.194.0 +--------------- + +- chore(deps): update astral-sh/setup-uv action to v8 (#3896) +- fix(deps): update dependency beautifulsoup4 to v4.14.3 (#3510) +- Wagtail and django upgrade (#3876) +- [pre-commit.ci] pre-commit autoupdate (#3898) + Version 0.193.2 (Released April 30, 2026) --------------- diff --git a/b2b_ecommerce/api.py b/b2b_ecommerce/api.py index 99d71ee1d..2112dde6a 100644 --- a/b2b_ecommerce/api.py +++ b/b2b_ecommerce/api.py @@ -75,7 +75,8 @@ def _generate_b2b_cybersource_sa_payload(*, order, receipt_url, cancel_url): product_version = order.product_version content_object = product_version.product.content_object - content_type = str(product_version.product.content_type) + _ct = product_version.product.content_type + content_type = f"{_ct.app_label} | {_ct.name}" price = order.total_price return { diff --git a/b2b_ecommerce/api_test.py b/b2b_ecommerce/api_test.py index e6b099798..1c8b46c9c 100644 --- a/b2b_ecommerce/api_test.py +++ b/b2b_ecommerce/api_test.py @@ -88,7 +88,7 @@ def test_signed_payload(mocker, contract_number): "item_0_code": "enrollment_code", "item_0_name": f"Enrollment codes for {product_version.description}"[:254], "item_0_quantity": order.num_seats, - "item_0_sku": f"enrollment_code-{str(product.content_type)}-{product.content_object.id}", # noqa: RUF010 + "item_0_sku": f"enrollment_code-{product.content_type.app_label} | {product.content_type.name}-{product.content_object.id}", "item_0_tax_amount": "0", "item_0_unit_price": str(total_price), "line_item_count": 1, diff --git a/b2b_ecommerce/models.py b/b2b_ecommerce/models.py index d3bb9b548..02974ddaa 100644 --- a/b2b_ecommerce/models.py +++ b/b2b_ecommerce/models.py @@ -188,8 +188,9 @@ def to_dict(self): **serialize_model_object(self.product_version), "product_info": { **serialize_model_object(self.product_version.product), - "content_type_string": str( - self.product_version.product.content_type + "content_type_string": "{} | {}".format( + self.product_version.product.content_type.app_label, + self.product_version.product.content_type.name, ), "content_object": serialize_model_object( self.product_version.product.content_object diff --git a/b2b_ecommerce/models_test.py b/b2b_ecommerce/models_test.py index b63048ed1..98d71f919 100644 --- a/b2b_ecommerce/models_test.py +++ b/b2b_ecommerce/models_test.py @@ -33,7 +33,7 @@ def test_b2b_order_audit(): **serialize_model_object(order.product_version), "product_info": { **serialize_model_object(order.product_version.product), - "content_type_string": str(order.product_version.product.content_type), + "content_type_string": f"{order.product_version.product.content_type.app_label} | {order.product_version.product.content_type.name}", "content_object": serialize_model_object( order.product_version.product.content_object ), diff --git a/cms/models.py b/cms/models.py index c01bafa66..f351f6ea4 100644 --- a/cms/models.py +++ b/cms/models.py @@ -1051,7 +1051,6 @@ class Meta: ], blank=True, help_text="The content of this tab on the program page", - use_json_field=True, ) content_panels = Page.content_panels + [ # noqa: RUF005 @@ -1726,7 +1725,6 @@ class UserTestimonialsPage(CourseProgramChildPage): [("testimonial", UserTestimonialBlock())], blank=False, help_text="Add testimonials to display in this section.", - use_json_field=True, ) content_panels = [ FieldPanel("heading"), @@ -1767,7 +1765,6 @@ class NewsAndEventsPage(DisableSitemapURLMixin, Page): [("news_and_events", NewsAndEventsBlock())], blank=False, help_text="Add news and events updates to display in this section.", - use_json_field=True, ) content_panels = [FieldPanel("heading"), FieldPanel("items")] api_fields = [ @@ -1825,7 +1822,6 @@ class LearningOutcomesPage(CourseProgramChildPage): [("outcome", TextBlock(icon="plus"))], blank=False, help_text="Detail about What you'll learn as learning outcome.", - use_json_field=True, ) content_panels = [ @@ -1851,7 +1847,6 @@ class LearningTechniquesPage(CourseProgramChildPage): [("techniques", LearningTechniqueBlock())], blank=False, help_text="Enter detail about how you'll learn.", - use_json_field=True, ) content_panels = [FieldPanel("title"), FieldPanel("technique_items")] @@ -2035,7 +2030,6 @@ class WhoShouldEnrollPage(CourseProgramChildPage): ], blank=False, help_text='Contents of the "Who Should Enroll" section.', - use_json_field=True, ) switch_layout = models.BooleanField( blank=True, @@ -2095,7 +2089,6 @@ class CoursesInProgramPage(CourseProgramChildPage): ], help_text="The courseware to display in this carousel", blank=True, - use_json_field=True, ) @property @@ -2155,7 +2148,6 @@ class FacultyMembersPage(CourseProgramChildPage): members = StreamField( [("member", FacultyBlock())], help_text="The faculty members to display on this page", - use_json_field=True, ) content_panels = [ FieldPanel("heading"), @@ -2180,7 +2172,6 @@ class AbstractImageCarousel(Page): [("image", ImageChooserBlock(help_text="Choose an image to upload."))], blank=False, help_text="Add images for this section.", - use_json_field=True, ) content_panels = [FieldPanel("title"), FieldPanel("images")] @@ -2240,7 +2231,6 @@ class ResourcePage(Page): [("content", ResourceBlock())], blank=False, help_text="Enter details of content.", - use_json_field=True, ) content_panels = Page.content_panels + [ # noqa: RUF005 @@ -2395,7 +2385,6 @@ class PartnerLogoPlacement(models.IntegerChoices): ), blank=True, help_text="You can choose upto 5 signatories.", - use_json_field=True, ) overrides = StreamField( @@ -2403,7 +2392,6 @@ class PartnerLogoPlacement(models.IntegerChoices): blank=True, help_text="Overrides for specific runs of this Course/Program", validators=[validate_unique_readable_ids], - use_json_field=True, ) display_mit_seal = models.BooleanField( @@ -2659,7 +2647,6 @@ class LearningJourneySection(EnterpriseChildPage): [("journey", TextBlock(icon="plus"))], blank=False, help_text="Enter the text for this learning journey item.", - use_json_field=True, ) call_to_action = models.CharField( max_length=30, @@ -2735,7 +2722,6 @@ class SuccessStoriesSection(EnterpriseChildPage): [("success_story", SuccessStoriesBlock())], blank=False, help_text="Manage the individual success stories. Each story is a separate block.", - use_json_field=True, ) content_panels = [ @@ -2794,7 +2780,6 @@ class EnterprisePage(WagtailCachedPageMixin, Page): headings = StreamField( [("heading", BannerHeadingBlock())], help_text="Add banner headings for this page.", - use_json_field=True, ) background_image = models.ForeignKey( Image, diff --git a/courses/utils_test.py b/courses/utils_test.py index ee5f260b5..50a1be684 100644 --- a/courses/utils_test.py +++ b/courses/utils_test.py @@ -2,8 +2,7 @@ Tests for signals """ -from datetime import timedelta, datetime -import pytz +from datetime import timedelta, datetime, timezone import re import factory @@ -34,8 +33,8 @@ pytestmark = pytest.mark.django_db -START_DT = datetime(2098, 1, 1, tzinfo=pytz.UTC) -END_DT = datetime(2099, 2, 1, tzinfo=pytz.UTC) +START_DT = datetime(2098, 1, 1, tzinfo=timezone.utc) +END_DT = datetime(2099, 2, 1, tzinfo=timezone.utc) def make_api_course(course_id, name): @@ -352,11 +351,13 @@ def test_sync_course_runs( if re.match(COURSE_KEY_PATTERN, data["courseware_id"]) ] mock_course_list.get_courses.assert_called_once_with( - course_keys=valid_course_keys, username=None + course_keys=valid_course_keys, + username=settings.OPENEDX_SERVICE_WORKER_USERNAME, ) elif not api_error: mock_course_list.get_courses.assert_called_once_with( - course_keys=[data["courseware_id"] for data in local_data], username=None + course_keys=[data["courseware_id"] for data in local_data], + username=settings.OPENEDX_SERVICE_WORKER_USERNAME, ) else: mock_course_list.get_courses.assert_called_once() diff --git a/courseware/migrations/0005_rename_openedxapiauth_user_access_token_expires_on_courseware__user_id_7b66fa_idx.py b/courseware/migrations/0005_rename_openedxapiauth_user_access_token_expires_on_courseware__user_id_7b66fa_idx.py new file mode 100644 index 000000000..cc49b0ab7 --- /dev/null +++ b/courseware/migrations/0005_rename_openedxapiauth_user_access_token_expires_on_courseware__user_id_7b66fa_idx.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.30 on 2026-04-10 10:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("courseware", "0004_add_courseware_related_names"), + ] + + operations = [ + migrations.RenameIndex( + model_name="openedxapiauth", + new_name="courseware__user_id_7b66fa_idx", + old_fields=("user", "access_token_expires_on"), + ), + ] diff --git a/courseware/models.py b/courseware/models.py index 7f39bae1a..2b5a501c9 100644 --- a/courseware/models.py +++ b/courseware/models.py @@ -47,4 +47,6 @@ def __str__(self): return f"OpenEdxApiAuth for {self.user}" class Meta: - index_together = ("user", "access_token_expires_on") + indexes = [ + models.Index(fields=["user", "access_token_expires_on"]), + ] diff --git a/ecommerce/api.py b/ecommerce/api.py index 593f6799a..3c9db1227 100644 --- a/ecommerce/api.py +++ b/ecommerce/api.py @@ -275,7 +275,8 @@ def _generate_cybersource_sa_payload(*, order, receipt_url, cancel_url, ip_addre product_version=product_version, tax_rate=order.tax_rate, ) - line_items[f"item_{i}_code"] = str(product_version.product.content_type) + ct = product_version.product.content_type + line_items[f"item_{i}_code"] = f"{ct.app_label} | {ct.name}" line_items[f"item_{i}_name"] = str(product_version.description)[:254] line_items[f"item_{i}_quantity"] = line.quantity line_items[f"item_{i}_sku"] = product_version.product.content_object.id @@ -296,7 +297,7 @@ def _generate_cybersource_sa_payload(*, order, receipt_url, cancel_url, ip_addre readable_id = get_readable_id(content_object) merchant_fields = { - "merchant_defined_data1": str(product.content_type), + "merchant_defined_data1": f"{product.content_type.app_label} | {product.content_type.name}", "merchant_defined_data2": readable_id, "merchant_defined_data3": "1", } diff --git a/ecommerce/models.py b/ecommerce/models.py index accbf0071..e4a2be96f 100644 --- a/ecommerce/models.py +++ b/ecommerce/models.py @@ -409,8 +409,9 @@ def to_dict(self): **serialize_model_object(line.product_version), "product_info": { **serialize_model_object(line.product_version.product), - "content_type_string": str( - line.product_version.product.content_type + "content_type_string": "{} | {}".format( + line.product_version.product.content_type.app_label, + line.product_version.product.content_type.name, ), "content_object": serialize_model_object( line.product_version.product.content_object diff --git a/ecommerce/models_test.py b/ecommerce/models_test.py index 7d16507b0..265b8c666 100644 --- a/ecommerce/models_test.py +++ b/ecommerce/models_test.py @@ -75,9 +75,7 @@ def test_order_audit(has_user, has_lines): **serialize_model_object(line.product_version), "product_info": { **serialize_model_object(line.product_version.product), - "content_type_string": str( - line.product_version.product.content_type - ), + "content_type_string": f"{line.product_version.product.content_type.app_label} | {line.product_version.product.content_type.name}", "content_object": serialize_model_object( line.product_version.product.content_object ), diff --git a/mail/api_test.py b/mail/api_test.py index 696fba0e3..13d0c50ad 100644 --- a/mail/api_test.py +++ b/mail/api_test.py @@ -246,8 +246,9 @@ def test_send_message(mailoutbox): send_messages(messages) + messages_to = [m.to for m in messages] for message in mailoutbox: - assert message in messages + assert message.to in messages_to def test_send_message_failure(mocker): diff --git a/mail/templatetags/timezone_converter_url.py b/mail/templatetags/timezone_converter_url.py index cb8f78b36..39e62d7c8 100644 --- a/mail/templatetags/timezone_converter_url.py +++ b/mail/templatetags/timezone_converter_url.py @@ -1,5 +1,7 @@ """custom template tag to convert a DateTimeField object to a URL for timezone conversion""" +from datetime import timezone as dt_timezone + from django import template from django.utils import timezone @@ -21,7 +23,7 @@ def timezone_converter_url(datetime_obj): return "" if timezone.is_naive(datetime_obj): - datetime_obj = timezone.make_aware(datetime_obj, timezone.utc) + datetime_obj = timezone.make_aware(datetime_obj, dt_timezone.utc) time_param = datetime_obj.strftime("%H%M") date_param = datetime_obj.strftime("%Y-%m-%d") diff --git a/maxmind/migrations/0001_add_geoname_and_netblock_tables.py b/maxmind/migrations/0001_add_geoname_and_netblock_tables.py index a5b4637ed..75280032a 100644 --- a/maxmind/migrations/0001_add_geoname_and_netblock_tables.py +++ b/maxmind/migrations/0001_add_geoname_and_netblock_tables.py @@ -110,7 +110,7 @@ class Migration(migrations.Migration): migrations.AddConstraint( model_name="netblock", constraint=models.CheckConstraint( - check=models.Q( + condition=models.Q( ("geoname_id__isnull", False), ("registered_country_geoname_id__isnull", False), ("represented_country_geoname_id__isnull", False), diff --git a/maxmind/models.py b/maxmind/models.py index 47cb3866b..a3a04d7c4 100644 --- a/maxmind/models.py +++ b/maxmind/models.py @@ -91,7 +91,7 @@ class NetBlock(models.Model): class Meta: constraints = [ models.CheckConstraint( - check=models.Q(geoname_id__isnull=False) + condition=models.Q(geoname_id__isnull=False) | models.Q(registered_country_geoname_id__isnull=False) | models.Q(represented_country_geoname_id__isnull=False), name="at_least_one_geoname_id", diff --git a/mitxpro/settings.py b/mitxpro/settings.py index 167dc4d89..8fc7037f8 100644 --- a/mitxpro/settings.py +++ b/mitxpro/settings.py @@ -26,7 +26,7 @@ from mitxpro.celery_utils import OffsettingSchedule from mitxpro.sentry import init_sentry -VERSION = "0.193.2" +VERSION = "0.194.0" env.reset() @@ -623,10 +623,14 @@ "AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, or " "AWS_STORAGE_BUCKET_NAME" ) +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"}, +} if MITXPRO_USE_S3: if CLOUDFRONT_DIST: AWS_S3_CUSTOM_DOMAIN = f"{CLOUDFRONT_DIST}.cloudfront.net" - DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage" + STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} FEATURES = get_features() @@ -950,6 +954,9 @@ OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" # noqa: S105 OAUTH2_PROVIDER = { + # Disable PKCE requirement to maintain backward compatibility with existing OAuth clients + # (PKCE_REQUIRED changed default to True in django-oauth-toolkit 2.0.0) + "PKCE_REQUIRED": False, # this is the list of available scopes "SCOPES": { "read": "Read scope", diff --git a/mitxpro/settings_test.py b/mitxpro/settings_test.py index 2328cc617..09e6ff419 100644 --- a/mitxpro/settings_test.py +++ b/mitxpro/settings_test.py @@ -85,7 +85,10 @@ def test_s3_settings(settings_sandbox): {"MITXPRO_USE_S3": "False", "AWS_ACCESS_KEY_ID": ""} ) - assert settings_vars.get("DEFAULT_FILE_STORAGE") is None + assert ( + settings_vars["STORAGES"]["default"]["BACKEND"] + == "django.core.files.storage.FileSystemStorage" + ) with pytest.raises(ImproperlyConfigured): settings_sandbox.patch({"MITXPRO_USE_S3": "True"}) @@ -100,7 +103,7 @@ def test_s3_settings(settings_sandbox): } ) assert ( - settings_vars.get("DEFAULT_FILE_STORAGE") + settings_vars["STORAGES"]["default"]["BACKEND"] == "storages.backends.s3boto3.S3Boto3Storage" ) diff --git a/pyproject.toml b/pyproject.toml index ea7a0941f..05409ca71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,17 +14,17 @@ classifiers = [ dependencies = [ "Pillow==10.4.0", "PyNaCl==1.6.2", - "beautifulsoup4==4.8.2", + "beautifulsoup4==4.14.3", "boto3==1.42.88", "celery==5.6.3", "celery-redbeat==2.3.3", "dj-database-url==3.1.2", - "django==4.2.30", + "django==5.2.13", "django-anymail[mailgun]==14.0", - "django-filter>=23.4,<24", + "django-filter>=24", "django-hijack==3.7.7", "django-ipware==7.0.1", - "django-oauth-toolkit==1.7.1", + "django-oauth-toolkit==3.2.0", "django-redis>=6.0.0,<7", "django-robots==6.1", "django-silk>=5.0.3,<6", @@ -60,7 +60,7 @@ dependencies = [ "user-agents==2.2.0", "user-util==2.0.0", "uwsgi==2.0.31", - "wagtail==5.2.8", + "wagtail==6.4.2", "wagtail-metadata==5.0.0", "xmltodict>=1.0.0,<2", "zeep==4.3.2", diff --git a/pytest.ini b/pytest.ini index 0a38df194..648849841 100644 --- a/pytest.ini +++ b/pytest.ini @@ -7,7 +7,6 @@ filterwarnings = ignore:Failed to load HostKeys ignore:Coverage disabled via --no-cov switch! ignore:.*Not importing directory.*:ImportWarning - ignore:.*:django.utils.deprecation.RemovedInDjango51Warning env = CELERY_TASK_ALWAYS_EAGER=True DJANGO_SETTINGS_MODULE=mitxpro.settings diff --git a/static/js/components/UserMenu.js b/static/js/components/UserMenu.js index 558dd7308..3f05d51f5 100644 --- a/static/js/components/UserMenu.js +++ b/static/js/components/UserMenu.js @@ -4,6 +4,7 @@ import React from "react"; import MixedLink from "./MixedLink"; import { routes } from "../lib/urls"; +import { getCookie } from "../lib/api"; import type { User } from "../flow/authTypes"; @@ -70,10 +71,21 @@ const UserMenu = ({ currentUser, onMouseDown }: Props) => {
- - - Sign Out - + ); diff --git a/static/js/components/UserMenu_test.js b/static/js/components/UserMenu_test.js index f1b75b892..dfb22a7ec 100644 --- a/static/js/components/UserMenu_test.js +++ b/static/js/components/UserMenu_test.js @@ -33,9 +33,8 @@ describe("UserMenu component", () => { it("has a link to logout", () => { assert.equal( shallow(