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
11 changes: 11 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
Release Notes
=============

Version 1.164.2
---------------

- Email context updates (#3911)
- fix: basket api discount-related bugfixes (#3898)
- fix: OpenAPI / Serializer improvements for BulkDiscountSerializer, CreateBasketWithProductsSerializer (#3902)
- fix: unbreak python-checks — ecommerce migration leaf conflict + stale Keycloak dataclasses (#3908)
- Skip downgrade for unenrolled learners on refund (#3901)
- fix: downgrade stale course run 404 errors from error to warning (#3904)
- Look up orders by reference_number on the CyberSource callback (#3899)

Version 1.164.1 (Released August 31, 2026)
---------------

Expand Down
4 changes: 3 additions & 1 deletion b2b/keycloak_admin_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -1211,7 +1211,9 @@ class OrganizationInvitationRepresentation(BaseModel):
sent_date: Annotated[int | None, Field(alias='sentDate')] = None
expires_at: Annotated[int | None, Field(alias='expiresAt')] = None
status: Status | None = None
invite_link: Annotated[str | None, Field(alias='inviteLink')] = None
invite_link: Annotated[str | None, Field(alias='inviteLink', deprecated=True)] = (
None
)


class OrganizationRepresentation(BaseModel):
Expand Down
6 changes: 3 additions & 3 deletions b2b/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,24 @@
from django.conf import settings
from mitol.common.utils.datetime import now_in_utc
from mitol.mail.api import get_message_sender
from mitol.mail.messages import TemplatedMessage

from b2b.models import ContractPage, DiscountContractAttachmentRedemption
from mail.messages import SiteTemplatedMessage

log = logging.getLogger(__name__)

ENROLLMENT_CODE_ASSINGMENT_TAG = "enrollment-code-assignment"


class BaseEnrollmentCodeAssignmentMessage(TemplatedMessage):
class BaseEnrollmentCodeAssignmentMessage(SiteTemplatedMessage):
template_name = "mail/enrollment_code_assignment"
name = "Enrollment Code Assignment"


class EnrollmentCodeAssignmentMessage(BaseEnrollmentCodeAssignmentMessage):
@staticmethod
def get_default_headers() -> dict:
base_headers = TemplatedMessage.get_default_headers()
base_headers = SiteTemplatedMessage.get_default_headers()
headers = base_headers.copy()
headers["X-Mailgun-Tag"] = ENROLLMENT_CODE_ASSINGMENT_TAG
return headers
Expand Down
2 changes: 1 addition & 1 deletion cms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1506,7 +1506,7 @@ def _get_current_finaid(self, request):
ecommerce_product, request.user
)

if discount and discount.check_validity(request.user):
if discount and discount.is_redeemable_by(request.user):
log.debug(
f"price is {ecommerce_product.price}, discount is {discount.discount_product(ecommerce_product)}" # noqa: G004
)
Expand Down
2 changes: 1 addition & 1 deletion courses/management/commands/create_verified_enrollment.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ def handle(self, *args, **options): # noqa: ARG002, C901
)
)

if not discount.check_validity(user):
if not discount.is_redeemable_by(user):
raise CommandError(
"That enrollment code {} for course with courseware_id={} is invalid for user {}".format( # noqa: EM103
options["code"], options["run"], options["user"]
Expand Down
10 changes: 5 additions & 5 deletions courses/messages.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Course email messages"""

from django.conf import settings
from mitol.mail.messages import TemplatedMessage

from courses.utils import is_uai_course_run
from mail.messages import SiteTemplatedMessage


class UAIEmailMixin:
Expand All @@ -25,28 +25,28 @@ def create(cls, **kwargs):
return super().create(**kwargs)


class CourseRunEnrollmentMessage(UAIEmailMixin, TemplatedMessage):
class CourseRunEnrollmentMessage(UAIEmailMixin, SiteTemplatedMessage):
"""Email message for course enrollment"""

name = "Course Run Enrollment"
template_name = "mail/course_run_enrollment"


class CourseRunUnenrollmentMessage(UAIEmailMixin, TemplatedMessage):
class CourseRunUnenrollmentMessage(UAIEmailMixin, SiteTemplatedMessage):
"""Email message for course unenrollment"""

name = "Course Run Unenrollment"
template_name = "mail/course_run_unenrollment"


class EnrollmentFailureMessage(TemplatedMessage):
class EnrollmentFailureMessage(SiteTemplatedMessage):
"""Email message for enrollment failures"""

name = "Enrollment Failure"
template_name = "mail/enrollment_failure"


class PartnerSchoolSharingMessage(TemplatedMessage):
class PartnerSchoolSharingMessage(SiteTemplatedMessage):
"""Email message for sharing learner records to partner schools"""

name = "Shared Learner Record"
Expand Down
15 changes: 14 additions & 1 deletion courses/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging
import re
from http import HTTPStatus
from urllib.parse import urljoin

from django.conf import settings
Expand Down Expand Up @@ -51,7 +52,19 @@ def exception_logging_generator(generator):
except StopIteration: # noqa: PERF203
return
except HTTPError as exc:
log.exception("EdX API error for fetching user grades %s:", exc) # noqa: TRY401
if (
exc.response is not None
and exc.response.status_code == HTTPStatus.NOT_FOUND
):
# Course run no longer exists in edX (e.g. an old run that was
# removed). This is expected for stale runs and shouldn't page
# Sentry every hour - see mitodl/hq#12729.
log.warning(
"EdX API 404 fetching user grades, course run may no longer exist in edX: %s",
exc,
)
else:
log.exception("EdX API error for fetching user grades %s:", exc) # noqa: TRY401
except Exception as exp: # pylint: disable=broad-except
log.exception("Error fetching user grades from edX %s:", exp) # noqa: TRY401

Expand Down
32 changes: 32 additions & 0 deletions courses/utils_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import pytest
from mitol.common.utils import now_in_utc
from requests.exceptions import HTTPError

from courses.factories import (
CourseFactory,
Expand All @@ -20,6 +21,7 @@
)
from courses.models import Course, CourseRun
from courses.utils import (
exception_logging_generator,
get_dated_courseruns,
get_enrollable_courseruns_qs,
get_enrollable_courses,
Expand Down Expand Up @@ -365,3 +367,33 @@ def test_is_uai_order_uses_purchased_object_when_available():
order = SimpleNamespace(lines=SimpleNamespace(all=lambda: [line]))

assert is_uai_order(order) is True


def _make_http_error(status_code):
error = HTTPError(f"{status_code} error")
error.response = SimpleNamespace(status_code=status_code)
return error


@pytest.mark.parametrize(
("status_code", "expected_level"),
[
(404, "WARNING"), # stale/deleted course run - shouldn't page Sentry
(500, "ERROR"), # real edX API failure - should still page Sentry
],
)
def test_exception_logging_generator_http_error_log_level(
caplog, status_code, expected_level
):
"""HTTPErrors should log at WARNING for 404s and ERROR otherwise, without stopping iteration."""

def gen():
yield 1
raise _make_http_error(status_code)

with caplog.at_level("WARNING"):
results = list(exception_logging_generator(gen()))

assert results == [1]
assert len(caplog.records) == 1
assert caplog.records[0].levelname == expected_level
62 changes: 31 additions & 31 deletions drf_lint_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,38 +22,38 @@
"courses/serializers/v2/programs.py:387:50:ORM002",
"courses/serializers/v2/programs.py:500:12:ORM002",
"courses/serializers/v3/courses.py:57:12:ORM002",
"ecommerce/serializers/__init__.py:205:17:ORM001",
"ecommerce/serializers/__init__.py:207:18:ORM001",
"ecommerce/serializers/__init__.py:206:17:ORM001",
"ecommerce/serializers/__init__.py:208:18:ORM001",
"ecommerce/serializers/__init__.py:230:26:ORM002",
"ecommerce/serializers/__init__.py:318:26:ORM002",
"ecommerce/serializers/__init__.py:326:31:ORM002",
"ecommerce/serializers/__init__.py:332:20:ORM002",
"ecommerce/serializers/__init__.py:337:31:ORM002",
"ecommerce/serializers/__init__.py:352:31:ORM002",
"ecommerce/serializers/__init__.py:427:24:ORM002",
"ecommerce/serializers/__init__.py:446:12:ORM001",
"ecommerce/serializers/__init__.py:470:22:ORM002",
"ecommerce/serializers/__init__.py:517:22:ORM002",
"ecommerce/serializers/__init__.py:587:20:ORM002",
"ecommerce/serializers/__init__.py:719:22:ORM002",
"ecommerce/serializers/__init__.py:901:28:ORM002",
"ecommerce/serializers/v0/__init__.py:292:17:ORM001",
"ecommerce/serializers/v0/__init__.py:294:18:ORM001",
"ecommerce/serializers/v0/__init__.py:295:18:ORM001",
"ecommerce/serializers/v0/__init__.py:317:26:ORM002",
"ecommerce/serializers/v0/__init__.py:405:26:ORM002",
"ecommerce/serializers/v0/__init__.py:414:35:ORM002",
"ecommerce/serializers/v0/__init__.py:421:20:ORM002",
"ecommerce/serializers/v0/__init__.py:427:35:ORM002",
"ecommerce/serializers/v0/__init__.py:443:31:ORM002",
"ecommerce/serializers/v0/__init__.py:540:24:ORM002",
"ecommerce/serializers/v0/__init__.py:559:12:ORM001",
"ecommerce/serializers/v0/__init__.py:583:22:ORM002",
"ecommerce/serializers/v0/__init__.py:630:22:ORM002",
"ecommerce/serializers/v0/__init__.py:705:20:ORM002",
"ecommerce/serializers/v0/__init__.py:854:22:ORM002",
"ecommerce/serializers/v0/__init__.py:998:28:ORM002",
"ecommerce/serializers/__init__.py:209:18:ORM001",
"ecommerce/serializers/__init__.py:231:26:ORM002",
"ecommerce/serializers/__init__.py:319:26:ORM002",
"ecommerce/serializers/__init__.py:327:31:ORM002",
"ecommerce/serializers/__init__.py:333:20:ORM002",
"ecommerce/serializers/__init__.py:338:31:ORM002",
"ecommerce/serializers/__init__.py:353:31:ORM002",
"ecommerce/serializers/__init__.py:428:24:ORM002",
"ecommerce/serializers/__init__.py:447:12:ORM001",
"ecommerce/serializers/__init__.py:471:22:ORM002",
"ecommerce/serializers/__init__.py:518:22:ORM002",
"ecommerce/serializers/__init__.py:588:20:ORM002",
"ecommerce/serializers/__init__.py:760:22:ORM002",
"ecommerce/serializers/__init__.py:942:28:ORM002",
"ecommerce/serializers/v0/__init__.py:288:17:ORM001",
"ecommerce/serializers/v0/__init__.py:290:18:ORM001",
"ecommerce/serializers/v0/__init__.py:291:18:ORM001",
"ecommerce/serializers/v0/__init__.py:313:26:ORM002",
"ecommerce/serializers/v0/__init__.py:401:26:ORM002",
"ecommerce/serializers/v0/__init__.py:410:35:ORM002",
"ecommerce/serializers/v0/__init__.py:417:20:ORM002",
"ecommerce/serializers/v0/__init__.py:423:35:ORM002",
"ecommerce/serializers/v0/__init__.py:439:31:ORM002",
"ecommerce/serializers/v0/__init__.py:536:24:ORM002",
"ecommerce/serializers/v0/__init__.py:555:12:ORM001",
"ecommerce/serializers/v0/__init__.py:579:22:ORM002",
"ecommerce/serializers/v0/__init__.py:626:22:ORM002",
"ecommerce/serializers/v0/__init__.py:701:20:ORM002",
"ecommerce/serializers/v0/__init__.py:832:22:ORM002",
"ecommerce/serializers/v0/__init__.py:976:28:ORM002",
"flexiblepricing/serializers.py:147:34:ORM001",
"flexiblepricing/serializers.py:170:34:ORM001",
"flexiblepricing/serializers.py:173:20:ORM001",
Expand Down
Loading
Loading