Skip to content
Open
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
168 changes: 168 additions & 0 deletions openedx/core/djangoapps/enrollments/tests/test_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
"""
Test for CourseEnrollmentViewStarted filter in enrollment views.
"""
import json

from django.test import override_settings
from django.urls import reverse
from openedx_filters import PipelineStep
from openedx_filters.learning.filters import CourseEnrollmentViewStarted
from rest_framework import status
from rest_framework.test import APITestCase

from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.tests.factories import UserFactory, UserProfileFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory

PATH = "openedx.core.djangoapps.enrollments.tests.test_filters.TestCourseEnrollmentViewStartedPipelineStep"


class TestCourseEnrollmentViewStartedPipelineStep(PipelineStep):
"""
Utility pipeline step that prevents enrollment via CourseEnrollmentViewStarted filter.
"""

def run_filter(self, user, course_key, requester_is_backend_service): # pylint: disable=arguments-differ
"""Pipeline step that prevents enrollment for specific users or conditions."""
if user.username == "blocked_user":
raise CourseEnrollmentViewStarted.PreventEnrollment(
"User is not allowed to enroll in this course."
)
# Set a side effect on the user to verify the filter was executed
user.profile.set_meta({"enrollment_filter_executed": True})
user.profile.save()
return {
"user": user,
"course_key": course_key,
"requester_is_backend_service": requester_is_backend_service,
}


@skip_unless_lms
class CourseEnrollmentViewStartedFilterTest(APITestCase, ModuleStoreTestCase):
"""
Tests for the CourseEnrollmentViewStarted filter in the enrollment view.

This class guarantees that the following filters are triggered when attempting
to enroll a user through the enrollment API endpoint:

- CourseEnrollmentViewStarted
"""

def setUp(self): # pylint: disable=arguments-differ
super().setUp()
self.course = CourseFactory.create(emit_signals=True)
self.user = UserFactory.create(
username="test_user",
email="test@example.com",
password="password",
)
self.blocked_user = UserFactory.create(
username="blocked_user",
email="blocked@example.com",
password="password",
)
UserProfileFactory.create(user=self.user, name="Test User")
UserProfileFactory.create(user=self.blocked_user, name="Blocked User")

# Create course modes
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='audit',
mode_display_name='Audit'
)

@override_settings(
OPEN_EDX_FILTERS_CONFIG={
"org.openedx.learning.course.enrollment.view.started.v1": {
"pipeline": [
PATH,
],
"fail_silently": False,
},
},
)
def test_course_enrollment_view_started_filter_executed(self):
"""
Test that the CourseEnrollmentViewStarted filter is executed when enrolling through the view.

Expected result:
- CourseEnrollmentViewStarted is triggered and executes the pipeline step.
- The enrollment is created successfully if the filter allows it.
- The filter pipeline step sets a side effect on the user's profile.
"""
self.client.login(username=self.user.username, password="password")

response = self.client.post(
reverse('courseenrollments'),
{
"course_details": {"course_id": str(self.course.id)},
"mode": "audit",
},
format="json",
)

assert response.status_code == status.HTTP_200_OK
# Verify the filter was executed by checking the side effect
self.user.profile.refresh_from_db()
meta = self.user.profile.get_meta()
assert meta.get("enrollment_filter_executed") is True

@override_settings(
OPEN_EDX_FILTERS_CONFIG={
"org.openedx.learning.course.enrollment.view.started.v1": {
"pipeline": [
PATH,
],
"fail_silently": False,
},
},
)
def test_course_enrollment_view_started_filter_prevent_enrollment(self):
"""
Test that enrollment is prevented when filter raises PreventEnrollment.

Expected result:
- CourseEnrollmentViewStarted is triggered and executes the pipeline step.
- The pipeline step raises PreventEnrollment for blocked_user.
- The endpoint returns HTTP 403 Forbidden.
"""
self.client.login(username=self.blocked_user.username, password="password")

response = self.client.post(
reverse('courseenrollments'),
{
"course_details": {"course_id": str(self.course.id)},
"mode": "audit",
},
format="json",
)

assert response.status_code == status.HTTP_403_FORBIDDEN
response_data = json.loads(response.content.decode('utf-8'))
assert "message" in response_data
assert "User is not allowed to enroll" in response_data["message"]

@override_settings(OPEN_EDX_FILTERS_CONFIG={})
def test_course_enrollment_view_started_without_filter_configuration(self):
"""
Test enrollment without filter configuration (no filter interference).

Expected result:
- CourseEnrollmentViewStarted does not have any effect.
- The enrollment process succeeds without filter intervention.
"""
self.client.login(username=self.user.username, password="password")

response = self.client.post(
reverse('courseenrollments'),
{
"course_details": {"course_id": str(self.course.id)},
"mode": "audit",
},
format="json",
)

assert response.status_code == status.HTTP_200_OK
39 changes: 1 addition & 38 deletions openedx/core/djangoapps/enrollments/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from zoneinfo import ZoneInfo

import ddt
import httpretty
import pytest
from django.conf import settings
from django.core.cache import cache
Expand Down Expand Up @@ -42,8 +41,6 @@
from openedx.core.djangoapps.user_api.models import RetirementState, UserOrgTag, UserRetirementStatus
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.core.lib.django_test_client_utils import get_absolute_url
from openedx.features.enterprise_support.tests import FAKE_ENTERPRISE_CUSTOMER
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseServiceMockMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls_range

Expand Down Expand Up @@ -155,7 +152,7 @@ def _get_enrollments(self):
@override_settings(EDX_API_KEY="i am a key")
@ddt.ddt
@skip_unless_lms
class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase, EnterpriseServiceMockMixin):
class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase):
"""
Test user enrollment, especially with different course modes.
"""
Expand Down Expand Up @@ -1235,40 +1232,6 @@ def test_enrollment_with_global_staff_permissions(self, using_global_staff_user,
assert course_mode == CourseMode.VERIFIED
self.client.logout()

@httpretty.activate
@override_settings(ENTERPRISE_SERVICE_WORKER_USERNAME='enterprise_worker',
FEATURES=dict(ENABLE_ENTERPRISE_INTEGRATION=True))
@patch('openedx.features.enterprise_support.api.enterprise_customer_from_api')
def test_enterprise_course_enrollment_with_ec_uuid(self, mock_enterprise_customer_from_api):
"""Verify that the enrollment completes when the EnterpriseCourseEnrollment creation succeeds. """
UserFactory.create(
username='enterprise_worker',
email=self.EMAIL,
password=self.PASSWORD,
)
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=CourseMode.DEFAULT_MODE_SLUG,
mode_display_name=CourseMode.DEFAULT_MODE_SLUG,
)
consent_kwargs = {
'username': self.user.username,
'course_id': str(self.course.id),
'ec_uuid': 'this-is-a-real-uuid'
}
mock_enterprise_customer_from_api.return_value = FAKE_ENTERPRISE_CUSTOMER
self.mock_enterprise_course_enrollment_post_api()
self.mock_consent_missing(**consent_kwargs)
self.mock_consent_post(**consent_kwargs)
self.assert_enrollment_status(
expected_status=status.HTTP_200_OK,
as_server=True,
username='enterprise_worker',
linked_enterprise_customer='this-is-a-real-uuid',
)
assert httpretty.last_request().path == '/consent/api/v1/data_sharing_consent' # pylint: disable=no-member
assert httpretty.last_request().method == httpretty.POST

def test_enrollment_attributes_always_written(self):
""" Enrollment attributes should always be written, regardless of whether
the enrollment is being created or updated.
Expand Down
67 changes: 27 additions & 40 deletions openedx/core/djangoapps/enrollments/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,27 @@

import logging

from django.core.exceptions import ( # pylint: disable=wrong-import-order
from django.core.exceptions import ( # lint-amnesty, pylint: disable=wrong-import-order
ObjectDoesNotExist,
ValidationError,
)
from django.db import IntegrityError # pylint: disable=wrong-import-order
from django.db.models import Q # pylint: disable=wrong-import-order
from django.utils.decorators import method_decorator # pylint: disable=wrong-import-order
from edx_rest_framework_extensions.auth.jwt.authentication import ( # pylint: disable=wrong-import-order
from django.db import IntegrityError # lint-amnesty, pylint: disable=wrong-import-order
from django.db.models import Q # lint-amnesty, pylint: disable=wrong-import-order
from django.utils.decorators import method_decorator # lint-amnesty, pylint: disable=wrong-import-order
from edx_rest_framework_extensions.auth.jwt.authentication import ( # lint-amnesty, pylint: disable=wrong-import-order
JwtAuthentication,
)
from edx_rest_framework_extensions.auth.session.authentication import ( # pylint: disable=wrong-import-order
from edx_rest_framework_extensions.auth.session.authentication import ( # lint-amnesty, pylint: disable=wrong-import-order
SessionAuthenticationAllowInactiveUser,
)
from opaque_keys import InvalidKeyError # pylint: disable=wrong-import-order
from opaque_keys.edx.keys import CourseKey # pylint: disable=wrong-import-order
from rest_framework import permissions, status # pylint: disable=wrong-import-order
from rest_framework.generics import ListAPIView # pylint: disable=wrong-import-order
from rest_framework.response import Response # pylint: disable=wrong-import-order
from rest_framework.throttling import UserRateThrottle # pylint: disable=wrong-import-order
from rest_framework.views import APIView # pylint: disable=wrong-import-order
from opaque_keys import InvalidKeyError # lint-amnesty, pylint: disable=wrong-import-order
from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=wrong-import-order
from openedx_filters.learning.filters import CourseEnrollmentViewStarted
from rest_framework import permissions, status # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.generics import ListAPIView # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.throttling import UserRateThrottle # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong-import-order

from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.auth import user_has_role
Expand Down Expand Up @@ -57,12 +58,6 @@
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
from openedx.core.lib.exceptions import CourseNotFoundError
from openedx.core.lib.log_utils import audit_log
from openedx.features.enterprise_support.api import (
ConsentApiServiceClient,
EnterpriseApiException,
EnterpriseApiServiceClient,
enterprise_enabled,
)

log = logging.getLogger(__name__)
REQUIRED_ATTRIBUTES = {
Expand Down Expand Up @@ -774,26 +769,18 @@ def post(self, request):
data={"message": ("'{value}' is an invalid enrollment activation status.").format(value=is_active)}, # noqa: UP032 # pylint: disable=line-too-long
)

explicit_linked_enterprise = request.data.get("linked_enterprise_customer")
if explicit_linked_enterprise and has_api_key_permissions and enterprise_enabled():
enterprise_api_client = EnterpriseApiServiceClient()
consent_client = ConsentApiServiceClient()
try:
enterprise_api_client.post_enterprise_course_enrollment(username, str(course_id))
except EnterpriseApiException as error:
log.exception(
"An unexpected error occurred while creating the new EnterpriseCourseEnrollment "
"for user [%s] in course run [%s]",
username,
course_id,
)
raise CourseEnrollmentError(str(error)) # pylint: disable=raise-missing-from # noqa: B904
kwargs = {
"username": username,
"course_id": str(course_id),
"enterprise_customer_uuid": explicit_linked_enterprise,
}
consent_client.provide_consent(**kwargs)
# Filter hook that allows plugins (e.g., Enterprise) to run enrollment-related logic
# and optionally prevent enrollment via CourseEnrollmentViewStarted.PreventEnrollment.
try:
# .. filter_implemented_name: CourseEnrollmentViewStarted
# .. filter_type: org.openedx.learning.course.enrollment.view.started.v1
CourseEnrollmentViewStarted.run_filter(
user=user,
course_key=course_id,
requester_is_backend_service=has_api_key_permissions,
)
except CourseEnrollmentViewStarted.PreventEnrollment as exc:
raise EnrollmentNotAllowed(str(exc)) from exc

enrollment_attributes = request.data.get("enrollment_attributes")
force_enrollment = request.data.get("force_enrollment")
Expand Down Expand Up @@ -924,7 +911,7 @@ def post(self, request):
log.exception("Missing cohort [%s] in course run [%s]", cohort_name, course_id)
return Response(
status=status.HTTP_400_BAD_REQUEST,
data={"message": "An error occured while adding to cohort [%s]" % cohort_name}, # noqa: UP031
data={"message": "An error occurred while adding to cohort [%s]" % cohort_name}, # noqa: UP031
)
finally:
# Assumes that the ecommerce service uses an API key to authenticate.
Expand Down
2 changes: 1 addition & 1 deletion requirements/constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ django-stubs<6
# The team that owns this package will manually bump this package rather than having it pulled in automatically.
# This is to allow them to better control its deployment and to do it in a process that works better
# for them.
edx-enterprise==8.3.0
edx-enterprise==8.4.0

# Date: 2023-07-26
# Our legacy Sass code is incompatible with anything except this ancient libsass version.
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ edx-drf-extensions==10.6.0
# enterprise-integrated-channels
# openedx-authz
# openedx-core
edx-enterprise==8.3.0
edx-enterprise==8.4.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/kernel.in
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/development.txt
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,7 @@ edx-drf-extensions==10.6.0
# enterprise-integrated-channels
# openedx-authz
# openedx-core
edx-enterprise==8.3.0
edx-enterprise==8.4.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/doc.txt
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/doc.txt
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,7 @@ edx-drf-extensions==10.6.0
# enterprise-integrated-channels
# openedx-authz
# openedx-core
edx-enterprise==8.3.0
edx-enterprise==8.4.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
Expand Down
2 changes: 1 addition & 1 deletion requirements/edx/testing.txt
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ edx-drf-extensions==10.6.0
# enterprise-integrated-channels
# openedx-authz
# openedx-core
edx-enterprise==8.3.0
edx-enterprise==8.4.0
# via
# -c requirements/constraints.txt
# -r requirements/edx/base.txt
Expand Down
Loading