Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ jobs:
id: yarn-cache-dir-path
run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT

- uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
id: yarn-cache # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
Expand Down
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ repos:
- "config/keycloak/*"
additional_dependencies: ["gibberish-detector"]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: "v0.14.13"
rev: "v0.15.0"
hooks:
- id: ruff-format
- id: ruff
Expand Down
12 changes: 12 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
Release Notes
=============

Version 0.138.0
---------------

- Hubspot sync for SCIM created users (#3269)
- Make min max product price required (#3262)
- Improve rich text field serialization (#3287)
- Update dependency webpack-dev-server to v5 [SECURITY] (#3270)
- [pre-commit.ci] pre-commit autoupdate (#3247)
- Update dependency lodash to v4.17.23 [SECURITY] (#3239)
- Update actions/cache digest to cdf6c1f (#3282)
- Test datagen for playwright test (#3280)

Version 0.137.14 (Released February 17, 2026)
----------------

Expand Down
9 changes: 0 additions & 9 deletions authentication/backends/apisix_remote_user_org.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
from mitol.apigateway.backends import ApisixRemoteUserBackend

from b2b.api import reconcile_user_orgs
from hubspot_sync.task_helpers import sync_hubspot_user

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -43,12 +42,4 @@ def configure_user(self, request, user, *args, created=True):
# Task should check to see if it needs to run or not
reconcile_user_orgs(user, org_uuids)

if created:
log.info(
"New user created via APISIX/Keycloak, syncing to HubSpot: user_id=%s, email=%s",
user.id,
user.email,
)
sync_hubspot_user(user)

return user
12 changes: 7 additions & 5 deletions b2b/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,10 @@ class OrganizationPageFactory(wagtail_factories.PageFactory):
logo = None
sso_organization_id = LazyAttribute(lambda _: uuid4())
parent = LazyAttribute(
lambda _: OrganizationIndexPage.objects.first()
or OrganizationIndexPageFactory.create()
lambda _: (
OrganizationIndexPage.objects.first()
or OrganizationIndexPageFactory.create()
)
)
slug = LazyAttribute(lambda _: FAKE.unique.slug())

Expand All @@ -56,9 +58,9 @@ class ContractPageFactory(wagtail_factories.PageFactory):
organization = SubFactory(OrganizationPageFactory)
parent = LazyAttribute(lambda o: o.organization)
integration_type = LazyFunction(
lambda: CONTRACT_MEMBERSHIP_NONSSO
if FAKE.boolean()
else CONTRACT_MEMBERSHIP_SSO
lambda: (
CONTRACT_MEMBERSHIP_NONSSO if FAKE.boolean() else CONTRACT_MEMBERSHIP_SSO
)
)
membership_type = LazyAttribute(lambda o: o.integration_type)
slug = LazyAttribute(lambda _: FAKE.unique.slug())
Expand Down
5 changes: 5 additions & 0 deletions b2b/serializers/v0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from b2b.models import ContractPage, OrganizationPage, UserOrganization
from cms.api import get_wagtail_img_src
from main.constants import USER_MSG_TYPE_B2B_CHOICES
from main.serializers import RichTextSerializer


class ContractPageSerializer(serializers.ModelSerializer):
Expand All @@ -15,6 +16,10 @@ class ContractPageSerializer(serializers.ModelSerializer):

membership_type = serializers.CharField()
programs = serializers.SerializerMethodField()
welcome_message_extra = RichTextSerializer(
help_text=ContractPage._meta.get_field("welcome_message_extra").help_text, # noqa: SLF001, not private https://docs.djangoproject.com/en/5.0/ref/models/meta/
read_only=True,
)

@extend_schema_field(serializers.ListField(child=serializers.IntegerField()))
def get_programs(self, instance):
Expand Down
29 changes: 29 additions & 0 deletions b2b/serializers/v0/serializers_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Tests for B2B serializers (v0)."""

import re

import pytest

from b2b.factories import ContractPageFactory
from b2b.serializers.v0 import ContractPageSerializer


@pytest.mark.django_db
def test_contract_page_serializer_expands_embeds():
"""Test that welcome_message_extra expands embed tags and preserves HTML."""
contract = ContractPageFactory.create(
welcome_message_extra=(
"<p>Hello, world</p>"
'<embed embedtype="media" url="https://www.youtube.com/watch?v=_AXZSRtsASE&amp;pp=ygUSZGltaXRyaXMgYmVydHNpbWFz"/>'
)
)

serializer = ContractPageSerializer(contract)
result = serializer.data["welcome_message_extra"]
pattern = (
r"<p>Hello, world</p><div>\s*"
r'<iframe\s+[^>]*src="https://www\.youtube\.com/embed/_AXZSRtsASE\?feature=oembed"[^>]*>'
r"</iframe>\s*"
r"</div>"
)
assert re.match(pattern, result, re.DOTALL)
14 changes: 5 additions & 9 deletions cms/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ def get_optional_placeholder_values_for_courseware_type(
"min_weeks": 1,
"max_weeks": 1,
"effort": "PLACEHOLDER - 1-2 hours per week",
"length": "PLACEHOLDER - 1 week",
"min_price": 37,
"max_price": 149,
"prerequisites": "PLACEHOLDER - No prerequisites, other than a willingness to learn",
Expand Down Expand Up @@ -449,16 +450,11 @@ def create_default_courseware_page(
raise ValidationError(f"No valid index page found for {courseware}.") # noqa: B904, EM102

if isinstance(courseware, Course):
page = CoursePage(
course=courseware, **page_framework, **course_only_kwargs, **optional_kwargs
)
merged_kwargs = page_framework | course_only_kwargs | optional_kwargs
page = CoursePage(course=courseware, **merged_kwargs)
else:
page = ProgramPage(
program=courseware,
**page_framework,
**program_only_kwargs,
**optional_kwargs,
)
merged_kwargs = page_framework | program_only_kwargs | optional_kwargs
page = ProgramPage(program=courseware, **merged_kwargs)

parent_page.add_child(instance=page)

Expand Down
5 changes: 3 additions & 2 deletions cms/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,9 @@ class Meta:
class InstructorPageFactory(wagtail_factories.PageFactory):
feature_image = factory.SubFactory(wagtail_factories.ImageFactory)
parent = LazyAttribute(
lambda _: InstructorIndexPage.objects.first()
or InstructorIndexPageFactory.create()
lambda _: (
InstructorIndexPage.objects.first() or InstructorIndexPageFactory.create()
)
)
title = factory.Faker("name")
instructor_name = factory.Faker("name")
Expand Down
31 changes: 19 additions & 12 deletions cms/management/commands/create_instructor_pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@
from wagtail.images.models import Image

from cms.models import (
CoursePage,
InstructorIndexPage,
InstructorPage,
InstructorPageLink,
)
from courses.models import Course
from courses.models import Course, Program


class Command(BaseCommand):
Expand All @@ -34,9 +33,9 @@ def add_arguments(self, parser) -> None:
help="If true, generate fake data instead of real data.",
)
parser.add_argument(
"--readable-id",
"--readable-ids",
type=str,
default=None,
default="",
help="The course to link the instructor to",
)
parser.add_argument(
Expand All @@ -60,7 +59,7 @@ def add_arguments(self, parser) -> None:
parser.add_argument(
"--link-instructor-id",
type=str,
default=None,
default="",
help="If specified, skip creation and only link the instructor with this ID to the course",
)

Expand Down Expand Up @@ -116,11 +115,16 @@ def get_instructor_data(
}
return instructor_payload

def link_instructor_to_course(self, instructor_page, readable_id):
course = Course.objects.filter(readable_id=readable_id).first()
if not course:
def link_instructor_to_course_or_program(self, instructor_page, readable_id):
courseware_object = Course.objects.filter(readable_id=readable_id).first()

if not courseware_object:
courseware_object = Program.objects.filter(readable_id=readable_id).first()

if not courseware_object:
self.error(f"Could not find course with id {readable_id}")
page = CoursePage.objects.filter(course_id=course.id).first()

page = courseware_object.page
if not page:
self.error(
f"Course {readable_id} does not have a CMS page to link to. Consider creating one with create_courseware_page and rerunning with ./manage.py create_instructor_pages --link-instructor-id='{instructor_page.id}' --readable-id='{readable_id}'."
Expand All @@ -129,7 +133,9 @@ def link_instructor_to_course(self, instructor_page, readable_id):

def handle(self, *args, **options): # pylint: disable=unused-argument # noqa: ARG002
use_fake_data = options["fake"]
readable_id = options["readable_id"]
readable_ids = (
options["readable_ids"].split(",") if options["readable_ids"] else []
)
link_instructor_id = options["link_instructor_id"]
if link_instructor_id:
instructor_page = InstructorPage.objects.filter(
Expand All @@ -149,5 +155,6 @@ def handle(self, *args, **options): # pylint: disable=unused-argument # noqa:
instructor_index_page.add_child(instance=instructor_page)
instructor_page.save_revision().publish()

if readable_id:
self.link_instructor_to_course(instructor_page, readable_id)
if readable_ids:
for readable_id in readable_ids:
self.link_instructor_to_course_or_program(instructor_page, readable_id)
75 changes: 75 additions & 0 deletions cms/migrations/0055_populate_min_max_price.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Generated by Django 5.1.15 on 2026-02-03 16:52

import re

from django.db import migrations


def extract_numeric_prices(price_stream):
"""
Extracts the first and second integer found in the text of the price StreamField block.
Returns (min_price, max_price) as integers, or (None, None) if not found.
"""

if not price_stream:
return (None, None)
for block in price_stream:
text = block.value.get("text")
if text:
# Find all numbers in the string
price_nums = re.findall(r"\d+", text)
if price_nums:
min_price = int(price_nums[0])
max_price = int(price_nums[1]) if len(price_nums) > 1 else min_price
return (min_price, max_price)
return (None, None)


def populate_min_max_price(apps, schema_editor):
CoursePage = apps.get_model("cms", "CoursePage")
for course_page in CoursePage.objects.all():
min_price, max_price = extract_numeric_prices(course_page.price)
updated = False
if course_page.min_price is None:
course_page.min_price = min_price if min_price is not None else 0
updated = True
if course_page.max_price is None:
course_page.max_price = max_price if max_price is not None else 0
updated = True
if updated:
course_page.save(
update_fields=[
f
for f in ["min_price", "max_price"]
if getattr(course_page, f) is not None
]
)

ProgramPage = apps.get_model("cms", "ProgramPage")
for program in ProgramPage.objects.all():
min_price, max_price = extract_numeric_prices(program.price)
updated = False
if program.min_price is None:
program.min_price = min_price if min_price is not None else 0
updated = True
if program.max_price is None:
program.max_price = max_price if max_price is not None else 0
updated = True
if updated:
program.save(
update_fields=[
f
for f in ["min_price", "max_price"]
if getattr(program, f) is not None
]
)


class Migration(migrations.Migration):
dependencies = [
("cms", "0054_alter_programpage_description"),
]

operations = [
migrations.RunPython(populate_min_max_price, migrations.RunPython.noop),
]
44 changes: 44 additions & 0 deletions cms/migrations/0056_alter_coursepage_max_price_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Generated by Django 5.1.15 on 2026-02-12 13:33

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("cms", "0055_populate_min_max_price"),
]

operations = [
migrations.AlterField(
model_name="coursepage",
name="max_price",
field=models.SmallIntegerField(
default=0,
help_text="Specify the maximum product price. This is used by MIT Learn.",
),
),
migrations.AlterField(
model_name="coursepage",
name="min_price",
field=models.SmallIntegerField(
default=0,
help_text="Specify the minimum product price. This is used by MIT Learn.",
),
),
migrations.AlterField(
model_name="programpage",
name="max_price",
field=models.SmallIntegerField(
default=0,
help_text="Specify the maximum product price. This is used by MIT Learn.",
),
),
migrations.AlterField(
model_name="programpage",
name="min_price",
field=models.SmallIntegerField(
default=0,
help_text="Specify the minimum product price. This is used by MIT Learn.",
),
),
]
Loading