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

Version 1.146.3
---------------

- Add soft delete to the partner school model (#3485)
- 10745 learn hubspot should be updated when user adds item to cart (#3474)

Version 1.146.2 (Released April 13, 2026)
---------------

Expand Down
10 changes: 10 additions & 0 deletions b2b/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from cms.api import get_home_page
from courses.constants import UAI_COURSEWARE_ID_PREFIX
from courses.models import Course, CourseRun, Department, EnrollmentMode
from courses.utils import is_uai_course_run
from ecommerce.constants import (
DISCOUNT_TYPE_FIXED_PRICE,
PAYMENT_TYPE_SALES,
Expand All @@ -50,6 +51,7 @@
DiscountProduct,
Product,
)
from hubspot_sync.task_helpers import sync_hubspot_cart_add
from main import constants as main_constants
from main.utils import date_to_datetime
from openedx.constants import EDX_ENROLLMENT_AUDIT_MODE, EDX_ENROLLMENT_VERIFIED_MODE
Expand Down Expand Up @@ -1014,6 +1016,14 @@ def _prepare_basket_for_b2b_enrollment(request, product: Product) -> Basket:
item = BasketItem.objects.create(product=product, basket=basket, quantity=1)
item.save()

# Sync with HubSpot for CourseRun products
if isinstance(product.purchasable_object, CourseRun):
sync_hubspot_cart_add(
request.user,
product,
is_uai_course=is_uai_course_run(product.purchasable_object),
)

return basket


Expand Down
22 changes: 22 additions & 0 deletions courses/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,22 @@ class PartnerSchoolAdmin(TimestampedModelAdmin):
list_display = ["name", "email"]
search_fields = ["name", "email"]

def get_queryset(self, request): # noqa: ARG002
"""Use the all_objects manager so we can see everything."""

return self.model.all_objects.get_queryset()

def delete_model(self, request, obj): # noqa: ARG002
"""Soft-delete the model."""

obj.is_active = False
obj.save()

def delete_queryset(self, request, queryset): # noqa: ARG002
"""Soft-delete using the queryset."""

queryset.update(is_active=False)


@admin.register(LearnerProgramRecordShare)
class LearnerProgramRecordShareAdmin(TimestampedModelAdmin):
Expand All @@ -719,6 +735,12 @@ class LearnerProgramRecordShareAdmin(TimestampedModelAdmin):
model = LearnerProgramRecordShare
list_display = ["share_uuid", "user", "partner_school", "is_active"]
search_fields = ["share_uuid"]
readonly_fields = [
"share_uuid",
"user",
"partner_school",
"program",
]


@admin.register(RelatedProgram)
Expand Down
17 changes: 17 additions & 0 deletions courses/migrations/0091_add_soft_delete_to_partner_schools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 5.1.15 on 2026-04-13 20:19

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("courses", "0090_alter_program_departments"),
]

operations = [
migrations.AddField(
model_name="partnerschool",
name="is_active",
field=models.BooleanField(blank=True, default=True),
),
]
20 changes: 20 additions & 0 deletions courses/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2375,17 +2375,37 @@ class Meta:
]


class PartnerSchoolActiveUndeleteManager(models.Manager):
"""Query manager for active objects"""

# This can be used generally, for the models that have `is_active` field
def get_queryset(self):
"""Getting the active queryset for manager"""
return super().get_queryset().filter(is_active=True)


class PartnerSchool(TimestampedModel):
"""
Model for partner school to send records to (copied from MicroMasters)
"""

name = models.CharField(max_length=255)
email = models.TextField(null=False)
is_active = models.BooleanField(default=True, blank=True)

objects = PartnerSchoolActiveUndeleteManager()
all_objects = models.Manager()

def __str__(self):
return self.name

def delete(self, *, using=None, keep_parents=False): # noqa: ARG002
"""Soft-delete the record."""

self.is_active = False
self.save(update_fields=("is_active",))
return (1, {"courses.PartnerSchool": 1})


class LearnerProgramRecordShare(TimestampedModel):
"""
Expand Down
12 changes: 11 additions & 1 deletion ecommerce/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
)
from courses.api import create_run_enrollments, deactivate_run_enrollment
from courses.constants import ENROLL_CHANGE_STATUS_REFUNDED
from courses.models import CourseRun
from courses.utils import is_uai_course_run
from ecommerce.constants import (
ALL_DISCOUNT_TYPES,
ALL_PAYMENT_TYPES,
Expand Down Expand Up @@ -57,7 +59,7 @@
)
from ecommerce.tasks import perform_downgrade_from_order
from flexiblepricing.api import determine_courseware_flexible_price_discount
from hubspot_sync.task_helpers import sync_hubspot_deal
from hubspot_sync.task_helpers import sync_hubspot_cart_add, sync_hubspot_deal
from main.constants import (
USER_MSG_TYPE_B2B_ERROR_MISSING_ENROLLMENT_CODE,
USER_MSG_TYPE_B2B_INVALID_BASKET,
Expand Down Expand Up @@ -1089,6 +1091,14 @@ def create_verified_program_course_run_enrollment(request, courserun, program):
redeemed_basket=basket,
)

# Sync with HubSpot for CourseRun products
if isinstance(product.purchasable_object, CourseRun):
sync_hubspot_cart_add(
request.user,
product,
is_uai_course=is_uai_course_run(product.purchasable_object),
)

if Decimal(
sum([basket_item.discounted_price for basket_item in basket.basket_items.all()])
) > Decimal(0):
Expand Down
18 changes: 18 additions & 0 deletions ecommerce/views/legacy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from rest_framework_extensions.mixins import NestedViewSetMixin

from courses.models import Course, CourseRun, Program, ProgramRun
from courses.utils import is_uai_course_run
from ecommerce import api
from ecommerce.constants import PAYMENT_TYPE_FINANCIAL_ASSISTANCE
from ecommerce.discounts import DiscountType
Expand Down Expand Up @@ -71,6 +72,7 @@
from flexiblepricing.api import determine_courseware_flexible_price_discount
from flexiblepricing.models import FlexiblePriceTier
from flexiblepricing.serializers import FlexiblePriceTierSerializer
from hubspot_sync.task_helpers import sync_hubspot_cart_add
from main import features
from main.constants import (
USER_MSG_TYPE_BASKET_EMPTY,
Expand Down Expand Up @@ -717,11 +719,27 @@ def add_to_cart(self, request):
# Add new item to basket
BasketItem.objects.create(basket=basket, product=product)
message = "Product added to cart"

# Sync with HubSpot for CourseRun products
if isinstance(product.purchasable_object, CourseRun):
sync_hubspot_cart_add(
self.request.user,
product,
is_uai_course=is_uai_course_run(product.purchasable_object),
)
else:
# Legacy behavior: add single item
BasketItem.objects.create(basket=basket, product=product)
message = "Product added to cart"

# Sync with HubSpot for CourseRun products
if isinstance(product.purchasable_object, CourseRun):
sync_hubspot_cart_add(
self.request.user,
product,
is_uai_course=is_uai_course_run(product.purchasable_object),
)

return Response(
{
"message": message,
Expand Down
44 changes: 44 additions & 0 deletions ecommerce/views/legacy/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,50 @@ def test_add_to_cart_api_with_feature_flag(
assert basket.basket_items.first().product == product2


def test_add_to_cart_triggers_hubspot_cart_add_for_uai_course(
user_drf_client, user, settings, mocker
):
"""Adding a UAI course run product should trigger UAI-routed HubSpot cart-add tracking."""
settings.ENABLE_MULTIPLE_CART_ITEMS = True
mock_sync = mocker.patch("ecommerce.views.legacy.sync_hubspot_cart_add")

course_run = CourseRunFactory.create(courseware_id="course-v1:UAI_MIT+1.001x+2026")
product = ProductFactory.create(purchasable_object=course_run)

resp = user_drf_client.post(
reverse("checkout_api-add_to_cart"),
data={"product_id": product.id},
)

assert resp.status_code == status.HTTP_200_OK
mock_sync.assert_called_once_with(
user,
product,
is_uai_course=True,
)


def test_add_to_cart_does_not_trigger_hubspot_for_duplicate_product(
user_drf_client, user, settings, mocker
):
"""When multiple cart items are enabled, duplicate adds should not emit a second tracking event."""
settings.ENABLE_MULTIPLE_CART_ITEMS = True
mock_sync = mocker.patch("ecommerce.views.legacy.sync_hubspot_cart_add")

product = ProductFactory.create()
basket = BasketFactory.create(user=user)
BasketItemFactory.create(basket=basket, product=product)

resp = user_drf_client.post(
reverse("checkout_api-add_to_cart"),
data={"product_id": product.id},
)

assert resp.status_code == status.HTTP_200_OK
assert resp.json()["message"] == "Product already in cart"
mock_sync.assert_not_called()


def test_discount_rest_api(admin_drf_client, user_drf_client):
"""
Checks that the admin REST API is only accessible by an admin
Expand Down
17 changes: 17 additions & 0 deletions ecommerce/views/v0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from rest_framework_extensions.mixins import NestedViewSetMixin

from courses.models import Course, CourseRun, Program, ProgramRun
from courses.utils import is_uai_course_run
from ecommerce.api import (
apply_discount_to_basket,
establish_basket,
Expand Down Expand Up @@ -68,6 +69,7 @@
)
from flexiblepricing.models import FlexiblePriceTier
from flexiblepricing.serializers import FlexiblePriceTierSerializer
from hubspot_sync.task_helpers import sync_hubspot_cart_add

log = logging.getLogger(__name__)
User = get_user_model()
Expand Down Expand Up @@ -229,6 +231,14 @@ def _create_basket_from_product(
basket=basket, product=product, defaults={"quantity": quantity}
)

# Sync with HubSpot for CourseRun products
if isinstance(product.purchasable_object, CourseRun):
sync_hubspot_cart_add(
request.user,
product,
is_uai_course=is_uai_course_run(product.purchasable_object),
)

existing_basket_discounts = [bd.redeemed_discount for bd in basket.discounts.all()]
discounts_to_apply = [
*existing_basket_discounts,
Expand Down Expand Up @@ -369,6 +379,13 @@ def create_basket_with_products(request):
BasketItem.objects.update_or_create(
basket=basket, product=product, defaults={"quantity": quantity}
)
# Sync with HubSpot for CourseRun products
if isinstance(product.purchasable_object, CourseRun):
sync_hubspot_cart_add(
request.user,
product,
is_uai_course=is_uai_course_run(product.purchasable_object),
)
except ProductBlockedError:
return Response(
{"error": "Product blocked from purchasing.", "product": product},
Expand Down
Loading
Loading