Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4735932
fix(deps): update dependency django to v4.2.25 [security] (#2991)
renovate[bot] Oct 2, 2025
ccb4127
Switch from nplusone to django-zeal (#2990)
rhysyngsun Oct 3, 2025
ac5af59
Fix flaky test regarding v2 program api (#2987)
cp-at-mit Oct 6, 2025
c417e00
chore(deps): update dependency python (#2031)
renovate[bot] Oct 6, 2025
2b00d1d
Don't raise zeal exceptions normally (#2994)
rhysyngsun Oct 6, 2025
85e8e2c
Add escape hatch to be able to disable zeal (#2995)
rhysyngsun Oct 6, 2025
d37a528
feat: management commands to create signatories in bulk and delete a …
arslanashraf7 Oct 7, 2025
cae19fb
login button redirect not working with apisix (#2996)
cp-at-mit Oct 7, 2025
82fbc6d
fix(deps): update dependency webpack-dev-server to v5 [security] (#2706)
renovate[bot] Oct 7, 2025
8d34cbd
fix(deps): update dependency serialize-javascript to v6 [security] (#…
renovate[bot] Oct 7, 2025
7d98161
Revert "fix(deps): update dependency webpack-dev-server to v5 [securi…
cp-at-mit Oct 7, 2025
c618969
fix(deps): update django-health-check digest to 592f6a8 (#2916)
renovate[bot] Oct 7, 2025
320ba6a
Removing first and last name requirement for a PATCH request (#2998)
annagav Oct 8, 2025
7054ed6
Fix perf issues with the departments API (#3001)
rhysyngsun Oct 8, 2025
d9a8503
Revert "chore(deps): update dependency python (#2031)" (#3003)
rhysyngsun Oct 8, 2025
6845e1d
fix: use global id for user sync filtering (#2992)
asajjad2 Oct 10, 2025
eb8d80d
chore(deps): update dependency authlib to v1.6.5 [security] (#3008)
renovate[bot] Oct 11, 2025
554d439
Merge branch 'release'
odlbot Oct 14, 2025
b3a2bfa
fix(deps): update dependency ramda to v0.32.0 (#2531)
renovate[bot] Oct 14, 2025
49d87c8
Release 0.131.3
odlbot Oct 14, 2025
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
22 changes: 22 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -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)
---------------

Expand Down
3 changes: 2 additions & 1 deletion b2b/views/v0/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
162 changes: 162 additions & 0 deletions cms/management/commands/bulk_populate_signatories.py
Original file line number Diff line number Diff line change
@@ -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}'
)
)
92 changes: 92 additions & 0 deletions cms/management/commands/remove_signatory.py
Original file line number Diff line number Diff line change
@@ -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"
)
)
13 changes: 0 additions & 13 deletions courses/api_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,6 @@
ProgramCertificateFactory,
ProgramEnrollmentFactory,
ProgramFactory,
ProgramRequirementFactory,
program_with_empty_requirements, # noqa: F401
program_with_requirements, # noqa: F401
)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading