From 987349ff7547d9a804095671cb60df3f4b220b15 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:05:56 -0400 Subject: [PATCH 01/10] Update dependency sharp to v0.34.5 (#2707) * Update dependency sharp to v0.34.5 * update yarn --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: shankar ambady --- frontends/main/package.json | 2 +- yarn.lock | 296 +----------------------------------- 2 files changed, 5 insertions(+), 293 deletions(-) diff --git a/frontends/main/package.json b/frontends/main/package.json index acccf5759c..fe1573debd 100644 --- a/frontends/main/package.json +++ b/frontends/main/package.json @@ -63,7 +63,7 @@ "react-hotkeys-hook": "^5.2.1", "react-markdown": "^10.0.0", "react-slick": "^0.31.0", - "sharp": "0.34.4", + "sharp": "0.34.5", "slick-carousel": "^1.8.1", "tiny-invariant": "^1.3.3", "video.js": "^8.23.7", diff --git a/yarn.lock b/yarn.lock index 7db1909227..0ccbbdd1d4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2035,15 +2035,6 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.5.0": - version: 1.7.0 - resolution: "@emnapi/runtime@npm:1.7.0" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10/4dc726eb42fe2c7777fd32090f3e5e006c630e1a732538139caa18daf586e883e81c562cd69b0622db16e76bb572a2dde30711494edcee4a34059b62f5f46267 - languageName: node - linkType: hard - "@emnapi/wasi-threads@npm:1.0.2": version: 1.0.2 resolution: "@emnapi/wasi-threads@npm:1.0.2" @@ -2620,18 +2611,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-darwin-arm64@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-darwin-arm64@npm:0.34.4" - dependencies: - "@img/sharp-libvips-darwin-arm64": "npm:1.2.3" - dependenciesMeta: - "@img/sharp-libvips-darwin-arm64": - optional: true - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@img/sharp-darwin-arm64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-darwin-arm64@npm:0.34.5" @@ -2644,18 +2623,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-darwin-x64@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-darwin-x64@npm:0.34.4" - dependencies: - "@img/sharp-libvips-darwin-x64": "npm:1.2.3" - dependenciesMeta: - "@img/sharp-libvips-darwin-x64": - optional: true - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@img/sharp-darwin-x64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-darwin-x64@npm:0.34.5" @@ -2668,13 +2635,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-darwin-arm64@npm:1.2.3": - version: 1.2.3 - resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.3" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@img/sharp-libvips-darwin-arm64@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-darwin-arm64@npm:1.2.4" @@ -2682,13 +2642,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-darwin-x64@npm:1.2.3": - version: 1.2.3 - resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.3" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@img/sharp-libvips-darwin-x64@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-darwin-x64@npm:1.2.4" @@ -2696,13 +2649,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-arm64@npm:1.2.3": - version: 1.2.3 - resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.3" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@img/sharp-libvips-linux-arm64@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-linux-arm64@npm:1.2.4" @@ -2710,13 +2656,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-arm@npm:1.2.3": - version: 1.2.3 - resolution: "@img/sharp-libvips-linux-arm@npm:1.2.3" - conditions: os=linux & cpu=arm & libc=glibc - languageName: node - linkType: hard - "@img/sharp-libvips-linux-arm@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-linux-arm@npm:1.2.4" @@ -2724,13 +2663,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-ppc64@npm:1.2.3": - version: 1.2.3 - resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.3" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - "@img/sharp-libvips-linux-ppc64@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-linux-ppc64@npm:1.2.4" @@ -2745,13 +2677,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-s390x@npm:1.2.3": - version: 1.2.3 - resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.3" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - "@img/sharp-libvips-linux-s390x@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-linux-s390x@npm:1.2.4" @@ -2759,13 +2684,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linux-x64@npm:1.2.3": - version: 1.2.3 - resolution: "@img/sharp-libvips-linux-x64@npm:1.2.3" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@img/sharp-libvips-linux-x64@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-linux-x64@npm:1.2.4" @@ -2773,13 +2691,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linuxmusl-arm64@npm:1.2.3": - version: 1.2.3 - resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.3" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-linuxmusl-arm64@npm:1.2.4" @@ -2787,13 +2698,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-libvips-linuxmusl-x64@npm:1.2.3": - version: 1.2.3 - resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.3" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@img/sharp-libvips-linuxmusl-x64@npm:1.2.4": version: 1.2.4 resolution: "@img/sharp-libvips-linuxmusl-x64@npm:1.2.4" @@ -2801,18 +2705,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-arm64@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-linux-arm64@npm:0.34.4" - dependencies: - "@img/sharp-libvips-linux-arm64": "npm:1.2.3" - dependenciesMeta: - "@img/sharp-libvips-linux-arm64": - optional: true - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@img/sharp-linux-arm64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-linux-arm64@npm:0.34.5" @@ -2825,18 +2717,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-arm@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-linux-arm@npm:0.34.4" - dependencies: - "@img/sharp-libvips-linux-arm": "npm:1.2.3" - dependenciesMeta: - "@img/sharp-libvips-linux-arm": - optional: true - conditions: os=linux & cpu=arm & libc=glibc - languageName: node - linkType: hard - "@img/sharp-linux-arm@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-linux-arm@npm:0.34.5" @@ -2849,18 +2729,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-ppc64@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-linux-ppc64@npm:0.34.4" - dependencies: - "@img/sharp-libvips-linux-ppc64": "npm:1.2.3" - dependenciesMeta: - "@img/sharp-libvips-linux-ppc64": - optional: true - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - "@img/sharp-linux-ppc64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-linux-ppc64@npm:0.34.5" @@ -2885,18 +2753,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-s390x@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-linux-s390x@npm:0.34.4" - dependencies: - "@img/sharp-libvips-linux-s390x": "npm:1.2.3" - dependenciesMeta: - "@img/sharp-libvips-linux-s390x": - optional: true - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - "@img/sharp-linux-s390x@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-linux-s390x@npm:0.34.5" @@ -2909,18 +2765,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linux-x64@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-linux-x64@npm:0.34.4" - dependencies: - "@img/sharp-libvips-linux-x64": "npm:1.2.3" - dependenciesMeta: - "@img/sharp-libvips-linux-x64": - optional: true - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@img/sharp-linux-x64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-linux-x64@npm:0.34.5" @@ -2933,18 +2777,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linuxmusl-arm64@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.4" - dependencies: - "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.3" - dependenciesMeta: - "@img/sharp-libvips-linuxmusl-arm64": - optional: true - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "@img/sharp-linuxmusl-arm64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-linuxmusl-arm64@npm:0.34.5" @@ -2957,18 +2789,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-linuxmusl-x64@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-linuxmusl-x64@npm:0.34.4" - dependencies: - "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.3" - dependenciesMeta: - "@img/sharp-libvips-linuxmusl-x64": - optional: true - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@img/sharp-linuxmusl-x64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-linuxmusl-x64@npm:0.34.5" @@ -2981,15 +2801,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-wasm32@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-wasm32@npm:0.34.4" - dependencies: - "@emnapi/runtime": "npm:^1.5.0" - conditions: cpu=wasm32 - languageName: node - linkType: hard - "@img/sharp-wasm32@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-wasm32@npm:0.34.5" @@ -2999,13 +2810,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-win32-arm64@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-win32-arm64@npm:0.34.4" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@img/sharp-win32-arm64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-win32-arm64@npm:0.34.5" @@ -3013,13 +2817,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-win32-ia32@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-win32-ia32@npm:0.34.4" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - "@img/sharp-win32-ia32@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-win32-ia32@npm:0.34.5" @@ -3027,13 +2824,6 @@ __metadata: languageName: node linkType: hard -"@img/sharp-win32-x64@npm:0.34.4": - version: 0.34.4 - resolution: "@img/sharp-win32-x64@npm:0.34.4" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@img/sharp-win32-x64@npm:0.34.5": version: 0.34.5 resolution: "@img/sharp-win32-x64@npm:0.34.5" @@ -11225,7 +11015,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.1.0, detect-libc@npm:^2.1.2": +"detect-libc@npm:^2.1.2": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766 @@ -16461,7 +16251,7 @@ __metadata: react-hotkeys-hook: "npm:^5.2.1" react-markdown: "npm:^10.0.0" react-slick: "npm:^0.31.0" - sharp: "npm:0.34.4" + sharp: "npm:0.34.5" slick-carousel: "npm:^1.8.1" tiny-invariant: "npm:^1.3.3" ts-jest: "npm:^29.2.4" @@ -20633,7 +20423,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.1, semver@npm:^7.7.2": +"semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.1": version: 7.7.2 resolution: "semver@npm:7.7.2" bin: @@ -20717,85 +20507,7 @@ __metadata: languageName: node linkType: hard -"sharp@npm:0.34.4": - version: 0.34.4 - resolution: "sharp@npm:0.34.4" - dependencies: - "@img/colour": "npm:^1.0.0" - "@img/sharp-darwin-arm64": "npm:0.34.4" - "@img/sharp-darwin-x64": "npm:0.34.4" - "@img/sharp-libvips-darwin-arm64": "npm:1.2.3" - "@img/sharp-libvips-darwin-x64": "npm:1.2.3" - "@img/sharp-libvips-linux-arm": "npm:1.2.3" - "@img/sharp-libvips-linux-arm64": "npm:1.2.3" - "@img/sharp-libvips-linux-ppc64": "npm:1.2.3" - "@img/sharp-libvips-linux-s390x": "npm:1.2.3" - "@img/sharp-libvips-linux-x64": "npm:1.2.3" - "@img/sharp-libvips-linuxmusl-arm64": "npm:1.2.3" - "@img/sharp-libvips-linuxmusl-x64": "npm:1.2.3" - "@img/sharp-linux-arm": "npm:0.34.4" - "@img/sharp-linux-arm64": "npm:0.34.4" - "@img/sharp-linux-ppc64": "npm:0.34.4" - "@img/sharp-linux-s390x": "npm:0.34.4" - "@img/sharp-linux-x64": "npm:0.34.4" - "@img/sharp-linuxmusl-arm64": "npm:0.34.4" - "@img/sharp-linuxmusl-x64": "npm:0.34.4" - "@img/sharp-wasm32": "npm:0.34.4" - "@img/sharp-win32-arm64": "npm:0.34.4" - "@img/sharp-win32-ia32": "npm:0.34.4" - "@img/sharp-win32-x64": "npm:0.34.4" - detect-libc: "npm:^2.1.0" - semver: "npm:^7.7.2" - dependenciesMeta: - "@img/sharp-darwin-arm64": - optional: true - "@img/sharp-darwin-x64": - optional: true - "@img/sharp-libvips-darwin-arm64": - optional: true - "@img/sharp-libvips-darwin-x64": - optional: true - "@img/sharp-libvips-linux-arm": - optional: true - "@img/sharp-libvips-linux-arm64": - optional: true - "@img/sharp-libvips-linux-ppc64": - optional: true - "@img/sharp-libvips-linux-s390x": - optional: true - "@img/sharp-libvips-linux-x64": - optional: true - "@img/sharp-libvips-linuxmusl-arm64": - optional: true - "@img/sharp-libvips-linuxmusl-x64": - optional: true - "@img/sharp-linux-arm": - optional: true - "@img/sharp-linux-arm64": - optional: true - "@img/sharp-linux-ppc64": - optional: true - "@img/sharp-linux-s390x": - optional: true - "@img/sharp-linux-x64": - optional: true - "@img/sharp-linuxmusl-arm64": - optional: true - "@img/sharp-linuxmusl-x64": - optional: true - "@img/sharp-wasm32": - optional: true - "@img/sharp-win32-arm64": - optional: true - "@img/sharp-win32-ia32": - optional: true - "@img/sharp-win32-x64": - optional: true - checksum: 10/8e6268e3b0fba7704291684e63c2829963a5ec311d8a8ebbcd32d750c4efb0b01594d925d289ccb5ac0ac373df40fedf5a05a8f331470db799b9c78c48923cba - languageName: node - linkType: hard - -"sharp@npm:^0.34.4": +"sharp@npm:0.34.5, sharp@npm:^0.34.4": version: 0.34.5 resolution: "sharp@npm:0.34.5" dependencies: From 9d0dd1645b67b3850e462a77b333fc3fd069863d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:11:06 -0400 Subject: [PATCH 02/10] [pre-commit.ci] pre-commit autoupdate (#2973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/scop/pre-commit-shfmt: v3.12.0-2 → v3.13.1-1](https://github.com/scop/pre-commit-shfmt/compare/v3.12.0-2...v3.13.1-1) - [github.com/astral-sh/ruff-pre-commit: v0.15.1 → v0.15.12](https://github.com/astral-sh/ruff-pre-commit/compare/v0.15.1...v0.15.12) * fix lint --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: shankar ambady --- .pre-commit-config.yaml | 4 ++-- learning_resources/etl/openedx.py | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3026d93278..fb9b5cd8df 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -53,7 +53,7 @@ repos: pass_filenames: false always_run: true - repo: https://github.com/scop/pre-commit-shfmt - rev: v3.12.0-2 + rev: v3.13.1-1 hooks: - id: shfmt - repo: https://github.com/adrienverge/yamllint.git @@ -90,7 +90,7 @@ repos: - "config/keycloak/realms/ol-local-realm.json" additional_dependencies: ["gibberish-detector"] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: "v0.15.1" + rev: "v0.15.12" hooks: - id: ruff-format - id: ruff diff --git a/learning_resources/etl/openedx.py b/learning_resources/etl/openedx.py index 2f5f574dce..cebd2c2bc4 100644 --- a/learning_resources/etl/openedx.py +++ b/learning_resources/etl/openedx.py @@ -378,9 +378,10 @@ def _transform_course_commitment(course_run) -> CommitmentConfig: ) if min_effort or max_effort: return CommitmentConfig( - commitment=f"{commit_str_prefix}{max_effort or min_effort} hour{ - 's' if max_effort > 1 else '' - }/week", + commitment=( + f"{commit_str_prefix}{max_effort or min_effort} " + f"hour{'s' if max_effort > 1 else ''}/week" + ), min_weekly_hours=min(min_effort, max_effort), max_weekly_hours=max(min_effort, max_effort), ) From 4f366cf30e9e6372767927600ca49bcd0da86bfd Mon Sep 17 00:00:00 2001 From: Shankar Ambady Date: Tue, 28 Apr 2026 13:17:44 -0400 Subject: [PATCH 03/10] flaky test test_learning_resources_serializer (#3252) * order by position and id * add migration * adding pytest repeat plugin * moving pytest-repeat to dev dependency --- ...alter_learningresourcerelationship_options.py | 16 ++++++++++++++++ learning_resources/models.py | 8 +++++--- pyproject.toml | 1 + uv.lock | 14 ++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 learning_resources/migrations/0113_alter_learningresourcerelationship_options.py diff --git a/learning_resources/migrations/0113_alter_learningresourcerelationship_options.py b/learning_resources/migrations/0113_alter_learningresourcerelationship_options.py new file mode 100644 index 0000000000..39184191d3 --- /dev/null +++ b/learning_resources/migrations/0113_alter_learningresourcerelationship_options.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.30 on 2026-04-27 16:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("learning_resources", "0112_contentfile_youtube_id"), + ] + + operations = [ + migrations.AlterModelOptions( + name="learningresourcerelationship", + options={"ordering": ["position", "id"]}, + ), + ] diff --git a/learning_resources/models.py b/learning_resources/models.py index c830f41701..58993ffee5 100644 --- a/learning_resources/models.py +++ b/learning_resources/models.py @@ -664,7 +664,9 @@ def children_for_serialization(self): if hasattr(self, "_children"): return self._children return list( - self.children.order_by("position").select_related("child", "child__image") + self.children.order_by("position", "id").select_related( + "child", "child__image" + ) ) def first_child_relationship_for_serialization(self): @@ -1012,7 +1014,7 @@ class LearningResourceRelationshipQuerySet(TimestampedModelQuerySet): def for_serialization(self): """Prefetch related objects used by API serializers""" - return self.select_related("child", "child__image").order_by("position") + return self.select_related("child", "child__image").order_by("position", "id") class LearningResourceRelationship(TimestampedModel): @@ -1038,7 +1040,7 @@ class LearningResourceRelationship(TimestampedModel): objects = LearningResourceRelationshipQuerySet.as_manager() class Meta: - ordering = ["position"] + ordering = ["position", "id"] class ContentFileQuerySet(TimestampedModelQuerySet): diff --git a/pyproject.toml b/pyproject.toml index 10b1bab7fb..2bafe84139 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,7 @@ dev = [ "pytest-env>=1.0.0,<2", "pytest-freezegun>=0.4.2,<0.5", "pytest-mock>=3.10.0,<4", + "pytest-repeat>=0.9.4", "responses>=0.25.0,<0.26", "ruff==0.14.14", "safety>=3.0.0,<4", diff --git a/uv.lock b/uv.lock index dc5222734e..001d7183cf 100644 --- a/uv.lock +++ b/uv.lock @@ -2616,6 +2616,7 @@ dev = [ { name = "pytest-env" }, { name = "pytest-freezegun" }, { name = "pytest-mock" }, + { name = "pytest-repeat" }, { name = "pytest-xdist", extra = ["psutil"] }, { name = "responses" }, { name = "ruff" }, @@ -2755,6 +2756,7 @@ dev = [ { name = "pytest-env", specifier = ">=1.0.0,<2" }, { name = "pytest-freezegun", specifier = ">=0.4.2,<0.5" }, { name = "pytest-mock", specifier = ">=3.10.0,<4" }, + { name = "pytest-repeat", specifier = ">=0.9.4" }, { name = "pytest-xdist", extras = ["psutil"], specifier = ">=3.6.1,<4" }, { name = "responses", specifier = ">=0.25.0,<0.26" }, { name = "ruff", specifier = "==0.14.14" }, @@ -4070,6 +4072,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, ] +[[package]] +name = "pytest-repeat" +version = "0.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/d4/69e9dbb9b8266df0b157c72be32083403c412990af15c7c15f7a3fd1b142/pytest_repeat-0.9.4.tar.gz", hash = "sha256:d92ac14dfaa6ffcfe6917e5d16f0c9bc82380c135b03c2a5f412d2637f224485", size = 6488, upload-time = "2025-04-07T14:59:53.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/d4/8b706b81b07b43081bd68a2c0359fe895b74bf664b20aca8005d2bb3be71/pytest_repeat-0.9.4-py3-none-any.whl", hash = "sha256:c1738b4e412a6f3b3b9e0b8b29fcd7a423e50f87381ad9307ef6f5a8601139f3", size = 4180, upload-time = "2025-04-07T14:59:51.492Z" }, +] + [[package]] name = "pytest-xdist" version = "3.8.0" From 701da1fb29bae286afaa0da8b8369182a691fe99 Mon Sep 17 00:00:00 2001 From: Carey P Gumaer Date: Tue, 28 Apr 2026 13:31:06 -0400 Subject: [PATCH 04/10] dashboard b2c series certificate display (#3256) * add context menu and series certificate button to program as course card Co-authored-by: Copilot * don't need double program enrollment status * Also display certificate link button on program dashboard page Co-authored-by: Copilot * add tests * remove some code duplication Co-authored-by: Copilot * correct program as course card context menu behavior Co-authored-by: Copilot * add tests for context menu in program as course card * fix type check * address sentry feedback about trailing slashes Co-authored-by: Copilot * don't show the unenroll option on crogram cards for verified enrollments Co-authored-by: Copilot * Only specify necessary style overrides on cert button * open program cert links in the same tab Co-authored-by: Copilot * use api cert link in tests too --------- Co-authored-by: Copilot --- .../EnrollmentDisplay.test.tsx | 98 ++++++++++ .../CoursewareDisplay/EnrollmentDisplay.tsx | 36 +++- .../ProgramAsCourseCard.test.tsx | 172 ++++++++++++++++++ .../CoursewareDisplay/ProgramAsCourseCard.tsx | 147 ++++++++++++++- 4 files changed, 435 insertions(+), 18 deletions(-) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx index 1ff5215871..324f84033e 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.test.tsx @@ -2291,5 +2291,103 @@ describe("EnrollmentDisplay", () => { expect(cards[1]).toHaveTextContent(courseA.title) expect(cards[2]).toHaveTextContent(courseB.title) }) + + test("displays certificate button when program enrollment has a certificate", async () => { + const mitxOnlineUser = mitxonline.factories.user.user() + setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) + + const certUuid = "test-program-cert-uuid" + const program = mitxonline.factories.programs.program({ + id: 456, + title: "Program With Certificate", + courses: [10, 11], + }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + id: program.id, + title: program.title, + live: program.live, + program_type: program.program_type, + readable_id: program.readable_id, + }, + certificate: { + uuid: certUuid, + link: `/certificate/program/${certUuid}/`, + }, + }) + const courses = mitxonline.factories.courses.courses({ count: 2 }) + + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) + setMockResponse.get( + mitxonline.urls.programEnrollments.enrollmentsListV3(), + [programEnrollment], + ) + setMockResponse.get(mitxonline.urls.programs.programDetail(456), program) + setMockResponse.get( + mitxonline.urls.courses.coursesList({ + id: program.courses, + page_size: program.courses.length, + }), + courses, + ) + + renderWithProviders() + + await screen.findByText("Program With Certificate") + const certButton = screen.getByRole("link", { name: "Certificate" }) + const expectedCertHref = programEnrollment.certificate?.link?.replace( + /\/$/, + "", + ) + expect(certButton).toBeInTheDocument() + expect(certButton).toHaveAttribute("href", expectedCertHref) + expect(certButton).not.toHaveAttribute("target") + }) + + test("does not display certificate button when program enrollment has no certificate", async () => { + const mitxOnlineUser = mitxonline.factories.user.user() + setMockResponse.get(mitxonline.urls.userMe.get(), mitxOnlineUser) + + const program = mitxonline.factories.programs.program({ + id: 457, + title: "Program Without Certificate", + courses: [12, 13], + }) + const programEnrollment = + mitxonline.factories.enrollment.programEnrollmentV3({ + program: { + id: program.id, + title: program.title, + live: program.live, + program_type: program.program_type, + readable_id: program.readable_id, + }, + certificate: null, + }) + const courses = mitxonline.factories.courses.courses({ count: 2 }) + + mockedUseFeatureFlagEnabled.mockReturnValue(true) + setMockResponse.get(mitxonline.urls.enrollment.enrollmentsListV3(), []) + setMockResponse.get( + mitxonline.urls.programEnrollments.enrollmentsListV3(), + [programEnrollment], + ) + setMockResponse.get(mitxonline.urls.programs.programDetail(457), program) + setMockResponse.get( + mitxonline.urls.courses.coursesList({ + id: program.courses, + page_size: program.courses.length, + }), + courses, + ) + + renderWithProviders() + + await screen.findByText("Program Without Certificate") + const certButton = screen.queryByRole("link", { name: "Certificate" }) + expect(certButton).not.toBeInTheDocument() + }) }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx index e89d7a66ed..02a06c2a29 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/EnrollmentDisplay.tsx @@ -13,7 +13,7 @@ import { styled, theme, } from "ol-components" -import { Alert } from "@mitodl/smoot-design" +import { Alert, ButtonLink } from "@mitodl/smoot-design" import { keepPreviousData, useQuery } from "@tanstack/react-query" import { EnrollmentStatus, @@ -43,6 +43,7 @@ import { mitxUserQueries } from "api/mitxonline-hooks/user" import NotFoundPage from "@/app-pages/ErrorPage/NotFoundPage" import { ProgramAsCourseCard } from "./ProgramAsCourseCard" import { getIdsFromReqTree } from "@/common/mitxonline" +import { RiAwardFill } from "@remixicon/react" const Wrapper = styled.div(({ theme }) => ({ marginTop: "32px", @@ -107,6 +108,11 @@ const ShowAllContainer = styled.div(({ theme }) => ({ }, })) +export const ProgramCertificateButton = styled(ButtonLink)(({ theme }) => ({ + color: theme.custom.colors.red, + width: "120px", +})) + const alphabeticalSort = (a: CourseRunEnrollmentV3, b: CourseRunEnrollmentV3) => a.run.course.title.localeCompare(b.run.course.title) @@ -550,6 +556,8 @@ const ProgramEnrollmentDisplay: React.FC = ({ programEnrollmentsById, ) + const programCertificateUrl = programEnrollment?.certificate?.link ?? null + if (isLoading) { return ( @@ -578,14 +586,26 @@ const ProgramEnrollmentDisplay: React.FC = ({ {program?.title} - - You have completed - - {" "} - {completedCount} of {totalCount} courses{" "} + + + You have completed + + {" "} + {completedCount} of {totalCount} courses{" "} + + for this program. - for this program. - + {programCertificateUrl && ( + } + href={programCertificateUrl} + > + Certificate + + )} + {requirementSections.map((section, index) => { const { completed: sectionCompleted, total: sectionTotal } = diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx index 82bd3becc3..3a501d41cf 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.test.tsx @@ -13,6 +13,14 @@ import { ProgramAsCourseCard } from "./ProgramAsCourseCard" import { waitFor } from "@testing-library/react" import invariant from "tiny-invariant" import moment from "moment" +import NiceModal from "@ebay/nice-modal-react" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { UnenrollProgramDialog } from "./DashboardDialogs" + +jest.mock("posthog-js/react") +const mockedUseFeatureFlagEnabled = jest + .mocked(useFeatureFlagEnabled) + .mockImplementation(() => false) describe("ProgramAsCourseCard", () => { setupLocationMock() @@ -364,4 +372,168 @@ describe("ProgramAsCourseCard", () => { screen.queryByRole("dialog", { name: moduleWithRun.title }), ).not.toBeInTheDocument() }) + + test("displays certificate button when program enrollment has a certificate", async () => { + const cardData = setupCardData({ includeProgramEnrollment: true }) + invariant(cardData.courseProgramEnrollment) + const certUuid = "test-certificate-uuid-123" + const programEnrollmentWithCert = { + ...cardData.courseProgramEnrollment, + certificate: { + uuid: certUuid, + link: `/certificate/program/${certUuid}/`, + }, + } + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const certButton = screen.getByRole("link", { name: "Certificate" }) + const expectedCertHref = programEnrollmentWithCert.certificate.link.replace( + /\/$/, + "", + ) + expect(certButton).toBeInTheDocument() + expect(certButton).toHaveAttribute("href", expectedCertHref) + expect(certButton).not.toHaveAttribute("target") + }) + + test("does not display certificate button when program enrollment has no certificate", async () => { + const cardData = setupCardData({ includeProgramEnrollment: true }) + invariant(cardData.courseProgramEnrollment) + const programEnrollmentNoCert = { + ...cardData.courseProgramEnrollment, + certificate: null, + } + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const certButton = screen.queryByRole("link", { name: "Certificate" }) + expect(certButton).not.toBeInTheDocument() + }) + + test("shows legacy details link in context menu when product pages flag is disabled", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + const cardData = setupCardData({ includeProgramEnrollment: true }) + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const programCard = screen.getByTestId("program-as-course-card") + await user.click(within(programCard).getAllByLabelText("More options")[0]) + + const detailsLink = await screen.findByRole("menuitem", { + name: "View Course Details", + }) + expect(detailsLink).toHaveAttribute( + "href", + expect.stringContaining( + `/programs/${cardData.courseProgram.readable_id}`, + ), + ) + expect(detailsLink).toHaveAttribute( + "href", + expect.stringContaining("ecom-service=true"), + ) + }) + + test("shows product-page details link in context menu when product pages flag is enabled", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(true) + const cardData = setupCardData({ includeProgramEnrollment: true }) + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const programCard = screen.getByTestId("program-as-course-card") + await user.click(within(programCard).getAllByLabelText("More options")[0]) + + const detailsLink = await screen.findByRole("menuitem", { + name: "View Course Details", + }) + expect(detailsLink).toHaveAttribute( + "href", + `/courses/p/${cardData.courseProgram.readable_id}`, + ) + }) + + test("clicking Unenroll menu item opens UnenrollProgramDialog with readable_id", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + const cardData = setupCardData({ includeProgramEnrollment: true }) + invariant(cardData.courseProgramEnrollment) + cardData.courseProgramEnrollment.enrollment_mode = "audit" + const modalShowSpy = jest.spyOn(NiceModal, "show") + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const programCard = screen.getByTestId("program-as-course-card") + await user.click(within(programCard).getAllByLabelText("More options")[0]) + await user.click(await screen.findByRole("menuitem", { name: "Unenroll" })) + + expect(modalShowSpy).toHaveBeenCalledWith(UnenrollProgramDialog, { + title: cardData.courseProgram.title, + enrollment: cardData.courseProgram.readable_id, + }) + modalShowSpy.mockRestore() + }) + + test("does not show Unenroll option in context menu for verified enrollment", async () => { + mockedUseFeatureFlagEnabled.mockReturnValue(false) + const cardData = setupCardData({ includeProgramEnrollment: true }) + invariant(cardData.courseProgramEnrollment) + cardData.courseProgramEnrollment.enrollment_mode = "verified" + + renderWithProviders( + , + ) + + await screen.findByText(cardData.courseProgram.title) + const programCard = screen.getByTestId("program-as-course-card") + await user.click(within(programCard).getAllByLabelText("More options")[0]) + + expect( + screen.queryByRole("menuitem", { name: "Unenroll" }), + ).not.toBeInTheDocument() + }) }) diff --git a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx index f3a4cf8d2c..752f47cba3 100644 --- a/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx +++ b/frontends/main/src/app-pages/DashboardPage/CoursewareDisplay/ProgramAsCourseCard.tsx @@ -1,5 +1,13 @@ import React from "react" -import { Link, Popover, Stack, Typography, styled } from "ol-components" +import { + Link, + Popover, + SimpleMenu, + SimpleMenuItem, + Stack, + Typography, + styled, +} from "ol-components" import { CourseRunEnrollmentV3, CourseWithCourseRunsSerializerV2, @@ -23,7 +31,16 @@ import { formatDate } from "ol-utilities" import { getIdsFromReqTree, isVerifiedEnrollmentMode, + mitxonlineLegacyUrl, } from "@/common/mitxonline" +import { ActionButton } from "@mitodl/smoot-design" +import { RiAwardFill, RiMore2Line } from "@remixicon/react" +import NiceModal from "@ebay/nice-modal-react" +import { UnenrollProgramDialog } from "./DashboardDialogs" +import { ProgramCertificateButton } from "./EnrollmentDisplay" +import { useFeatureFlagEnabled } from "posthog-js/react" +import { FeatureFlags } from "@/common/feature_flags" +import { programPageView } from "@/common/urls" const ProgramCardRoot = styled.div(({ theme }) => ({ display: "flex", @@ -127,6 +144,23 @@ const ProgramCardBody = styled.div({ borderRadius: "0 0 8px 8px", }) +const MenuButton = styled(ActionButton)<{ + status: EnrollmentStatus +}>(({ theme, status }) => [ + { + marginLeft: "-8px", + [theme.breakpoints.down("md")]: { + position: "absolute", + top: "0", + right: "0", + }, + }, + status !== EnrollmentStatus.Completed && + status !== EnrollmentStatus.Enrolled && { + visibility: "hidden", + }, +]) + const getTimezone = (dateString: string): string => { const tz = new Date(dateString) @@ -246,19 +280,65 @@ const getRelativeDateContent = ( } } +const getContextMenuItems = ( + title: string, + resource: ProgramAsCourse, + enrollmentMode: string | null | undefined, + additionalItems: SimpleMenuItem[] = [], + useProductPages = false, +) => { + const menuItems = [] + const detailsUrl = useProductPages + ? programPageView({ + readable_id: resource.readable_id, + display_mode: "course", + }) + : mitxonlineLegacyUrl(`/programs/${resource.readable_id}`) + + const courseMenuItems = [] + + if (detailsUrl) { + courseMenuItems.push({ + className: "dashboard-card-menu-item", + key: "view-course-details", + label: "View Course Details", + href: detailsUrl, + }) + } + + if (enrollmentMode && !isVerifiedEnrollmentMode(enrollmentMode)) { + courseMenuItems.push({ + className: "dashboard-card-menu-item", + key: "unenroll", + label: "Unenroll", + onClick: () => { + NiceModal.show(UnenrollProgramDialog, { + title, + enrollment: resource.readable_id, + }) + }, + }) + } + + menuItems.push(...courseMenuItems) + return [...menuItems, ...additionalItems] +} + +interface ProgramAsCourse { + id: number + readable_id: string + title?: string | null + start_date?: string | null + end_date?: string | null + courses?: number[] + req_tree?: V2ProgramRequirement[] +} + interface ProgramAsCourseCardProps { /** * The courselike program to display. */ - courseProgram: { - id: number - readable_id: string - title?: string | null - start_date?: string | null - end_date?: string | null - courses?: number[] - req_tree?: V2ProgramRequirement[] - } + courseProgram: ProgramAsCourse /** * child courses of the program. These correspond to nodes in the req_tree. */ @@ -289,6 +369,7 @@ interface ProgramAsCourseCardProps { enrollment_mode?: string | null } Component?: React.ElementType + contextMenuItems?: SimpleMenuItem[] className?: string } @@ -313,8 +394,12 @@ const ProgramAsCourseCard: React.FC = ({ courseProgramEnrollment, ancestorProgramEnrollment, Component, + contextMenuItems = [], className, }) => { + const useProductPages = useFeatureFlagEnabled( + FeatureFlags.MitxOnlineProductPages, + ) const moduleRequirementSection = courseProgram?.req_tree?.find( (node) => node.data.node_type === "operator", ) @@ -384,6 +469,35 @@ const ProgramAsCourseCard: React.FC = ({ ancestorProgramEnrollment?.enrollment_mode, ].some(isVerifiedEnrollmentMode) + const programCertificateUrl = + courseProgramEnrollment?.certificate?.link ?? null + + // Build context menu + const menuItems = getContextMenuItems( + courseProgram.title ?? "", + courseProgram, + courseProgramEnrollment?.enrollment_mode, + contextMenuItems, + useProductPages ?? false, + ) + + const contextMenu = ( +