Skip to content

Commit 05687c8

Browse files
igobrancosandroscosta
authored andcommitted
feat: add enrollment_date and custom fields to profile data csv
Add `enrollment_date` column on the csv file of all students enrolled in a course. Allow site operators to include on the export of profile information as CSV custom fields if the platform has an extending User model. This can be used if you have an extended model that include for example an university student number and site operator want to export the student number on the student profile information CSV. GN-914
1 parent c45cad1 commit 05687c8

File tree

2 files changed

+140
-13
lines changed

2 files changed

+140
-13
lines changed

lms/djangoapps/instructor_analytics/basic.py

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import logging
1111

1212
from django.conf import settings
13-
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
1413
from django.core.exceptions import ObjectDoesNotExist
1514
from django.core.serializers.json import DjangoJSONEncoder
1615
from django.db.models import Count # lint-amnesty, pylint: disable=unused-import
@@ -38,6 +37,7 @@
3837
'level_of_education', 'mailing_address', 'goals', 'meta',
3938
'city', 'country')
4039
PROGRAM_ENROLLMENT_FEATURES = ('external_user_key', )
40+
ENROLLMENT_FEATURES = ('enrollment_date', )
4141
ORDER_ITEM_FEATURES = ('list_price', 'unit_cost', 'status')
4242
ORDER_FEATURES = ('purchase_time',)
4343

@@ -49,7 +49,7 @@
4949
'bill_to_street2', 'bill_to_city', 'bill_to_state', 'bill_to_postalcode',
5050
'bill_to_country', 'order_type', 'created')
5151

52-
AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES
52+
AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES + ENROLLMENT_FEATURES
5353
COURSE_REGISTRATION_FEATURES = ('code', 'course_id', 'created_by', 'created_at', 'is_valid')
5454
COUPON_FEATURES = ('code', 'course_id', 'percentage_discount', 'description', 'expiration_date', 'is_active')
5555
CERTIFICATE_FEATURES = ('course_id', 'mode', 'status', 'grade', 'created_date', 'is_active', 'error_reason')
@@ -84,7 +84,62 @@ def issued_certificates(course_key, features):
8484
return generated_certificates
8585

8686

87-
def enrolled_students_features(course_key, features):
87+
def get_student_features_with_custom(course_key):
88+
"""
89+
Allow site operators to include on the export custom fields if platform has an extending
90+
User model. This can be used if you have an extended model that include for example
91+
an university student number.
92+
Basic example of adding age:
93+
```python
94+
def get_age(self):
95+
return datetime.datetime.now().year - self.profile.year_of_birth
96+
setattr(User, 'age', property(get_age))
97+
```
98+
Then you have to add `age` to both site configurations:
99+
- `student_profile_download_fields_custom_student_attributes`
100+
- `student_profile_download_fields` site configurations`
101+
```json
102+
"student_profile_download_fields_custom_student_attributes": ["age"],
103+
"student_profile_download_fields": [
104+
"id", "username", "name", "email", "language", "location",
105+
"year_of_birth", "gender", "level_of_education", "mailing_address",
106+
"goals", "enrollment_mode", "last_login", "date_joined", "external_user_key",
107+
"enrollment_date", "age"
108+
]
109+
```
110+
Example if the platform has a custom user extended model like a One-To-One Link
111+
with the User Model:
112+
```python
113+
def get_user_extended_model_custom_field(self):
114+
if hasattr(self, "userextendedmodel"):
115+
return self.userextendedmodel.custom_field
116+
return None
117+
setattr(User, 'user_extended_model_custom_field', property(get_user_extended_model_custom_field))
118+
```
119+
```json
120+
"student_profile_download_fields_custom_student_attributes": ["user_extended_model_custom_field"],
121+
"student_profile_download_fields": [
122+
"id", "username", "name", "email", "language", "location",
123+
"year_of_birth", "gender", "level_of_education", "mailing_address",
124+
"goals", "enrollment_mode", "last_login", "date_joined", "external_user_key",
125+
"enrollment_date", "user_extended_model_custom_field"
126+
]
127+
```
128+
"""
129+
return STUDENT_FEATURES + tuple(
130+
configuration_helpers.get_value_for_org(
131+
course_key.org,
132+
"student_profile_download_fields_custom_student_attributes",
133+
getattr(
134+
settings,
135+
"STUDENT_PROFILE_DOWNLOAD_FIELDS_CUSTOM_STUDENT_ATTRIBUTES",
136+
(),
137+
),
138+
)
139+
)
140+
141+
142+
def enrolled_students_features(course_key, features): # lint-amnesty, pylint: disable=too-many-statements
88143
"""
89144
Return list of student features as dictionaries.
90145
@@ -101,18 +156,25 @@ def enrolled_students_features(course_key, features):
101156
include_enrollment_mode = 'enrollment_mode' in features
102157
include_verification_status = 'verification_status' in features
103158
include_program_enrollments = 'external_user_key' in features
159+
include_enrollment_date = 'enrollment_date' in features
104160
external_user_key_dict = {}
105161

106-
students = User.objects.filter(
107-
courseenrollment__course_id=course_key,
108-
courseenrollment__is_active=1,
109-
).order_by('username').select_related('profile')
162+
enrollments = CourseEnrollment.objects.filter(
163+
course_id=course_key,
164+
is_active=1,
165+
).select_related('user').order_by('user__username').select_related('user__profile')
110166

111167
if include_cohort_column:
112-
students = students.prefetch_related('course_groups')
168+
enrollments = enrollments.prefetch_related('user__course_groups')
113169

114170
if include_team_column:
115-
students = students.prefetch_related('teams')
171+
enrollments = enrollments.prefetch_related('user__teams')
172+
173+
174+
students = [enrollment.user for enrollment in enrollments]
175+
176+
# student_features = [x for x in get_student_features_with_custom(course_key) if x in features]
177+
# profile_features = [x for x in PROFILE_FEATURES if x in features]
116178

117179
if include_program_enrollments and len(students) > 0:
118180
program_enrollments = fetch_program_enrollments_by_students(users=students, realized_only=True)
@@ -128,11 +190,15 @@ def extract_attr(student, feature):
128190
except TypeError:
129191
return str(attr)
130192

131-
def extract_student(student, features):
193+
def extract_enrollment_student(enrollment, features):
132194
""" convert student to dictionary """
195+
133196
student_features = [x for x in STUDENT_FEATURES if x in features]
134197
profile_features = [x for x in PROFILE_FEATURES if x in features]
135198

199+
student = enrollment.user
200+
201+
136202
# For data extractions on the 'meta' field
137203
# the feature name should be in the format of 'meta.foo' where
138204
# 'foo' is the keyname in the meta dictionary
@@ -189,9 +255,12 @@ def extract_student(student, features):
189255
# extra external_user_key
190256
student_dict['external_user_key'] = external_user_key_dict.get(student.id, '')
191257

258+
if include_enrollment_date:
259+
student_dict['enrollment_date'] = enrollment.created
260+
192261
return student_dict
193262

194-
return [extract_student(student, features) for student in students]
263+
return [extract_enrollment_student(enrollment, features) for enrollment in enrollments]
195264

196265

197266
def list_may_enroll(course_key, features):

lms/djangoapps/instructor_analytics/tests/test_basic.py

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55

66
from unittest.mock import MagicMock, Mock, patch
77

8+
import random
9+
import datetime
810
import ddt
911
import json # lint-amnesty, pylint: disable=wrong-import-order
12+
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
1013
from edx_proctoring.api import create_exam
1114
from edx_proctoring.models import ProctoredExamStudentAttempt
1215
from opaque_keys.edx.locator import UsageKey
@@ -15,6 +18,7 @@
1518
PROFILE_FEATURES,
1619
PROGRAM_ENROLLMENT_FEATURES,
1720
STUDENT_FEATURES,
21+
ENROLLMENT_FEATURES,
1822
StudentModule,
1923
enrolled_students_features,
2024
get_proctored_exam_results,
@@ -24,6 +28,7 @@
2428
)
2529
from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory
2630
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
31+
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory
2732
from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed
2833
from common.djangoapps.student.tests.factories import InstructorFactory
2934
from common.djangoapps.student.tests.factories import UserFactory
@@ -250,8 +255,61 @@ def test_enrolled_student_features_external_user_keys(self):
250255
assert '' == report['external_user_key']
251256

252257
def test_available_features(self):
253-
assert len(AVAILABLE_FEATURES) == len(STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES)
254-
assert set(AVAILABLE_FEATURES) == set(STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_ENROLLMENT_FEATURES)
258+
assert len(AVAILABLE_FEATURES) == len(
259+
STUDENT_FEATURES +
260+
PROFILE_FEATURES +
261+
PROGRAM_ENROLLMENT_FEATURES +
262+
ENROLLMENT_FEATURES
263+
)
264+
assert set(AVAILABLE_FEATURES) == set(
265+
STUDENT_FEATURES +
266+
PROFILE_FEATURES +
267+
PROGRAM_ENROLLMENT_FEATURES +
268+
ENROLLMENT_FEATURES
269+
)
270+
271+
def test_enrolled_students_enrollment_date(self):
272+
query_features = ('username', 'enrollment_date',)
273+
for feature in query_features:
274+
assert feature in AVAILABLE_FEATURES
275+
with self.assertNumQueries(2):
276+
userreports = enrolled_students_features(self.course_key, query_features)
277+
assert len(userreports) == len(self.users)
278+
279+
userreports = sorted(userreports, key=lambda u: u["username"])
280+
users = sorted(self.users, key=lambda u: u.username)
281+
for userreport, user in zip(userreports, users):
282+
assert set(userreport.keys()) == set(query_features)
283+
assert userreport['enrollment_date'] == CourseEnrollment.enrollments_for_user(user)[0].created
284+
285+
def test_enrolled_students_extended_model_age(self):
286+
SiteConfigurationFactory.create(
287+
site_values={
288+
'course_org_filter': ['robot'],
289+
'student_profile_download_fields_custom_student_attributes': ['age'],
290+
}
291+
)
292+
293+
def get_age(self):
294+
return datetime.datetime.now().year - self.profile.year_of_birth
295+
setattr(User, "age", property(get_age)) # lint-amnesty, pylint: disable=literal-used-as-attribute
296+
297+
for user in self.users:
298+
user.profile.year_of_birth = random.randint(1900, 2000)
299+
user.profile.save()
300+
301+
query_features = ('username', 'age',)
302+
with self.assertNumQueries(3):
303+
userreports = enrolled_students_features(self.course_key, query_features)
304+
assert len(userreports) == len(self.users)
305+
306+
userreports = sorted(userreports, key=lambda u: u["username"])
307+
users = sorted(self.users, key=lambda u: u.username)
308+
for userreport, user in zip(userreports, users):
309+
assert set(userreport.keys()) == set(query_features)
310+
assert userreport['age'] == str(user.age)
311+
312+
delattr(User, "age") # lint-amnesty, pylint: disable=literal-used-as-attribute
255313

256314
def test_list_may_enroll(self):
257315
may_enroll = list_may_enroll(self.course_key, ['email'])

0 commit comments

Comments
 (0)