From 8cdd0e1fc04b7e18f0a4e77fbeae4a7f4db8477c Mon Sep 17 00:00:00 2001 From: 0x29a Date: Wed, 3 Jan 2024 10:21:02 +0100 Subject: [PATCH 1/4] build: enable CI for pull requests --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21c8eb9b79..c13ff70f50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [master] pull_request: - branches: [master] concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} From c9be6164e2f9829ca1c9cb6d337122dadaa0d5e5 Mon Sep 17 00:00:00 2001 From: 0x29a Date: Wed, 6 Dec 2023 13:19:41 +0100 Subject: [PATCH 2/4] feat: per-user secured Algolia API keys --- .../api/v1/views/enterprise_customer.py | 44 ++++++++++++++++- enterprise/settings/test.py | 2 + requirements/base.in | 1 + requirements/dev.txt | 6 +++ requirements/doc.txt | 3 ++ requirements/test-master.txt | 5 ++ requirements/test.txt | 3 ++ tests/test_enterprise/api/test_views.py | 48 +++++++++++++++++++ 8 files changed, 111 insertions(+), 1 deletion(-) diff --git a/enterprise/api/v1/views/enterprise_customer.py b/enterprise/api/v1/views/enterprise_customer.py index 3046b30660..7748cc7679 100644 --- a/enterprise/api/v1/views/enterprise_customer.py +++ b/enterprise/api/v1/views/enterprise_customer.py @@ -4,10 +4,11 @@ from urllib.parse import quote_plus, unquote +from algoliasearch.search_client import SearchClient from edx_rbac.decorators import permission_required from rest_framework import permissions from rest_framework.decorators import action -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import NotFound, ValidationError from rest_framework.response import Response from rest_framework.status import ( HTTP_200_OK, @@ -17,12 +18,15 @@ HTTP_409_CONFLICT, ) +from django.conf import settings from django.contrib import auth from django.core import exceptions from django.db import transaction from django.db.models import Q +from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator +from django.utils.translation import gettext_lazy as _ from enterprise import models from enterprise.api.filters import EnterpriseLinkedUserFilterBackend @@ -442,3 +446,41 @@ def unlink_users(self, request, pk=None): # pylint: disable=unused-argument raise UnlinkUserFromEnterpriseError(msg) from exc return Response(status=HTTP_200_OK) + + @action(detail=False) + def algolia_key(self, request, *args, **kwargs): + """ + Returns an Algolia API key that is secured to only allow searching for + objects associated with enterprise customers that the user is linked to. + + This endpoint is used with `frontend-app-learner-portal-enterprise` MFE + currently. + """ + + if not (api_key := getattr(settings, "ENTERPRISE_ALGOLIA_SEARCH_API_KEY", "")): + LOGGER.warning("Algolia search API key is not configured. To enable this view, " + "set `ENTERPRISE_ALGOLIA_SEARCH_API_KEY` in settings.") + raise Http404 + + queryset = self.queryset.filter( + **{ + self.USER_ID_FILTER: request.user.id, + "enterprise_customer_users__linked": True + } + ).values_list("uuid", flat=True) + + if len(queryset) == 0: + raise NotFound(_("User is not linked to any enterprise customers.")) + + secured_key = SearchClient.generate_secured_api_key( + api_key, + { + "filters": " OR ".join( + f"enterprise_customer_uuids:{enterprise_customer_uuid}" + for enterprise_customer_uuid + in queryset + ), + } + ) + + return Response({"key": secured_key}, status=HTTP_200_OK) diff --git a/enterprise/settings/test.py b/enterprise/settings/test.py index c2c9032b14..998af939fb 100644 --- a/enterprise/settings/test.py +++ b/enterprise/settings/test.py @@ -224,6 +224,8 @@ def root(*args): 'status': 'published' } +ENTERPRISE_ALGOLIA_SEARCH_API_KEY = 'test' + SNOWFLAKE_SERVICE_USER = 'TEST@EDX.ORG' SNOWFLAKE_SERVICE_USER_PASSWORD = 'secret' diff --git a/requirements/base.in b/requirements/base.in index b54f17646e..650a0fdc86 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -2,6 +2,7 @@ # This file contains the dependencies explicitly needed for this library. # # Packages directly used by this library that we do not need pinned to a specific version. +algoliasearch bleach celery code-annotations diff --git a/requirements/dev.txt b/requirements/dev.txt index 435655137a..b3db4533c7 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -24,6 +24,11 @@ alabaster==0.7.13 # via # -r requirements/doc.txt # sphinx +algoliasearch==2.6.3 + # via + # -r requirements/doc.txt + # -r requirements/test-master.txt + # -r requirements/test.txt amqp==5.2.0 # via # -r requirements/doc.txt @@ -755,6 +760,7 @@ requests==2.31.0 # -r requirements/doc.txt # -r requirements/test-master.txt # -r requirements/test.txt + # algoliasearch # coreapi # django-oauth-toolkit # edx-drf-extensions diff --git a/requirements/doc.txt b/requirements/doc.txt index 0374344a8b..b4413da24d 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -16,6 +16,8 @@ aiosignal==1.3.1 # aiohttp alabaster==0.7.13 # via sphinx +algoliasearch==2.6.3 + # via -r requirements/test-master.txt amqp==5.2.0 # via # -r requirements/test-master.txt @@ -426,6 +428,7 @@ readme-renderer==43.0 requests==2.31.0 # via # -r requirements/test-master.txt + # algoliasearch # coreapi # django-oauth-toolkit # edx-drf-extensions diff --git a/requirements/test-master.txt b/requirements/test-master.txt index 932e08a15f..a69c6434ae 100644 --- a/requirements/test-master.txt +++ b/requirements/test-master.txt @@ -12,6 +12,10 @@ aiosignal==1.3.1 # via # -c requirements/edx-platform-constraints.txt # aiohttp +algoliasearch==2.6.3 + # via + # -c requirements/edx-platform-constraints.txt + # -r requirements/base.in amqp==5.2.0 # via kombu aniso8601==9.0.1 @@ -410,6 +414,7 @@ requests==2.31.0 # via # -c requirements/edx-platform-constraints.txt # -r requirements/base.in + # algoliasearch # coreapi # django-oauth-toolkit # edx-drf-extensions diff --git a/requirements/test.txt b/requirements/test.txt index 39c22077ca..c0cc65aecd 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -15,6 +15,8 @@ aiosignal==1.3.1 # via # -r requirements/test-master.txt # kombu +algoliasearch==2.6.3 + # via -r requirements/test-master.txt aniso8601==9.0.1 # via # -r requirements/test-master.txt @@ -403,6 +405,7 @@ pyyaml==6.0.1 requests==2.31.0 # via # -r requirements/test-master.txt + # algoliasearch # coreapi # django-oauth-toolkit # edx-drf-extensions diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index c656264d8d..55ba486550 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -2,6 +2,7 @@ Tests for the `edx-enterprise` api module. """ +import base64 import copy import json import logging @@ -151,6 +152,7 @@ ENTERPRISE_LEARNER_LIST_ENDPOINT = reverse('enterprise-learner-list') ENTERPRISE_CUSTOMER_WITH_ACCESS_TO_ENDPOINT = reverse('enterprise-customer-with-access-to') ENTERPRISE_CUSTOMER_UNLINK_USERS_ENDPOINT = reverse('enterprise-customer-unlink-users', kwargs={'pk': FAKE_UUIDS[0]}) +ENTERPRISE_CUSTOMER_ALGOLIA_KEY_ENDPOINT = reverse('enterprise-customer-algolia-key') PENDING_ENTERPRISE_LEARNER_LIST_ENDPOINT = reverse('pending-enterprise-learner-list') LICENSED_ENTERPRISE_COURSE_ENROLLMENTS_REVOKE_ENDPOINT = reverse( 'licensed-enterprise-course-enrollment-license-revoke' @@ -2002,6 +2004,52 @@ def test_unlink_users(self, enterprise_role, enterprise_uuid_for_role, is_relink assert enterprise_customer_user_2.is_relinkable == is_relinkable assert enterprise_customer_user_2.is_relinkable == is_relinkable + def test_algolia_key(self): + """ + Tests that the endpoint algolia_key endpoint returns the correct secured key. + """ + + # Test that the endpoint returns 401 if the user is not logged in. + self.client.logout() + response = self.client.get(ENTERPRISE_CUSTOMER_ALGOLIA_KEY_ENDPOINT) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + username = 'test_learner_portal_user' + self.create_user(username=username, is_staff=False) + self.client.login(username=username, password=TEST_PASSWORD) + + # Test that the endpoint returns 404 if the Algolia Search API key is not set. + with override_settings(ENTERPRISE_ALGOLIA_SEARCH_API_KEY=None): + response = self.client.get(ENTERPRISE_CUSTOMER_ALGOLIA_KEY_ENDPOINT) + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Test that the endpoint returns 404 if the user is not linked to any enterprise. + response = self.client.get(ENTERPRISE_CUSTOMER_ALGOLIA_KEY_ENDPOINT) + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Test that the endpoint returns 200 if the user is linked to at least one enterprise. + enterprise_customer_1 = factories.EnterpriseCustomerFactory(uuid=FAKE_UUIDS[0]) + enterprise_customer_2 = factories.EnterpriseCustomerFactory(uuid=FAKE_UUIDS[1]) + factories.EnterpriseCustomerFactory(uuid=FAKE_UUIDS[2]) # extra unlinked enterprise + + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=enterprise_customer_1 + ) + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=enterprise_customer_2 + ) + + response = self.client.get(ENTERPRISE_CUSTOMER_ALGOLIA_KEY_ENDPOINT) + assert response.status_code == status.HTTP_200_OK + + # Test that the endpoint returns the key encoding correct filters. + decoded_key = base64.b64decode(response.json()["key"]).decode("utf-8") + assert decoded_key.endswith( + f"filters=enterprise_customer_uuids%3A{FAKE_UUIDS[0]}+OR+enterprise_customer_uuids%3A{FAKE_UUIDS[1]}" + ) + @ddt.ddt @mark.django_db From 41f33275c0abf9ccaea7d5d36f6708187945d423 Mon Sep 17 00:00:00 2001 From: Arunmozhi Date: Fri, 16 Aug 2024 17:29:10 +1000 Subject: [PATCH 3/4] feat: adds support for enrolling users in invite-only courses --- enterprise/admin/__init__.py | 2 +- enterprise/admin/forms.py | 1 + enterprise/api_client/lms.py | 26 ++++++ ...ollment_in_invite_only_courses_and_more.py | 23 +++++ enterprise/models.py | 8 ++ enterprise/utils.py | 21 +++++ enterprise/views.py | 9 ++ tests/test_enterprise/api_client/test_lms.py | 54 ++++++++++++ tests/test_enterprise/test_utils.py | 83 +++++++++++++++++++ .../test_grant_data_sharing_permissions.py | 67 +++++++++++++++ tests/test_utilities.py | 1 + 11 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 enterprise/migrations/0207_enterprisecustomer_allow_enrollment_in_invite_only_courses_and_more.py diff --git a/enterprise/admin/__init__.py b/enterprise/admin/__init__.py index a5b8d3e929..d754cddb8f 100644 --- a/enterprise/admin/__init__.py +++ b/enterprise/admin/__init__.py @@ -204,7 +204,7 @@ class EnterpriseCustomerAdmin(DjangoObjectActions, SimpleHistoryAdmin): ('Integration and learning platform settings', { 'fields': ('enable_portal_lms_configurations_screen', 'enable_portal_saml_configuration_screen', 'enable_slug_login', 'replace_sensitive_sso_username', 'hide_course_original_price', - 'enable_generation_of_api_credentials') + 'enable_generation_of_api_credentials', 'allow_enrollment_in_invite_only_courses') }), ('Recommended default settings for all enterprise customers', { 'fields': ('site', 'customer_type', 'enable_learner_portal', diff --git a/enterprise/admin/forms.py b/enterprise/admin/forms.py index fd076aa4d4..28837d9b68 100644 --- a/enterprise/admin/forms.py +++ b/enterprise/admin/forms.py @@ -395,6 +395,7 @@ class Meta: "enable_audit_data_reporting", "replace_sensitive_sso_username", "hide_course_original_price", + "allow_enrollment_in_invite_only_courses", "enable_portal_code_management_screen", "enable_portal_subscription_management_screen", "enable_learner_portal", diff --git a/enterprise/api_client/lms.py b/enterprise/api_client/lms.py index 47e08edb49..e869e759be 100644 --- a/enterprise/api_client/lms.py +++ b/enterprise/api_client/lms.py @@ -6,6 +6,7 @@ import time from urllib.parse import urljoin +import requests from opaque_keys.edx.keys import CourseKey from requests.exceptions import ( # pylint: disable=redefined-builtin ConnectionError, @@ -274,6 +275,31 @@ def get_enrolled_courses(self, username): response.raise_for_status() return response.json() + def allow_enrollment(self, email: str, course_id: str, auto_enroll: bool = False): + """ + Call the enrollment API to allow enrollment for the given email and course_id. + Args: + email (str): The email address of the user to be allowed to enroll in the course. + course_id (str): The string value of the course's unique identifier. + auto_enroll (bool): Whether to auto-enroll the user in the course upon registration / activation. + Returns: + dict: A dictionary containing details of the created CourseEnrollmentAllowed object. + """ + api_url = self.get_api_url("enrollment_allowed") + response = self.client.post( + f"{api_url}/", + json={ + 'email': email, + 'course_id': course_id, + 'auto_enroll': auto_enroll, + } + ) + if response.status_code == requests.codes.conflict: + LOGGER.info(response.json()["message"]) + else: + response.raise_for_status() + return response.json() + class CourseApiClient(NoAuthAPIClient): """ diff --git a/enterprise/migrations/0207_enterprisecustomer_allow_enrollment_in_invite_only_courses_and_more.py b/enterprise/migrations/0207_enterprisecustomer_allow_enrollment_in_invite_only_courses_and_more.py new file mode 100644 index 0000000000..d72b12033e --- /dev/null +++ b/enterprise/migrations/0207_enterprisecustomer_allow_enrollment_in_invite_only_courses_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2024-12-06 03:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0206_auto_20240408_1344'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisecustomer', + name='allow_enrollment_in_invite_only_courses', + field=models.BooleanField(default=False, help_text="Specifies if learners are allowed to enroll into courses marked as 'invitation-only', when they attempt to enroll from the landing page."), + ), + migrations.AddField( + model_name='historicalenterprisecustomer', + name='allow_enrollment_in_invite_only_courses', + field=models.BooleanField(default=False, help_text="Specifies if learners are allowed to enroll into courses marked as 'invitation-only', when they attempt to enroll from the landing page."), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index fffff4a64b..a956c7cd1e 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -447,6 +447,14 @@ class Meta: help_text=_("Display Demo data from analyitcs and learner progress report for demo customer.") ) + allow_enrollment_in_invite_only_courses = models.BooleanField( + default=False, + help_text=_( + "Specifies if learners are allowed to enroll into courses marked as 'invitation-only', " + "when they attempt to enroll from the landing page." + ) + ) + contact_email = models.EmailField( verbose_name="Customer admin contact email:", null=True, diff --git a/enterprise/utils.py b/enterprise/utils.py index 435796453b..0b722112a3 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -2028,6 +2028,8 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis [{ 'user_id': , 'email': , 'course_run_key': } ... ] } """ + from enterprise.api_client.lms import EnrollmentApiClient # pylint: disable=import-outside-toplevel + enrollment_api_client = EnrollmentApiClient() results = { 'successes': [], 'pending': [], @@ -2042,6 +2044,7 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis transaction_id = subsidy_user_info.get('transaction_id') activation_link = subsidy_user_info.get('activation_link') force_enrollment = subsidy_user_info.get('force_enrollment', False) + invitation_only = subsidy_user_info.get('invitation_only') if user_id and user_email: user = User.objects.filter(id=subsidy_user_info['user_id']).first() @@ -2064,6 +2067,8 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis enrollment_source = enterprise_enrollment_source_model().get_source( enterprise_enrollment_source_model().CUSTOMER_ADMIN ) + if invitation_only and enterprise_customer.allow_enrollment_in_invite_only_courses: + ensure_course_enrollment_is_allowed(course_run_key, user.email, enrollment_api_client) succeeded, created, source_uuid = customer_admin_enroll_user_with_status( enterprise_customer, user, @@ -2102,6 +2107,8 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis discount=discount, license_uuid=license_uuid ) + if invitation_only and enterprise_customer.allow_enrollment_in_invite_only_courses: + ensure_course_enrollment_is_allowed(course_run_key, user_email, enrollment_api_client) results['pending'].append({ 'user': pending_user, 'email': user_email, @@ -2455,3 +2462,17 @@ def get_integrations_for_customers(customer_uuid): if choice.objects.filter(enterprise_customer__uuid=customer_uuid, active=True): unique_integrations.append(code) return unique_integrations + + +def ensure_course_enrollment_is_allowed(course_id: str, email: str, enrollment_api_client): + """ + Calls the enrollment API to create a CourseEnrollmentAllowed object for + invitation-only courses. + Arguments: + course_id (str): ID of the course to allow enrollment + email (str): email of the user whose enrollment should be allowed + enrollment_api_client (:class:`enterprise.api_client.lms.EnrollmentApiClient`): Enrollment API Client + """ + course_details = enrollment_api_client.get_course_details(course_id) + if course_details["invite_only"]: + enrollment_api_client.allow_enrollment(email, course_id) diff --git a/enterprise/views.py b/enterprise/views.py index 2516c7e2cf..ee72f60023 100644 --- a/enterprise/views.py +++ b/enterprise/views.py @@ -63,6 +63,7 @@ CourseEnrollmentPermissionError, NotConnectedToOpenEdX, clean_html_for_template_rendering, + ensure_course_enrollment_is_allowed, filter_audit_course_modes, format_price, get_active_course_runs, @@ -689,6 +690,14 @@ def _enroll_learner_in_course( course_modes=course_mode ) ) + if enterprise_customer.allow_enrollment_in_invite_only_courses: + ensure_course_enrollment_is_allowed(course_id, request.user.email, enrollment_api_client) + LOGGER.info( + 'User {user} is allowed to enroll in Course {course_id}.'.format( + user=request.user.username, + course_id=course_id + ) + ) try: enrollment_api_client.enroll_user_in_course( request.user.username, diff --git a/tests/test_enterprise/api_client/test_lms.py b/tests/test_enterprise/api_client/test_lms.py index abaa1307d0..0de7e03ea8 100644 --- a/tests/test_enterprise/api_client/test_lms.py +++ b/tests/test_enterprise/api_client/test_lms.py @@ -449,6 +449,60 @@ def test_unenroll_already_unenrolled(): assert not unenrolled +@responses.activate +@mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) +def test_allow_enrollment(): + email = "student@enterprise.com" + course_id = "course-v1:edX+DemoX+Demo_Course" + expected_response = { + "email": email, + "course_id": course_id, + "auto_enroll": False + } + responses.add( + responses.POST, + _url("enrollment", "enrollment_allowed/"), + json=expected_response, + ) + + client = lms_api.EnrollmentApiClient() + allowed = client.allow_enrollment(email, course_id) + assert allowed == expected_response + + +@responses.activate +@mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) +def test_allow_enrollment_raises_an_exception_on_error(): + expected_response = {"message": "Bad Request"} + responses.add( + responses.POST, + _url("enrollment", "enrollment_allowed/"), + json=expected_response, + status=requests.codes.bad_request + ) + + client = lms_api.EnrollmentApiClient() + with raises(requests.HTTPError): + client.allow_enrollment("", "") + + +@responses.activate +@mock.patch('enterprise.api_client.client.JwtBuilder', mock.Mock()) +def test_allow_enrollment_does_not_raise_exception_on_conflict(): + email = "student@enterprise.com" + course_id = "course-v1:edX+DemoX+Demo_Course" + expected_response = {"message": f"An enrollment allowed with email {email} and course {course_id} already exists."} + responses.add( + responses.POST, + _url("enrollment", "enrollment_allowed/"), + json=expected_response, + status=requests.codes.conflict + ) + + client = lms_api.EnrollmentApiClient() + assert expected_response == client.allow_enrollment(email, course_id) + + @responses.activate def test_get_full_course_details(): course_id = "course-v1:edX+DemoX+Demo_Course" diff --git a/tests/test_enterprise/test_utils.py b/tests/test_enterprise/test_utils.py index fcc2fa793f..4eec797e0f 100644 --- a/tests/test_enterprise/test_utils.py +++ b/tests/test_enterprise/test_utils.py @@ -17,6 +17,7 @@ from enterprise.models import EnterpriseCourseEnrollment, LicensedEnterpriseCourseEnrollment from enterprise.utils import ( enroll_subsidy_users_in_courses, + ensure_course_enrollment_is_allowed, get_default_invite_key_expiration_date, get_idiff_list, get_platform_logo_url, @@ -525,6 +526,69 @@ def test_enroll_subsidy_users_in_courses_user_identifier_failures( ) self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 0) + @ddt.unpack + @ddt.data( + (True, True), + (True, False), + (False, True), + (False, False), + ) + @mock.patch('enterprise.utils.lms_update_or_create_enrollment') + @mock.patch('enterprise.utils.ensure_course_enrollment_is_allowed') + def test_enroll_subsidy_users_in_courses_for_invite_only_courses( + self, + invite_only, + enrollment_allowed, + mock_ensure_course_enrollment_is_allowed, + mock_update_or_create_enrollment, + ): + """ + Test that the users ensure_course_enrollemnt_is_allowed is called for + invitiation-only courses when the enterprise_customer has the flag enabled. + """ + self.create_user() + + ent_customer = factories.EnterpriseCustomerFactory( + uuid=FAKE_UUIDS[0], + name="test_enterprise", + allow_enrollment_in_invite_only_courses=enrollment_allowed, + ) + factories.EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=ent_customer, + ) + licensed_users_info = [{ + 'email': self.user.email, + 'course_run_key': 'course-key-v1', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + 'invitation_only': invite_only, + }] + + mock_update_or_create_enrollment.return_value = True + result = enroll_subsidy_users_in_courses(ent_customer, licensed_users_info) + self.assertEqual( + { + 'pending': [], + 'successes': [{ + 'user_id': self.user.id, + 'email': self.user.email, + 'course_run_key': 'course-key-v1', + 'user': self.user, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': LicensedEnterpriseCourseEnrollment.objects.first().uuid, + }], + 'failures': [] + }, + result + ) + self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 1) + if invite_only and enrollment_allowed: + mock_ensure_course_enrollment_is_allowed.assert_called() + else: + mock_ensure_course_enrollment_is_allowed.assert_not_called() + def test_enroll_pending_licensed_users_in_courses_succeeds(self): """ Test that users that do not exist are pre-enrolled by enroll_subsidy_users_in_courses and returned under the @@ -650,3 +714,22 @@ def test_truncate_string(self): (truncated_string, was_truncated) = truncate_string(test_string_2) self.assertTrue(was_truncated) self.assertEqual(len(truncated_string), MAX_ALLOWED_TEXT_LENGTH) + + @ddt.data(True, False) + def test_ensure_course_enrollment_is_allowed(self, invite_only): + """ + Test that the enrollment allow endpoint is called for the "invite_only" courses. + """ + self.create_user() + mock_enrollment_api = mock.Mock() + mock_enrollment_api.get_course_details.return_value = {"invite_only": invite_only} + + ensure_course_enrollment_is_allowed("test-course-id", self.user.email, mock_enrollment_api) + + if invite_only: + mock_enrollment_api.allow_enrollment.assert_called_with( + self.user.email, + "test-course-id", + ) + else: + mock_enrollment_api.allow_enrollment.assert_not_called() diff --git a/tests/test_enterprise/views/test_grant_data_sharing_permissions.py b/tests/test_enterprise/views/test_grant_data_sharing_permissions.py index 642a9bcd8e..91ad9b397b 100644 --- a/tests/test_enterprise/views/test_grant_data_sharing_permissions.py +++ b/tests/test_enterprise/views/test_grant_data_sharing_permissions.py @@ -17,6 +17,7 @@ from django.test import Client, TestCase from django.urls import reverse +from enterprise.constants import CourseModes from enterprise.models import EnterpriseCourseEnrollment, LicensedEnterpriseCourseEnrollment from enterprise.views import FAILED_ENROLLMENT_REASON_QUERY_PARAM, VERIFIED_MODE_UNAVAILABLE, add_reason_to_failure_url from integrated_channels.exceptions import ClientError @@ -991,6 +992,72 @@ def test_get_dsc_verified_mode_unavailable( license_uuid=license_uuid, ).exists() is False + @mock.patch('enterprise.views.render', side_effect=fake_render) + @mock.patch('enterprise.views.EnrollmentApiClient') + @mock.patch('enterprise.models.EnterpriseCatalogApiClient') + @mock.patch('enterprise.api_client.discovery.CourseCatalogApiServiceClient') + @mock.patch('enterprise.views.get_best_mode_from_course_key') + @mock.patch('enterprise.views.ensure_course_enrollment_is_allowed') + @ddt.data(True, False) + def test_get_invite_only_enrollment( + self, + enrollment_allowed, + course_enrollment_allowed_mock, + best_mode_mock, + course_catalog_api_client_mock, + enterprise_catalog_client_mock, + enrollment_api_client_mock, + *args, + ): + course_id = 'course-v1:edX+DemoX+Demo_Course' + content_filter = {'key': [course_id]} + course_run_details = { + 'start': '2013-02-05T05:00:00Z', + 'title': 'Demo Course', + } + + enterprise_catalog_client_mock.return_value.enterprise_contains_content_items.return_value = True + + mock_discovery_catalog_api_client = course_catalog_api_client_mock.return_value + mock_discovery_catalog_api_client.get_course_id.return_value = course_id + mock_discovery_catalog_api_client.get_course_run.return_value = course_run_details + mock_discovery_catalog_api_client.get_course_details.return_value = course_run_details + + mock_enrollment_api_client = enrollment_api_client_mock.return_value + mock_enrollment_api_client.get_course_enrollment.return_value = None + + best_mode_mock.return_value = CourseModes.NO_ID_PROFESSIONAL + + self._login() + enterprise_customer = EnterpriseCustomerFactory( + name='Starfleet Academy', + enable_data_sharing_consent=False, + allow_enrollment_in_invite_only_courses=enrollment_allowed, + ) + EnterpriseCustomerCatalogFactory( + enterprise_customer=enterprise_customer, + content_filter=content_filter + ) + EnterpriseCustomerUserFactory( + user_id=self.user.id, + enterprise_customer=enterprise_customer + ) + license_uuid = str(uuid.uuid4()) + params = { + 'enterprise_customer_uuid': str(enterprise_customer.uuid), + 'course_id': course_id, + 'next': 'https://enrollment-succeeded.com', + 'failure_url': 'https://enrollment-failed.com', + 'license_uuid': license_uuid, + } + response = self.client.get(self.url, data=params) + assert response.status_code == 302 + assert response.url == 'https://enrollment-succeeded.com' + if enrollment_allowed: + course_enrollment_allowed_mock.assert_called() + else: + course_enrollment_allowed_mock.assert_not_called() + @mark.django_db @ddt.ddt diff --git a/tests/test_utilities.py b/tests/test_utilities.py index 1f863c5117..0b4224b597 100644 --- a/tests/test_utilities.py +++ b/tests/test_utilities.py @@ -180,6 +180,7 @@ def setUp(self): "enable_academies", "enable_one_academy", "groups", + "allow_enrollment_in_invite_only_courses", ] ), ( From b41e133396a5448ab84319dc3156a74a6689fc33 Mon Sep 17 00:00:00 2001 From: 0x29a Date: Mon, 22 Apr 2024 19:19:41 +0200 Subject: [PATCH 4/4] fix: xmlsec issue https://github.com/xmlsec/python-xmlsec/issues/314 --- .github/workflows/mysql8-migrations.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/mysql8-migrations.yml b/.github/workflows/mysql8-migrations.yml index b42ee35537..8e2ed8af9c 100644 --- a/.github/workflows/mysql8-migrations.yml +++ b/.github/workflows/mysql8-migrations.yml @@ -56,7 +56,7 @@ jobs: pip uninstall -y mysqlclient pip install --no-binary mysqlclient mysqlclient pip uninstall -y xmlsec - pip install --no-binary xmlsec xmlsec + pip install --no-binary xmlsec xmlsec==1.3.13 pip install backports.zoneinfo - name: Initiate Services run: |