From a0135c876c48ba721dfcf7a96f4dd5b54959f251 Mon Sep 17 00:00:00 2001 From: Asad Ali Date: Fri, 3 Apr 2026 18:29:08 +0500 Subject: [PATCH 1/8] feat: list filter b2b programs (#3456) --- courses/admin.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/courses/admin.py b/courses/admin.py index 8ea342f1a6..db96deeb05 100644 --- a/courses/admin.py +++ b/courses/admin.py @@ -61,11 +61,12 @@ class ProgramAdmin(admin.ModelAdmin): "id", "title", "live", + "b2b_only", "readable_id", "program_type", "display_mode", ) - list_filter = ["live", "program_type", "display_mode", "departments"] + list_filter = ["live", "b2b_only", "program_type", "display_mode", "departments"] inlines = [ProgramContractPageInline] From a319db3919a56fb13837014a1324f4204507f448 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Mon, 6 Apr 2026 14:21:38 -0500 Subject: [PATCH 2/8] Fix issues with the program certificate audit courses test (#3460) Co-authored-by: Asad Ali --- courses/api_test.py | 2 +- courses/factories.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/courses/api_test.py b/courses/api_test.py index ba3896364f..4eb13f9878 100644 --- a/courses/api_test.py +++ b/courses/api_test.py @@ -1915,7 +1915,7 @@ def test_generate_program_certificate_audit_courses(user, default_mode_records): cert_course.save() audit_course = CourseFactory.create() - audit_course_run = CourseRunFactory.create(course=audit_course) + audit_course_run = CourseRunFactory.create(course=audit_course, enrollment_modes=[]) # Potential flakiness: if the fixture is changed and these aren't in the right # order, this will be wrong. audit_course_run.enrollment_modes.add(default_mode_records[0]) diff --git a/courses/factories.py b/courses/factories.py index 031734d808..21bd13444e 100644 --- a/courses/factories.py +++ b/courses/factories.py @@ -159,7 +159,7 @@ class CourseRunFactory(DjangoModelFactory): def enrollment_modes(self, create, extracted, **kwargs): # noqa: ARG002 """ Post-generation method to add enrollment modes to the course run. - By default, adds the verified mode if no modes are provided. + By default, adds the audit and verified modes if no modes are provided. Args: create: Whether the instance is being created (as opposed to just built). @@ -171,6 +171,9 @@ def enrollment_modes(self, create, extracted, **kwargs): # noqa: ARG002 if extracted is not None: self.enrollment_modes.set(extracted) else: + self.enrollment_modes.add( + EnrollmentModeFactory(mode_slug=EDX_ENROLLMENT_AUDIT_MODE) + ) self.enrollment_modes.add( EnrollmentModeFactory(mode_slug=EDX_ENROLLMENT_VERIFIED_MODE) ) From 4433791b4e8b5a645e1a9739c768b5453d423cdd Mon Sep 17 00:00:00 2001 From: James Kachel Date: Mon, 6 Apr 2026 14:22:21 -0500 Subject: [PATCH 3/8] Add API support for a B2B contract management dashboard (#3424) --- b2b/admin.py | 32 ++ .../0021_userorganization_is_manager.py | 22 + b2b/models.py | 25 + b2b/permissions.py | 60 +++ b2b/serializers/v0/__init__.py | 57 ++- b2b/serializers/v0/manager.py | 130 ++++++ b2b/views/v0/manager.py | 268 +++++++++++ b2b/views/v0/manager_test.py | 442 ++++++++++++++++++ b2b/views/v0/urls.py | 23 +- openapi/specs/v0.yaml | 371 ++++++++++++++- openapi/specs/v1.yaml | 371 ++++++++++++++- openapi/specs/v2.yaml | 371 ++++++++++++++- 12 files changed, 2088 insertions(+), 84 deletions(-) create mode 100644 b2b/migrations/0021_userorganization_is_manager.py create mode 100644 b2b/permissions.py create mode 100644 b2b/serializers/v0/manager.py create mode 100644 b2b/views/v0/manager.py create mode 100644 b2b/views/v0/manager_test.py diff --git a/b2b/admin.py b/b2b/admin.py index 0bbd492cfc..a7a548d297 100644 --- a/b2b/admin.py +++ b/b2b/admin.py @@ -1,5 +1,7 @@ """B2B model admin. Only for convenience; you should use the Wagtail interface instead.""" +from collections.abc import Sequence + from django.contrib import admin from b2b.models import ( @@ -7,13 +9,29 @@ ContractProgramItem, DiscountContractAttachmentRedemption, OrganizationPage, + UserOrganization, ) from courses.models import CourseRun +class UserOrganizationAdminInline(admin.TabularInline): + """Inline that filters to just show organization admins""" + + model = UserOrganization + extra = 0 + verbose_name = "Organization Admin" + + def get_queryset(self, request): + """Filter the queryset to just users with Manager access.""" + + return super().get_queryset(request).filter(is_manager=True) + + class ReadOnlyModelAdmin(admin.ModelAdmin): """Read-only admin for models.""" + fields: Sequence[str] = [] + def __init__(self, *args, **kwargs): """Set the readonly_fields to the fields if we can.""" @@ -171,3 +189,17 @@ class OrganizationPageAdmin(ReadOnlyModelAdmin): "logo", "sso_organization_id", ] + + inlines = [ + UserOrganizationAdminInline, + ] + + +@admin.register(UserOrganization) +class UserOrganizationAdmin(admin.ModelAdmin): + """Admin for user organization memberships.""" + + list_display = ["user", "organization", "is_manager", "keep_until_seen"] + list_filter = ["is_manager", "keep_until_seen", "organization"] + search_fields = ["user__email", "user__username", "organization__name"] + fields = ["user", "organization", "is_manager", "keep_until_seen"] diff --git a/b2b/migrations/0021_userorganization_is_manager.py b/b2b/migrations/0021_userorganization_is_manager.py new file mode 100644 index 0000000000..206972b9ec --- /dev/null +++ b/b2b/migrations/0021_userorganization_is_manager.py @@ -0,0 +1,22 @@ +# Generated by Django 5.1.15 on 2026-03-20 15:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("b2b", "0020_add_google_sheets_fields"), + ] + + operations = [ + migrations.AddField( + model_name="userorganization", + name="is_manager", + field=models.BooleanField( + default=False, + null=True, + blank=True, + help_text="If True, the user is a manager of this organization and can access organization dashboard features.", + ), + ), + ] diff --git a/b2b/models.py b/b2b/models.py index 19bb5ce0bf..59df315923 100644 --- a/b2b/models.py +++ b/b2b/models.py @@ -609,6 +609,12 @@ class UserOrganization(models.Model): default=False, help_text="If True, the user will be kept in the organization until the organization is seen in their SSO data.", ) + is_manager = models.BooleanField( + default=False, + null=True, + blank=True, + help_text="If True, the user is a manager of this organization and can access organization dashboard features.", + ) class Meta: unique_together = ("user", "organization") @@ -617,3 +623,22 @@ def __str__(self): """Return a reasonable representation of the object as a string.""" return f"UserOrganization: {self.user} in {self.organization}" + + +def is_organization_manager(user, org_id): + """ + Check if a user is a manager of the specified organization. + + Args: + user: The user to check + org_id: The organization ID to check against + + Returns: + bool: True if the user is a manager of the organization, False otherwise + """ + if not user or not user.is_authenticated: + return False + + return UserOrganization.objects.filter( + user=user, organization_id=org_id, is_manager=True + ).exists() diff --git a/b2b/permissions.py b/b2b/permissions.py new file mode 100644 index 0000000000..a7c65609e9 --- /dev/null +++ b/b2b/permissions.py @@ -0,0 +1,60 @@ +"""B2B permissions for organization manager dashboard.""" + +from rest_framework import permissions + +from b2b.models import is_organization_manager + + +class IsOrganizationManager(permissions.BasePermission): + """ + Custom permission to only allow organization managers to access + their organization's data. + """ + + def has_permission(self, request, view): + """ + Check if the user has permission to access the view. + + The user must be authenticated and be a manager of the organization + specified in the URL parameters. + """ + if not request.user or not request.user.is_authenticated: + return False + + if request.user and request.user.is_superuser: + return True + + # Get org_id from URL kwargs + org_id = view.kwargs.get("parent_lookup_organization") + if not org_id: + return False + + return is_organization_manager(request.user, org_id) + + def has_object_permission(self, request, view, obj): + """ + Check if the user has permission to access a specific object. + + For contract-related objects, ensure the contract belongs to + an organization that the user manages. Superusers are always allowed. + """ + org_id = view.kwargs.get("parent_lookup_organization") + + if request.user and request.user.is_superuser: + return True + + # Check if user is manager of the organization + if is_organization_manager(request.user, org_id): + # If the object has an organization relation, verify it matches + if hasattr(obj, "organization_id"): + return obj.organization_id == int(org_id) + elif hasattr(obj, "organization"): + return obj.organization.id == int(org_id) + elif hasattr(obj, "b2b_contract"): + # For course runs and enrollments + return obj.b2b_contract.organization_id == int(org_id) + elif hasattr(obj, "run") and hasattr(obj.run, "b2b_contract"): + # For enrollments via course run + return obj.run.b2b_contract.organization_id == int(org_id) + + return False diff --git a/b2b/serializers/v0/__init__.py b/b2b/serializers/v0/__init__.py index 72c0759083..89292312ac 100644 --- a/b2b/serializers/v0/__init__.py +++ b/b2b/serializers/v0/__init__.py @@ -9,22 +9,10 @@ from main.serializers import RichTextSerializer -class ContractPageSerializer(serializers.ModelSerializer): - """ - Serializer for the ContractPage model. - """ +class BaseContractPageSerializer(serializers.ModelSerializer): + """Simplified serializer for the ContractPage model.""" membership_type = serializers.CharField() - programs = serializers.SerializerMethodField() - welcome_message_extra = RichTextSerializer( - help_text=ContractPage._meta.get_field("welcome_message_extra").help_text, # noqa: SLF001, not private https://docs.djangoproject.com/en/5.0/ref/models/meta/ - read_only=True, - ) - - @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) - def get_programs(self, instance): - """Get the ordered list of program IDs for this contract""" - return list(instance.programs.values_list("id", flat=True)) class Meta: model = ContractPage @@ -32,24 +20,17 @@ class Meta: "id", "name", "description", - "welcome_message", - "welcome_message_extra", - "integration_type", "membership_type", "organization", "contract_start", "contract_end", "active", "slug", - "organization", - "programs", ] read_only_fields = [ "id", "name", "description", - "welcome_message", - "welcome_message_extra", "integration_type", "membership_type", "organization", @@ -57,7 +38,39 @@ class Meta: "contract_end", "active", "slug", - "organization", + ] + + +class ContractPageSerializer(BaseContractPageSerializer): + """ + Serializer for the ContractPage model. + """ + + programs = serializers.SerializerMethodField() + welcome_message_extra = RichTextSerializer( + help_text=ContractPage._meta.get_field("welcome_message_extra").help_text, # noqa: SLF001, not private https://docs.djangoproject.com/en/5.0/ref/models/meta/ + read_only=True, + ) + + @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) + def get_programs(self, instance): + """Get the ordered list of program IDs for this contract""" + return list(instance.programs.values_list("id", flat=True)) + + class Meta: + model = ContractPage + fields = [ + *BaseContractPageSerializer.Meta.fields, + "welcome_message", + "welcome_message_extra", + "integration_type", + "programs", + ] + read_only_fields = [ + *BaseContractPageSerializer.Meta.read_only_fields, + "welcome_message", + "welcome_message_extra", + "integration_type", "programs", ] diff --git a/b2b/serializers/v0/manager.py b/b2b/serializers/v0/manager.py new file mode 100644 index 0000000000..f30d7e1a24 --- /dev/null +++ b/b2b/serializers/v0/manager.py @@ -0,0 +1,130 @@ +"""B2B manager dashboard serializers.""" + +from datetime import datetime + +from rest_framework import serializers + +from b2b.models import ContractPage +from b2b.serializers.v0 import ContractPageSerializer +from courses.models import CourseRun, CourseRunEnrollment +from ecommerce.models import Discount + + +class ManagerContractDetailSerializer(ContractPageSerializer): + """Serializer for detailed contract view with statistics.""" + + attachment_percentage = serializers.SerializerMethodField() + total_enrollments = serializers.SerializerMethodField() + total_codes = serializers.SerializerMethodField() + + class Meta: + model = ContractPage + fields = [ + *ContractPageSerializer.Meta.fields, + "attachment_percentage", + "total_enrollments", + "total_codes", + ] + read_only_fields = [ + *ContractPageSerializer.Meta.read_only_fields, + "attachment_percentage", + "total_enrollments", + "total_codes", + ] + + def get_attachment_percentage(self, obj) -> float | None: + """Calculate attachment percentage if seat-limited.""" + if not obj.max_learners: + return None + + attached_count = obj.users.count() + return round((attached_count / obj.max_learners) * 100, 2) + + def get_total_enrollments(self, obj) -> int: + """Get total number of enrollments across all contract course runs.""" + return obj.enrollment_count + + def get_total_codes(self, obj) -> int: + """Get total number of discount codes for this contract.""" + return obj.discount_count + + +class ManagerCourseRunSerializer(serializers.ModelSerializer): + """Serializer for course runs in a contract.""" + + class Meta: + model = CourseRun + fields = [ + "readable_id", + "title", + "start_date", + "end_date", + "enrollment_start", + "enrollment_end", + "certificate_available_date", + "live", + ] + + +class ManagerEnrollmentSerializer(serializers.ModelSerializer): + """Serializer for enrollments in a specific course run.""" + + learner_name = serializers.CharField(source="user.name") + learner_email = serializers.CharField(source="user.email") + enrollment_date = serializers.DateTimeField(source="created_on") + enrollment_type = serializers.CharField(source="enrollment_mode") + enrollment_status = serializers.CharField(source="change_status") + + class Meta: + model = CourseRunEnrollment + fields = [ + "learner_name", + "learner_email", + "enrollment_date", + "enrollment_type", + "enrollment_status", + "active", + ] + + +class ManagerEnrollmentCodeSerializer(serializers.ModelSerializer): + """Serializer for enrollment codes available to a contract.""" + + code = serializers.CharField(source="discount_code") + is_redeemed = serializers.SerializerMethodField() + redeemed_by = serializers.SerializerMethodField() + redeemed_on = serializers.SerializerMethodField() + + class Meta: + model = Discount + fields = ["id", "code", "is_redeemed", "redeemed_by", "redeemed_on"] + + def get_is_redeemed(self, obj) -> bool: + """Check if this code has been used for contract attachment.""" + contract = self.context.get("contract") + if not contract: + return False + + return obj.is_redeemed + + def get_redeemed_by(self, obj) -> str | None: + """Return the user that redeemed the code (last).""" + + contract = self.context.get("contract") + if not contract: + return None + + return ( + obj.last_redemption_email if hasattr(obj, "last_redemption_email") else None + ) + + def get_redeemed_on(self, obj) -> datetime | None: + """Return the date that the code was redeemed on last.""" + + contract = self.context.get("contract") + if not contract: + return None + + return ( + obj.last_redemption_date if hasattr(obj, "last_redemption_date") else None + ) diff --git a/b2b/views/v0/manager.py b/b2b/views/v0/manager.py new file mode 100644 index 0000000000..2e5f933af1 --- /dev/null +++ b/b2b/views/v0/manager.py @@ -0,0 +1,268 @@ +"""B2B manager dashboard views.""" + +from django.contrib.contenttypes.models import ContentType +from django.db.models import Count, Exists, OuterRef, Subquery +from django.shortcuts import get_object_or_404 +from drf_spectacular.utils import ( + OpenApiParameter, + extend_schema, +) +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework_extensions.mixins import NestedViewSetMixin + +from b2b.constants import CONTRACT_MEMBERSHIP_AUTOS +from b2b.models import ( + ContractPage, + DiscountContractAttachmentRedemption, + OrganizationPage, +) +from b2b.permissions import IsOrganizationManager +from b2b.serializers.v0 import ( + BaseContractPageSerializer, + OrganizationPageSerializer, +) +from b2b.serializers.v0.manager import ( + ManagerContractDetailSerializer, + ManagerCourseRunSerializer, + ManagerEnrollmentCodeSerializer, + ManagerEnrollmentSerializer, +) +from courses.models import CourseRun, CourseRunEnrollment +from ecommerce.models import Discount + +courserun_content_type = ContentType.objects.get_for_model(CourseRun) + + +class ManagerOrganizationViewSet(viewsets.ReadOnlyModelViewSet): + """List organizations available for the current user.""" + + permission_classes = [IsAuthenticated] + serializer_class = OrganizationPageSerializer + + def get_queryset(self): + """Filter to organizations where the user is a manager.""" + return ( + OrganizationPage.objects.distinct() + if self.request.user and self.request.user.is_superuser + else OrganizationPage.objects.filter( + organization_users__user=self.request.user, + organization_users__is_manager=True, + ).distinct() + ) + + @extend_schema( + operation_id="b2b_manager_organizations_list", + description="List managed organizations", + ) + def list(self, request, *args, **kwargs): + """List the orgs.""" + + return super().list(request, *args, **kwargs) + + @extend_schema( + operation_id="b2b_manager_organizations_detail", + description="Retrieve managed organizations", + parameters=[ + OpenApiParameter( + name="id", + type=int, + location=OpenApiParameter.PATH, + description="ID of the organization", + required=True, + ), + ], + ) + def retrieve(self, request, *args, **kwargs): + """Retrieve an org.""" + + return super().retrieve(request, *args, **kwargs) + + +@extend_schema( + parameters=[ + OpenApiParameter( + name="parent_lookup_organization", + type=int, + location=OpenApiParameter.PATH, + description="ID of the parent organization", + required=True, + ), + OpenApiParameter( + name="id", + type=int, + location=OpenApiParameter.PATH, + description="ID of the contract", + required=True, + ), + ] +) +class ManagerContractViewSet(NestedViewSetMixin, viewsets.ReadOnlyModelViewSet): + """List an organization's contracts.""" + + permission_classes = [IsAuthenticated, IsOrganizationManager] + + def get_queryset(self): + """Get the queryset; add some annotations/etc for computed fields""" + return ( + ContractPage.objects.select_related("organization") + .prefetch_related("users") + .annotate( + discount_count=Count( + Subquery( + Discount.objects.filter( + products__product__is_active=True, + products__product__content_type=courserun_content_type, + products__product__object_id__in=CourseRun.objects.filter( + b2b_contract=OuterRef("pk") + ).all(), + ).values("id") + ) + ) + ) + .annotate( + enrollment_count=Count( + Subquery( + CourseRunEnrollment.objects.filter( + run__b2b_contract=OuterRef("pk") + ).values("id") + ) + ) + ) + .filter( + organization__organization_users__user=self.request.user, + organization__organization_users__is_manager=True, + ) + ) + + def get_serializer_class(self): + """Use different serializers for list vs detail views.""" + if self.action == "list": + return BaseContractPageSerializer + return ManagerContractDetailSerializer + + @action(detail=True, methods=["get"]) + def course_runs(self, request, **kwargs): # noqa: ARG002 + """ + List course runs available for a specific contract. + + GET /api/v0/b2b/orgs/{org_id}/manager/contracts/{contract_id}/course_runs/ + """ + contract = self.get_object() + course_runs = contract.get_course_runs() + serializer = ManagerCourseRunSerializer(course_runs, many=True) + return Response(serializer.data) + + @extend_schema( + parameters=[ + OpenApiParameter( + name="course_run_id", + type=str, + location=OpenApiParameter.PATH, + description="Courseware ID to pull enrollments for.", + required=True, + ), + ], + ) + @action( + detail=True, + methods=["get"], + url_path="course_runs/(?P[^/.]+)/enrollments", + ) + def course_run_enrollments(self, request, **kwargs): # noqa: ARG002 + """List enrollments for a specific course run within a contract.""" + contract = self.get_object() + course_run_id = kwargs.pop("course_run_id") + + # Get the course run and verify it belongs to this contract + course_run = get_object_or_404( + CourseRun, courseware_id=course_run_id, b2b_contract=contract + ) + + # Get enrollments for this course run + enrollments = ( + CourseRunEnrollment.objects.filter(run=course_run) + .select_related("user") + .order_by("-created_on") + ) + + serializer = ManagerEnrollmentSerializer(enrollments, many=True) + return Response(serializer.data) + + @action(detail=True, methods=["get"]) + def codes(self, request, **kwargs): # noqa: ARG002 + """ + List enrollment codes for a contract. + + Only shows codes for contracts that require them (non-auto membership types). + Logic varies based on whether contract has learner limits. + """ + contract = self.get_object() + + # Skip if contract has auto membership type (no codes needed) + if contract.membership_type in CONTRACT_MEMBERSHIP_AUTOS: + return Response([]) + + discounts = ( + contract.get_discounts() + .annotate( + is_redeemed=Exists( + DiscountContractAttachmentRedemption.objects.filter( + discount=OuterRef("pk") + ) + ) + ) + .annotate( + last_redemption_email=Subquery( + DiscountContractAttachmentRedemption.objects.select_related("user") + .filter(discount=OuterRef("pk")) + .order_by("-created_on") + .values("user__email")[:1] + ) + ) + .annotate( + last_redemption_date=Subquery( + DiscountContractAttachmentRedemption.objects.select_related("user") + .filter(discount=OuterRef("pk")) + .order_by("-created_on") + .values("created_on")[:1] + ) + ) + ) + + if not contract.max_learners: + # No learner limit - show first code only, if there is one + return Response( + ManagerEnrollmentCodeSerializer( + [discounts.order_by("id").first()], + context={"contract": contract}, + many=True, + ).data + ) + + else: + # Has learner limit - show redeemed codes + enough unused codes to fill remaining seats + discounts = discounts.annotate( + num_redemptions=Count("contract_redemptions") + ).order_by("-num_redemptions", "id") + + codes_for_output = discounts.filter(num_redemptions__gt=0).all() + + # The point of this is to ensure we always get _all_ the redeemed + # codes, and then some unredeemed ones if there's space allowed. + # I didn't want to just call all() and grab a slice because we might + # have _more_ redemptions that we technically allow (if, say, we + # adjust the limit down or manually create some redemptions or + # something). + + if codes_for_output.count() < contract.max_learners: + # We have seats available, so grab some more codes. + codes_for_output = discounts.all()[: contract.max_learners] + + return Response( + ManagerEnrollmentCodeSerializer( + codes_for_output, many=True, context={"contract": contract} + ).data + ) diff --git a/b2b/views/v0/manager_test.py b/b2b/views/v0/manager_test.py new file mode 100644 index 0000000000..2967559b84 --- /dev/null +++ b/b2b/views/v0/manager_test.py @@ -0,0 +1,442 @@ +"""Tests for the B2B Manager views""" + +import pytest +import reversion +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from b2b.api import ensure_enrollment_codes_exist +from b2b.constants import CONTRACT_MEMBERSHIP_CODE +from b2b.factories import ContractPageFactory +from b2b.models import DiscountContractAttachmentRedemption, UserOrganization +from b2b.serializers.v0 import ( + BaseContractPageSerializer, +) +from b2b.serializers.v0.manager import ManagerEnrollmentSerializer +from courses.factories import CourseRunFactory +from courses.models import CourseRunEnrollment +from ecommerce.factories import ProductFactory +from main.test_utils import assert_drf_json_equal +from users.factories import UserFactory + +pytestmark = [pytest.mark.django_db] + + +@pytest.fixture(autouse=True) +def mock_hubspot(mocker): + """Mock out some hubspot stuff""" + + mocker.patch("hubspot_sync.task_helpers.sync_hubspot_user") + mocker.patch("hubspot_sync.tasks.sync_contact_with_hubspot.delay") + + +@pytest.fixture(autouse=True) +def org_setup(): + """ + Generate a basic B2B org setup for these tests. + + Creates a user to act as manager, a handful of contracts, some organizations, + some course runs, and the associations for that. Specifically: + - A manager user (which is a regular user otherwise, not a superuser) + - Two contracts in a single organization + - Both contracts are "code" type, so will generate enrollment codes + - First contract is seat-limited to 20, the second is unlimited + - An attachment for the manager user to that org, with the "is_manager" flag set + - Two course runs with products for each contract + - A third contract in a separate organization, of "code" type with 20 seats + - Two course runs with products in the third contract + + This will assert that the correct number of discount codes are created for + each contract. + + It then returns these items back so they can be used in tests. This is a + tuple containing: + - manager_user + - tuple of organizations + - tuple for each contract containing + - contract + - tuple of (course run, product) for each run/product + """ + + manager_user = UserFactory.create() + + # Contract/org creation + + contract_1 = ContractPageFactory.create( + integration_type=CONTRACT_MEMBERSHIP_CODE, + membership_type=CONTRACT_MEMBERSHIP_CODE, + max_learners=20, + ) + contract_2 = ContractPageFactory.create( + integration_type=CONTRACT_MEMBERSHIP_CODE, + membership_type=CONTRACT_MEMBERSHIP_CODE, + max_learners=0, + organization=contract_1.organization, + ) + UserOrganization.objects.create( + user=manager_user, + organization=contract_1.organization, + keep_until_seen=True, + is_manager=True, + ) + + contract_3 = ContractPageFactory.create( + integration_type=CONTRACT_MEMBERSHIP_CODE, + membership_type=CONTRACT_MEMBERSHIP_CODE, + max_learners=20, + ) + + # Course run and products creation + + contract_1_run_1 = CourseRunFactory.create(b2b_contract=contract_1) + contract_1_run_2 = CourseRunFactory.create(b2b_contract=contract_1) + + with reversion.create_revision(): + contract_1_run_1_product = ProductFactory.create( + purchasable_object=contract_1_run_1 + ) + contract_1_run_2_product = ProductFactory.create( + purchasable_object=contract_1_run_2 + ) + + contract_2_run_1 = CourseRunFactory.create(b2b_contract=contract_2) + contract_2_run_2 = CourseRunFactory.create(b2b_contract=contract_2) + + with reversion.create_revision(): + contract_2_run_1_product = ProductFactory.create( + purchasable_object=contract_2_run_1 + ) + contract_2_run_2_product = ProductFactory.create( + purchasable_object=contract_2_run_2 + ) + + contract_3_run_1 = CourseRunFactory.create(b2b_contract=contract_3) + contract_3_run_2 = CourseRunFactory.create(b2b_contract=contract_3) + + with reversion.create_revision(): + contract_3_run_1_product = ProductFactory.create( + purchasable_object=contract_3_run_1 + ) + contract_3_run_2_product = ProductFactory.create( + purchasable_object=contract_3_run_2 + ) + + # Enrollment code creation + # For contracts 1 and 3, we should end up with 40 codes total - 20 for + # each course run. For contract 2, we should end up with 2 codes. + + created, updated, errored = ensure_enrollment_codes_exist(contract_1) + + assert created == 40 + assert updated == 0 + assert errored == 0 + + created, updated, errored = ensure_enrollment_codes_exist(contract_2) + + assert created == 2 + assert updated == 0 + assert errored == 0 + + created, updated, errored = ensure_enrollment_codes_exist(contract_3) + + assert created == 40 + assert updated == 0 + assert errored == 0 + + return ( + manager_user, + (contract_1.organization, contract_3.organization), + ( + contract_1, + ( + contract_1_run_1, + contract_1_run_1_product, + ), + ( + contract_1_run_2, + contract_1_run_2_product, + ), + ), + ( + contract_2, + ( + contract_2_run_1, + contract_2_run_1_product, + ), + ( + contract_2_run_2, + contract_2_run_2_product, + ), + ), + ( + contract_3, + ( + contract_3_run_1, + contract_3_run_1_product, + ), + ( + contract_3_run_2, + contract_3_run_2_product, + ), + ), + ) + + +@pytest.fixture +def manager_drf_client(org_setup): + """Return an APIClient set up with the manager account.""" + + client = APIClient() + client.force_login(org_setup[0]) + + return client + + +def test_org_setup(org_setup): + """Very basic test that the org setup worked as expected""" + + assert len(org_setup) == 5 + + +def test_org_contract_lists(org_setup, manager_drf_client): + """Test that the org and contract list pages work as expected""" + + _, orgs, contract_1, contract_2, contract_3 = org_setup + + manager_org_list = reverse("b2b:b2b-manager-organization-list") + + resp = manager_drf_client.get(manager_org_list) + + assert resp.status_code == status.HTTP_200_OK + assert len(resp.json()) == 1 + assert resp.json()[0]["id"] == orgs[0].id + + manager_contract_list = reverse( + "b2b:b2b-manager-org-contract-list", + kwargs={"parent_lookup_organization": orgs[0].id}, + ) + + resp = manager_drf_client.get(manager_contract_list) + + assert resp.status_code == status.HTTP_200_OK + resp_json = resp.json() + + assert len(resp_json) == 2 + assert_drf_json_equal( + resp_json, + BaseContractPageSerializer([contract_1[0], contract_2[0]], many=True).data, + ignore_order=True, + ) + assert contract_3[0].id not in [contract["id"] for contract in resp_json] + + manager_contract_list = reverse( + "b2b:b2b-manager-org-contract-list", + kwargs={"parent_lookup_organization": orgs[1].id}, + ) + + resp = manager_drf_client.get(manager_contract_list) + + assert resp.status_code == status.HTTP_403_FORBIDDEN + + +def test_org_contract_run_list(org_setup, manager_drf_client): + """Test that we can get the course runs out of the contract as expected.""" + + # Extracting just the stuff we want. + _, _, contract_1, _, contract_3 = org_setup + contract, *runs = contract_1 + runs = [run[0] for run in runs] + + manager_contract_run_list = reverse( + "b2b:b2b-manager-org-contract-course-runs", + kwargs={ + "parent_lookup_organization": contract.organization.id, + "pk": contract.id, + }, + ) + + resp = manager_drf_client.get(manager_contract_run_list) + assert resp.status_code == status.HTTP_200_OK + + assert len(resp.json()) == 2 + assert sorted([run.readable_id for run in runs]) == sorted( + [run["readable_id"] for run in resp.json()] + ) + + contract, *_ = contract_3 + + manager_contract_run_list = reverse( + "b2b:b2b-manager-org-contract-course-runs", + kwargs={ + "parent_lookup_organization": contract.organization.id, + "pk": contract.id, + }, + ) + + resp = manager_drf_client.get(manager_contract_run_list) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + +def test_org_contract_run_enrollments(org_setup, manager_drf_client): + """Test that we can get enrollments in a contract run.""" + + # Extracting just the stuff we want. + _, _, contract_1, _, contract_3 = org_setup + contract, *runs = contract_1 + runs = [run[0] for run in runs] + + users_to_enroll = UserFactory.create_batch(3) + + run_enrollments = [ + [ + CourseRunEnrollment.objects.create( + user=users_to_enroll[0], + run=runs[0], + ), + CourseRunEnrollment.objects.create( + user=users_to_enroll[1], + run=runs[0], + ), + ], + [ + CourseRunEnrollment.objects.create( + user=users_to_enroll[2], + run=runs[1], + ) + ], + ] + + for idx, run in enumerate(runs): + manager_contract_enrol_list = reverse( + "b2b:b2b-manager-org-contract-course-run-enrollments", + kwargs={ + "parent_lookup_organization": contract.organization.id, + "pk": contract.id, + "course_run_id": run.courseware_id, + }, + ) + + resp = manager_drf_client.get(manager_contract_enrol_list) + assert resp.status_code == status.HTTP_200_OK + + assert len(resp.json()) == len(run_enrollments[idx]) + assert_drf_json_equal( + resp.json(), + ManagerEnrollmentSerializer(run_enrollments[idx], many=True).data, + ignore_order=True, + ) + + contract, *runs = contract_3 + run = runs[0][0] + + manager_contract_enrol_list = reverse( + "b2b:b2b-manager-org-contract-course-run-enrollments", + kwargs={ + "parent_lookup_organization": contract.organization.id, + "pk": contract.id, + "course_run_id": run.courseware_id, + }, + ) + + resp = manager_drf_client.get(manager_contract_enrol_list) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + +def test_org_contract_codes(org_setup, manager_drf_client): + """Test that we can retrieve codes as expected.""" + + _, _, (contract_1, *_), (contract_2, *_), (contract_3, *_) = org_setup + some_users = UserFactory.create_batch(3) + + for contract in [contract_1, contract_2]: + # Sanity checks. We should have more discounts than max_learners, because + # _total_ we should have max_learners * course run count discounts. + discount_count = contract.get_discounts().count() + assert discount_count > contract.max_learners + + expected_code_count = contract.max_learners if contract.max_learners > 0 else 1 + contract_codes = [ + discount.discount_code + for discount in contract.get_discounts() + .order_by("id") + .all()[:expected_code_count] + ] + + # Pull the codes - we should get max_learner codes back and they should + # match the sorting order above. + + manager_contract_code_list = reverse( + "b2b:b2b-manager-org-contract-codes", + kwargs={ + "parent_lookup_organization": contract.organization.id, + "pk": contract.id, + }, + ) + + resp = manager_drf_client.get(manager_contract_code_list) + assert resp.status_code == status.HTTP_200_OK + + resp_codes = [resp_code["code"] for resp_code in resp.json()] + assert len(resp_codes) == expected_code_count + + assert contract_codes == resp_codes + + # Do it again - since we're providing a subset of the codes, it should + # be the _same_ codes each time. + + resp = manager_drf_client.get(manager_contract_code_list) + assert resp.status_code == status.HTTP_200_OK + + resp_codes = [resp_code["code"] for resp_code in resp.json()] + + assert len(resp_codes) == expected_code_count + assert contract_codes == resp_codes + + # Create some redemptions. The API/etc only considers attachment + # redemptions to count; enrollment (order) redemptions don't matter here. + # Use discounts from the end of the list to make it easier to check that + # the subset of discounts hasn't changed. + + if contract.max_learners > 1: + discounts_to_use = contract.get_discounts().order_by("-id")[:3] + else: + # This is the unlimited seat one, so just use the same discount 3 times. + discount_to_use = contract.get_discounts().order_by("id").last() + discounts_to_use = [discount_to_use, discount_to_use, discount_to_use] + + for idx, user in enumerate(some_users): + DiscountContractAttachmentRedemption.objects.create( + discount=discounts_to_use[idx], user=user, contract=contract + ) + + resp = manager_drf_client.get(manager_contract_code_list) + assert resp.status_code == status.HTTP_200_OK + + resp_codes = [resp_code["code"] for resp_code in resp.json()] + + if contract.max_learners > 1: + assert len(resp_codes) == expected_code_count + + # The redeemed codes should be at the top, followed by the same set we + # had before (up to the end of the list) + used_codes = [discount.discount_code for discount in discounts_to_use] + assert sorted(used_codes) == sorted(resp_codes[:3]) + assert resp_codes[3:] == contract_codes[:17] + + assert resp.json()[0]["is_redeemed"] + assert not resp.json()[3]["is_redeemed"] + else: + # For the unlimited seat one, we only get back the single code. + + assert len(resp_codes) == 1 + + manager_contract_code_list = reverse( + "b2b:b2b-manager-org-contract-codes", + kwargs={ + "parent_lookup_organization": contract_3.organization.id, + "pk": contract_3.id, + }, + ) + resp = manager_drf_client.get(manager_contract_code_list) + assert resp.status_code == status.HTTP_403_FORBIDDEN diff --git a/b2b/views/v0/urls.py b/b2b/views/v0/urls.py index ff0fb09f29..bf47399dba 100644 --- a/b2b/views/v0/urls.py +++ b/b2b/views/v0/urls.py @@ -1,7 +1,6 @@ """URL routing for v0 of the B2B API.""" from django.urls import include, path, re_path -from rest_framework.routers import SimpleRouter from b2b.views.v0 import ( AttachContractApi, @@ -9,10 +8,15 @@ Enroll, OrganizationPageViewSet, ) +from b2b.views.v0.manager import ( + ManagerContractViewSet, + ManagerOrganizationViewSet, +) +from main.routers import SimpleRouterWithNesting app_name = "b2b" -v0_router = SimpleRouter() +v0_router = SimpleRouterWithNesting() v0_router.register( r"organizations", OrganizationPageViewSet, @@ -24,6 +28,21 @@ basename="b2b-contract", ) +# Manager dashboard routes +manager_org = v0_router.register( + r"manager/organizations", + ManagerOrganizationViewSet, + basename="b2b-manager-organization", +) +manager_org.register( + r"contracts", + ManagerContractViewSet, + basename="b2b-manager-org-contract", + parents_query_lookups=[ + "organization", + ], +) + urlpatterns = [ re_path(r"^", include(v0_router.urls)), path(r"enroll//", Enroll.as_view(), name="enroll-user"), diff --git a/openapi/specs/v0.yaml b/openapi/specs/v0.yaml index 0ea3718602..677c9ed163 100644 --- a/openapi/specs/v0.yaml +++ b/openapi/specs/v0.yaml @@ -193,6 +193,186 @@ paths: schema: $ref: '#/components/schemas/CreateB2BEnrollment' description: '' + /api/v0/b2b/manager/organizations/: + get: + operationId: b2b_manager_organizations_list + description: List managed organizations + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OrganizationPage' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/: + get: + operationId: b2b_manager_organizations_contracts_list + description: List an organization's contracts. + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BaseContractPage' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/: + get: + operationId: b2b_manager_organizations_contracts_retrieve + description: List an organization's contracts. + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/: + get: + operationId: b2b_manager_organizations_contracts_codes_retrieve + description: |- + List enrollment codes for a contract. + + Only shows codes for contracts that require them (non-auto membership types). + Logic varies based on whether contract has learner limits. + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/course_runs/: + get: + operationId: b2b_manager_organizations_contracts_course_runs_retrieve + description: |- + List course runs available for a specific contract. + + GET /api/v0/b2b/orgs/{org_id}/manager/contracts/{contract_id}/course_runs/ + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/course_runs/{course_run_id}/enrollments/: + get: + operationId: b2b_manager_organizations_contracts_course_runs_enrollments_retrieve + description: List enrollments for a specific course run within a contract. + parameters: + - in: path + name: course_run_id + schema: + type: string + description: Courseware ID to pull enrollments for. + required: true + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{id}/: + get: + operationId: b2b_manager_organizations_detail + description: Retrieve managed organizations + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationPage' + description: '' /api/v0/b2b/organizations/: get: operationId: b2b_organizations_list @@ -3273,6 +3453,58 @@ components: x-enum-descriptions: - anytime - dated + BaseContractPage: + type: object + description: Simplified serializer for the ContractPage model. + properties: + id: + type: integer + readOnly: true + name: + type: string + readOnly: true + description: The name of the contract. + description: + type: string + readOnly: true + description: Any useful extra information about the contract. + membership_type: + type: string + organization: + type: integer + readOnly: true + description: The organization that owns this contract. + contract_start: + type: string + format: date + readOnly: true + nullable: true + description: The start date of the contract. + contract_end: + type: string + format: date + readOnly: true + nullable: true + description: The end date of the contract. + active: + type: boolean + readOnly: true + description: Whether this contract is active or not. Date rules still apply. + slug: + type: string + readOnly: true + description: The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/ + pattern: ^[-\w]+$ + required: + - active + - contract_end + - contract_start + - description + - id + - membership_type + - name + - organization + - slug BaseCourse: type: object description: Basic course model serializer @@ -3657,26 +3889,6 @@ components: type: string readOnly: true description: Any useful extra information about the contract. - welcome_message: - type: string - readOnly: true - description: A welcome message for learners. - welcome_message_extra: - type: string - readOnly: true - description: Additional welcome message content for learners. - integration_type: - allOf: - - $ref: '#/components/schemas/IntegrationTypeEnum' - readOnly: true - description: |- - The type of integration for this contract. - - * `sso` - SSO - * `non-sso` - Non-SSO - * `managed` - Managed - * `code` - Enrollment Code - * `auto` - Auto Enrollment membership_type: type: string organization: @@ -3704,6 +3916,26 @@ components: readOnly: true description: The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/ pattern: ^[-\w]+$ + welcome_message: + type: string + readOnly: true + description: A welcome message for learners. + welcome_message_extra: + type: string + readOnly: true + description: Additional welcome message content for learners. + integration_type: + allOf: + - $ref: '#/components/schemas/IntegrationTypeEnum' + readOnly: true + description: |- + The type of integration for this contract. + + * `sso` - SSO + * `non-sso` - Non-SSO + * `managed` - Managed + * `code` - Enrollment Code + * `auto` - Auto Enrollment programs: type: array items: @@ -5349,6 +5581,105 @@ components: - quantity - total_price - unit_price + ManagerContractDetail: + type: object + description: Serializer for detailed contract view with statistics. + properties: + id: + type: integer + readOnly: true + name: + type: string + readOnly: true + description: The name of the contract. + description: + type: string + readOnly: true + description: Any useful extra information about the contract. + membership_type: + type: string + organization: + type: integer + readOnly: true + description: The organization that owns this contract. + contract_start: + type: string + format: date + readOnly: true + nullable: true + description: The start date of the contract. + contract_end: + type: string + format: date + readOnly: true + nullable: true + description: The end date of the contract. + active: + type: boolean + readOnly: true + description: Whether this contract is active or not. Date rules still apply. + slug: + type: string + readOnly: true + description: The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/ + pattern: ^[-\w]+$ + welcome_message: + type: string + readOnly: true + description: A welcome message for learners. + welcome_message_extra: + type: string + readOnly: true + description: Additional welcome message content for learners. + integration_type: + allOf: + - $ref: '#/components/schemas/IntegrationTypeEnum' + readOnly: true + description: |- + The type of integration for this contract. + + * `sso` - SSO + * `non-sso` - Non-SSO + * `managed` - Managed + * `code` - Enrollment Code + * `auto` - Auto Enrollment + programs: + type: array + items: + type: integer + readOnly: true + attachment_percentage: + type: number + format: double + nullable: true + description: Calculate attachment percentage if seat-limited. + readOnly: true + total_enrollments: + type: integer + description: Get total number of enrollments across all contract course + runs. + readOnly: true + total_codes: + type: integer + description: Get total number of discount codes for this contract. + readOnly: true + required: + - active + - attachment_percentage + - contract_end + - contract_start + - description + - id + - integration_type + - membership_type + - name + - organization + - programs + - slug + - total_codes + - total_enrollments + - welcome_message + - welcome_message_extra Nested: type: object properties: diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index d111124152..c96ce624a2 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -193,6 +193,186 @@ paths: schema: $ref: '#/components/schemas/CreateB2BEnrollment' description: '' + /api/v0/b2b/manager/organizations/: + get: + operationId: b2b_manager_organizations_list + description: List managed organizations + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OrganizationPage' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/: + get: + operationId: b2b_manager_organizations_contracts_list + description: List an organization's contracts. + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BaseContractPage' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/: + get: + operationId: b2b_manager_organizations_contracts_retrieve + description: List an organization's contracts. + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/: + get: + operationId: b2b_manager_organizations_contracts_codes_retrieve + description: |- + List enrollment codes for a contract. + + Only shows codes for contracts that require them (non-auto membership types). + Logic varies based on whether contract has learner limits. + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/course_runs/: + get: + operationId: b2b_manager_organizations_contracts_course_runs_retrieve + description: |- + List course runs available for a specific contract. + + GET /api/v0/b2b/orgs/{org_id}/manager/contracts/{contract_id}/course_runs/ + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/course_runs/{course_run_id}/enrollments/: + get: + operationId: b2b_manager_organizations_contracts_course_runs_enrollments_retrieve + description: List enrollments for a specific course run within a contract. + parameters: + - in: path + name: course_run_id + schema: + type: string + description: Courseware ID to pull enrollments for. + required: true + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{id}/: + get: + operationId: b2b_manager_organizations_detail + description: Retrieve managed organizations + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationPage' + description: '' /api/v0/b2b/organizations/: get: operationId: b2b_organizations_list @@ -3273,6 +3453,58 @@ components: x-enum-descriptions: - anytime - dated + BaseContractPage: + type: object + description: Simplified serializer for the ContractPage model. + properties: + id: + type: integer + readOnly: true + name: + type: string + readOnly: true + description: The name of the contract. + description: + type: string + readOnly: true + description: Any useful extra information about the contract. + membership_type: + type: string + organization: + type: integer + readOnly: true + description: The organization that owns this contract. + contract_start: + type: string + format: date + readOnly: true + nullable: true + description: The start date of the contract. + contract_end: + type: string + format: date + readOnly: true + nullable: true + description: The end date of the contract. + active: + type: boolean + readOnly: true + description: Whether this contract is active or not. Date rules still apply. + slug: + type: string + readOnly: true + description: The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/ + pattern: ^[-\w]+$ + required: + - active + - contract_end + - contract_start + - description + - id + - membership_type + - name + - organization + - slug BaseCourse: type: object description: Basic course model serializer @@ -3657,26 +3889,6 @@ components: type: string readOnly: true description: Any useful extra information about the contract. - welcome_message: - type: string - readOnly: true - description: A welcome message for learners. - welcome_message_extra: - type: string - readOnly: true - description: Additional welcome message content for learners. - integration_type: - allOf: - - $ref: '#/components/schemas/IntegrationTypeEnum' - readOnly: true - description: |- - The type of integration for this contract. - - * `sso` - SSO - * `non-sso` - Non-SSO - * `managed` - Managed - * `code` - Enrollment Code - * `auto` - Auto Enrollment membership_type: type: string organization: @@ -3704,6 +3916,26 @@ components: readOnly: true description: The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/ pattern: ^[-\w]+$ + welcome_message: + type: string + readOnly: true + description: A welcome message for learners. + welcome_message_extra: + type: string + readOnly: true + description: Additional welcome message content for learners. + integration_type: + allOf: + - $ref: '#/components/schemas/IntegrationTypeEnum' + readOnly: true + description: |- + The type of integration for this contract. + + * `sso` - SSO + * `non-sso` - Non-SSO + * `managed` - Managed + * `code` - Enrollment Code + * `auto` - Auto Enrollment programs: type: array items: @@ -5349,6 +5581,105 @@ components: - quantity - total_price - unit_price + ManagerContractDetail: + type: object + description: Serializer for detailed contract view with statistics. + properties: + id: + type: integer + readOnly: true + name: + type: string + readOnly: true + description: The name of the contract. + description: + type: string + readOnly: true + description: Any useful extra information about the contract. + membership_type: + type: string + organization: + type: integer + readOnly: true + description: The organization that owns this contract. + contract_start: + type: string + format: date + readOnly: true + nullable: true + description: The start date of the contract. + contract_end: + type: string + format: date + readOnly: true + nullable: true + description: The end date of the contract. + active: + type: boolean + readOnly: true + description: Whether this contract is active or not. Date rules still apply. + slug: + type: string + readOnly: true + description: The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/ + pattern: ^[-\w]+$ + welcome_message: + type: string + readOnly: true + description: A welcome message for learners. + welcome_message_extra: + type: string + readOnly: true + description: Additional welcome message content for learners. + integration_type: + allOf: + - $ref: '#/components/schemas/IntegrationTypeEnum' + readOnly: true + description: |- + The type of integration for this contract. + + * `sso` - SSO + * `non-sso` - Non-SSO + * `managed` - Managed + * `code` - Enrollment Code + * `auto` - Auto Enrollment + programs: + type: array + items: + type: integer + readOnly: true + attachment_percentage: + type: number + format: double + nullable: true + description: Calculate attachment percentage if seat-limited. + readOnly: true + total_enrollments: + type: integer + description: Get total number of enrollments across all contract course + runs. + readOnly: true + total_codes: + type: integer + description: Get total number of discount codes for this contract. + readOnly: true + required: + - active + - attachment_percentage + - contract_end + - contract_start + - description + - id + - integration_type + - membership_type + - name + - organization + - programs + - slug + - total_codes + - total_enrollments + - welcome_message + - welcome_message_extra Nested: type: object properties: diff --git a/openapi/specs/v2.yaml b/openapi/specs/v2.yaml index 973858ebfa..15b6263c22 100644 --- a/openapi/specs/v2.yaml +++ b/openapi/specs/v2.yaml @@ -193,6 +193,186 @@ paths: schema: $ref: '#/components/schemas/CreateB2BEnrollment' description: '' + /api/v0/b2b/manager/organizations/: + get: + operationId: b2b_manager_organizations_list + description: List managed organizations + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/OrganizationPage' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/: + get: + operationId: b2b_manager_organizations_contracts_list + description: List an organization's contracts. + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/BaseContractPage' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/: + get: + operationId: b2b_manager_organizations_contracts_retrieve + description: List an organization's contracts. + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/codes/: + get: + operationId: b2b_manager_organizations_contracts_codes_retrieve + description: |- + List enrollment codes for a contract. + + Only shows codes for contracts that require them (non-auto membership types). + Logic varies based on whether contract has learner limits. + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/course_runs/: + get: + operationId: b2b_manager_organizations_contracts_course_runs_retrieve + description: |- + List course runs available for a specific contract. + + GET /api/v0/b2b/orgs/{org_id}/manager/contracts/{contract_id}/course_runs/ + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{parent_lookup_organization}/contracts/{id}/course_runs/{course_run_id}/enrollments/: + get: + operationId: b2b_manager_organizations_contracts_course_runs_enrollments_retrieve + description: List enrollments for a specific course run within a contract. + parameters: + - in: path + name: course_run_id + schema: + type: string + description: Courseware ID to pull enrollments for. + required: true + - in: path + name: id + schema: + type: integer + description: ID of the contract + required: true + - in: path + name: parent_lookup_organization + schema: + type: integer + description: ID of the parent organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ManagerContractDetail' + description: '' + /api/v0/b2b/manager/organizations/{id}/: + get: + operationId: b2b_manager_organizations_detail + description: Retrieve managed organizations + parameters: + - in: path + name: id + schema: + type: integer + description: ID of the organization + required: true + tags: + - b2b + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/OrganizationPage' + description: '' /api/v0/b2b/organizations/: get: operationId: b2b_organizations_list @@ -3273,6 +3453,58 @@ components: x-enum-descriptions: - anytime - dated + BaseContractPage: + type: object + description: Simplified serializer for the ContractPage model. + properties: + id: + type: integer + readOnly: true + name: + type: string + readOnly: true + description: The name of the contract. + description: + type: string + readOnly: true + description: Any useful extra information about the contract. + membership_type: + type: string + organization: + type: integer + readOnly: true + description: The organization that owns this contract. + contract_start: + type: string + format: date + readOnly: true + nullable: true + description: The start date of the contract. + contract_end: + type: string + format: date + readOnly: true + nullable: true + description: The end date of the contract. + active: + type: boolean + readOnly: true + description: Whether this contract is active or not. Date rules still apply. + slug: + type: string + readOnly: true + description: The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/ + pattern: ^[-\w]+$ + required: + - active + - contract_end + - contract_start + - description + - id + - membership_type + - name + - organization + - slug BaseCourse: type: object description: Basic course model serializer @@ -3657,26 +3889,6 @@ components: type: string readOnly: true description: Any useful extra information about the contract. - welcome_message: - type: string - readOnly: true - description: A welcome message for learners. - welcome_message_extra: - type: string - readOnly: true - description: Additional welcome message content for learners. - integration_type: - allOf: - - $ref: '#/components/schemas/IntegrationTypeEnum' - readOnly: true - description: |- - The type of integration for this contract. - - * `sso` - SSO - * `non-sso` - Non-SSO - * `managed` - Managed - * `code` - Enrollment Code - * `auto` - Auto Enrollment membership_type: type: string organization: @@ -3704,6 +3916,26 @@ components: readOnly: true description: The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/ pattern: ^[-\w]+$ + welcome_message: + type: string + readOnly: true + description: A welcome message for learners. + welcome_message_extra: + type: string + readOnly: true + description: Additional welcome message content for learners. + integration_type: + allOf: + - $ref: '#/components/schemas/IntegrationTypeEnum' + readOnly: true + description: |- + The type of integration for this contract. + + * `sso` - SSO + * `non-sso` - Non-SSO + * `managed` - Managed + * `code` - Enrollment Code + * `auto` - Auto Enrollment programs: type: array items: @@ -5349,6 +5581,105 @@ components: - quantity - total_price - unit_price + ManagerContractDetail: + type: object + description: Serializer for detailed contract view with statistics. + properties: + id: + type: integer + readOnly: true + name: + type: string + readOnly: true + description: The name of the contract. + description: + type: string + readOnly: true + description: Any useful extra information about the contract. + membership_type: + type: string + organization: + type: integer + readOnly: true + description: The organization that owns this contract. + contract_start: + type: string + format: date + readOnly: true + nullable: true + description: The start date of the contract. + contract_end: + type: string + format: date + readOnly: true + nullable: true + description: The end date of the contract. + active: + type: boolean + readOnly: true + description: Whether this contract is active or not. Date rules still apply. + slug: + type: string + readOnly: true + description: The name of the page as it will appear in URLs e.g http://domain.com/blog/[my-slug]/ + pattern: ^[-\w]+$ + welcome_message: + type: string + readOnly: true + description: A welcome message for learners. + welcome_message_extra: + type: string + readOnly: true + description: Additional welcome message content for learners. + integration_type: + allOf: + - $ref: '#/components/schemas/IntegrationTypeEnum' + readOnly: true + description: |- + The type of integration for this contract. + + * `sso` - SSO + * `non-sso` - Non-SSO + * `managed` - Managed + * `code` - Enrollment Code + * `auto` - Auto Enrollment + programs: + type: array + items: + type: integer + readOnly: true + attachment_percentage: + type: number + format: double + nullable: true + description: Calculate attachment percentage if seat-limited. + readOnly: true + total_enrollments: + type: integer + description: Get total number of enrollments across all contract course + runs. + readOnly: true + total_codes: + type: integer + description: Get total number of discount codes for this contract. + readOnly: true + required: + - active + - attachment_percentage + - contract_end + - contract_start + - description + - id + - integration_type + - membership_type + - name + - organization + - programs + - slug + - total_codes + - total_enrollments + - welcome_message + - welcome_message_extra Nested: type: object properties: From edbc084a5fed2911844585d88da59490d2066c6f Mon Sep 17 00:00:00 2001 From: James Kachel Date: Mon, 6 Apr 2026 15:59:31 -0500 Subject: [PATCH 4/8] Update the DRF linter config to look at serialiers in a serializer folder (#3458) --- .pre-commit-config.yaml | 2 +- drf_lint_baseline.json | 65 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1cdf9e4f68..b23b92e593 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -73,7 +73,7 @@ repos: entry: drf-lint args: [--baseline, drf_lint_baseline.json] language: python - files: 'serializers\.py$' + files: '(serializers\.py$|serializers/.*\.py$)' additional_dependencies: - mitol-drf-lint - "setuptools<82" diff --git a/drf_lint_baseline.json b/drf_lint_baseline.json index ae26698361..00c023b938 100644 --- a/drf_lint_baseline.json +++ b/drf_lint_baseline.json @@ -1,4 +1,7 @@ [ + "b2b/serializers/v0/__init__.py:164:12:ORM002", + "b2b/serializers/v0/__init__.py:27:20:ORM002", + "b2b/serializers/v0/__init__.py:75:27:ORM002", "cms/serializers.py:114:16:ORM001", "cms/serializers.py:153:41:ORM001", "cms/serializers.py:306:12:ORM001", @@ -6,6 +9,68 @@ "cms/serializers.py:352:39:ORM001", "cms/serializers.py:83:12:ORM001", "cms/serializers.py:96:12:ORM001", + "courses/serializers/base.py:53:16:ORM001", + "courses/serializers/v1/base.py:69:20:ORM002", + "courses/serializers/v1/courses.py:171:18:ORM001", + "courses/serializers/v1/courses.py:57:16:ORM001", + "courses/serializers/v1/programs.py:181:12:ORM001", + "courses/serializers/v1/programs.py:196:12:ORM001", + "courses/serializers/v1/programs.py:208:12:ORM001", + "courses/serializers/v1/programs.py:300:23:ORM001", + "courses/serializers/v1/programs.py:307:27:ORM001", + "courses/serializers/v1/programs.py:322:16:ORM001", + "courses/serializers/v1/programs.py:332:16:ORM001", + "courses/serializers/v1/programs.py:346:17:ORM001", + "courses/serializers/v1/programs.py:368:16:ORM001", + "courses/serializers/v2/courses.py:285:17:ORM002", + "courses/serializers/v2/courses.py:300:21:ORM002", + "courses/serializers/v2/courses.py:366:18:ORM001", + "courses/serializers/v2/departments.py:35:40:ORM002", + "courses/serializers/v2/departments.py:49:42:ORM002", + "courses/serializers/v2/programs.py:199:48:ORM002", + "courses/serializers/v2/programs.py:205:30:ORM001", + "courses/serializers/v2/programs.py:339:20:ORM002", + "courses/serializers/v2/programs.py:344:20:ORM002", + "courses/serializers/v2/programs.py:483:27:ORM002", + "courses/serializers/v2/programs.py:499:53:ORM002", + "courses/serializers/v2/programs.py:619:12:ORM002", + "courses/serializers/v3/courses.py:111:14:ORM001", + "courses/serializers/v3/courses.py:55:12:ORM002", + "courses/serializers/v3/programs.py:55:22:ORM001", + "ecommerce/serializers/__init__.py:199:17:ORM001", + "ecommerce/serializers/__init__.py:201:18:ORM001", + "ecommerce/serializers/__init__.py:202:18:ORM001", + "ecommerce/serializers/__init__.py:224:26:ORM002", + "ecommerce/serializers/__init__.py:312:26:ORM002", + "ecommerce/serializers/__init__.py:320:31:ORM002", + "ecommerce/serializers/__init__.py:326:20:ORM002", + "ecommerce/serializers/__init__.py:331:31:ORM002", + "ecommerce/serializers/__init__.py:346:31:ORM002", + "ecommerce/serializers/__init__.py:417:24:ORM002", + "ecommerce/serializers/__init__.py:436:12:ORM001", + "ecommerce/serializers/__init__.py:460:22:ORM002", + "ecommerce/serializers/__init__.py:507:22:ORM002", + "ecommerce/serializers/__init__.py:571:20:ORM002", + "ecommerce/serializers/__init__.py:704:22:ORM002", + "ecommerce/serializers/__init__.py:821:28:ORM002", + "ecommerce/serializers/__init__.py:891:28:ORM002", + "ecommerce/serializers/v0/__init__.py:270:17:ORM001", + "ecommerce/serializers/v0/__init__.py:272:18:ORM001", + "ecommerce/serializers/v0/__init__.py:273:18:ORM001", + "ecommerce/serializers/v0/__init__.py:295:26:ORM002", + "ecommerce/serializers/v0/__init__.py:383:26:ORM002", + "ecommerce/serializers/v0/__init__.py:392:35:ORM002", + "ecommerce/serializers/v0/__init__.py:399:20:ORM002", + "ecommerce/serializers/v0/__init__.py:405:35:ORM002", + "ecommerce/serializers/v0/__init__.py:421:31:ORM002", + "ecommerce/serializers/v0/__init__.py:493:24:ORM002", + "ecommerce/serializers/v0/__init__.py:512:12:ORM001", + "ecommerce/serializers/v0/__init__.py:536:22:ORM002", + "ecommerce/serializers/v0/__init__.py:583:22:ORM002", + "ecommerce/serializers/v0/__init__.py:648:20:ORM002", + "ecommerce/serializers/v0/__init__.py:798:22:ORM002", + "ecommerce/serializers/v0/__init__.py:82:28:ORM002", + "ecommerce/serializers/v0/__init__.py:942:28:ORM002", "flexiblepricing/serializers.py:129:38:ORM001", "flexiblepricing/serializers.py:132:34:ORM001", "flexiblepricing/serializers.py:147:34:ORM001", From 436b950d30948e65b0f108d6f704571ae0e04ce2 Mon Sep 17 00:00:00 2001 From: annagav Date: Tue, 7 Apr 2026 10:07:49 -0400 Subject: [PATCH 5/8] Update program requirement box for Public Policy (#3457) --- frontend/public/scss/product-page/product-details.scss | 4 +++- frontend/public/src/components/ProgramInfoBox.js | 3 +-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/frontend/public/scss/product-page/product-details.scss b/frontend/public/scss/product-page/product-details.scss index e55c67bb7d..96cf236608 100644 --- a/frontend/public/scss/product-page/product-details.scss +++ b/frontend/public/scss/product-page/product-details.scss @@ -356,7 +356,9 @@ body.new-design { width: min-content; text-align: right; } - + .enrollment-info-text.enrollment-requirement { + max-width: 340px; + } .enrollment-info-text { width: auto; flex-grow: 1; diff --git a/frontend/public/src/components/ProgramInfoBox.js b/frontend/public/src/components/ProgramInfoBox.js index 3f08075c99..33c61049a3 100644 --- a/frontend/public/src/components/ProgramInfoBox.js +++ b/frontend/public/src/components/ProgramInfoBox.js @@ -93,7 +93,6 @@ export default class ProgramInfoBox extends React.PureComponent 0) { const electives = this.getReqNode(false) - if (electives.data.operator !== "all_of") { electiveCountPrefix = `${electives.data.operator_value} of ` } @@ -109,7 +108,7 @@ export default class ProgramInfoBox extends React.PureComponent -
+
{reqCount} {this.getRequiredTitle()}: Complete All {electiveCount > 0 ? ( <> From 5c55bef31a8616be34ca0169f5d4b88779cc4e32 Mon Sep 17 00:00:00 2001 From: James Kachel Date: Tue, 7 Apr 2026 14:26:08 -0500 Subject: [PATCH 6/8] Move call for a content type back into the viewset; fix some n+1 errors (#3465) --- b2b/models.py | 21 ++++++++++ b2b/serializers/v0/__init__.py | 72 ++-------------------------------- b2b/views/v0/__init__.py | 25 ++++++++++-- b2b/views/v0/manager.py | 32 ++++++++++----- b2b/views/v0/manager_test.py | 10 ++++- 5 files changed, 77 insertions(+), 83 deletions(-) diff --git a/b2b/models.py b/b2b/models.py index 59df315923..1caac5efff 100644 --- a/b2b/models.py +++ b/b2b/models.py @@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.http import Http404 +from django.utils.functional import cached_property from django.utils.text import slugify from mitol.common.models import TimestampedModel from mitol.common.utils import now_in_utc @@ -195,6 +196,16 @@ def __str__(self): return f"{self.name} <{self.org_key}>" + @cached_property + def active_contracts(self): + """Returns the active contracts for the organization.""" + + return ( + self._active_contracts + if hasattr(self, "_active_contracts") + else self.contracts.filter(active=True).all() + ) + class Meta: """Meta options for the OrganizationPage.""" @@ -341,6 +352,16 @@ def programs(self): "contract_memberships__sort_order" ) + @cached_property + def contract_program_ids(self): + """Return the contract's programs in the proper order.""" + + return ( + self._contract_program_ids + if hasattr(self, "_contract_program_ids") + else self.contract_programs.order_by("sort_order").all() + ) + content_panels = [ FieldPanel("name"), MultiFieldPanel( diff --git a/b2b/serializers/v0/__init__.py b/b2b/serializers/v0/__init__.py index 89292312ac..fca6d20a25 100644 --- a/b2b/serializers/v0/__init__.py +++ b/b2b/serializers/v0/__init__.py @@ -3,8 +3,7 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from b2b.models import ContractPage, OrganizationPage, UserOrganization -from cms.api import get_wagtail_img_src +from b2b.models import ContractPage, OrganizationPage from main.constants import USER_MSG_TYPE_B2B_CHOICES from main.serializers import RichTextSerializer @@ -55,7 +54,7 @@ class ContractPageSerializer(BaseContractPageSerializer): @extend_schema_field(serializers.ListField(child=serializers.IntegerField())) def get_programs(self, instance): """Get the ordered list of program IDs for this contract""" - return list(instance.programs.values_list("id", flat=True)) + return [program.program_id for program in instance.contract_program_ids] class Meta: model = ContractPage @@ -85,8 +84,7 @@ class OrganizationPageSerializer(serializers.ModelSerializer): @extend_schema_field(ContractPageSerializer(many=True)) def get_contracts(self, instance): """Get only active contracts for the organization""" - active_contracts = instance.contracts.filter(active=True) - return ContractPageSerializer(active_contracts, many=True).data + return ContractPageSerializer(instance.active_contracts, many=True).data class Meta: model = OrganizationPage @@ -151,67 +149,3 @@ class CreateB2BEnrollmentSerializer(serializers.Serializer): max_digits=None, decimal_places=2, read_only=True, required=False ) checkout_result = GenerateCheckoutPayloadSerializer(required=False) - - -class UserOrganizationSerializer(serializers.ModelSerializer): - """ - Serializer for user organization data. - - Return the user's organizations in a manner that makes them look like - OrganizationPage objects. (Previously, the user organizations were a queryset - of OrganizationPages that related to the user, but now we have a through - table.) - """ - - contracts = serializers.SerializerMethodField() - id = serializers.IntegerField(source="organization.id") - name = serializers.CharField(source="organization.name") - description = serializers.CharField(source="organization.description") - logo = serializers.SerializerMethodField() - slug = serializers.CharField(source="organization.slug") - - @extend_schema_field(ContractPageSerializer(many=True)) - def get_contracts(self, instance): - """Get the contracts for the organization for the user""" - contracts = ( - self.context["user"] - .b2b_contracts.filter( - organization=instance.organization, - active=True, - ) - .all() - ) - return ContractPageSerializer(contracts, many=True).data - - @extend_schema_field(str) - def get_logo(self, instance): - """Get logo""" - - if hasattr(instance.organization, "logo"): - try: - return get_wagtail_img_src(instance.organization.logo) - except AttributeError: - pass - - return None - - class Meta: - """Meta opts for the serializer.""" - - model = UserOrganization - fields = [ - "id", - "name", - "description", - "logo", - "slug", - "contracts", - ] - read_only_fields = [ - "id", - "name", - "description", - "logo", - "slug", - "contracts", - ] diff --git a/b2b/views/v0/__init__.py b/b2b/views/v0/__init__.py index 464aa29b6d..a7ace742b5 100644 --- a/b2b/views/v0/__init__.py +++ b/b2b/views/v0/__init__.py @@ -1,7 +1,7 @@ """Views for the B2B API (v0).""" from django.contrib.contenttypes.models import ContentType -from django.db.models import Count, Q +from django.db.models import Count, Prefetch, Q from django.views.decorators.csrf import csrf_exempt from drf_spectacular.utils import extend_schema from mitol.common.utils.datetime import now_in_utc @@ -14,6 +14,7 @@ from b2b.api import create_b2b_enrollment, process_add_org_membership from b2b.models import ( ContractPage, + ContractProgramItem, DiscountContractAttachmentRedemption, OrganizationPage, ) @@ -35,7 +36,19 @@ class OrganizationPageViewSet(viewsets.ReadOnlyModelViewSet): Viewset for the OrganizationPage model. """ - queryset = OrganizationPage.objects.all() + queryset = OrganizationPage.objects.prefetch_related( + Prefetch( + "contracts", + queryset=ContractPage.objects.prefetch_related( + Prefetch( + "contract_programs", + queryset=ContractProgramItem.objects.order_by("sort_order"), + to_attr="_contract_program_ids", + ) + ).filter(active=True), + to_attr="_active_contracts", + ) + ) serializer_class = OrganizationPageSerializer permission_classes = [IsAdminOrReadOnly | HasAPIKey] lookup_field = "slug" @@ -54,7 +67,13 @@ class ContractPageViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): """Filter to only return active contracts by default.""" - return ContractPage.objects.filter(active=True) + return ContractPage.objects.filter(active=True).prefetch_related( + Prefetch( + "contract_programs", + queryset=ContractProgramItem.objects.order_by("sort_order"), + to_attr="_contract_program_ids", + ) + ) class Enroll(APIView): diff --git a/b2b/views/v0/manager.py b/b2b/views/v0/manager.py index 2e5f933af1..69770f4f14 100644 --- a/b2b/views/v0/manager.py +++ b/b2b/views/v0/manager.py @@ -1,7 +1,7 @@ """B2B manager dashboard views.""" from django.contrib.contenttypes.models import ContentType -from django.db.models import Count, Exists, OuterRef, Subquery +from django.db.models import Count, Exists, OuterRef, Prefetch, Q, Subquery from django.shortcuts import get_object_or_404 from drf_spectacular.utils import ( OpenApiParameter, @@ -16,6 +16,7 @@ from b2b.constants import CONTRACT_MEMBERSHIP_AUTOS from b2b.models import ( ContractPage, + ContractProgramItem, DiscountContractAttachmentRedemption, OrganizationPage, ) @@ -33,8 +34,6 @@ from courses.models import CourseRun, CourseRunEnrollment from ecommerce.models import Discount -courserun_content_type = ContentType.objects.get_for_model(CourseRun) - class ManagerOrganizationViewSet(viewsets.ReadOnlyModelViewSet): """List organizations available for the current user.""" @@ -44,10 +43,24 @@ class ManagerOrganizationViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): """Filter to organizations where the user is a manager.""" + + org_qset = OrganizationPage.objects.prefetch_related( + Prefetch( + "contracts", + queryset=ContractPage.objects.prefetch_related( + Prefetch( + "contract_programs", + queryset=ContractProgramItem.objects.order_by("sort_order"), + to_attr="contract_program_ids", + ) + ).filter(active=True), + to_attr="_active_contracts", + ), + ) return ( - OrganizationPage.objects.distinct() + org_qset.distinct() if self.request.user and self.request.user.is_superuser - else OrganizationPage.objects.filter( + else org_qset.filter( organization_users__user=self.request.user, organization_users__is_manager=True, ).distinct() @@ -106,6 +119,7 @@ class ManagerContractViewSet(NestedViewSetMixin, viewsets.ReadOnlyModelViewSet): def get_queryset(self): """Get the queryset; add some annotations/etc for computed fields""" + courserun_content_type = ContentType.objects.get_for_model(CourseRun) return ( ContractPage.objects.select_related("organization") .prefetch_related("users") @@ -124,11 +138,9 @@ def get_queryset(self): ) .annotate( enrollment_count=Count( - Subquery( - CourseRunEnrollment.objects.filter( - run__b2b_contract=OuterRef("pk") - ).values("id") - ) + "course_runs__enrollments", + filter=Q(course_runs__enrollments__active=True), + distinct=True, ) ) .filter( diff --git a/b2b/views/v0/manager_test.py b/b2b/views/v0/manager_test.py index 2967559b84..d7aaa94404 100644 --- a/b2b/views/v0/manager_test.py +++ b/b2b/views/v0/manager_test.py @@ -300,10 +300,18 @@ def test_org_contract_run_enrollments(org_setup, manager_drf_client): ), ], [ + CourseRunEnrollment.objects.create( + user=users_to_enroll[0], + run=runs[1], + ), + CourseRunEnrollment.objects.create( + user=users_to_enroll[1], + run=runs[1], + ), CourseRunEnrollment.objects.create( user=users_to_enroll[2], run=runs[1], - ) + ), ], ] From 2baf9ee308ebb4132ebc81b5136928ac1c3fc625 Mon Sep 17 00:00:00 2001 From: Nathan Levesque Date: Tue, 7 Apr 2026 15:47:25 -0500 Subject: [PATCH 7/8] Fix github actions django env vars (#3463) --- .github/workflows/ci.yml | 44 +++++++++++----------------------------- 1 file changed, 12 insertions(+), 32 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d58bc163ca..1680c12168 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,38 +52,7 @@ jobs: run: | celery -A main worker -B -l INFO & sleep 10 - env: - CELERY_TASK_ALWAYS_EAGER: 'True' - CELERY_BROKER_URL: redis://localhost:6379/4 - CELERY_RESULT_BACKEND: redis://localhost:6379/4 - SECRET_KEY: local_unsafe_key # pragma: allowlist secret - MITX_ONLINE_BASE_URL: http://localhost:8013 - MAILGUN_SENDER_DOMAIN: other.fake.site - MAILGUN_KEY: fake_mailgun_key - MITX_ONLINE_ADMIN_EMAIL: example@localhost - OPENEDX_API_CLIENT_ID: fake_client_id - OPENEDX_API_CLIENT_SECRET: fake_client_secret # pragma: allowlist secret - - - name: Django system checks - run: uv run ./manage.py check --fail-level WARNING - env: - CELERY_TASK_ALWAYS_EAGER: 'True' - CELERY_BROKER_URL: redis://localhost:6379/4 - CELERY_RESULT_BACKEND: redis://localhost:6379/4 - SECRET_KEY: local_unsafe_key # pragma: allowlist secret - MITX_ONLINE_BASE_URL: http://localhost:8013 - MAILGUN_SENDER_DOMAIN: other.fake.site - MAILGUN_KEY: fake_mailgun_key - MITX_ONLINE_ADMIN_EMAIL: example@localhost - OPENEDX_API_CLIENT_ID: fake_client_id - OPENEDX_API_CLIENT_SECRET: fake_client_secret # pragma: allowlist secret - - - name: Tests - run: | - export MEDIA_ROOT="$(mktemp -d)" - cp scripts/test/data/webpack-stats/* webpack-stats/ - ./scripts/test/python_tests.sh - env: + env: &django-env-vars DEBUG: False NODE_ENV: 'production' CELERY_TASK_ALWAYS_EAGER: 'True' @@ -104,6 +73,17 @@ jobs: OPENEDX_API_CLIENT_SECRET: fake_client_secret # pragma: allowlist secret SECRET_KEY: local_unsafe_key # pragma: allowlist secret + - name: Django system checks + run: uv run ./manage.py check --fail-level WARNING + env: *django-env-vars + + - name: Tests + run: | + export MEDIA_ROOT="$(mktemp -d)" + cp scripts/test/data/webpack-stats/* webpack-stats/ + ./scripts/test/python_tests.sh + env: *django-env-vars + javascript-tests: runs-on: ubuntu-24.04 steps: From 1d22914f2799e954c9e315fe8bbccbffa5c18cfe Mon Sep 17 00:00:00 2001 From: Doof Date: Tue, 7 Apr 2026 20:48:04 +0000 Subject: [PATCH 8/8] Release 1.145.1 --- RELEASE.rst | 11 +++++++++++ main/settings.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/RELEASE.rst b/RELEASE.rst index 4a13e129d0..7b7882cf53 100644 --- a/RELEASE.rst +++ b/RELEASE.rst @@ -1,6 +1,17 @@ Release Notes ============= +Version 1.145.1 +--------------- + +- Fix github actions django env vars (#3463) +- Move call for a content type back into the viewset; fix some n+1 errors (#3465) +- Update program requirement box for Public Policy (#3457) +- Update the DRF linter config to look at serialiers in a serializer folder (#3458) +- Add API support for a B2B contract management dashboard (#3424) +- Fix issues with the program certificate audit courses test (#3460) +- feat: list filter b2b programs (#3456) + Version 1.144.5 (Released April 02, 2026) --------------- diff --git a/main/settings.py b/main/settings.py index a5f77f01be..59feeb1403 100644 --- a/main/settings.py +++ b/main/settings.py @@ -37,7 +37,7 @@ from main.sentry import init_sentry from openapi.settings_spectacular import open_spectacular_settings -VERSION = "1.144.5" +VERSION = "1.145.1" log = logging.getLogger()