diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 1b05d545a84b..c747cdcf409f 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -84,6 +84,65 @@ def issued_certificates(course_key, features): return generated_certificates +def get_student_features_with_custom(course_key): + """ + Allow site operators to include on the export custom fields if platform has an extending + User model. This can be used if you have an extended model that include for example + an university student number. + + Basic example of adding age: + ```python + def get_age(self): + return datetime.datetime.now().year - self.profile.year_of_birth + setattr(User, 'age', property(get_age)) + ``` + Then you have to add `age` to both site configurations: + - `student_profile_download_fields_custom_student_attributes` + - `student_profile_download_fields` site configurations` + + ```json + "student_profile_download_fields_custom_student_attributes": ["age"], + "student_profile_download_fields": [ + "id", "username", "name", "email", "language", "location", + "year_of_birth", "gender", "level_of_education", "mailing_address", + "goals", "enrollment_mode", "last_login", "date_joined", "external_user_key", + "enrollment_date", "age" + ] + ``` + + Example if the platform has a custom user extended model like a One-To-One Link + with the User Model: + ```python + def get_user_extended_model_custom_field(self): + if hasattr(self, "userextendedmodel"): + return self.userextendedmodel.custom_field + return None + setattr(User, 'user_extended_model_custom_field', property(get_user_extended_model_custom_field)) + ``` + + ```json + "student_profile_download_fields_custom_student_attributes": ["user_extended_model_custom_field"], + "student_profile_download_fields": [ + "id", "username", "name", "email", "language", "location", + "year_of_birth", "gender", "level_of_education", "mailing_address", + "goals", "enrollment_mode", "last_login", "date_joined", "external_user_key", + "enrollment_date", "user_extended_model_custom_field" + ] + ``` + """ + return STUDENT_FEATURES + tuple( + configuration_helpers.get_value_for_org( + course_key.org, + "student_profile_download_fields_custom_student_attributes", + getattr( + settings, + "STUDENT_PROFILE_DOWNLOAD_FIELDS_CUSTOM_STUDENT_ATTRIBUTES", + (), + ), + ) + ) + + def enrolled_students_features(course_key, features): # lint-amnesty, pylint: disable=too-many-statements """ Return list of student features as dictionaries. @@ -117,7 +176,7 @@ def enrolled_students_features(course_key, features): # lint-amnesty, pylint: d students = [enrollment.user for enrollment in enrollments] - student_features = [x for x in STUDENT_FEATURES if x in features] + student_features = [x for x in get_student_features_with_custom(course_key) if x in features] profile_features = [x for x in PROFILE_FEATURES if x in features] if include_program_enrollments and len(students) > 0: diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index 28e30683b36a..fe0fb6daacb2 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -5,8 +5,11 @@ from unittest.mock import MagicMock, Mock, patch +import random +import datetime import ddt import json # lint-amnesty, pylint: disable=wrong-import-order +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from edx_proctoring.api import create_exam from edx_proctoring.models import ProctoredExamStudentAttempt from opaque_keys.edx.locator import UsageKey @@ -25,6 +28,7 @@ ) from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed from common.djangoapps.student.tests.factories import InstructorFactory from common.djangoapps.student.tests.factories import UserFactory @@ -132,7 +136,7 @@ def test_enrolled_students_features_keys(self): user.profile.save() for feature in query_features: assert feature in AVAILABLE_FEATURES - with self.assertNumQueries(1): + with self.assertNumQueries(2): userreports = enrolled_students_features(self.course_key, query_features) assert len(userreports) == len(self.users) @@ -160,7 +164,7 @@ def test_enrolled_students_meta_features_keys(self): Assert that we can query individual fields in the 'meta' field in the UserProfile """ query_features = ('meta.position', 'meta.company') - with self.assertNumQueries(1): + with self.assertNumQueries(2): userreports = enrolled_students_features(self.course_key, query_features) assert len(userreports) == len(self.users) for userreport in userreports: @@ -217,7 +221,7 @@ def test_enrolled_students_features_keys_cohorted(self): # enrolled_students_features. The first query comes from the call to # User.objects.filter(...), and the second comes from # prefetch_related('course_groups'). - with self.assertNumQueries(2): + with self.assertNumQueries(3): userreports = enrolled_students_features(course.id, query_features) assert len([r for r in userreports if r['username'] in cohorted_usernames]) == len(cohorted_students) assert len([r for r in userreports if r['username'] == non_cohorted_student.username]) == 1 @@ -239,7 +243,7 @@ def test_enrolled_student_features_external_user_keys(self): ProgramEnrollmentFactory.create(user=user, external_user_key=external_user_key) username_with_external_user_key_dict[user.username] = external_user_key - with self.assertNumQueries(2): + with self.assertNumQueries(3): userreports = enrolled_students_features(self.course_key, query_features) assert len(userreports) == 30 for report in userreports: @@ -268,7 +272,7 @@ def test_enrolled_students_enrollment_date(self): query_features = ('username', 'enrollment_date',) for feature in query_features: assert feature in AVAILABLE_FEATURES - with self.assertNumQueries(1): + with self.assertNumQueries(2): userreports = enrolled_students_features(self.course_key, query_features) assert len(userreports) == len(self.users) @@ -278,6 +282,35 @@ def test_enrolled_students_enrollment_date(self): assert set(userreport.keys()) == set(query_features) assert userreport['enrollment_date'] == CourseEnrollment.enrollments_for_user(user)[0].created + def test_enrolled_students_extended_model_age(self): + SiteConfigurationFactory.create( + site_values={ + 'course_org_filter': ['robot'], + 'student_profile_download_fields_custom_student_attributes': ['age'], + } + ) + + def get_age(self): + return datetime.datetime.now().year - self.profile.year_of_birth + setattr(User, "age", property(get_age)) # lint-amnesty, pylint: disable=literal-used-as-attribute + + for user in self.users: + user.profile.year_of_birth = random.randint(1900, 2000) + user.profile.save() + + query_features = ('username', 'age',) + with self.assertNumQueries(3): + userreports = enrolled_students_features(self.course_key, query_features) + assert len(userreports) == len(self.users) + + userreports = sorted(userreports, key=lambda u: u["username"]) + users = sorted(self.users, key=lambda u: u.username) + for userreport, user in zip(userreports, users): + assert set(userreport.keys()) == set(query_features) + assert userreport['age'] == str(user.age) + + delattr(User, "age") # lint-amnesty, pylint: disable=literal-used-as-attribute + def test_list_may_enroll(self): may_enroll = list_may_enroll(self.course_key, ['email']) assert len(may_enroll) == (len(self.students_who_may_enroll) - len(self.users))