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 .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.15.12"
rev: "v0.15.13"
hooks:
- id: ruff-format
- id: ruff
Expand Down
10 changes: 10 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
Release Notes
=============

Version 1.150.8
---------------

- Add language handling flags for b2b_courserun (#3610)
- Fix issues with learner record api (#3600)
- fix: handle missing signature image in CertificatePage signatory_items (#3608)
- fix: add audit trail on first enrollment as well (#3589)
- [pre-commit.ci] pre-commit autoupdate (#3588)
- Update dependency webpack-dev-server to v5.2.4 [SECURITY] (#3591)

Version 1.150.7 (Released May 26, 2026)
---------------

Expand Down
23 changes: 22 additions & 1 deletion b2b/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ def _get_source_runs_for_course(
course: Course,
*,
require_designated: bool = True,
ignore_langs: bool = True,
only_lang: str | None = None,
) -> list[CourseRun]:
"""
Return the set of source runs for a course.
Expand All @@ -289,6 +291,10 @@ def _get_source_runs_for_course(
course: The course to inspect.
require_designated: If True, raise SourceCourseIncompleteError when no
source run exists. If False, fall back to the newest non-B2B run.
ignore_langs: If True, only pull the source run that has `is_primary_language`
set or has the language code set to empty string.
only_lang: If set, only add the specified additional language (plus the
default)
Returns:
List of distinct source CourseRun objects, one per language (or one
for the "no language" legacy case).
Expand Down Expand Up @@ -324,13 +330,21 @@ def _get_source_runs_for_course(
msg = f"No source run found for {course}."
raise SourceCourseIncompleteError(msg)

if ignore_langs:
return [primary_source_run]

# Pull all the other source runs for the course and run tag combo
source_runs = (
CourseRun.all_objects.filter(course=course)
.filter(is_source_run=True, run_tag=primary_source_run.run_tag)
.all()
)

if only_lang:
source_runs = source_runs.filter(
Q(language=only_lang) | (Q(language="") | Q(is_primary_language=True))
)

seen_languages: list = []
filtered_run_list = {}
for run in source_runs:
Expand Down Expand Up @@ -358,6 +372,8 @@ def create_contract_run( # noqa: PLR0913
org_prefix: str | None = UAI_COURSEWARE_ID_PREFIX,
no_reruns: bool = False,
queue_codes: bool = False,
ignore_langs: bool = False,
only_lang: str | None = None,
) -> list[tuple[CourseRun, Product]]:
"""
Create run(s) for the specified contract.
Expand Down Expand Up @@ -405,13 +421,18 @@ def create_contract_run( # noqa: PLR0913
org_prefix (str): Organization prefix. For UAI courses, this should be "UAI_".
no_reruns (bool): Don't rerun the course - raise an exception instead.
queue_codes (bool): Queue enrollment code generation after saving.
ignore_langs (bool): Only create a run for the primary language.
only_lang (str|None): Only create a run for the primary language and the specified one.
Returns:
list[tuple[CourseRun, Product]]: One (CourseRun, Product) pair per
source language run. Legacy single-language courses produce a one-element
list.
"""
source_runs = _get_source_runs_for_course(
course, require_designated=require_designated_source_run
course,
require_designated=require_designated_source_run,
ignore_langs=ignore_langs,
only_lang=only_lang,
)

content_type = ContentType.objects.filter(
Expand Down
28 changes: 25 additions & 3 deletions b2b/management/commands/b2b_courseware.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,16 @@ class Command(BaseCommand):
Specifying a program will only unlink the program from the contract, unless "--remove-program-runs" is set. If it is, then all the runs that belong to both the contract and the program's courses will be removed from the contract. Note that doing this and then re-adding the program will *not* re-attach the existing runs to the contract - you will need to do that manually.
"""

def create_run(
def create_run( # noqa: PLR0913
self,
contract,
courseware,
*,
skip_edx=False,
org_prefix=None,
no_reruns=False,
ignore_langs=False,
only_lang=None,
):
"""Create a run for the specified contract."""
try:
Expand All @@ -77,6 +79,8 @@ def create_run(
skip_edx=skip_edx,
org_prefix=org_prefix,
no_reruns=no_reruns,
ignore_langs=ignore_langs,
only_lang=only_lang,
)
except InvalidKeyError:
self.stderr.write(
Expand Down Expand Up @@ -168,6 +172,16 @@ def add_arguments(self, parser):
action="store_true",
help="Create enrollment codes after adding course(s) to the contract. (Skips this by default; run b2b_codes validate afterward if the contract requires codes.)",
)
add_subparser.add_argument(
"--no-lang",
action="store_true",
help="Ignore languages - only create runs for the primary language (or the blank language)",
)
add_subparser.add_argument(
"--lang",
type=str,
help="Include the default and the specified language code only.",
)

remove_subparser = subparsers.add_parser(
"remove",
Expand All @@ -182,7 +196,7 @@ def add_arguments(self, parser):

return super().add_arguments(parser)

def handle_add(self, contract, coursewares, **kwargs): # noqa: C901
def handle_add(self, contract, coursewares, **kwargs): # noqa: C901, PLR0915
"""Handle the add subcommand."""

skip_edx = kwargs.pop("no_create_runs", False)
Expand All @@ -191,6 +205,8 @@ def handle_add(self, contract, coursewares, **kwargs): # noqa: C901
org_prefix = kwargs.pop("prefix")
make_codes = kwargs.pop("make_codes", False)
no_reruns = not kwargs.pop("allow_reruns", True)
ignore_langs = kwargs.pop("no_lang", False)
only_lang = kwargs.pop("lang", None)

managed = 0

Expand Down Expand Up @@ -250,7 +266,11 @@ def handle_add(self, contract, coursewares, **kwargs): # noqa: C901
)

prog_add, prog_no_source = contract.add_program_courses(
courseware, skip_edx=skip_edx, no_reruns=no_reruns
courseware,
skip_edx=skip_edx,
no_reruns=no_reruns,
ignore_langs=ignore_langs,
only_lang=only_lang,
)
if prog_no_source > 0:
self.stdout.write(
Expand Down Expand Up @@ -300,6 +320,8 @@ def handle_add(self, contract, coursewares, **kwargs): # noqa: C901
skip_edx=skip_edx,
org_prefix=org_prefix,
no_reruns=no_reruns,
ignore_langs=ignore_langs,
only_lang=only_lang,
):
# This is a course, so create a run (unless we've been told not to).

Expand Down
188 changes: 188 additions & 0 deletions b2b/management/tests/b2b_courseware_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,3 +262,191 @@ def test_add_courserun_existing_contract(force):

run.refresh_from_db()
assert run.b2b_contract == (contract if force else existing_contract)


@pytest.mark.parametrize(
"explicit_primary",
[
True,
False,
],
)
def test_add_course_ignore_langs(mock_clone_courserun, explicit_primary):
"""Test that adding a single course with translations works still if we ignore the translations."""

primary_opts = {
"language": "en" if explicit_primary else "",
"is_primary_language": explicit_primary,
}

contract = ContractPageFactory.create()
run = CourseRunFactory.create(
is_source_run=True,
**primary_opts,
)
_add_run_languages(run)

command = b2b_courseware.Command()

command.handle(
subcommand="add",
contract=str(contract.id),
courseware=str(run.course.readable_id),
allow_reruns=True,
force=False,
can_import="",
prefix="",
make_code=False,
no_lang=True,
)

assert contract.get_course_runs().count() == 1


@pytest.mark.parametrize(
"explicit_primary",
[
True,
False,
],
)
def test_add_program_ignore_langs(mock_clone_courserun, explicit_primary):
"""Test that adding a program with translations works still if we ignore the translations."""

primary_opts = {
"language": "en" if explicit_primary else "",
"is_primary_language": explicit_primary,
}

contract = ContractPageFactory.create()
run = CourseRunFactory.create(
is_source_run=True,
**primary_opts,
)
_add_run_languages(run)

run2 = CourseRunFactory.create(
is_source_run=True,
**primary_opts,
)
_add_run_languages(run2)

program = ProgramFactory.create()
program.add_requirement(run.course)
program.add_requirement(run2.course)

command = b2b_courseware.Command()

command.handle(
subcommand="add",
contract=str(contract.id),
courseware=str(program.readable_id),
allow_reruns=True,
force=False,
can_import="",
prefix="",
make_code=False,
no_lang=True,
)

assert contract.get_course_runs().count() == 2
assert contract.get_course_runs().filter(course=run.course).count() == 1
assert contract.get_course_runs().filter(course=run2.course).count() == 1


@pytest.mark.parametrize(
"explicit_primary",
[
True,
False,
],
)
def test_add_course_specific_lang(mock_clone_courserun, explicit_primary):
"""Test that adding a single course with translations works still if we ignore the translations."""

primary_opts = {
"language": "en" if explicit_primary else "",
"is_primary_language": explicit_primary,
}

contract = ContractPageFactory.create()
run = CourseRunFactory.create(
is_source_run=True,
**primary_opts,
)
_add_run_languages(run)

command = b2b_courseware.Command()

command.handle(
subcommand="add",
contract=str(contract.id),
courseware=str(run.course.readable_id),
allow_reruns=True,
force=False,
can_import="",
prefix="",
make_code=False,
lang="sw",
)

assert contract.get_course_runs().count() == 2
assert contract.get_course_runs().filter(language="sw").exists()


@pytest.mark.parametrize(
"explicit_primary",
[
True,
False,
],
)
def test_add_program_specific_lang(mock_clone_courserun, explicit_primary):
"""Test that adding a program with translations works still if we ignore the translations."""

primary_opts = {
"language": "en" if explicit_primary else "",
"is_primary_language": explicit_primary,
}

contract = ContractPageFactory.create()
run = CourseRunFactory.create(
is_source_run=True,
**primary_opts,
)
_add_run_languages(run)

run2 = CourseRunFactory.create(
is_source_run=True,
**primary_opts,
)
_add_run_languages(run2)

program = ProgramFactory.create()
program.add_requirement(run.course)
program.add_requirement(run2.course)

command = b2b_courseware.Command()

command.handle(
subcommand="add",
contract=str(contract.id),
courseware=str(program.readable_id),
allow_reruns=True,
force=False,
can_import="",
prefix="",
make_code=False,
lang="sw",
)

assert contract.get_course_runs().count() == 4
assert contract.get_course_runs().filter(course=run.course).count() == 2
assert (
contract.get_course_runs().filter(course=run.course, language="sw").count() == 1
)
assert contract.get_course_runs().filter(course=run2.course).count() == 2
assert (
contract.get_course_runs().filter(course=run2.course, language="sw").count()
== 1
)
Loading
Loading