diff --git a/RELEASE.rst b/RELEASE.rst
index 718f48699c..1e39cfae2b 100644
--- a/RELEASE.rst
+++ b/RELEASE.rst
@@ -1,6 +1,28 @@
Release Notes
=============
+Version 0.131.3
+---------------
+
+- fix(deps): update dependency ramda to v0.32.0 (#2531)
+- chore(deps): update dependency authlib to v1.6.5 [security] (#3008)
+- fix: use global id for user sync filtering (#2992)
+- Revert "chore(deps): update dependency python (#2031)" (#3003)
+- Fix perf issues with the departments API (#3001)
+- Removing first and last name requirement for a PATCH request (#2998)
+- fix(deps): update django-health-check digest to 592f6a8 (#2916)
+- Revert "fix(deps): update dependency webpack-dev-server to v5 [securi⦠(#2999)
+- fix(deps): update dependency serialize-javascript to v6 [security] (#2538)
+- fix(deps): update dependency webpack-dev-server to v5 [security] (#2706)
+- login button redirect not working with apisix (#2996)
+- feat: management commands to create signatories in bulk and delete a signatory (#2986)
+- Add escape hatch to be able to disable zeal (#2995)
+- Don't raise zeal exceptions normally (#2994)
+- chore(deps): update dependency python (#2031)
+- Fix flaky test regarding v2 program api (#2987)
+- Switch from nplusone to django-zeal (#2990)
+- fix(deps): update dependency django to v4.2.25 [security] (#2991)
+
Version 0.131.2 (Released October 14, 2025)
---------------
diff --git a/b2b/views/v0/views_test.py b/b2b/views/v0/views_test.py
index a0519d711d..2db61bdf54 100644
--- a/b2b/views/v0/views_test.py
+++ b/b2b/views/v0/views_test.py
@@ -23,7 +23,7 @@
)
from users.factories import UserFactory
-pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("raise_nplusone")]
+pytestmark = [pytest.mark.django_db]
def test_b2b_contract_attachment_bad_code(user):
@@ -223,6 +223,7 @@ def test_b2b_contract_attachment_full_contract():
).exists()
+@pytest.mark.skip_nplusone_check
@pytest.mark.parametrize("user_has_edx_user", [True, False])
@pytest.mark.parametrize("has_price", [True, False])
def test_b2b_enroll(mocker, settings, user_has_edx_user, has_price):
diff --git a/cms/management/commands/bulk_populate_signatories.py b/cms/management/commands/bulk_populate_signatories.py
new file mode 100644
index 0000000000..a5b636e477
--- /dev/null
+++ b/cms/management/commands/bulk_populate_signatories.py
@@ -0,0 +1,162 @@
+"""Reads the signatories from a CSV file and populates the Signatory model in bulk."""
+
+import csv
+import logging
+from pathlib import Path
+
+import requests
+from django.core.exceptions import ValidationError
+from django.core.files.base import ContentFile
+from django.core.management.base import BaseCommand
+from wagtail.images.models import Image
+
+from cms.models import SignatoryIndexPage, SignatoryPage
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ """Bulk populate signatories from a CSV file."""
+
+ help = "Bulk populate signatories from a CSV file"
+
+ def add_arguments(self, parser):
+ """Parses command line arguments."""
+ parser.add_argument(
+ "--csv-file",
+ type=str,
+ help="Path to the CSV file containing signatory data",
+ required=True,
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Show which signatories would be created without making any changes",
+ )
+
+ def handle(self, *args, **options): # noqa: ARG002
+ """Handles the command execution."""
+ csv_file_path = options["csv_file"]
+ dry_run = options["dry_run"]
+
+ if dry_run:
+ self.stdout.write(self.style.WARNING("[DRY RUN] - No changes will be made"))
+ try:
+ with Path(csv_file_path).open("r", newline="", encoding="utf-8") as csvfile:
+ reader = csv.DictReader(csvfile)
+ for row_num, row in enumerate(reader, start=2): # Start at 2 for header
+ self.process_signatory(row, row_num, dry_run)
+
+ except FileNotFoundError:
+ self.stdout.write(self.style.ERROR(f"CSV file not found: {csv_file_path}"))
+ except Exception as e: # noqa: BLE001
+ self.stdout.write(self.style.ERROR(f"Error processing CSV: {e!s}"))
+
+ def fetch_or_create_signature_image(self, signatory_name, image_url):
+ """Fetches an image from a URL and creates a Wagtail Image instance."""
+ if not image_url:
+ return None
+ try:
+ signature_image_title = f"{signatory_name} Signature"
+ existing_image = Image.objects.filter(
+ title__icontains=signature_image_title
+ ).first()
+
+ if existing_image:
+ return existing_image
+ else:
+ response = requests.get(image_url, timeout=10)
+ response.raise_for_status()
+
+ # Extract filename from URL or create one
+ filename = Path(image_url.split("?")[0]).name
+ if not filename or "." not in filename:
+ filename = (
+ f"signatory_{signatory_name.replace(' ', '_').lower()}.jpg"
+ )
+
+ # Create Wagtail Image instance
+ image_file = ContentFile(response.content, name=filename)
+ signature_image = Image(
+ title=f"{signatory_name} Signature", file=image_file
+ )
+ signature_image.save()
+ except Exception as e: # noqa: BLE001
+ self.stdout.write(
+ self.style.WARNING(
+ f"Could not download image for '{signatory_name}': {e!s}"
+ )
+ )
+ return None
+ else:
+ return signature_image
+
+ def process_signatory(self, row, row_num, dry_run):
+ """Processes a single signatory row."""
+ name = row.get("name", "").strip()
+ signatory_title = row.get("signatory_title", "").strip()
+ signatory_image_url = row.get("signatory_image_url", "").strip()
+
+ if not name:
+ self.stdout.write(
+ self.style.WARNING(f"Row {row_num}: Skipping - no name provided")
+ )
+ return
+
+ # Check for duplicates
+ existing_signatory = SignatoryPage.objects.filter(name=name).first()
+
+ try:
+ if dry_run:
+ if existing_signatory:
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'[DRY RUN] Row {row_num}: Signatory already exists - would skip "{name}"'
+ )
+ )
+ else:
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'[DRY RUN] Row {row_num}: Would create signatory "{name}"'
+ )
+ )
+ else:
+ signature_image = self.fetch_or_create_signature_image(
+ name, signatory_image_url
+ )
+
+ if existing_signatory:
+ self.stdout.write(
+ self.style.SUCCESS(
+ f'Row {row_num}: Signatory already exists - skipping "{name}"'
+ )
+ )
+ else:
+ # Create new signatory
+ # Download and create image if URL provided
+ signatory_index_page = SignatoryIndexPage.objects.first()
+ if not signatory_index_page:
+ raise ValidationError("No SignatoryIndexPage found in the CMS.") # noqa: EM101, TRY301
+
+ signatory_page = SignatoryPage(
+ name=name,
+ title_1=signatory_title,
+ signature_image=signature_image,
+ )
+ signatory_index_page.add_child(instance=signatory_page)
+ signatory_page.save_revision().publish()
+
+ self.stdout.write(
+ self.style.SUCCESS(f'Row {row_num}: Created signatory "{name}"')
+ )
+
+ except ValidationError as e:
+ self.stdout.write(
+ self.style.ERROR(f'Row {row_num}: Validation error for "{name}": {e!s}')
+ )
+ except Exception as e: # noqa: BLE001
+ self.stdout.write(
+ self.style.ERROR(
+ f'Row {row_num}: Error processing signatory "{name}": {e!s}'
+ )
+ )
diff --git a/cms/management/commands/remove_signatory.py b/cms/management/commands/remove_signatory.py
new file mode 100644
index 0000000000..0cc8ee8df0
--- /dev/null
+++ b/cms/management/commands/remove_signatory.py
@@ -0,0 +1,92 @@
+from django.core.management.base import BaseCommand
+from wagtail.blocks import StreamValue
+
+from cms.models import CertificatePage
+
+
+class Command(BaseCommand):
+ """Django management command to remove a signatory from all certificate pages.
+
+ This command finds all certificate pages that contain a specific signatory
+ and removes that signatory from their signatories field, creating new
+ revisions and publishing the updated pages.
+ """
+
+ help = "Remove a signatory from all certificate pages and create new revisions"
+
+ def add_arguments(self, parser):
+ """Parses command line arguments."""
+ parser.add_argument(
+ "--signatory-id",
+ type=int,
+ help="ID of the signatory page to remove",
+ required=True,
+ )
+ parser.add_argument(
+ "--dry-run",
+ action="store_true",
+ help="Show which certificate pages would be updated without making changes",
+ )
+
+ def handle(self, *args, **options): # noqa: ARG002
+ """Handles the command execution."""
+ signatory_id = options["signatory_id"]
+ dry_run = options["dry_run"]
+
+ # Find all certificate pages that have this signatory
+ certificate_pages = CertificatePage.objects.all()
+ updated_count = 0
+
+ if not certificate_pages:
+ self.stdout.write(
+ self.style.WARNING(
+ f"No certificate pages found with signatory ID {signatory_id}"
+ )
+ )
+ return
+
+ for cert_page in certificate_pages:
+ signatory_blocks = list(cert_page.signatories)
+ filtered_data = [
+ {
+ "type": block.block_type,
+ "value": block.value.id, # pass the page ID, NOT the whole page instance
+ }
+ for block in signatory_blocks
+ if block.value.id != signatory_id
+ ]
+
+ if len(filtered_data) != len(signatory_blocks):
+ if dry_run:
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"[DRY RUN] Would remove signatory from certificate page {cert_page}"
+ )
+ )
+ else:
+ cert_page.signatories = StreamValue(
+ cert_page.signatories.stream_block, filtered_data, is_lazy=True
+ )
+ cert_page.save_revision().publish()
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Successfully removed signatory from the certificate page {cert_page}"
+ )
+ )
+ updated_count += 1
+
+ if updated_count == 0:
+ self.stdout.write(
+ self.style.WARNING(
+ f"No certificate pages found with signatory ID {signatory_id}"
+ )
+ )
+ else:
+ action_text = (
+ "[DRY RUN] Would remove" if dry_run else "Successfully removed"
+ )
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"{action_text} signatory from {updated_count} certificate pages"
+ )
+ )
diff --git a/courses/api_test.py b/courses/api_test.py
index a5f1eeb1eb..f9166686fc 100644
--- a/courses/api_test.py
+++ b/courses/api_test.py
@@ -51,7 +51,6 @@
ProgramCertificateFactory,
ProgramEnrollmentFactory,
ProgramFactory,
- ProgramRequirementFactory,
program_with_empty_requirements, # noqa: F401
program_with_requirements, # noqa: F401
)
@@ -239,7 +238,6 @@ def test_create_run_enrollments_multiple_programs(
)
program_with_empty_requirements.add_requirement(test_enrollment.run.course)
program2 = ProgramFactory.create()
- ProgramRequirementFactory.add_root(program2)
root_node = program2.requirements_root
root_node.add_child(
@@ -1213,7 +1211,6 @@ def test_generate_program_certificate_failure_missing_certificates(
"""
course = CourseFactory.create()
CourseRunFactory.create_batch(3, course=course)
- ProgramRequirementFactory.add_root(program_with_requirements.program)
program_with_requirements.program.add_requirement(course)
result = generate_program_certificate(
@@ -1262,7 +1259,6 @@ def test_generate_program_certificate_success_single_requirement_course(user, mo
)
course = CourseFactory.create()
program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(program)
root_node = program.requirements_root
root_node.add_child(
@@ -1295,7 +1291,6 @@ def test_generate_program_certificate_success_multiple_required_courses(user, mo
)
courses = CourseFactory.create_batch(3)
program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(program)
root_node = program.requirements_root
root_node.add_child(
@@ -1328,7 +1323,6 @@ def test_generate_program_certificate_success_minimum_electives_not_met(user, mo
# Create Program with 2 minimum elective courses.
program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(program)
root_node = program.requirements_root
root_node.add_child(
@@ -1457,7 +1451,6 @@ def test_generate_program_certificate_failure_not_all_passed_nested_elective_sti
)
# Create Program
program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(program)
root_node = program.requirements_root
root_node.add_child(
@@ -1544,12 +1537,10 @@ def test_generate_program_certificate_with_subprogram_requirement(user, mocker):
# Create a sub-program that the user will complete
sub_program = ProgramFactory.create()
sub_course = CourseFactory.create()
- ProgramRequirementFactory.add_root(sub_program)
sub_program.add_requirement(sub_course)
# Create the main program that requires the sub-program
main_program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(main_program)
main_program.add_program_requirement(sub_program)
# User completes the sub-program course and gets a certificate
@@ -1591,12 +1582,10 @@ def test_generate_program_certificate_with_subprogram_requirement_missing_certif
# Create a sub-program
sub_program = ProgramFactory.create()
sub_course = CourseFactory.create()
- ProgramRequirementFactory.add_root(sub_program)
sub_program.add_requirement(sub_course)
# Create the main program that requires the sub-program
main_program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(main_program)
main_program.add_program_requirement(sub_program)
# User does NOT complete the sub-program course (no certificate)
@@ -1622,12 +1611,10 @@ def test_generate_program_certificate_with_revoked_subprogram_certificate(user,
# Create a sub-program
sub_program = ProgramFactory.create()
sub_course = CourseFactory.create()
- ProgramRequirementFactory.add_root(sub_program)
sub_program.add_requirement(sub_course)
# Create the main program that requires the sub-program
main_program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(main_program)
main_program.add_program_requirement(sub_program)
# User completes the sub-program and gets a certificate, but it gets revoked
diff --git a/courses/conftest.py b/courses/conftest.py
index 9a36081ceb..d1b448c458 100644
--- a/courses/conftest.py
+++ b/courses/conftest.py
@@ -8,7 +8,6 @@
CourseFactory,
CourseRunFactory,
ProgramFactory,
- ProgramRequirementFactory,
)
from courses.models import (
ProgramRequirement,
@@ -84,7 +83,6 @@ def _create_course(n):
def _create_program(courses):
program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(program)
root_node = program.requirements_root
required_courses_node = root_node.add_child(
node_type=ProgramRequirementNodeType.OPERATOR,
diff --git a/courses/factories.py b/courses/factories.py
index 8506875767..70e5144774 100644
--- a/courses/factories.py
+++ b/courses/factories.py
@@ -1,6 +1,8 @@
"""Factories for creating course data in tests"""
+import json
import string # noqa: F401
+from pathlib import Path
from types import SimpleNamespace
import factory
@@ -34,13 +36,22 @@
FAKE = faker.Factory.create()
+# stub data that mirrors production
+DEPARTMENTS = {
+ dept["slug"]: dept["name"]
+ for dept in json.loads(Path("courses/test_data/departments.json").read_text())
+}
+
class DepartmentFactory(DjangoModelFactory):
- name = factory.Sequence(lambda x: f"Testing - {x} Department")
+ name = factory.LazyAttribute(lambda dept: DEPARTMENTS[dept.slug])
+ slug = factory.Iterator(DEPARTMENTS.keys())
class Meta:
model = Department
+ django_get_or_create = ("slug",)
+
class ProgramFactory(DjangoModelFactory):
"""Factory for Programs"""
@@ -55,7 +66,7 @@ class ProgramFactory(DjangoModelFactory):
def departments(self, create, extracted, **kwargs): # noqa: ARG002
if not create or not extracted:
return
- self.departments.add(*extracted)
+ self.departments.set(extracted)
class Meta:
model = Program
@@ -166,14 +177,6 @@ class ProgramRequirementFactory(DjangoModelFactory):
class Meta:
model = ProgramRequirement
- @classmethod
- def add_root(cls, program):
- if not ProgramRequirement.get_root_nodes().filter(program=program).exists():
- return ProgramRequirement.add_root(
- program=program, node_type=ProgramRequirementNodeType.PROGRAM_ROOT.value
- )
- return program.get_requirements_root()
-
class BlockedCountryFactory(DjangoModelFactory):
"""Factory for BlockedCountry"""
@@ -272,7 +275,6 @@ class Meta:
@pytest.fixture
def program_with_empty_requirements():
program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(program)
root_node = program.requirements_root
root_node.add_child(
diff --git a/courses/migrations/0068_department_related_names.py b/courses/migrations/0068_department_related_names.py
new file mode 100644
index 0000000000..cdc7022e84
--- /dev/null
+++ b/courses/migrations/0068_department_related_names.py
@@ -0,0 +1,26 @@
+# Generated by Django 4.2.25 on 2025-10-07 21:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("courses", "0067_program_courses_pro_live_e5a870_idx_and_more"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="course",
+ name="departments",
+ field=models.ManyToManyField(
+ related_name="courses", to="courses.department"
+ ),
+ ),
+ migrations.AlterField(
+ model_name="program",
+ name="departments",
+ field=models.ManyToManyField(
+ related_name="programs", to="courses.department"
+ ),
+ ),
+ ]
diff --git a/courses/models.py b/courses/models.py
index 0b07f16c44..9790cf4dbd 100644
--- a/courses/models.py
+++ b/courses/models.py
@@ -11,12 +11,12 @@
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
from django.db import models
-from django.db.models import Q
+from django.db.models import Exists, OuterRef, Prefetch, Q
from django.db.models.constraints import CheckConstraint, UniqueConstraint
from django.utils.functional import cached_property
from django.utils.text import slugify
from django_countries.fields import CountryField
-from mitol.common.models import TimestampedModel
+from mitol.common.models import TimestampedModel, TimestampedModelQuerySet
from mitol.common.utils.datetime import now_in_utc
from mitol.openedx.utils import get_course_number
from modelcluster.fields import ParentalManyToManyField
@@ -182,6 +182,32 @@ def get_queryset(self):
)
+class DepartmentQuerySet(TimestampedModelQuerySet):
+ """QuerySet for Department"""
+
+ def for_serialization(self):
+ return self.prefetch_related(
+ Prefetch(
+ "courses",
+ queryset=Course.objects.annotate(
+ has_enrollable_courserun=Exists(
+ CourseRun.objects.enrollable().filter(course_id=OuterRef("pk"))
+ ),
+ )
+ .filter(
+ live=True,
+ page__live=True,
+ has_enrollable_courserun=True,
+ )
+ .only("id"),
+ ),
+ Prefetch(
+ "programs",
+ queryset=Program.objects.filter(live=True, page__live=True).only("id"),
+ ),
+ )
+
+
class Department(TimestampedModel):
"""
Departments.
@@ -190,6 +216,8 @@ class Department(TimestampedModel):
name = models.CharField(max_length=128, unique=True)
slug = models.SlugField(max_length=128, unique=True)
+ objects = DepartmentQuerySet.as_manager()
+
def __str__(self):
return self.name
@@ -222,7 +250,9 @@ class Meta:
blank=True,
null=True,
)
- departments = models.ManyToManyField(Department, blank=False)
+ departments = models.ManyToManyField(
+ Department, blank=False, related_name="programs"
+ )
availability = models.CharField(
choices=AVAILABILITY_CHOICES, default=AVAILABILITY_ANYTIME, max_length=255
)
@@ -821,7 +851,9 @@ class Meta:
max_length=255, unique=True, validators=[validate_url_path_field]
)
live = models.BooleanField(default=False, db_index=True)
- departments = models.ManyToManyField(Department, blank=False)
+ departments = models.ManyToManyField(
+ Department, blank=False, related_name="courses"
+ )
flexible_prices = GenericRelation(
"flexiblepricing.FlexiblePrice",
object_id_field="courseware_object_id",
diff --git a/courses/models_test.py b/courses/models_test.py
index 4a7117b9a8..57eac49354 100644
--- a/courses/models_test.py
+++ b/courses/models_test.py
@@ -22,7 +22,6 @@
ProgramCertificateFactory,
ProgramEnrollmentFactory,
ProgramFactory,
- ProgramRequirementFactory,
program_with_empty_requirements, # noqa: F401
program_with_requirements, # noqa: F401
)
@@ -1028,7 +1027,6 @@ def test_program_minimum_elective_courses_requirement():
"""Tests to make sure the related programs functionality in the model works."""
minimum_elective_required = 5
program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(program)
root_node = program.requirements_root
root_node.add_child(
@@ -1050,7 +1048,6 @@ def test_program_minimum_elective_courses_requirement():
def test_program_minimum_elective_courses_requirement_no_elective_node():
"""Tests to make sure the related programs functionality in the model works."""
program = ProgramFactory.create()
- ProgramRequirementFactory.add_root(program)
root_node = program.requirements_root
root_node.add_child(
diff --git a/courses/serializers/v1/departments.py b/courses/serializers/v1/departments.py
index ecd120acac..f2ede677b3 100644
--- a/courses/serializers/v1/departments.py
+++ b/courses/serializers/v1/departments.py
@@ -14,8 +14,8 @@ class Meta:
class DepartmentWithCountSerializer(DepartmentSerializer):
"""CourseRun model serializer that includes the number of courses and programs associated with each departments"""
- courses = serializers.IntegerField()
- programs = serializers.IntegerField()
+ courses = serializers.IntegerField(source="courses_count")
+ programs = serializers.IntegerField(source="program_count")
class Meta:
model = models.Department
diff --git a/courses/serializers/v2/departments.py b/courses/serializers/v2/departments.py
index 62dd38f545..923ce4a030 100644
--- a/courses/serializers/v2/departments.py
+++ b/courses/serializers/v2/departments.py
@@ -1,7 +1,7 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
-from courses.models import CourseRun, Department
+from courses.models import Department
class DepartmentSerializer(serializers.ModelSerializer):
@@ -18,7 +18,7 @@ class DepartmentWithCoursesAndProgramsSerializer(DepartmentSerializer):
course_ids = serializers.SerializerMethodField()
program_ids = serializers.SerializerMethodField()
- @extend_schema_field(serializers.ListField)
+ @extend_schema_field(serializers.ListField(child=serializers.IntegerField()))
def get_course_ids(self, instance):
"""
Returns a list of course IDs associated with courses which are live and
@@ -32,19 +32,9 @@ def get_course_ids(self, instance):
Returns:
list: Course IDs associated with the Department.
"""
- related_courses = instance.course_set.filter(live=True, page__live=True)
- relevant_courseruns = (
- CourseRun.objects.enrollable()
- .filter(course__in=related_courses)
- .values_list("id", flat=True)
- )
- return (
- related_courses.filter(courseruns__id__in=relevant_courseruns)
- .distinct()
- .values_list("id", flat=True)
- )
+ return [course.id for course in instance.courses.all()]
- @extend_schema_field(serializers.ListField)
+ @extend_schema_field(serializers.ListField(child=serializers.IntegerField()))
def get_program_ids(self, instance):
"""
Returns a list of program IDs associated with the department
@@ -56,11 +46,7 @@ def get_program_ids(self, instance):
Returns:
list: Program IDs associated with the Department.
"""
- return (
- instance.program_set.filter(live=True, page__live=True)
- .distinct()
- .values_list("id", flat=True)
- )
+ return [program.id for program in instance.programs.all()]
class Meta:
model = Department
diff --git a/courses/test_data/departments.json b/courses/test_data/departments.json
new file mode 100644
index 0000000000..a48fd338e8
--- /dev/null
+++ b/courses/test_data/departments.json
@@ -0,0 +1,90 @@
+[
+ {
+ "name": "Aeronautics and Astronautics",
+ "slug": "aeronautics-and-astronautics"
+ },
+ {
+ "name": "Anthropology",
+ "slug": "anthropology"
+ },
+ {
+ "name": "Biology",
+ "slug": "biology"
+ },
+ {
+ "name": "Brain and Cognitive Sciences",
+ "slug": "brain-and-cognitive-sciences"
+ },
+ {
+ "name": "Center for Transportation and Logistics",
+ "slug": "center-for-transportation-and-logistics"
+ },
+ {
+ "name": "Chemical Engineering",
+ "slug": "chemical-engineering"
+ },
+ {
+ "name": "Chemistry",
+ "slug": "chemistry"
+ },
+ {
+ "name": "Economics",
+ "slug": "economics"
+ },
+ {
+ "name": "Electrical Engineering and Computer Science",
+ "slug": "electrical-engineering-and-computer-science"
+ },
+ {
+ "name": "Global Studies and Languages",
+ "slug": "global-studies-and-languages"
+ },
+ {
+ "name": "History",
+ "slug": "history"
+ },
+ {
+ "name": "Humanities",
+ "slug": "humanities"
+ },
+ {
+ "name": "Linguistics and Philosophy",
+ "slug": "linguistics-and-philosophy"
+ },
+ {
+ "name": "Literature",
+ "slug": "literature"
+ },
+ {
+ "name": "Management",
+ "slug": "management"
+ },
+ {
+ "name": "Materials Science and Engineering",
+ "slug": "materials-science-and-engineering"
+ },
+ {
+ "name": "Mathematics",
+ "slug": "mathematics"
+ },
+ {
+ "name": "Mechanical Engineering",
+ "slug": "mechanical-engineering"
+ },
+ {
+ "name": "Music and Theater Arts",
+ "slug": "music-and-theater-arts"
+ },
+ {
+ "name": "Physics",
+ "slug": "physics"
+ },
+ {
+ "name": "Political Science",
+ "slug": "political-science"
+ },
+ {
+ "name": "Urban Studies and Planning",
+ "slug": "urban-studies-and-planning"
+ }
+]
diff --git a/courses/views/v1/__init__.py b/courses/views/v1/__init__.py
index d5afaa34a3..902ae35bdf 100644
--- a/courses/views/v1/__init__.py
+++ b/courses/views/v1/__init__.py
@@ -710,7 +710,7 @@ class DepartmentViewSet(viewsets.ReadOnlyModelViewSet):
def get_queryset(self):
return Department.objects.annotate(
- courses=Count("course"), programs=Count("program")
+ course_count=Count("courses"), program_count=Count("programs")
)
@extend_schema(
diff --git a/courses/views/v1/views_test.py b/courses/views/v1/views_test.py
index 9a57cde15d..7b84128458 100644
--- a/courses/views/v1/views_test.py
+++ b/courses/views/v1/views_test.py
@@ -52,7 +52,7 @@
from main.utils import encode_json_cookie_value
from openedx.exceptions import NoEdxApiAuthError
-pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("raise_nplusone")]
+pytestmark = [pytest.mark.django_db]
EXAMPLE_URL = "http://example.com"
@@ -146,6 +146,7 @@ def test_delete_program(
assert resp.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
+@pytest.mark.skip_nplusone_check
@pytest.mark.parametrize("course_catalog_course_count", [100], indirect=True)
@pytest.mark.parametrize("course_catalog_program_count", [15], indirect=True)
def test_get_courses(
@@ -301,6 +302,7 @@ def test_delete_course(
assert resp.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
+@pytest.mark.skip_nplusone_check
def test_get_course_runs(user_drf_client, course_runs, django_assert_max_num_queries):
"""Test the view that handles requests for all CourseRuns"""
with django_assert_max_num_queries(38) as context:
@@ -419,6 +421,7 @@ def test_delete_course_run(user_drf_client, course_runs, django_assert_max_num_q
assert resp.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
+@pytest.mark.skip_nplusone_check
def test_user_enrollments_list(user_drf_client, user):
"""The user enrollments view should return serialized enrollments for the logged-in user"""
assert UserEnrollmentsApiViewSet.serializer_class == CourseRunEnrollmentSerializer
@@ -740,6 +743,7 @@ def test_update_user_enrollment_failure(
patched_log_exception.assert_called_once()
+@pytest.mark.skip_nplusone_check
def test_program_enrollments(user_drf_client, user, programs):
"""
Tests the program enrollments API, which should show the user's enrollment
diff --git a/courses/views/v2/__init__.py b/courses/views/v2/__init__.py
index f01fd7d415..d5d5ce90bd 100644
--- a/courses/views/v2/__init__.py
+++ b/courses/views/v2/__init__.py
@@ -148,6 +148,7 @@ def get_queryset(self):
queryset=ProgramCollection.objects.only("id", "title"),
),
)
+ .order_by("title")
)
@extend_schema(
@@ -301,7 +302,7 @@ class DepartmentViewSet(viewsets.ReadOnlyModelViewSet):
permission_classes = []
def get_queryset(self):
- return Department.objects.all().order_by("name")
+ return Department.objects.for_serialization().order_by("name")
@extend_schema(
operation_id="departments_retrieve_v2",
diff --git a/courses/views/v2/views_test.py b/courses/views/v2/views_test.py
index e7dd7e026b..96a862855f 100644
--- a/courses/views/v2/views_test.py
+++ b/courses/views/v2/views_test.py
@@ -50,11 +50,12 @@
from main.test_utils import assert_drf_json_equal, duplicate_queries_check
from users.factories import UserFactory
-pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("raise_nplusone")]
+pytestmark = [pytest.mark.django_db]
logger = logging.getLogger(__name__)
faker = Faker()
+@pytest.mark.skip_nplusone_check
@pytest.mark.parametrize("course_catalog_course_count", [100], indirect=True)
@pytest.mark.parametrize("course_catalog_program_count", [12], indirect=True)
def test_get_programs(
@@ -194,6 +195,7 @@ def test_delete_program(
assert resp.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
+@pytest.mark.skip_nplusone_check
@pytest.mark.usefixtures("course_catalog_data")
@pytest.mark.parametrize("course_catalog_course_count", [100], indirect=True)
@pytest.mark.parametrize("course_catalog_program_count", [12], indirect=True)
@@ -351,6 +353,7 @@ def test_filter_with_org_id_user_not_associated_with_org_returns_no_courses(
@pytest.mark.django_db
+@pytest.mark.skip_nplusone_check
def test_filter_without_org_id_authenticated_user(user_drf_client):
course_with_contract = CourseFactory(title="Contract Course")
contract = ContractPageFactory(active=True)
@@ -573,6 +576,7 @@ def test_next_run_id_with_org_filter( # noqa: PLR0915
assert resp.status_code == 404
+@pytest.mark.skip_nplusone_check
def test_user_enrollments_b2b_organization_filter(user_drf_client, user):
"""Test that user enrollments can be filtered by B2B organization ID"""
diff --git a/ecommerce/api_test.py b/ecommerce/api_test.py
index c12e13321c..f0d3db460e 100644
--- a/ecommerce/api_test.py
+++ b/ecommerce/api_test.py
@@ -410,6 +410,7 @@ def test_unenrollment_unenrolls_learner(mocker, user):
unenroll_mock.assert_called()
+@pytest.mark.skip_nplusone_check
def test_process_cybersource_payment_response( # noqa: PLR0913
settings, rf, mocker, user_client, user, products
):
@@ -446,6 +447,7 @@ def test_process_cybersource_payment_response( # noqa: PLR0913
assert result == OrderStatus.FULFILLED
+@pytest.mark.skip_nplusone_check
@pytest.mark.parametrize("include_discount", [True, False])
def test_process_cybersource_payment_decline_response( # noqa: PLR0913
rf, mocker, user_client, user, products, include_discount
diff --git a/ecommerce/mail_api_test.py b/ecommerce/mail_api_test.py
index 07be434a48..a490543fb8 100644
--- a/ecommerce/mail_api_test.py
+++ b/ecommerce/mail_api_test.py
@@ -15,6 +15,7 @@ def products():
return ProductFactory.create_batch(5)
+@pytest.mark.skip_nplusone_check
def test_mail_api_task_called( # noqa: PLR0913
settings, mocker, user, products, user_client, django_capture_on_commit_callbacks
):
@@ -36,6 +37,7 @@ def test_mail_api_task_called( # noqa: PLR0913
assert mock_delayed_send_ecommerce_order_receipt.call_args[0][0] == order.id
+@pytest.mark.skip_nplusone_check
def test_mail_api_receipt_generation( # noqa: PLR0913
settings, mocker, user, products, user_client, django_capture_on_commit_callbacks
):
@@ -64,6 +66,7 @@ def test_mail_api_receipt_generation( # noqa: PLR0913
assert str(lines[0].unit_price) in rendered_template.body
+@pytest.mark.skip_nplusone_check
def test_mail_api_refund_email_generation(
settings, mocker, user, products, user_client
):
diff --git a/ecommerce/serializers_test.py b/ecommerce/serializers_test.py
index 7aec397e84..d18c280c4c 100644
--- a/ecommerce/serializers_test.py
+++ b/ecommerce/serializers_test.py
@@ -353,6 +353,7 @@ def get_receipt_serializer_test_data(mocker, user, products, user_client):
return (order, test_data)
+@pytest.mark.skip_nplusone_check
def test_order_receipt_purchase_serializer(
settings, mocker, user, products, user_client
):
@@ -366,6 +367,7 @@ def test_order_receipt_purchase_serializer(
assert serialized_data == test_data["receipt"]
+@pytest.mark.skip_nplusone_check
def test_order_receipt_purchaser_serializer(
settings, mocker, user, products, user_client
):
@@ -379,6 +381,7 @@ def test_order_receipt_purchaser_serializer(
assert serialized_data == test_data["purchaser"]
+@pytest.mark.skip_nplusone_check
def test_order_receipt_order_serializer(settings, mocker, user, products, user_client):
settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105
(order, test_data) = get_receipt_serializer_test_data(
@@ -391,6 +394,7 @@ def test_order_receipt_order_serializer(settings, mocker, user, products, user_c
assert serialized_data == test_data["order"]
+@pytest.mark.skip_nplusone_check
def test_order_receipt_lines_serializer(settings, mocker, user, products, user_client):
settings.OPENEDX_SERVICE_WORKER_API_TOKEN = "mock_api_token" # noqa: S105
(order, test_data) = get_receipt_serializer_test_data(
diff --git a/ecommerce/tasks_test.py b/ecommerce/tasks_test.py
index a343c453ed..473ece8d23 100644
--- a/ecommerce/tasks_test.py
+++ b/ecommerce/tasks_test.py
@@ -15,6 +15,7 @@ def products():
return ProductFactory.create_batch(5)
+@pytest.mark.skip_nplusone_check
def test_delayed_order_receipt_sends_email( # noqa: PLR0913
settings, mocker, user, products, user_client, django_capture_on_commit_callbacks
):
@@ -35,6 +36,7 @@ def test_delayed_order_receipt_sends_email( # noqa: PLR0913
mock_send_ecommerce_order_receipt.assert_called()
+@pytest.mark.skip_nplusone_check
def test_delayed_order_refund_sends_email(
settings, mocker, user, products, user_client
):
diff --git a/ecommerce/views_test.py b/ecommerce/views_test.py
index 80c129971f..b96765cc45 100644
--- a/ecommerce/views_test.py
+++ b/ecommerce/views_test.py
@@ -89,6 +89,7 @@ def mock_create_run_enrollments(request, mocker):
)
+@pytest.mark.skip_nplusone_check
def test_list_products(user_drf_client, products):
resp = user_drf_client.get(reverse("products_api-list"), {"l": 10, "o": 0})
resp_products = sorted(resp.json()["results"], key=op.itemgetter("id"))
@@ -438,6 +439,7 @@ def test_redeem_time_limited_discount( # noqa: PLR0913
assert "not found" in resp_json
+@pytest.mark.skip_nplusone_check
def test_start_checkout(user, user_drf_client, products):
"""
Hits the start checkout view, which should create an Order record
@@ -455,6 +457,7 @@ def test_start_checkout(user, user_drf_client, products):
assert order.state == OrderStatus.PENDING
+@pytest.mark.skip_nplusone_check
def test_start_checkout_with_discounts(user, user_drf_client, products, discounts):
"""
Applies a discount, then hits the start checkout view, which should create
@@ -499,6 +502,7 @@ def test_start_checkout_with_invalid_discounts(user, user_client, products, disc
assert resp.status_code == 302
+@pytest.mark.skip_nplusone_check
@pytest.mark.parametrize(
"apply_discount",
[
@@ -549,6 +553,7 @@ def test_start_checkout_with_discounts_and_b2b(
assert resp.status_code == 302
+@pytest.mark.skip_nplusone_check
@pytest.mark.parametrize(
"decision, expected_redirect_url, expected_state, basket_exists", # noqa: PT006
[
@@ -811,6 +816,7 @@ def test_discount_rest_api(admin_drf_client, user_drf_client):
assert Discount.objects.filter(pk=discount_payload["id"]).count() == 0
+@pytest.mark.skip_nplusone_check
def test_discount_redemptions_api(
user, products, discounts, admin_drf_client, user_drf_client
):
@@ -895,6 +901,7 @@ def test_user_discounts_api(user_drf_client, admin_drf_client, discounts, user):
assert data["count"] > 0
+@pytest.mark.skip_nplusone_check
def test_paid_and_unpaid_courserun_checkout(
settings, user, user_client, user_drf_client, products
):
@@ -931,6 +938,7 @@ def test_paid_and_unpaid_courserun_checkout(
assert resp.status_code == 200
+@pytest.mark.skip_nplusone_check
@pytest.mark.parametrize(
"decision, expected_state, basket_exists", # noqa: PT006
[
@@ -1011,6 +1019,7 @@ def test_checkout_api_result( # noqa: PLR0913
assert Basket.objects.filter(id=basket.id).exists() is basket_exists
+@pytest.mark.skip_nplusone_check
def test_checkout_api_result_verification_failure(
user_client,
api_client,
@@ -1043,6 +1052,7 @@ def test_checkout_api_result_verification_failure(
assert resp.status_code == 403
+@pytest.mark.skip_nplusone_check
@pytest.mark.parametrize(
"upgrade_deadline, status_code", # noqa: PT006
[
@@ -1077,6 +1087,7 @@ def test_non_upgradable_courserun_checkout( # noqa: PLR0913
)
+@pytest.mark.skip_nplusone_check
def test_start_checkout_with_zero_value(settings, user, user_client, products):
"""
Check that the checkout redirects the user to dashboard when basket price is zero
@@ -1101,6 +1112,7 @@ def test_start_checkout_with_zero_value(settings, user, user_client, products):
)
+@pytest.mark.skip_nplusone_check
def test_start_checkout_and_ensure_edx_username_created(mocker, settings, products):
"""
Check that checking out with a user that doesn't have an edx username
diff --git a/fixtures/common.py b/fixtures/common.py
index 7f611a0118..e00953d3fc 100644
--- a/fixtures/common.py
+++ b/fixtures/common.py
@@ -6,8 +6,8 @@
import pytest
import responses
from django.test.client import Client
-from nplusone.core import profiler
from rest_framework.test import APIClient
+from zeal import zeal_ignore
from users.factories import UserFactory
@@ -136,12 +136,6 @@ def user_profile_dict():
)
-@pytest.fixture
-def nplusone_fail(settings):
- """Configures the nplusone app to raise errors"""
- settings.NPLUSONE_RAISE = True
-
-
@pytest.fixture(autouse=True)
def webpack_stats(settings):
"""Mocks out webpack stats"""
@@ -156,10 +150,11 @@ def webpack_stats(settings):
)
-@pytest.fixture
-def raise_nplusone(request):
- if request.node.get_closest_marker("skip_nplusone"):
- yield
- else:
- with profiler.Profiler():
+@pytest.fixture(autouse=True)
+def check_nplusone(request):
+ """Raise nplusone errors"""
+ if request.node.get_closest_marker("skip_nplusone_check"):
+ with zeal_ignore():
yield
+ else:
+ yield
diff --git a/flexiblepricing/api_test.py b/flexiblepricing/api_test.py
index cb4e81f0b4..d304114f7b 100644
--- a/flexiblepricing/api_test.py
+++ b/flexiblepricing/api_test.py
@@ -22,7 +22,6 @@
CourseFactory,
CourseRunFactory,
ProgramFactory,
- ProgramRequirementFactory,
ProgramRunFactory,
)
from courses.models import (
@@ -170,7 +169,6 @@ def create_courseware(create_tiers=True, past=False): # noqa: FBT002
"""
end_date = None
program = ProgramFactory.create(live=True)
- ProgramRequirementFactory.add_root(program)
root_node = program.requirements_root
root_node.add_child(
diff --git a/flexiblepricing/views_test.py b/flexiblepricing/views_test.py
index 623ce075bd..458bf267e4 100644
--- a/flexiblepricing/views_test.py
+++ b/flexiblepricing/views_test.py
@@ -81,6 +81,7 @@ def test_basic_exchange_rates(user_drf_client, exchange_rate):
assert len(resp.json()) >= 2
+@pytest.mark.skip_nplusone_check
def test_basic_flex_payments(
user_drf_client, admin_drf_client, user, flexible_price_application, mocker
):
diff --git a/frontend/public/package.json b/frontend/public/package.json
index e1e4175841..496b8ae410 100644
--- a/frontend/public/package.json
+++ b/frontend/public/package.json
@@ -79,7 +79,7 @@
"prop-types": "15.8.1",
"query-string": "6.14.1",
"raf": "3.4.1",
- "ramda": "0.26.1",
+ "ramda": "0.32.0",
"react": "16.14.0",
"react-addons-shallow-compare": "15.6.3",
"react-day-picker": "7.4.10",
@@ -111,7 +111,7 @@
"sass": "1.49.11",
"sass-lint": "1.13.1",
"sass-loader": "12.6.0",
- "serialize-javascript": "3.1.0",
+ "serialize-javascript": "6.0.2",
"shelljs": "0.8.5",
"sinon": "4.5.0",
"slick-carousel": "^1.8.1",
diff --git a/frontend/public/src/containers/pages/login/LoginSso.js b/frontend/public/src/containers/pages/login/LoginSso.js
index e0fe02f646..4867d9bd77 100644
--- a/frontend/public/src/containers/pages/login/LoginSso.js
+++ b/frontend/public/src/containers/pages/login/LoginSso.js
@@ -1,9 +1,22 @@
// @flow
-import React from "react"
-import { Redirect } from "react-router-dom"
+import React, { useEffect } from "react"
+import qs from "query-string"
const LoginSso = () => {
- return