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

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