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
This commit is contained in:
KEVYN SUAREZ
2025-12-03 10:43:40 -05:00
committed by GitHub
parent 1fed4be5c9
commit 6f391d93b9
3 changed files with 475 additions and 102 deletions

View File

@@ -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)

View File

@@ -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):

View File

@@ -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