Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
bea5f36
feat: add exception message in the user interface
MaferMazu Sep 25, 2022
534084e
fix: correct test that fails due to custom changes
MaferMazu May 16, 2022
6ce0c46
fix: pep8 and pylint issues
MaferMazu May 16, 2022
d02f983
GN-265 Always show optional fields on registry.
igobranco Feb 15, 2021
435f891
Fix use a custom registration field order when using a registration e…
igobranco Feb 19, 2021
4e8800e
Adding the capacity to see and edit nau extension fields on the accou…
felipemontoya Aug 30, 2019
a14f90d
fix: missing module
Henrrypg May 2, 2022
2b83ce4
fix: import modules path
Henrrypg May 3, 2022
52a693e
feat: move custom data authorization to register bottom
igobranco Apr 12, 2024
028b378
fix: generate password with configured validators
igobranco Sep 12, 2023
2397f18
Adding password police support to generate password.
Nov 12, 2020
1898b1e
fix: import string
BetoFandino Jul 2, 2024
902a9ee
feat: fix cherry-pick
BetoFandino Jul 3, 2024
3cd9c76
fix: import modules path
Henrrypg May 3, 2022
888e523
fix: pep8 and pylint issues
MaferMazu May 16, 2022
ab1b787
fix: remove extra code on RegisterView.js
DonatoBD Jul 24, 2024
b644449
fix: registration
igobranco Nov 15, 2024
973f557
fix: merge for redwood
igobranco Nov 15, 2024
af47f1a
feat: add support for extended checkbox field in account settings
BryanttV Dec 11, 2024
141cbae
style: add styling for checkbox field
BryanttV Dec 13, 2024
defca06
chore: convert indentation to spaces
BryanttV Dec 19, 2024
7ee3ea6
feat: add exception message has localizedMessage
igobranco Mar 13, 2023
3b7cc80
feat: add feature toggle for private fields in profile information re…
BryanttV May 12, 2025
ba9707c
feat: add user certificate to Certificate render start context
igobranco Sep 10, 2025
ec7f330
feat: add enrollment_date and custom fields to profile data csv
igobranco May 11, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions common/djangoapps/student/views/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,8 @@ def change_enrollment(request, check_access=True):
enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes)
if enroll_mode:
CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode)
except EnrollmentNotAllowed as exc:
return HttpResponseBadRequest(str(exc))
except EnrollmentNotAllowed as exec: # pylint: disable=broad-except
return HttpResponseBadRequest(str(exec))
except Exception: # pylint: disable=broad-except
return HttpResponseBadRequest(_("Could not enroll"))

Expand Down
15 changes: 15 additions & 0 deletions lms/djangoapps/certificates/views/webview.py
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,21 @@ def render_html_view(request, course_id, certificate=None): # pylint: disable=t
context.update(get_certificate_header_context(is_secure=request.is_secure()))
context.update(get_certificate_footer_context())

# Append/Override the existing view context values with plugin defined values
run_extension_point(
'NAU_CERTIFICATE_CONTEXT_EXTENSION',
context=context,
request=request,
course=course,
user=user,
user_certificate=user_certificate,
configuration=configuration,
certificate_language=certificate_language,
)

# NAU Customization so the filter know which certificate is being rendered
context['user_certificate'] = user_certificate

# Append/Override the existing view context values with any course-specific static values from Advanced Settings
context.update(course.cert_html_view_overrides)

Expand Down
18 changes: 18 additions & 0 deletions lms/djangoapps/instructor/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from django.http import HttpRequest, HttpResponse
from django.test import RequestFactory, TestCase
from django.test.client import MULTIPART_CONTENT
from django.test.utils import override_settings
from django.urls import reverse as django_reverse
from django.utils.translation import gettext as _
from edx_when.api import get_dates_for_course, get_overrides_for_user, set_date_for_block
Expand Down Expand Up @@ -2626,6 +2627,23 @@ def test_get_students_features(self):
assert student_json['city'] == student.profile.city
assert student_json['country'] == ''

@ddt.data(True, False)
def test_get_students_features_private_fields(self, show_private_fields):
"""
Test that the get_students_features returns the expected private fields
"""
with override_settings(FEATURES={'SHOW_PRIVATE_FIELDS_IN_PROFILE_INFORMATION_REPORT': show_private_fields}):
url = reverse('get_students_features', kwargs={'course_id': str(self.course.id)})
response = self.client.post(url, {})
res_json = json.loads(response.content.decode('utf-8'))

assert 'students' in res_json
for student in res_json['students']:
if show_private_fields:
assert 'year_of_birth' in student
else:
assert 'year_of_birth' not in student

@ddt.data(True, False)
def test_get_students_features_cohorted(self, is_cohorted):
"""
Expand Down
71 changes: 38 additions & 33 deletions lms/djangoapps/instructor/views/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1485,40 +1485,45 @@ def post(self, request, course_id, csv=False): # pylint: disable=redefined-oute
# We need to clone the list because we modify it below
query_features = list(configuration_helpers.get_value('student_profile_download_fields', []))

if not query_features:
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'
]
keep_field_private(query_features, 'year_of_birth') # protected information

# 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.
query_features_names = {
'id': _('User ID'),
'username': _('Username'),
'name': _('Name'),
'email': _('Email'),
'language': _('Language'),
'location': _('Location'),
# 'year_of_birth': _('Birth Year'), treated as privileged information as of TNL-10683,
# not to go in reports
'gender': _('Gender'),
'level_of_education': _('Level of Education'),
'mailing_address': _('Mailing Address'),
'goals': _('Goals'),
'enrollment_mode': _('Enrollment Mode'),
'last_login': _('Last Login'),
'date_joined': _('Date Joined'),
'external_user_key': _('External User Key'),
}
if not query_features:
query_features = [
'id', 'username', 'name', 'email', 'language', 'location',
'year_of_birth', 'gender', 'level_of_education', 'mailing_address',
'goals', 'enrollment_mode', 'verification_status',
'last_login', 'date_joined', 'external_user_key',
'enrollment_date'
]

# 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.
query_features_names = {
'id': _('User ID'),
'username': _('Username'),
'name': _('Name'),
'email': _('Email'),
'language': _('Language'),
'location': _('Location'),
'year_of_birth': _('Birth Year'),
'gender': _('Gender'),
'level_of_education': _('Level of Education'),
'mailing_address': _('Mailing Address'),
'goals': _('Goals'),
'enrollment_mode': _('Enrollment Mode'),
'last_login': _('Last Login'),
'date_joined': _('Date Joined'),
'external_user_key': _('External User Key'),
'enrollment_date': _('Enrollment Date'),
}

if not settings.FEATURES.get('SHOW_PRIVATE_FIELDS_IN_PROFILE_INFORMATION_REPORT', False):
keep_field_private(query_features, 'year_of_birth')
query_features_names.pop('year_of_birth', None)

if is_course_cohorted(course.id):
# Translators: 'Cohort' refers to a group of students within a course.
query_features.append('cohort')
query_features_names['cohort'] = _('Cohort')
if is_course_cohorted(course.id):
# Translators: 'Cohort' refers to a group of students within a course.
query_features.append('cohort')
query_features_names['cohort'] = _('Cohort')

if course.teams_enabled:
query_features.append('team')
Expand Down
91 changes: 80 additions & 11 deletions lms/djangoapps/instructor_analytics/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
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 # lint-amnesty, pylint: disable=unused-import
Expand Down Expand Up @@ -38,6 +37,7 @@
'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',)

Expand All @@ -49,7 +49,7 @@
'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')
Expand Down Expand Up @@ -84,7 +84,62 @@ def issued_certificates(course_key, features):
return generated_certificates


def enrolled_students_features(course_key, features):
def get_student_features_with_custom(course_key):
"""
Allow site operators to include on the export custom fields if platform has an extending
User model. This can be used if you have an extended model that include for example
an university student number.
Basic example of adding age:
```python
def get_age(self):
return datetime.datetime.now().year - self.profile.year_of_birth
setattr(User, 'age', property(get_age))
```
Then you have to add `age` to both site configurations:
- `student_profile_download_fields_custom_student_attributes`
- `student_profile_download_fields` site configurations`
```json
"student_profile_download_fields_custom_student_attributes": ["age"],
"student_profile_download_fields": [
"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",
"enrollment_date", "age"
]
```
Example if the platform has a custom user extended model like a One-To-One Link
with the User Model:
```python
def get_user_extended_model_custom_field(self):
if hasattr(self, "userextendedmodel"):
return self.userextendedmodel.custom_field
return None
setattr(User, 'user_extended_model_custom_field', property(get_user_extended_model_custom_field))
```
```json
"student_profile_download_fields_custom_student_attributes": ["user_extended_model_custom_field"],
"student_profile_download_fields": [
"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",
"enrollment_date", "user_extended_model_custom_field"
]
```
"""
return STUDENT_FEATURES + tuple(
configuration_helpers.get_value_for_org(
course_key.org,
"student_profile_download_fields_custom_student_attributes",
getattr(
settings,
"STUDENT_PROFILE_DOWNLOAD_FIELDS_CUSTOM_STUDENT_ATTRIBUTES",
(),
),
)
)


def enrolled_students_features(course_key, features): # lint-amnesty, pylint: disable=too-many-statements
"""
Return list of student features as dictionaries.

Expand All @@ -101,18 +156,25 @@ def enrolled_students_features(course_key, features):
include_enrollment_mode = 'enrollment_mode' in features
include_verification_status = 'verification_status' in features
include_program_enrollments = 'external_user_key' in features
include_enrollment_date = 'enrollment_date' 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')
enrollments = enrollments.prefetch_related('user__course_groups')

if include_team_column:
students = students.prefetch_related('teams')
enrollments = enrollments.prefetch_related('user__teams')


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 include_program_enrollments and len(students) > 0:
program_enrollments = fetch_program_enrollments_by_students(users=students, realized_only=True)
Expand All @@ -128,11 +190,15 @@ def extract_attr(student, feature):
except TypeError:
return str(attr)

def extract_student(student, features):
def extract_enrollment_student(enrollment, 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]

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
Expand Down Expand Up @@ -189,9 +255,12 @@ def extract_student(student, features):
# extra external_user_key
student_dict['external_user_key'] = external_user_key_dict.get(student.id, '')

if include_enrollment_date:
student_dict['enrollment_date'] = enrollment.created

return student_dict

return [extract_student(student, features) for student in students]
return [extract_enrollment_student(enrollment, features) for enrollment in enrollments]


def list_may_enroll(course_key, features):
Expand Down
62 changes: 60 additions & 2 deletions lms/djangoapps/instructor_analytics/tests/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@

from unittest.mock import MagicMock, Mock, patch

import random
import datetime
import ddt
import json # lint-amnesty, pylint: disable=wrong-import-order
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from edx_proctoring.api import create_exam
from edx_proctoring.models import ProctoredExamStudentAttempt
from opaque_keys.edx.locator import UsageKey
Expand All @@ -15,6 +18,7 @@
PROFILE_FEATURES,
PROGRAM_ENROLLMENT_FEATURES,
STUDENT_FEATURES,
ENROLLMENT_FEATURES,
StudentModule,
enrolled_students_features,
get_proctored_exam_results,
Expand All @@ -24,6 +28,7 @@
)
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
Expand Down Expand Up @@ -250,8 +255,61 @@ def test_enrolled_student_features_external_user_keys(self):
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):
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)

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['enrollment_date'] == CourseEnrollment.enrollments_for_user(user)[0].created

def test_enrolled_students_extended_model_age(self):
SiteConfigurationFactory.create(
site_values={
'course_org_filter': ['robot'],
'student_profile_download_fields_custom_student_attributes': ['age'],
}
)

def get_age(self):
return datetime.datetime.now().year - self.profile.year_of_birth
setattr(User, "age", property(get_age)) # lint-amnesty, pylint: disable=literal-used-as-attribute

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)

delattr(User, "age") # lint-amnesty, pylint: disable=literal-used-as-attribute

def test_list_may_enroll(self):
may_enroll = list_may_enroll(self.course_key, ['email'])
Expand Down
2 changes: 1 addition & 1 deletion lms/djangoapps/support/views/manage_user.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from openedx.core.djangoapps.user_api.accounts.serializers import AccountUserSerializer
from openedx.core.djangolib.oauth2_retirement_utils import retire_dot_oauth2_models

from edx_django_utils.user import generate_password # lint-amnesty, pylint: disable=wrong-import-order
from openedx.core.djangoapps.user_authn.utils import generate_password # lint-amnesty, pylint: disable=wrong-import-order


class ManageUserSupportView(View):
Expand Down
Loading