diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index e943701cab..af8408ba8f 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -22,6 +22,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.http import HttpRequest, HttpResponse from django.test import RequestFactory, TestCase from django.test.client import MULTIPART_CONTENT +from django.test.utils import override_settings from django.urls import reverse as django_reverse from django.utils.translation import gettext as _ from edx_toggles.toggles.testutils import override_waffle_flag @@ -96,6 +97,7 @@ from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context from openedx.core.djangoapps.user_api.preferences.api import delete_user_preference from openedx.core.lib.teams_config import TeamsConfig from openedx.core.lib.xblock_utils import grade_histogram @@ -2739,6 +2741,63 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment assert student_json['city'] == student.profile.city assert student_json['country'] == '' + def test_get_students_features_private_fields(self): + """ + Test that the get_students_features does not return the private fields + if they are by default in the `PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS` setting. + """ + url = reverse("get_students_features", kwargs={"course_id": str(self.course.id)}) + response = self.client.post(url, {}) + res_json = json.loads(response.content.decode("utf-8")) + + assert "students" in res_json + for student in res_json["students"]: + for field in settings.PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS: + assert field not in student + + def test_get_students_features_private_fields_with_custom_config(self): + """ + Test that the get_students_features does not return the private custom + fields set in the `PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS` setting. + """ + private_fields = ["email", "location", "gender"] + + with override_settings(PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS=private_fields): + url = reverse("get_students_features", kwargs={"course_id": str(self.course.id)}) + response = self.client.post(url, {}) + res_json = json.loads(response.content.decode("utf-8")) + + assert "students" in res_json + for student in res_json["students"]: + for field in private_fields: + assert field not in student + + @override_settings(PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS=[]) + def test_get_students_features_private_fields_empty(self): + """ + Test that the get_students_features returns all the fields if the + `PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS` setting is empty. + """ + custom_config = { + "student_profile_download_fields": [ + "id", + "username", + "email", + "language", + "year_of_birth", + ] + } + + with with_site_configuration_context(configuration=custom_config): + url = reverse("get_students_features", kwargs={"course_id": str(self.course.id)}) + response = self.client.post(url, {}) + res_json = json.loads(response.content.decode("utf-8")) + + assert "students" in res_json + for student in res_json["students"]: + for field in custom_config["student_profile_download_fields"]: + assert field in student + @ddt.data(True, False) def test_get_students_features_cohorted(self, is_cohorted): """ @@ -2800,6 +2859,16 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment else: assert student_json['external_user_key'] == '' + def test_get_students_features_without_permissions(self): + """ Test that get_students_features returns 403 without credentials. """ + + # removed both roles from courses for instructor + CourseDataResearcherRole(self.course.id).remove_users(self.instructor) + CourseInstructorRole(self.course.id).remove_users(self.instructor) + url = reverse('get_students_features', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, {}) + assert response.status_code == 403 + def test_get_students_who_may_enroll(self): """ Test whether get_students_who_may_enroll returns an appropriate diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 8c842f6c4c..8fdfd06ae2 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1497,7 +1497,6 @@ class GetStudentsFeatures(DeveloperErrorViewMixin, APIView): 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals', 'enrollment_mode', 'last_login', 'date_joined', 'external_user_key' ] - keep_field_private(query_features, 'year_of_birth') # protected information # Provide human-friendly and translatable names for these features. These names # will be displayed in the table generated in data_download.js. It is not (yet) @@ -1509,8 +1508,7 @@ class GetStudentsFeatures(DeveloperErrorViewMixin, APIView): 'email': _('Email'), 'language': _('Language'), 'location': _('Location'), - # 'year_of_birth': _('Birth Year'), treated as privileged information as of TNL-10683, - # not to go in reports + 'year_of_birth': _('Birth Year'), 'gender': _('Gender'), 'level_of_education': _('Level of Education'), 'mailing_address': _('Mailing Address'), @@ -1521,6 +1519,10 @@ class GetStudentsFeatures(DeveloperErrorViewMixin, APIView): 'external_user_key': _('External User Key'), } + for field in settings.PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS: + keep_field_private(query_features, field) + query_features_names.pop(field, None) + if is_course_cohorted(course.id): # Translators: 'Cohort' refers to a group of students within a course. query_features.append('cohort') diff --git a/lms/envs/common.py b/lms/envs/common.py index c0fb469124..e0f106ee80 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -4257,6 +4257,14 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { ], } +# .. setting_name: PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS +# .. setting_default: ["year_of_birth"] +# .. setting_description: List of private fields that will be hidden from the profile information report. +# .. setting_use_cases: open_edx +# .. setting_creation_date: 2025-07-07 +# .. setting_tickets: https://github.com/openedx/edx-platform/pull/36688 +PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS = ["year_of_birth"] + # The list of all fields that are shared with other users using the bulk 'all_users' privacy setting ACCOUNT_VISIBILITY_CONFIGURATION["bulk_shareable_fields"] = ( ACCOUNT_VISIBILITY_CONFIGURATION["public_fields"] + [