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:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user