From 6f391d93b9dd7e7bb848ad3a919c04522aed9b69 Mon Sep 17 00:00:00 2001 From: KEVYN SUAREZ <91025555+efortish@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:43:40 -0500 Subject: [PATCH] feat: enrollment_date added to csv report and add custom fields method (#37264) * chore: enrollment_date added to csv report and add custom fields method managing * test: tests added * fix: pylint fix * fix: new line at test_basic.py added * feat: new function added to handle available features with custom fields * chore: replace include_ parameters with direct feature checks * feat: type validation for custom attributes added * chore: site config name and variable updated, attribute fixing erased * test: tests updated --- lms/djangoapps/instructor/views/api.py | 43 ++- lms/djangoapps/instructor_analytics/basic.py | 303 ++++++++++++------ .../instructor_analytics/tests/test_basic.py | 231 ++++++++++++- 3 files changed, 475 insertions(+), 102 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index eae657ed19..7778f6a2a7 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1477,7 +1477,7 @@ class GetStudentsFeatures(DeveloperErrorViewMixin, APIView): course_key = CourseKey.from_string(course_id) course = get_course_by_id(course_key) report_type = _('enrolled learner profile') - available_features = instructor_analytics_basic.AVAILABLE_FEATURES + available_features = instructor_analytics_basic.get_available_features(course_key) # Allow for sites to be able to define additional columns. # Note that adding additional columns has the potential to break @@ -1493,9 +1493,40 @@ class GetStudentsFeatures(DeveloperErrorViewMixin, APIView): query_features = [ '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' + 'goals', 'enrollment_mode', 'last_login', 'date_joined', 'external_user_key', + 'enrollment_date', ] + additional_attributes = configuration_helpers.get_value_for_org( + course_key.org, + "additional_student_profile_attributes" + ) + if additional_attributes: + # Fail fast: must be list/tuple of strings. + if not isinstance(additional_attributes, (list, tuple)): + return JsonResponseBadRequest( + _('Invalid additional student attribute configuration: expected list of strings, got {type}.') + .format(type=type(additional_attributes).__name__) + ) + if not all(isinstance(v, str) for v in additional_attributes): + return JsonResponseBadRequest( + _('Invalid additional student attribute configuration: all entries must be strings.') + ) + # Reject empty string entries explicitly. + if any(v == '' for v in additional_attributes): + return JsonResponseBadRequest( + _('Invalid additional student attribute configuration: empty attribute names are not allowed.') + ) + # Validate each attribute is in available_features; allow duplicates as provided. + invalid = [v for v in additional_attributes if v not in available_features] + if invalid: + return JsonResponseBadRequest( + _('Invalid additional student attributes: {attrs}').format( + attrs=', '.join(invalid) + ) + ) + query_features.extend(additional_attributes) + # 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) # used as the header row in the CSV, but could be in the future. @@ -1515,8 +1546,16 @@ class GetStudentsFeatures(DeveloperErrorViewMixin, APIView): 'last_login': _('Last Login'), 'date_joined': _('Date Joined'), 'external_user_key': _('External User Key'), + 'enrollment_date': _('Enrollment Date'), } + if additional_attributes: + for attr in additional_attributes: + if attr not in query_features_names: + formatted_name = attr.replace('_', ' ').title() + # pylint: disable-next=translation-of-non-string + query_features_names[attr] = _(formatted_name) + for field in settings.PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS: keep_field_private(query_features, field) query_features_names.pop(field, None) diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index 6b5a772519..7dcceab5b4 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -10,7 +10,6 @@ import json import logging from django.conf import settings -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.exceptions import ObjectDoesNotExist from django.core.serializers.json import DjangoJSONEncoder from django.db.models import Count, F @@ -38,6 +37,7 @@ PROFILE_FEATURES = ('name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals', 'meta', 'city', 'country') PROGRAM_ENROLLMENT_FEATURES = ('external_user_key', ) +ENROLLMENT_FEATURES = ('enrollment_date', ) ORDER_ITEM_FEATURES = ('list_price', 'unit_cost', 'status') ORDER_FEATURES = ('purchase_time',) @@ -49,7 +49,7 @@ SALE_ORDER_FEATURES = ('id', 'company_name', 'company_contact_name', 'company_co 'bill_to_street2', 'bill_to_city', 'bill_to_state', 'bill_to_postalcode', 'bill_to_country', 'order_type', 'created') -AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES +AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES + ENROLLMENT_FEATURES COURSE_REGISTRATION_FEATURES = ('code', 'course_id', 'created_by', 'created_at', 'is_valid') COUPON_FEATURES = ('code', 'course_id', 'percentage_discount', 'description', 'expiration_date', 'is_active') CERTIFICATE_FEATURES = ('course_id', 'mode', 'status', 'grade', 'created_date', 'is_active', 'error_reason') @@ -84,6 +84,192 @@ def issued_certificates(course_key, features): return generated_certificates +def get_student_features_with_custom(course_key): + """ + Allow site operators to include custom fields in student profile exports. + + This function enables platforms with extended User models to include additional + fields in CSV exports by configuring site settings and adding properties to the User model. + + Basic example of adding age from user profile: + ```python + def get_age(self): + if hasattr(self, 'profile') and self.profile.year_of_birth: + return datetime.datetime.now().year - self.profile.year_of_birth + return None + User.age = property(get_age) + ``` + + Example with extended User model (One-To-One relationship): + ```python + def get_student_number(self): + try: + return self.userextendedmodel.student_number + except UserExtendedModel.DoesNotExist: + return None + + def get_employment_status(self): + try: + return self.userextendedmodel.employment_status + except UserExtendedModel.DoesNotExist: + return None + + User.student_number = property(get_student_number) + User.employment_status = property(get_employment_status) + ``` + + Site configuration required for these new 3 extra fields: + ```json + { + "additional_student_profile_attributes": [ + "age", + "student_number", + "employment_status" + ], + "course_org_filter": ["your-org"] + } + ``` + + Important notes: + - Custom attributes are automatically added to the standard student features + - If the extended model is guaranteed to exist, the try/except can be omitted + - Properties must be added to the User model before this function is called + + Args: + course_key: CourseKey object for the course + + Returns: + tuple: Combined tuple of standard STUDENT_FEATURES and custom attributes + """ + additional_attributes = configuration_helpers.get_value_for_org( + course_key.org, + "additional_student_profile_attributes" + ) + + if additional_attributes: + return STUDENT_FEATURES + tuple(additional_attributes) + + return STUDENT_FEATURES + + +def get_available_features(course_key): + """ + Return all available features including custom student attributes for a course. + + This function dynamically builds the available features list by combining + standard features with any custom attributes configured for the course organization. + + Args: + course_key: CourseKey object for the course + + Returns: + tuple: Combined tuple of all available features (standard + custom) + """ + student_features = get_student_features_with_custom(course_key) + return student_features + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES + ENROLLMENT_FEATURES + + +def _extract_attr(student, feature): + """Helper function for extracting student attributes""" + try: + attr = getattr(student, feature) + except AttributeError: + log.warning( + "Custom student attribute '%s' not found on %s model. " + "Please ensure the attribute is properly added to the model or " + "remove it from the site configuration.", + feature, + student.__class__.__name__ + ) + return None + + try: + DjangoJSONEncoder().default(attr) + return attr + except TypeError: + return str(attr) + + +def _extract_enrollment_student(enrollment, features, course_key, student_features, + profile_features, external_user_key_dict): + """ + Helper function for converting enrollment to dictionary. + + Args: + enrollment: CourseEnrollment object + features: List of all requested features + course_key: CourseKey object + student_features: List of student model features to extract + profile_features: List of profile features to extract + external_user_key_dict: Dictionary mapping user IDs to external keys + + Returns: + Dictionary containing student features + """ + student = enrollment.user + + # For data extractions on the 'meta' field + # the feature name should be in the format of 'meta.foo' where + # 'foo' is the keyname in the meta dictionary + meta_features = [] + for feature in features: + if 'meta.' in feature: + meta_key = feature.split('.')[1] + meta_features.append((feature, meta_key)) + + student_dict = {feature: _extract_attr(student, feature) for feature in student_features} + profile = student.profile + if profile is not None: + profile_dict = {feature: _extract_attr(profile, feature) for feature in profile_features} + student_dict.update(profile_dict) + + # now fetch the requested meta fields + meta_dict = json.loads(profile.meta) if profile.meta else {} + for meta_feature, meta_key in meta_features: + student_dict[meta_feature] = meta_dict.get(meta_key) + + # There are two separate places where the city value can be stored, + # one used by account settings and the other used by the registration form. + # If the account settings value (meta.city) is set, it takes precedence. + if 'city' in features: + meta_city = meta_dict.get('city') + if meta_city: + student_dict['city'] = meta_city + + if 'cohort' in features: + # Note that we use student.course_groups.all() here instead of + # student.course_groups.filter(). The latter creates a fresh query, + # therefore negating the performance gain from prefetch_related(). + student_dict['cohort'] = next( + (cohort.name for cohort in student.course_groups.all() if cohort.course_id == course_key), + "[unassigned]" + ) + + if 'team' in features: + student_dict['team'] = next( + (team.name for team in student.teams.all() if team.course_id == course_key), + UNAVAILABLE + ) + + if 'enrollment_mode' in features or 'verification_status' in features: + enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_key)[0] + if 'verification_status' in features: + student_dict['verification_status'] = IDVerificationService.verification_status_for_user( + student, + enrollment_mode + ) + if 'enrollment_mode' in features: + student_dict['enrollment_mode'] = enrollment_mode + + if 'external_user_key' in features: + student_dict['external_user_key'] = external_user_key_dict.get(student.id, '') + + if 'enrollment_date' in features: + student_dict['enrollment_date'] = enrollment.created + + return student_dict + + def enrolled_students_features(course_key, features): """ Return list of student features as dictionaries. @@ -95,103 +281,40 @@ def enrolled_students_features(course_key, features): {'username': 'username3', 'first_name': 'firstname3'} ] """ - include_cohort_column = 'cohort' in features - include_team_column = 'team' in features - include_city_column = 'city' in features - include_enrollment_mode = 'enrollment_mode' in features - include_verification_status = 'verification_status' in features - include_program_enrollments = 'external_user_key' in features external_user_key_dict = {} - students = User.objects.filter( - courseenrollment__course_id=course_key, - courseenrollment__is_active=1, - ).order_by('username').select_related('profile') + enrollments = CourseEnrollment.objects.filter( + course_id=course_key, + is_active=1, + ).select_related('user').order_by('user__username').select_related('user__profile') - if include_cohort_column: - students = students.prefetch_related('course_groups') + if 'cohort' in features: + enrollments = enrollments.prefetch_related('user__course_groups') - if include_team_column: - students = students.prefetch_related('teams') + if 'team' in features: + enrollments = enrollments.prefetch_related('user__teams') - if include_program_enrollments and len(students) > 0: + students = [enrollment.user for enrollment in enrollments] + + 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 'external_user_key' in features and len(students) > 0: program_enrollments = fetch_program_enrollments_by_students(users=students, realized_only=True) for program_enrollment in program_enrollments: external_user_key_dict[program_enrollment.user_id] = program_enrollment.external_user_key - def extract_attr(student, feature): - """Evaluate a student attribute that is ready for JSON serialization""" - attr = getattr(student, feature) - try: - DjangoJSONEncoder().default(attr) - return attr - except TypeError: - return str(attr) - - def extract_student(student, features): - """ convert student to dictionary """ - student_features = [x for x in STUDENT_FEATURES if x in features] - profile_features = [x for x in PROFILE_FEATURES if x in features] - - # For data extractions on the 'meta' field - # the feature name should be in the format of 'meta.foo' where - # 'foo' is the keyname in the meta dictionary - meta_features = [] - for feature in features: - if 'meta.' in feature: - meta_key = feature.split('.')[1] - meta_features.append((feature, meta_key)) - - student_dict = {feature: extract_attr(student, feature) for feature in student_features} - profile = student.profile - if profile is not None: - profile_dict = {feature: extract_attr(profile, feature) for feature in profile_features} - student_dict.update(profile_dict) - - # now fetch the requested meta fields - meta_dict = json.loads(profile.meta) if profile.meta else {} - for meta_feature, meta_key in meta_features: - student_dict[meta_feature] = meta_dict.get(meta_key) - - # There are two separate places where the city value can be stored, - # one used by account settings and the other used by the registration form. - # If the account settings value (meta.city) is set, it takes precedence. - meta_city = meta_dict.get('city') - if include_city_column and meta_city: - student_dict['city'] = meta_city - - if include_cohort_column: - # Note that we use student.course_groups.all() here instead of - # student.course_groups.filter(). The latter creates a fresh query, - # therefore negating the performance gain from prefetch_related(). - student_dict['cohort'] = next( - (cohort.name for cohort in student.course_groups.all() if cohort.course_id == course_key), - "[unassigned]" - ) - - if include_team_column: - student_dict['team'] = next( - (team.name for team in student.teams.all() if team.course_id == course_key), - UNAVAILABLE - ) - - if include_enrollment_mode or include_verification_status: - enrollment_mode = CourseEnrollment.enrollment_mode_for_user(student, course_key)[0] - if include_verification_status: - student_dict['verification_status'] = IDVerificationService.verification_status_for_user( - student, - enrollment_mode - ) - if include_enrollment_mode: - student_dict['enrollment_mode'] = enrollment_mode - - if include_program_enrollments: - # extra external_user_key - student_dict['external_user_key'] = external_user_key_dict.get(student.id, '') - - return student_dict - - return [extract_student(student, features) for student in students] + return [ + _extract_enrollment_student( + enrollment, + features, + course_key, + student_features, + profile_features, + external_user_key_dict + ) + for enrollment in enrollments + ] def list_may_enroll(course_key, features): diff --git a/lms/djangoapps/instructor_analytics/tests/test_basic.py b/lms/djangoapps/instructor_analytics/tests/test_basic.py index 83f23246ea..a9642ea70f 100644 --- a/lms/djangoapps/instructor_analytics/tests/test_basic.py +++ b/lms/djangoapps/instructor_analytics/tests/test_basic.py @@ -5,31 +5,40 @@ Tests for instructor.basic from unittest.mock import MagicMock, Mock, patch +import datetime +import random import ddt import json # lint-amnesty, pylint: disable=wrong-import-order +from django.contrib.auth import get_user_model from edx_proctoring.api import create_exam from edx_proctoring.models import ProctoredExamStudentAttempt from opaque_keys.edx.locator import UsageKey from lms.djangoapps.instructor_analytics.basic import ( # lint-amnesty, pylint: disable=unused-import AVAILABLE_FEATURES, + ENROLLMENT_FEATURES, PROFILE_FEATURES, PROGRAM_ENROLLMENT_FEATURES, STUDENT_FEATURES, StudentModule, enrolled_students_features, + get_available_features, get_proctored_exam_results, get_response_state, + get_student_features_with_custom, list_may_enroll, list_problem_responses ) 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 from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +User = get_user_model() + @ddt.ddt class TestAnalyticsBasic(ModuleStoreTestCase): @@ -131,7 +140,7 @@ class TestAnalyticsBasic(ModuleStoreTestCase): 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) @@ -159,7 +168,7 @@ class TestAnalyticsBasic(ModuleStoreTestCase): 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: @@ -212,11 +221,12 @@ class TestAnalyticsBasic(ModuleStoreTestCase): self.client.login(username=instructor.username, password='test') query_features = ('username', 'cohort') - # There should be a constant of 2 SQL queries when calling - # 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): + # There should be a constant of 3 SQL queries when calling + # enrolled_students_features. The first query comes from the call to + # CourseEnrollment.objects.filter(...) with select_related for user and profile, + # the second comes from prefetch_related('user__course_groups'), and the third + # comes from configuration_helpers.get_value_for_org() for custom student attributes. + 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 @@ -238,7 +248,7 @@ class TestAnalyticsBasic(ModuleStoreTestCase): 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: @@ -250,8 +260,66 @@ class TestAnalyticsBasic(ModuleStoreTestCase): assert '' == report['external_user_key'] def test_available_features(self): - assert len(AVAILABLE_FEATURES) == len(STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES) - assert set(AVAILABLE_FEATURES) == set(STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES) + assert len(AVAILABLE_FEATURES) == len( + STUDENT_FEATURES + + PROFILE_FEATURES + + PROGRAM_ENROLLMENT_FEATURES + + ENROLLMENT_FEATURES + ) + assert set(AVAILABLE_FEATURES) == set( + STUDENT_FEATURES + + PROFILE_FEATURES + + PROGRAM_ENROLLMENT_FEATURES + + ENROLLMENT_FEATURES + ) + + def test_enrolled_students_enrollment_date(self): + """Test that enrollment_date feature works correctly and returns the correct enrollment date.""" + query_features = ('username', 'enrollment_date',) + for feature in query_features: + assert feature in AVAILABLE_FEATURES + with self.assertNumQueries(2): + userreports = enrolled_students_features(self.course_key, query_features) + assert len(userreports) == len(self.users) + + enrollment_dict = {} + for user in self.users: + enrollment = CourseEnrollment.objects.get(user=user, course_id=self.course_key) + enrollment_dict[user.username] = enrollment.created + + for userreport in userreports: + expected_enrollment_date = enrollment_dict[userreport['username']] + assert userreport['enrollment_date'] == expected_enrollment_date + + def test_enrolled_students_extended_model_age(self): + """Test that custom age attribute works correctly with user profile year_of_birth.""" + SiteConfigurationFactory.create( + site_values={ + 'course_org_filter': ['robot'], + 'additional_student_profile_attributes': ['age'], + } + ) + + def get_age(self): + return datetime.datetime.now().year - self.profile.year_of_birth + User.age = property(get_age) + + 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) + + del User.age def test_list_may_enroll(self): may_enroll = list_may_enroll(self.course_key, ['email']) @@ -295,3 +363,146 @@ class TestAnalyticsBasic(ModuleStoreTestCase): assert len(proctored_exam_attempts) == 3 for proctored_exam_attempt in proctored_exam_attempts: assert set(proctored_exam_attempt.keys()) == set(query_features) + + def test_get_student_features_with_custom_attributes(self): + """Test that get_student_features_with_custom works with custom attributes.""" + + # Test without custom attributes - should return standard features + features = get_student_features_with_custom(self.course_key) + assert features == STUDENT_FEATURES + + # Test with custom attributes + SiteConfigurationFactory.create( + site_values={ + 'course_org_filter': ['robot'], + 'additional_student_profile_attributes': ['employee_id', 'department'], + } + ) + + features = get_student_features_with_custom(self.course_key) + expected = STUDENT_FEATURES + ('employee_id', 'department') + assert features == expected + + def test_enrolled_students_multiple_custom_fields(self): + """Test that multiple custom fields work correctly together.""" + SiteConfigurationFactory.create( + site_values={ + 'course_org_filter': ['robot'], + 'additional_student_profile_attributes': [ + 'student_id', + 'employment_status', + 'graduation_year' + ], + } + ) + + def get_student_id(self): + """Generate dummy student ID""" + try: + return f"ID{self.id:05d}" + except AttributeError: + return None + + def get_employment_status(self): + """Generate dummy employment status""" + try: + statuses = ['Student', 'Employed', 'Unemployed', 'Self-employed', 'Retired'] + return statuses[self.id % len(statuses)] + except AttributeError: + return None + + def get_graduation_year(self): + """Generate dummy graduation year""" + try: + return str(2020 + (self.id % 10)) + except AttributeError: + return None + + User.student_id = property(get_student_id) + User.employment_status = property(get_employment_status) + User.graduation_year = property(get_graduation_year) + + query_features = ('username', 'student_id', 'employment_status', 'graduation_year') + with self.assertNumQueries(3): + userreports = enrolled_students_features(self.course_key, query_features) + + assert len(userreports) == len(self.users) + + for userreport in userreports: + assert set(userreport.keys()) == set(query_features) + # Verify all custom fields have values + assert userreport['student_id'] is not None + assert userreport['student_id'].startswith('ID') + assert userreport['employment_status'] in ['Student', 'Employed', 'Unemployed', 'Self-employed', 'Retired'] + assert userreport['graduation_year'] in [str(year) for year in range(2020, 2030)] + + del User.student_id + del User.employment_status + del User.graduation_year + + def get_badge_count(self): + """Generate dummy badge count""" + try: + return str(self.id % 10) # 0-9 badges + except AttributeError: + return "0" + + def test_custom_attributes_without_org_filter(self): + """Test that custom attributes require course_org_filter to work properly.""" + # Create configuration without course_org_filter + SiteConfigurationFactory.create( + site_values={ + 'additional_student_profile_attributes': ['badge_count'], + } + ) + + User.badge_count = property(self.get_badge_count) + + # Without org filter, custom attributes should NOT be added + features = get_student_features_with_custom(self.course_key) + # Should return only standard features (no badge_count) + assert features == STUDENT_FEATURES + + # Clean up + del User.badge_count + + def test_custom_attributes_with_non_matching_org_filter(self): + """Test that custom attributes don't work with non-matching course_org_filter.""" + # Create configuration with course_org_filter that DOESN'T match our test course org + SiteConfigurationFactory.create( + site_values={ + 'course_org_filter': ['different_org'], + 'additional_student_profile_attributes': ['badge_count'], + } + ) + + # With non-matching org filter, custom attributes should NOT be added + features = get_student_features_with_custom(self.course_key) + # Should return only standard features (no badge_count) + assert features == STUDENT_FEATURES + + def test_get_available_features_includes_additional_attributes(self): + """ + get_available_features should include additional_student_profile_attributes + for the org, on top of the standard features. + """ + SiteConfigurationFactory.create( + site_values={ + 'course_org_filter': ['robot'], + 'additional_student_profile_attributes': ['employee_id', 'department'], + } + ) + + features = get_available_features(self.course_key) + + # Decompose what we expect: + # student part = STUDENT_FEATURES + additional + expected_student = STUDENT_FEATURES + ('employee_id', 'department') + expected_all = ( + expected_student + + PROFILE_FEATURES + + PROGRAM_ENROLLMENT_FEATURES + + ENROLLMENT_FEATURES + ) + + assert features == expected_all