From 5879a52b72518c6609bacbdbe52deee0cb14d62d Mon Sep 17 00:00:00 2001 From: Muhammad Faraz Maqsood Date: Wed, 16 Jul 2025 14:09:38 +0500 Subject: [PATCH] feat: add data report for enrolled & inactive user Add new data report for learners who are enrolled in a course and have not activated their account --- lms/djangoapps/instructor/views/api.py | 40 +++++++++++++++++++ lms/djangoapps/instructor/views/api_urls.py | 2 + .../instructor/views/instructor_dashboard.py | 3 ++ lms/djangoapps/instructor_analytics/basic.py | 30 +++++++++++++- lms/djangoapps/instructor_task/api.py | 16 ++++++++ lms/djangoapps/instructor_task/api_helper.py | 2 +- lms/djangoapps/instructor_task/data.py | 1 + lms/djangoapps/instructor_task/tasks.py | 20 +++++++++- .../tasks_helper/enrollments.py | 38 +++++++++++++++++- .../js/instructor_dashboard/data_download.js | 26 ++++++++++++ .../instructor_dashboard_2/data_download.html | 4 ++ .../data_download_2/reports.html | 7 ++++ 12 files changed, 185 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 8fdfd06ae2..f5e6d007e8 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1598,6 +1598,46 @@ class GetStudentsWhoMayEnroll(DeveloperErrorViewMixin, APIView): raise MethodNotAllowed('GET') +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class GetInactiveEnrolledStudents(DeveloperErrorViewMixin, APIView): + """ + Initiate generation of a CSV file containing information about + students who are enrolled in a course but have inactive account. + """ + + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH + + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Initiate generation of a CSV file containing information about + students who are enrolled in a course but have inactive account. + + Responds with JSON + {"status": "... status message ..."} + """ + course_key = CourseKey.from_string(course_id) + query_features = ["email"] + report_type = _("inactive enrollment") + try: + task_api.submit_calculate_inactive_enrolled_students_csv( + request, course_key, query_features + ) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + except Exception as e: + raise self.api_error( + status.HTTP_400_BAD_REQUEST, str(e), "Requested task is already running" + ) + + return JsonResponse({"status": success_status}) + + def get(self, request, *args, **kwargs): + raise MethodNotAllowed("GET") + + def _cohorts_csv_validator(file_storage, file_to_validate): """ Verifies that the expected columns are present in the CSV used to add users to cohorts. diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 56d47f24c4..f47fc2d299 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -31,6 +31,8 @@ urlpatterns = [ re_path(r'^get_students_features(?P/csv)?$', api.GetStudentsFeatures.as_view(), name='get_students_features'), path('get_grading_config', api.GetGradingConfig.as_view(), name='get_grading_config'), path('get_students_who_may_enroll', api.GetStudentsWhoMayEnroll.as_view(), name='get_students_who_may_enroll'), + path('get_enrolled_students_with_inactive_account', api.GetInactiveEnrolledStudents.as_view(), + name='get_enrolled_students_with_inactive_account'), path('get_anon_ids', api.GetAnonIds.as_view(), name='get_anon_ids'), path('get_student_enrollment_status', api.GetStudentEnrollmentStatus.as_view(), name="get_student_enrollment_status"), diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index b6057f8194..be2ae51dbd 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -650,6 +650,9 @@ def _section_data_download(course, access): 'get_students_who_may_enroll_url': reverse( 'get_students_who_may_enroll', kwargs={'course_id': str(course_key)} ), + 'get_inactive_enrolled_students_url': reverse( + 'get_enrolled_students_with_inactive_account', kwargs={'course_id': str(course_key)} + ), 'get_anon_ids_url': reverse('get_anon_ids', kwargs={'course_id': str(course_key)}), 'list_proctored_results_url': reverse( 'get_proctored_exam_results', kwargs={'course_id': str(course_key)} diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index c7bc6ca6da..6b5a772519 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -13,7 +13,7 @@ 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 +from django.db.models import Count, F from django.urls import reverse from edx_proctoring.api import get_exam_violation_report from opaque_keys.edx.keys import CourseKey, UsageKey @@ -219,6 +219,34 @@ def list_may_enroll(course_key, features): return [extract_student(student, features) for student in may_enroll_and_unenrolled] +def list_inactive_enrolled_students(course_key, features): + """ + Return info about students who are enrolled in a course but have not activated their account. + + list_enrolled_inactive_students(course_key, ['email']) + would return [ + {'email': 'email1'} + {'email': 'email2'} + {'email': 'email3'} + ] + """ + enrolled_inactive_user_emails = CourseEnrollment.objects.filter( + course_id=course_key, + is_active=True, + user__is_active=False + ).annotate( + email=F('user__email') + ).values('email') + + def extract_student(student, features): + """ + Build dict containing information about a single inactive enrolled student. + """ + return {feature: student.get(feature, None) for feature in features} + + return [extract_student(student, features) for student in enrolled_inactive_user_emails] + + def get_proctored_exam_results(course_key, features): """ Return info about proctored exam results in a course as a dict. diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index 62095c3ae3..6474efc1d3 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -32,6 +32,7 @@ from lms.djangoapps.instructor_task.data import InstructorTaskTypes from lms.djangoapps.instructor_task.models import InstructorTask, InstructorTaskSchedule, SCHEDULED from lms.djangoapps.instructor_task.tasks import ( calculate_grades_csv, + calculate_inactive_enrolled_students_info_csv, calculate_may_enroll_csv, calculate_problem_grade_report, calculate_problem_responses_csv, @@ -409,6 +410,21 @@ def submit_calculate_may_enroll_csv(request, course_key, features): return submit_task(request, task_type, task_class, course_key, task_input, task_key) +def submit_calculate_inactive_enrolled_students_csv(request, course_key, features): + """ + Submits a task to generate a CSV file containing information about + enrolled students in a course who have not activated their account yet. + + Raises AlreadyRunningError if said file is already being updated. + """ + task_type = InstructorTaskTypes.INACTIVE_ENROLLED_STUDENTS_INFO_CSV + task_class = calculate_inactive_enrolled_students_info_csv + task_input = {'features': features} + task_key = "" + + return submit_task(request, task_type, task_class, course_key, task_input, task_key) + + def submit_course_survey_report(request, course_key): """ Submits a task to generate a HTML File containing the executive summary report. diff --git a/lms/djangoapps/instructor_task/api_helper.py b/lms/djangoapps/instructor_task/api_helper.py index 9c3f900fee..9e6bd2f6c9 100644 --- a/lms/djangoapps/instructor_task/api_helper.py +++ b/lms/djangoapps/instructor_task/api_helper.py @@ -114,7 +114,7 @@ def generate_already_running_error_message(task_type): 'proctored_exam_results_report': _('proctored exam results'), 'export_ora2_data': _('ORA data'), 'grade_course': _('grade'), - + 'inactive_enrolled_students_info_csv': _('inactive enrollment') } if report_types.get(task_type): diff --git a/lms/djangoapps/instructor_task/data.py b/lms/djangoapps/instructor_task/data.py index c9da5eda7d..7a09f7d082 100644 --- a/lms/djangoapps/instructor_task/data.py +++ b/lms/djangoapps/instructor_task/data.py @@ -24,6 +24,7 @@ class InstructorTaskTypes(str, Enum): GRADE_COURSE = "grade_course" GRADE_PROBLEMS = "grade_problems" MAY_ENROLL_INFO_CSV = "may_enroll_info_csv" + INACTIVE_ENROLLED_STUDENTS_INFO_CSV = "inactive_enrolled_students_info_csv" OVERRIDE_PROBLEM_SCORE = "override_problem_score" PROBLEM_RESPONSES_CSV = "problem_responses_csv" PROCTORED_EXAM_RESULTS_REPORT = "proctored_exam_results_report" diff --git a/lms/djangoapps/instructor_task/tasks.py b/lms/djangoapps/instructor_task/tasks.py index 9603a4fa18..7a9dabe3d5 100644 --- a/lms/djangoapps/instructor_task/tasks.py +++ b/lms/djangoapps/instructor_task/tasks.py @@ -30,7 +30,11 @@ from edx_django_utils.monitoring import set_code_owner_attribute from lms.djangoapps.bulk_email.tasks import perform_delegate_email_batches from lms.djangoapps.instructor_task.tasks_base import BaseInstructorTask from lms.djangoapps.instructor_task.tasks_helper.certs import generate_students_certificates -from lms.djangoapps.instructor_task.tasks_helper.enrollments import upload_may_enroll_csv, upload_students_csv +from lms.djangoapps.instructor_task.tasks_helper.enrollments import ( + upload_inactive_enrolled_students_info_csv, + upload_may_enroll_csv, + upload_students_csv +) from lms.djangoapps.instructor_task.tasks_helper.grades import CourseGradeReport, ProblemGradeReport, ProblemResponses from lms.djangoapps.instructor_task.tasks_helper.misc import ( cohort_students_and_upload, @@ -267,6 +271,20 @@ def calculate_may_enroll_csv(entry_id, xblock_instance_args): return run_main_task(entry_id, task_fn, action_name) +@shared_task(base=BaseInstructorTask) +@set_code_owner_attribute +def calculate_inactive_enrolled_students_info_csv(entry_id, xblock_instance_args): + """ + Compute information about invited students who have not enrolled + in a given course yet and upload the CSV to an S3 bucket for + download. + """ + # Translators: This is a past-tense verb that is inserted into task progress messages as {action}. + action_name = gettext_noop('generated') + task_fn = partial(upload_inactive_enrolled_students_info_csv, xblock_instance_args) + return run_main_task(entry_id, task_fn, action_name) + + @shared_task(base=BaseInstructorTask) @set_code_owner_attribute def generate_certificates(entry_id, xblock_instance_args): diff --git a/lms/djangoapps/instructor_task/tasks_helper/enrollments.py b/lms/djangoapps/instructor_task/tasks_helper/enrollments.py index 0309b7883a..468786323b 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/enrollments.py +++ b/lms/djangoapps/instructor_task/tasks_helper/enrollments.py @@ -7,7 +7,11 @@ import logging from datetime import datetime from time import time from pytz import UTC -from lms.djangoapps.instructor_analytics.basic import enrolled_students_features, list_may_enroll +from lms.djangoapps.instructor_analytics.basic import ( + enrolled_students_features, + list_inactive_enrolled_students, + list_may_enroll, +) from lms.djangoapps.instructor_analytics.csvs import format_dictlist from common.djangoapps.student.models import CourseEnrollment # lint-amnesty, pylint: disable=unused-import @@ -50,6 +54,38 @@ def upload_may_enroll_csv(_xblock_instance_args, _entry_id, course_id, task_inpu return task_progress.update_task_state(extra_meta=current_step) +def upload_inactive_enrolled_students_info_csv(_xblock_instance_args, _entry_id, course_id, task_input, action_name): + """ + For a given `course_id`, generate a CSV file containing + information about students who are enrolled in a course but have not + activated their account yet, and store using a `ReportStore`. + """ + start_time = time() + start_date = datetime.now(UTC) + num_reports = 1 + task_progress = TaskProgress(action_name, num_reports, start_time) + current_step = {'step': 'Calculating info about students who are enrolled and their account is inactive'} + task_progress.update_task_state(extra_meta=current_step) + + # Compute result table and format it + query_features = task_input.get('features') + student_data = list_inactive_enrolled_students(course_id, query_features) + header, rows = format_dictlist(student_data, query_features) + + task_progress.attempted = task_progress.succeeded = len(rows) + task_progress.skipped = task_progress.total - task_progress.attempted + + rows.insert(0, header) + + current_step = {'step': 'Uploading CSV'} + task_progress.update_task_state(extra_meta=current_step) + + # Perform the upload + upload_csv_to_report_store(rows, 'inactive_enrolled_students_info', course_id, start_date) + + return task_progress.update_task_state(extra_meta=current_step) + + def upload_students_csv(_xblock_instance_args, _entry_id, course_id, task_input, action_name): """ For a given `course_id`, generate a CSV file containing profile diff --git a/lms/static/js/instructor_dashboard/data_download.js b/lms/static/js/instructor_dashboard/data_download.js index 54492a06d5..94f406b4f3 100644 --- a/lms/static/js/instructor_dashboard/data_download.js +++ b/lms/static/js/instructor_dashboard/data_download.js @@ -101,6 +101,7 @@ this.$proctored_exam_csv_btn = this.$section.find("input[name='proctored-exam-results-report']"); this.$survey_results_csv_btn = this.$section.find("input[name='survey-results-report']"); this.$list_may_enroll_csv_btn = this.$section.find("input[name='list-may-enroll-csv']"); + this.$list_learners_not_activated_csv_btn = this.$section.find("input[name='list-learners-not-activated-csv']"); this.$list_problem_responses_csv_input = this.$section.find("input[name='problem-location']"); this.$list_problem_responses_csv_btn = this.$section.find("input[name='list-problem-responses-csv']"); this.$list_anon_btn = this.$section.find("input[name='list-anon-ids']"); @@ -321,6 +322,31 @@ } }); }); + this.$list_learners_not_activated_csv_btn.click(function() { + var url = dataDownloadObj.$list_learners_not_activated_csv_btn.data('endpoint'); + var errorMessage = gettext('Error generating list of inactive students who are enrolled in a course. Please try again.'); + dataDownloadObj.clear_display(); + return $.ajax({ + type: 'POST', + dataType: 'json', + url: url, + error: function(error) { + if (error.responseText) { + errorMessage = JSON.parse(error.responseText); + } + dataDownloadObj.$reports_request_response_error.text(errorMessage); + return dataDownloadObj.$reports_request_response_error.css({ + display: 'block' + }); + }, + success: function(data) { + dataDownloadObj.$reports_request_response.text(data.status); + return $('.msg-confirm').css({ + display: 'block' + }); + } + }); + }); this.$grade_config_btn.click(function() { var url = dataDownloadObj.$grade_config_btn.data('endpoint'); return $.ajax({ diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html index 4e9a656319..b3c3b6a9b4 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download.html @@ -37,6 +37,10 @@ from openedx.core.djangolib.markup import HTML, Text

+

${_("Click to generate a CSV file that lists learners who are enrolled in the course but have not yet activated their account.")}

+ +

+

${_("Click to download a CSV of anonymized student IDs:")}

diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download_2/reports.html b/lms/templates/instructor/instructor_dashboard_2/data_download_2/reports.html index 4a70e99b70..5db83bd1c5 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download_2/reports.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download_2/reports.html @@ -34,6 +34,10 @@ from openedx.core.djangolib.markup import HTML, Text Learner who can enroll +