diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3555723138..993c325a02 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -15,6 +15,30 @@ Change Log Unreleased ---------- +[4.13.12] +--------- +* feat: adding additional info to the enterprise group membership serializer + +[4.13.11] +--------- +* feat: pass force_enrollment when bulk enrolling learners + +[4.13.10] +--------- +* fix: remove filter to debug failing transmissions + +[4.13.9] +--------- +* fix: add missing filter to disable failing transmissions for 24hrs + +[4.13.8] +--------- +* feat: adding an activated_at value to group membership records + +[4.13.7] +--------- +* fix: adding get_queryset for fix of integrated channel api logs loading + [4.13.6] --------- * feat: disable failing transmissions for 24hrs @@ -65,7 +89,7 @@ Unreleased [4.12.1] --------- -* feat: unlink canvas user if not decommissioned on canvas side +* feat: unlink canvas user if decommissioned on canvas side [4.12.0] --------- diff --git a/enterprise/__init__.py b/enterprise/__init__.py index e8d7931c2a..e5c45313d8 100644 --- a/enterprise/__init__.py +++ b/enterprise/__init__.py @@ -2,4 +2,4 @@ Your project description goes here. """ -__version__ = "4.13.6" +__version__ = "4.13.12" diff --git a/enterprise/api/v1/serializers.py b/enterprise/api/v1/serializers.py index 41bccb2f63..41968b3fe8 100644 --- a/enterprise/api/v1/serializers.py +++ b/enterprise/api/v1/serializers.py @@ -601,9 +601,49 @@ class EnterpriseGroupMembershipSerializer(serializers.ModelSerializer): enterprise_group_membership_uuid = serializers.UUIDField(source='uuid', allow_null=True, read_only=True) enterprise_customer = EnterpriseCustomerSerializer(source='group.enterprise_customer', read_only=True) + member_details = serializers.SerializerMethodField() + recent_action = serializers.SerializerMethodField() + member_status = serializers.SerializerMethodField() + class Meta: model = models.EnterpriseGroupMembership - fields = ('learner_id', 'pending_learner_id', 'enterprise_group_membership_uuid', 'enterprise_customer') + fields = ( + 'learner_id', + 'pending_learner_id', + 'enterprise_group_membership_uuid', + 'member_details', + 'recent_action', + 'member_status', + 'enterprise_customer' + ) + + def get_member_details(self, obj): + """ + Return either the member's name and email if it's the case that the member is realized, otherwise just email + """ + if user := obj.enterprise_customer_user: + return {"user_email": user.user_email, "user_name": user.name} + return {"user_email": obj.pending_enterprise_customer_user.user_email} + + def get_recent_action(self, obj): + """ + Return the timestamp and name of the most recent action associated with the membership. + """ + if obj.is_removed: + return f"Removed: {obj.modified.strftime('%B %d, %Y')}" + if obj.enterprise_customer_user and obj.activated_at: + return f"Accepted: {obj.activated_at.strftime('%B %d, %Y')}" + return f"Invited: {obj.created.strftime('%B %d, %Y')}" + + def get_member_status(self, obj): + """ + Return the status related to the membership. + """ + if obj.is_removed: + return "removed" + if obj.enterprise_customer_user: + return "accepted" + return "pending" class EnterpriseCustomerUserReadOnlySerializer(serializers.ModelSerializer): diff --git a/enterprise/api/v1/views/enterprise_customer.py b/enterprise/api/v1/views/enterprise_customer.py index 1685304d17..3046b30660 100644 --- a/enterprise/api/v1/views/enterprise_customer.py +++ b/enterprise/api/v1/views/enterprise_customer.py @@ -171,9 +171,15 @@ def enroll_learners_in_courses(self, request, pk): Parameters: enrollments_info (list of dicts): an array of dictionaries, each containing the necessary information to create an enrollment based on a subsidy for a user in a specified course. Each dictionary must contain - a user email (or user_id), a course run key, and either a UUID of the license that the learner is using - to enroll with or a transaction ID related to Executive Education the enrollment. `licenses_info` is - also accepted as a body param name. + the following keys: + + * 'user_id' OR 'email': Either unique identifier describing the user to enroll. + * 'course_run_key': The course to enroll into. + * 'license_uuid' OR 'transaction_id': ID of either accepted form of subsidy. `license_uuid` refers to + subscription licenses, and `transaction_id` refers to Learner Credit transactions. + * 'force_enrollment' (bool, optional): Enroll even if enrollment deadline is expired (default False). + + `licenses_info` is also accepted as a body param name. Example:: diff --git a/enterprise/api/v1/views/enterprise_group.py b/enterprise/api/v1/views/enterprise_group.py index 0e8e3bbdd5..cf8e9f82cf 100644 --- a/enterprise/api/v1/views/enterprise_group.py +++ b/enterprise/api/v1/views/enterprise_group.py @@ -18,6 +18,7 @@ from enterprise.api.v1.views.base_views import EnterpriseReadWriteModelViewSet from enterprise.logging import getEnterpriseLogger from enterprise.tasks import send_group_membership_invitation_notification, send_group_membership_removal_notification +from enterprise.utils import localized_utcnow LOGGER = getEnterpriseLogger(__name__) @@ -200,6 +201,7 @@ def assign_learners(self, request, group_uuid): # Extend the list of memberships that need to be created associated with existing Users ent_customer_users = [ models.EnterpriseGroupMembership( + activated_at=localized_utcnow(), enterprise_customer_user=ecu, group=group ) diff --git a/enterprise/migrations/0203_auto_20240312_1527.py b/enterprise/migrations/0203_auto_20240312_1527.py new file mode 100644 index 0000000000..29635c9c74 --- /dev/null +++ b/enterprise/migrations/0203_auto_20240312_1527.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.23 on 2024-03-12 15:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('enterprise', '0202_enterprisegroup_applies_to_all_contexts_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='enterprisegroupmembership', + name='activated_at', + field=models.DateTimeField(blank=True, default=None, help_text='The moment at which the membership record is written with an Enterprise Customer User record.', null=True), + ), + migrations.AddField( + model_name='historicalenterprisegroupmembership', + name='activated_at', + field=models.DateTimeField(blank=True, default=None, help_text='The moment at which the membership record is written with an Enterprise Customer User record.', null=True), + ), + ] diff --git a/enterprise/models.py b/enterprise/models.py index 8cb4f96bb8..34bc2d006a 100644 --- a/enterprise/models.py +++ b/enterprise/models.py @@ -1138,6 +1138,15 @@ def username(self): return self.user.username return None + @property + def name(self): + """ + Return linked user's name. + """ + if self.user is not None: + return f"{self.user.first_name} {self.user.last_name}" + return None + @property def data_sharing_consent_records(self): """ @@ -1484,6 +1493,7 @@ def fulfill_pending_group_memberships(self, enterprise_customer_user): enterprise_customer_user: a EnterpriseCustomerUser instance """ self.memberships.update( + activated_at=localized_utcnow(), pending_enterprise_customer_user=None, enterprise_customer_user=enterprise_customer_user ) @@ -4310,6 +4320,14 @@ class EnterpriseGroupMembership(TimeStampedModel, SoftDeletableModel): related_name='memberships', on_delete=models.deletion.CASCADE, ) + activated_at = models.DateTimeField( + default=None, + blank=True, + null=True, + help_text=_( + "The moment at which the membership record is written with an Enterprise Customer User record." + ), + ) history = HistoricalRecords() class Meta: diff --git a/enterprise/utils.py b/enterprise/utils.py index 631eeefd48..f0f47d38b5 100644 --- a/enterprise/utils.py +++ b/enterprise/utils.py @@ -1807,6 +1807,7 @@ def customer_admin_enroll_user_with_status( enrollment_source=None, license_uuid=None, transaction_id=None, + force_enrollment=False, ): """ For use with bulk enrollment, or any use case of admin enrolling a user @@ -1848,6 +1849,7 @@ def customer_admin_enroll_user_with_status( course_mode, is_active=True, enterprise_uuid=enterprise_customer.uuid, + force_enrollment=force_enrollment, ) succeeded = True LOGGER.info("Successfully enrolled user %s in course %s", user.id, course_id) @@ -1987,6 +1989,7 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis * 'course_run_key': The course to enroll into. * 'course_mode': The course mode. * 'license_uuid' OR 'transaction_id': ID of either accepted form of subsidy. + * 'force_enrollment' (bool, optional): Enroll user even enrollment deadline is expired (default False). Example:: @@ -2037,6 +2040,7 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis license_uuid = subsidy_user_info.get('license_uuid') 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) if user_id and user_email: user = User.objects.filter(id=subsidy_user_info['user_id']).first() @@ -2066,7 +2070,8 @@ def enroll_subsidy_users_in_courses(enterprise_customer, subsidy_users_info, dis course_run_key, enrollment_source, license_uuid, - transaction_id + transaction_id, + force_enrollment=force_enrollment, ) if succeeded: success_dict = { diff --git a/integrated_channels/integrated_channel/admin/__init__.py b/integrated_channels/integrated_channel/admin/__init__.py index c3ee07a92c..c262ce2994 100644 --- a/integrated_channels/integrated_channel/admin/__init__.py +++ b/integrated_channels/integrated_channel/admin/__init__.py @@ -99,16 +99,10 @@ class IntegratedChannelAPIRequestLogAdmin(admin.ModelAdmin): list_display = [ "endpoint", - "enterprise_customer", + "enterprise_customer_id", "time_taken", "status_code", ] - list_filter = [ - "status_code", - "enterprise_customer", - "endpoint", - "time_taken", - ] search_fields = [ "status_code", "enterprise_customer", @@ -130,5 +124,9 @@ class IntegratedChannelAPIRequestLogAdmin(admin.ModelAdmin): list_per_page = 20 + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.select_related('enterprise_customer') + class Meta: model = IntegratedChannelAPIRequestLogs diff --git a/tests/test_enterprise/api/test_views.py b/tests/test_enterprise/api/test_views.py index d7947bb68f..3ee8e6bfbe 100644 --- a/tests/test_enterprise/api/test_views.py +++ b/tests/test_enterprise/api/test_views.py @@ -7318,6 +7318,7 @@ def setUp(self): group=self.group_1, pending_enterprise_customer_user=None, enterprise_customer_user__enterprise_customer=self.enterprise_customer, + activated_at=datetime.now() )) def test_group_permissions(self): @@ -7354,6 +7355,52 @@ def test_successful_retrieve_group(self): response = self.client.get(url) assert response.json().get('uuid') == str(self.group_1.uuid) + def test_list_learner_pending_learner_data(self): + """ + Test the response data of the list learners in group endpoint when the membership is pending + """ + group = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) + url = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': group.uuid}, + ) + pending_user = PendingEnterpriseCustomerUserFactory() + EnterpriseGroupMembershipFactory( + group=group, + pending_enterprise_customer_user=pending_user, + enterprise_customer_user=None, + ) + response = self.client.get(url) + assert response.json().get('results')[0].get('member_details') == {'user_email': pending_user.user_email} + assert response.json().get('results')[0].get( + 'recent_action' + ) == f'Invited: {datetime.now().strftime("%B %d, %Y")}' + + def test_list_learner_statuses(self): + """ + Test the response data of the list learners in group endpoint when the membership is pending + """ + group = EnterpriseGroupFactory(enterprise_customer=self.enterprise_customer) + url = settings.TEST_SERVER + reverse( + 'enterprise-group-learners', + kwargs={'group_uuid': group.uuid}, + ) + EnterpriseGroupMembershipFactory( + group=group, + pending_enterprise_customer_user=PendingEnterpriseCustomerUserFactory(), + enterprise_customer_user=None, + ) + EnterpriseGroupMembershipFactory( + group=group, + pending_enterprise_customer_user=None, + enterprise_customer_user__enterprise_customer=self.enterprise_customer, + activated_at=datetime.now() + ) + response = self.client.get(url) + assert response.json().get('count') == 2 + statuses = [result.get('member_status') for result in response.json().get('results')] + assert statuses.sort() == ['accepted', 'pending'].sort() + def test_successful_list_learners(self): """ Test a successful GET request to the list endpoint. @@ -7365,14 +7412,21 @@ def test_successful_list_learners(self): ) results_list = [] for i in reversed(range(1, 11)): + member_user = self.enterprise_group_memberships[i].enterprise_customer_user results_list.append( { - 'learner_id': self.enterprise_group_memberships[i].enterprise_customer_user.id, + 'learner_id': member_user.id, 'pending_learner_id': None, 'enterprise_group_membership_uuid': str(self.enterprise_group_memberships[i].uuid), 'enterprise_customer': { 'name': self.enterprise_customer.name, - } + }, + 'member_details': { + 'user_name': member_user.name, + 'user_email': member_user.user_email + }, + 'recent_action': f'Accepted: {datetime.now().strftime("%B %d, %Y")}', + 'member_status': 'accepted', }, ) expected_response = { @@ -7397,18 +7451,25 @@ def test_successful_list_learners(self): kwargs={'group_uuid': self.group_1.uuid}, ) + '?page=2' page_2_response = self.client.get(url_page_2) + user = self.enterprise_group_memberships[0].enterprise_customer_user expected_response_page_2 = { 'count': 11, 'next': None, 'previous': f'http://testserver/enterprise/api/v1/enterprise-group/{self.group_1.uuid}/learners', 'results': [ { - 'learner_id': self.enterprise_group_memberships[0].enterprise_customer_user.id, + 'learner_id': user.id, 'pending_learner_id': None, 'enterprise_group_membership_uuid': str(self.enterprise_group_memberships[0].uuid), 'enterprise_customer': { 'name': self.enterprise_customer.name, - } + }, + 'member_details': { + 'user_name': user.name, + 'user_email': user.user_email + }, + 'recent_action': f'Accepted: {datetime.now().strftime("%B %d, %Y")}', + 'member_status': 'accepted', } ], } @@ -7747,6 +7808,28 @@ def test_group_applies_to_all_contexts_learner_list(self): for result in results: assert (result.get('pending_learner_id') == pending_user.id) or (result.get('learner_id') == new_user.id) + def test_group_assign_realized_learner_adds_activated_at(self): + """ + Test that newly created membership records associated with an existing user have an activated at value written + but records associated with pending memberships do not. + """ + url = settings.TEST_SERVER + reverse( + 'enterprise-group-assign-learners', + kwargs={'group_uuid': self.group_2.uuid}, + ) + request_data = {'learner_emails': f"{UserFactory().email},email@example.com"} + self.client.post(url, data=request_data) + membership = EnterpriseGroupMembership.objects.filter( + group=self.group_2, + pending_enterprise_customer_user__isnull=True + ).first() + assert membership.activated_at + pending_membership = EnterpriseGroupMembership.objects.filter( + group=self.group_2, + enterprise_customer_user__isnull=True + ).first() + assert not pending_membership.activated_at + @mark.django_db class TestEnterpriseCustomerSsoConfigurationViewSet(APITest): diff --git a/tests/test_enterprise/test_signals.py b/tests/test_enterprise/test_signals.py index e93f14e758..ef5e6c47ff 100644 --- a/tests/test_enterprise/test_signals.py +++ b/tests/test_enterprise/test_signals.py @@ -256,10 +256,11 @@ def test_handle_user_post_save_fulfills_pending_group_memberships(self): email = "jackie.chan@hollywood.com" user = UserFactory(id=1, email=email) pending_user = PendingEnterpriseCustomerUserFactory(user_email=email) - EnterpriseGroupMembershipFactory( + new_membership = EnterpriseGroupMembershipFactory( pending_enterprise_customer_user=pending_user, enterprise_customer_user=None ) + assert not new_membership.activated_at parameters = {"instance": user, "created": False} handle_user_post_save(mock.Mock(), **parameters) # Should delete pending link @@ -267,8 +268,10 @@ def test_handle_user_post_save_fulfills_pending_group_memberships(self): assert len(EnterpriseGroupMembership.objects.all()) == 1 new_enterprise_user = EnterpriseCustomerUser.objects.get(user_id=user.id) - assert EnterpriseGroupMembership.objects.first().pending_enterprise_customer_user is None - assert EnterpriseGroupMembership.objects.first().enterprise_customer_user == new_enterprise_user + membership = EnterpriseGroupMembership.objects.first() + assert membership.pending_enterprise_customer_user is None + assert membership.enterprise_customer_user == new_enterprise_user + assert membership.activated_at @mark.django_db diff --git a/tests/test_enterprise/test_utils.py b/tests/test_enterprise/test_utils.py index 9dc3cac117..fcc2fa793f 100644 --- a/tests/test_enterprise/test_utils.py +++ b/tests/test_enterprise/test_utils.py @@ -4,6 +4,7 @@ import unittest from datetime import timedelta from unittest import mock +from unittest.mock import call from urllib.parse import quote, urlencode import ddt @@ -325,6 +326,119 @@ def test_enroll_subsidy_users_in_courses_with_user_id_succeeds( ) self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 2) + @mock.patch('enterprise.utils.lms_update_or_create_enrollment') + def test_enroll_subsidy_users_in_courses_with_force_enrollment( + self, + mock_update_or_create_enrollment, + ): + """ + """ + self.create_user() + another_user_1 = factories.UserFactory(is_active=True) + another_user_2 = factories.UserFactory(is_active=True) + ent_customer = factories.EnterpriseCustomerFactory( + uuid=FAKE_UUIDS[0], + name="test_enterprise" + ) + licensed_users_info = [ + { + # Should succeed with force_enrollment passed as False under the hood. + 'user_id': self.user.id, + 'course_run_key': 'course-key-1', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + }, + { + # Should also succeed with force_enrollment passed as False. + 'user_id': another_user_1.id, + 'course_run_key': 'course-key-2', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + 'force_enrollment': False, + }, + { + # Should succeed with force_enrollment passed as True. + 'user_id': another_user_2.id, + 'course_run_key': 'course-key-3', + 'course_mode': 'verified', + 'license_uuid': '5b77bdbade7b4fcb838f8111b68e18ae', + 'force_enrollment': True, + }, + ] + + 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-1', + 'user': self.user, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=self.user.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, + }, + { + 'user_id': another_user_1.id, + 'email': another_user_1.email, + 'course_run_key': 'course-key-2', + 'user': another_user_1, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=another_user_1.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, + }, + { + 'user_id': another_user_2.id, + 'email': another_user_2.email, + 'course_run_key': 'course-key-3', + 'user': another_user_2, + 'created': True, + 'activation_link': None, + 'enterprise_fulfillment_source_uuid': EnterpriseCourseEnrollment.objects.filter( + enterprise_customer_user__user_id=another_user_2.id + ).first().licensedenterprisecourseenrollment_enrollment_fulfillment.uuid, + }, + ], + 'failures': [], + }, + result + ) + self.assertEqual(len(EnterpriseCourseEnrollment.objects.all()), 3) + assert mock_update_or_create_enrollment.mock_calls == [ + call( + self.user.username, + 'course-key-1', + 'verified', + is_active=True, + enterprise_uuid=ent_customer.uuid, + force_enrollment=False, + ), + call( + another_user_1.username, + 'course-key-2', + 'verified', + is_active=True, + enterprise_uuid=ent_customer.uuid, + force_enrollment=False, + ), + call( + another_user_2.username, + 'course-key-3', + 'verified', + is_active=True, + enterprise_uuid=ent_customer.uuid, + force_enrollment=True, + ), + ] + @mock.patch('enterprise.utils.lms_update_or_create_enrollment') def test_enroll_subsidy_users_in_courses_user_identifier_failures( self,