From 269a00addea70117fb996b59d69442be6acba051 Mon Sep 17 00:00:00 2001 From: Ahmed Khalid <106074266+ahmed-arb@users.noreply.github.com> Date: Mon, 1 Jul 2024 19:26:39 +0500 Subject: [PATCH] Sow 8 (#441) * feat: bypass course filtering rules set of lms in admin dashboard * feat: add new column to all courses enrollment report * feat: add users enrollment report in admin dashboard * feat: rearrange admin reports * feat: add grade report in student admin tab * Relocate and simplify grade and certificates instructor reports * allow course staff to access data download section --------- Co-authored-by: Abdul-Muqadim-Arbisoft <139064778+Abdul-Muqadim-Arbisoft@users.noreply.github.com> Co-authored-by: Abdul-Muqadim-Arbisoft --- lms/djangoapps/instructor/permissions.py | 2 +- lms/djangoapps/instructor/views/api.py | 41 +++++- lms/djangoapps/instructor/views/api_urls.py | 1 + .../instructor/views/instructor_dashboard.py | 8 +- lms/djangoapps/instructor_analytics/basic.py | 25 +++- .../instructor_task/tasks_helper/grades.py | 20 ++- .../js/instructor_dashboard/student_admin.js | 57 +++++++- lms/static/js/instructor_dashboard/util.js | 1 + .../sass/course/instructor/_instructor_2.scss | 10 ++ .../instructor_dashboard_2/data_download.html | 50 +++---- .../instructor_dashboard_2/student_admin.html | 36 ++++- .../admin_dashboard/admin_task/api.py | 23 ++++ .../admin_dashboard/course_reports.py | 28 +++- .../course_versions/task_helper.py | 43 +++++- .../admin_dashboard/course_versions/utils.py | 39 +++++- .../admin_dashboard/js/course_reports.js | 4 + .../admin_dashboard/tasks.py | 21 ++- .../course_report/course-reports.html | 128 ++++++++++-------- .../admin_dashboard/urls.py | 7 +- .../wikimedia_general/utils.py | 45 ++++-- 20 files changed, 453 insertions(+), 136 deletions(-) diff --git a/lms/djangoapps/instructor/permissions.py b/lms/djangoapps/instructor/permissions.py index 20426761e66b..afe042828019 100644 --- a/lms/djangoapps/instructor/permissions.py +++ b/lms/djangoapps/instructor/permissions.py @@ -43,7 +43,7 @@ perms[VIEW_ISSUED_CERTIFICATES] = HasAccessRule('staff') | HasRolesRule('data_researcher') # only global staff or those with the data_researcher role can access the data download tab # HasAccessRule('staff') also includes course staff -perms[CAN_RESEARCH] = is_staff | HasRolesRule('data_researcher') +perms[CAN_RESEARCH] = is_staff | HasRolesRule('data_researcher', 'staff', 'instructor') perms[CAN_ENROLL] = HasAccessRule('staff') perms[CAN_BETATEST] = HasAccessRule('instructor') perms[ENROLLMENT_REPORT] = HasAccessRule('staff') | HasRolesRule('data_researcher') diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 5de1d32b6fbd..66c572374168 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1233,12 +1233,13 @@ def get_issued_certificates(request, course_id): course_key = CourseKey.from_string(course_id) csv_required = request.GET.get('csv', 'false') - query_features = ['course_id', 'mode', 'total_issued_certificate', 'report_run_date'] + query_features = ['user', 'grade', 'mode', 'created_date', 'verify_uuid'] query_features_names = [ - ('course_id', _('CourseID')), + ('user', _('Username')), + ('grade', _('Grade')), ('mode', _('Certificate Type')), - ('total_issued_certificate', _('Total Certificates Issued')), - ('report_run_date', _('Date Report Run')) + ('created_date', _('Certificate Creation Date')), + ('verify_uuid', _('Certificate link')) ] certificates_data = instructor_analytics_basic.issued_certificates(course_key, query_features) if csv_required.lower() == 'true': @@ -2345,6 +2346,38 @@ def _list_report_downloads(request, course_id): return JsonResponse(response_payload) +@require_POST +@ensure_csrf_cookie +def list_report_downloads_student_admin(request, course_id): + """ + List grade CSV files that are available for download for this course. + """ + return _list_report_downloads_student_admin(request=request, course_id=course_id) + + +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@require_course_permission(permissions.CAN_RESEARCH) +def _list_report_downloads_student_admin(request, course_id): + """ + List grade CSV files that are available for download for this course for student admins. + + Internal function with common code shared between DRF and functional views. + """ + course_id = CourseKey.from_string(course_id) + report_store = ReportStore.from_config(config_name="GRADES_DOWNLOAD") + report_names = ["grade_report"] + course_run = course_id.run # because filenames don't have course version + + response_payload = {"downloads": []} + for name, url in report_store.links_for(course_id): + if any(f"{course_run}_{report_name}" in name for report_name in report_names): + response_payload["downloads"].append( + dict(name=name, url=url, link=HTML('{}').format(HTML(url), Text(name))) + ) + + return JsonResponse(response_payload) + + @require_GET @ensure_csrf_cookie def pending_tasks(request, course_id=None): diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index e18b99fca1a2..b88b1bbab8e5 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -65,6 +65,7 @@ # Grade downloads... path('list_report_downloads', api.list_report_downloads, name='list_report_downloads'), + path('list_report_downloads_student_admin', api.list_report_downloads_student_admin, name='list_report_downloads_student_admin'), path('calculate_grades_csv', api.calculate_grades_csv, name='calculate_grades_csv'), path('problem_grade_report', api.problem_grade_report, name='problem_grade_report'), diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 4143d39c098c..9de2e8f1b796 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -146,7 +146,7 @@ def instructor_dashboard_2(request, course_id): # lint-amnesty, pylint: disable sections_content.append(_section_discussions_management(course, access)) sections.extend(sections_content) - if access['data_researcher']: + if access['data_researcher'] or access['staff'] or access['instructor']: sections.append(_section_data_download(course, access)) analytics_dashboard_message = None @@ -563,6 +563,10 @@ def _section_student_admin(course, access): kwargs={'course_id': str(course_key)} ), 'spoc_gradebook_url': reverse('spoc_gradebook', kwargs={'course_id': str(course_key)}), + 'calculate_grades_csv_url': reverse('calculate_grades_csv', kwargs={'course_id': str(course_key)}), + 'list_report_downloads_url': reverse( + 'list_report_downloads_student_admin', kwargs={'course_id': str(course_key)} + ), } if is_writable_gradebook_enabled(course_key) and settings.WRITABLE_GRADEBOOK_URL: section_data['writable_gradebook_url'] = f'{settings.WRITABLE_GRADEBOOK_URL}/{str(course_key)}' @@ -628,7 +632,7 @@ def _section_data_download(course, access): ), 'export_ora2_summary_url': reverse('export_ora2_summary', kwargs={'course_id': str(course_key)}), } - if not access.get('data_researcher'): + if not (access.get('data_researcher') or access.get('staff') or access.get('instructor')): section_data['is_hidden'] = True return section_data diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index afce0620aab0..4855834178cb 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -28,7 +28,7 @@ from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.markup import HTML, Text - +from openedx.core.djangoapps.theming.helpers import get_current_site log = logging.getLogger(__name__) @@ -52,7 +52,7 @@ AVAILABLE_FEATURES = STUDENT_FEATURES + PROFILE_FEATURES + PROGRAM_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') +CERTIFICATE_FEATURES = ('course_id', 'mode', 'status', 'grade', 'created_date', 'is_active', 'error_reason', 'download_url', 'name', 'grade', 'user', 'verify_uuid') UNAVAILABLE = "[unavailable]" @@ -74,15 +74,30 @@ def issued_certificates(course_key, features): generated_certificates = list(GeneratedCertificate.eligible_certificates.filter( course_id=course_key, status=CertificateStatuses.downloadable - ).values(*certificate_features).annotate(total_issued_certificate=Count('mode'))) + ).values(*certificate_features)) - # Report run date for data in generated_certificates: data['report_run_date'] = report_run_date - data['course_id'] = str(data['course_id']) + user_id = data.pop('user', None) + if user_id: + user = User.objects.get(id=user_id) + data['user'] = user.username + + verify_uuid = data.pop('verify_uuid', None) + if verify_uuid: + certificate_url = generate_certificate_url(verify_uuid) + data['verify_uuid'] = certificate_url return generated_certificates +def generate_certificate_url(verify_uuid): + """ + Generate the certificate URL based on the verify_uuid. + """ + current_site = get_current_site() + scheme = 'https' if settings.HTTPS == 'on' else 'http' + base_url = f"{scheme}://{current_site.domain}" + return f"{base_url}/certificates/{verify_uuid}" def enrolled_students_features(course_key, features): """ diff --git a/lms/djangoapps/instructor_task/tasks_helper/grades.py b/lms/djangoapps/instructor_task/tasks_helper/grades.py index f4dcf3d49f04..4d41f354a89b 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/grades.py +++ b/lms/djangoapps/instructor_task/tasks_helper/grades.py @@ -43,6 +43,7 @@ from xmodule.modulestore.django import modulestore from xmodule.partitions.partitions_service import PartitionService from xmodule.split_test_module import get_split_user_partitions +from xmodule.util.misc import get_default_short_labeler from .runner import TaskProgress from .utils import upload_csv_to_report_store @@ -252,14 +253,15 @@ def graded_assignments(self): """ grading_cxt = grades_context.grading_context(self.course, self.course_structure) graded_assignments_map = OrderedDict() + short_labeler = get_default_short_labeler(self.course) + for assignment_type_name, subsection_infos in grading_cxt['all_graded_subsections_by_type'].items(): graded_subsections_map = OrderedDict() for subsection_index, subsection_info in enumerate(subsection_infos, start=1): subsection = subsection_info['subsection_block'] - header_name = "{assignment_type} {subsection_index}: {subsection_name}".format( - assignment_type=assignment_type_name, - subsection_index=subsection_index, - subsection_name=subsection.display_name, + short_label = short_labeler(subsection.format) + header_name = "{short_label}".format( + short_label=short_label, ) graded_subsections_map[subsection.location] = header_name @@ -443,10 +445,7 @@ def _success_headers(self, context): self._grades_header(context) + (['Cohort Name'] if context.cohorts_enabled else []) + [f'Experiment Group ({partition.name})' for partition in context.course_experiments] + - (['Team Name'] if context.teams_enabled else []) + - ['Enrollment Track', 'Verification Status'] + - ['Certificate Eligible', 'Certificate Delivered', 'Certificate Type'] + - ['Enrollment Status'] + (['Team Name'] if context.teams_enabled else []) ) def _error_headers(self): @@ -709,10 +708,7 @@ def _rows_for_users(self, context, users): self._user_grades(course_grade, context) + self._user_cohort_group_names(user, context) + self._user_experiment_group_names(user, context) + - self._user_team_names(user, bulk_context.teams) + - self._user_verification_mode(user, context, bulk_context.enrollments) + - self._user_certificate_info(user, context, course_grade, bulk_context.certs) + - [_user_enrollment_status(user, context.course_id)] + self._user_team_names(user, bulk_context.teams) ) return success_rows, error_rows diff --git a/lms/static/js/instructor_dashboard/student_admin.js b/lms/static/js/instructor_dashboard/student_admin.js index 54b773faaae6..98529cf705a3 100644 --- a/lms/static/js/instructor_dashboard/student_admin.js +++ b/lms/static/js/instructor_dashboard/student_admin.js @@ -2,7 +2,7 @@ (function() { 'use strict'; - var PendingInstructorTasks, createTaskListTable, findAndAssert, statusAjaxError; + var PendingInstructorTasks, ReportDownloads, createTaskListTable, findAndAssert, statusAjaxError; statusAjaxError = function() { return window.InstructorDashboard.util.statusAjaxError.apply(this, arguments); @@ -16,6 +16,10 @@ return window.InstructorDashboard.util.PendingInstructorTasks; }; + ReportDownloads = function() { + return window.InstructorDashboard.util.ReportDownloads; + }; + findAndAssert = function($root, selector) { var item, msg; item = $root.find(selector); @@ -26,7 +30,6 @@ return item; } }; - this.StudentAdmin = (function() { function StudentAdmin($section) { var studentadmin = this; @@ -65,7 +68,11 @@ this.$btn_rescore_problem_if_higher_all = this.$section.find("input[name='rescore-problem-all-if-higher']"); this.$btn_task_history_all = this.$section.find("input[name='task-history-all']"); this.$table_task_history_all = this.$section.find('.task-history-all-table'); + this.report_downloads = new (ReportDownloads())(this.$section); this.instructor_tasks = new (PendingInstructorTasks())(this.$section); + this.$reports = this.$section.find('.reports-download-container'); + this.$reports_request_response = this.$reports.find('.request-response'); + this.$reports_request_response_error = this.$reports.find('.request-response-error'); this.$request_err_enrollment_status = findAndAssert(this.$section, '.student-enrollment-status-container .request-response-error'); this.$request_err_progress = findAndAssert(this.$section, '.student-progress-container .request-response-error'); this.$request_err_grade = findAndAssert(this.$section, '.student-grade-container .request-response-error'); @@ -73,6 +80,8 @@ this.$request_response_error_all = this.$section.find('.course-specific-container .request-response-error'); this.$enrollment_status_link = findAndAssert(this.$section, 'a.enrollment-status-link'); this.$enrollment_status = findAndAssert(this.$section, '.student-enrollment-status'); + this.$async_report_btn = this.$section.find("input[class='async-report-btn']"); + this.clear_display(); this.$enrollment_status_link.click(function(e) { var errorMessage, fullErrorMessage, uniqStudentIdentifier; e.preventDefault(); @@ -476,6 +485,33 @@ }) }); }); + this.$async_report_btn.click(function(e) { + var url = $(e.target).data('endpoint'); + var errorMessage = ''; + studentadmin.clear_display(); + return $.ajax({ + type: 'POST', + dataType: 'json', + url: url, + error: function(error) { + if (error.responseText) { + errorMessage = JSON.parse(error.responseText); + } else if (e.target.name === 'calculate-grades-csv') { + errorMessage = gettext('Error generating grades. Please try again.'); + } + studentadmin.$reports_request_response_error.text(errorMessage); + return studentadmin.$reports_request_response_error.css({ + display: 'block' + }); + }, + success: function(data) { + studentadmin.$reports_request_response.text(data.status); + return $('.msg-confirm').css({ + display: 'block' + }); + } + }); + }); } StudentAdmin.prototype.rescore_problem_single = function(onlyIfHigher) { @@ -686,11 +722,24 @@ }; StudentAdmin.prototype.onClickTitle = function() { - return this.instructor_tasks.task_poller.start(); + this.clear_display(); + this.instructor_tasks.task_poller.start(); + return this.report_downloads.downloads_poller.start(); }; StudentAdmin.prototype.onExit = function() { - return this.instructor_tasks.task_poller.stop(); + this.instructor_tasks.task_poller.stop(); + return this.report_downloads.downloads_poller.stop(); + }; + StudentAdmin.prototype.clear_display = function() { + this.$reports_request_response.empty(); + this.$reports_request_response_error.empty(); + $('.msg-confirm').css({ + display: 'none' + }); + return $('.msg-error').css({ + display: 'none' + }); }; return StudentAdmin; diff --git a/lms/static/js/instructor_dashboard/util.js b/lms/static/js/instructor_dashboard/util.js index 578efecb598f..2c110587ca3c 100644 --- a/lms/static/js/instructor_dashboard/util.js +++ b/lms/static/js/instructor_dashboard/util.js @@ -517,6 +517,7 @@ $tablePlaceholder = $('
', { class: 'slickgrid' }); + $tablePlaceholder.css("height", "300px"); this.$report_downloads_table.append($tablePlaceholder); grid = new Slick.Grid($tablePlaceholder, reportDownloadsData, columns, options); grid.onClick.subscribe(function(event) { diff --git a/lms/static/sass/course/instructor/_instructor_2.scss b/lms/static/sass/course/instructor/_instructor_2.scss index 3bd8e8a0c251..dcc04c72ba4b 100644 --- a/lms/static/sass/course/instructor/_instructor_2.scss +++ b/lms/static/sass/course/instructor/_instructor_2.scss @@ -1504,6 +1504,16 @@ font-style: italic; font-size: 0.9em; } + .report-downloads-table { + .slickgrid { + height: 300px; + padding: ($baseline/4); + } + // Disable horizontal scroll bar when grid only has 1 column. Remove this CSS class when more columns added. + .slick-viewport { + overflow-x: hidden !important; + } + } } // view - data download diff --git a/lms/templates/instructor/instructor_dashboard_2/data_download.html b/lms/templates/instructor/instructor_dashboard_2/data_download.html index 20950eca11f1..c9c15269f0da 100644 --- a/lms/templates/instructor/instructor_dashboard_2/data_download.html +++ b/lms/templates/instructor/instructor_dashboard_2/data_download.html @@ -29,6 +29,31 @@

${_("Reports")}

${_("Please be patient and do not click these buttons multiple times. Clicking these buttons multiple times will significantly slow the generation process.")}

+ %if settings.FEATURES.get('ALLOW_COURSE_STAFF_GRADE_DOWNLOADS') or section_data['access']['admin']: +

${_("Click to generate a CSV grade report for all currently enrolled students.")}

+

+ + + + +

+ +

${_("Click to generate a ZIP file that contains all submission texts and attachments.")}

+ +

+ + %endif +
+

${_("Click to list certificates that are issued for this course:")}

+

+ + +

+
+
+
+ +

${_("Click to generate a CSV file of all students enrolled in this course, along with profile information such as email address and username:")}

@@ -76,37 +101,12 @@

${_("Reports")}

)}
-
-

${_("Click to list certificates that are issued for this course:")}

-

- - -

-
-
-
- % if not disable_buttons:

${_("For smaller courses, click to list profile information for enrolled students directly on this page:")}

%endif
- %if settings.FEATURES.get('ALLOW_COURSE_STAFF_GRADE_DOWNLOADS') or section_data['access']['admin']: -

${_("Click to generate a CSV grade report for all currently enrolled students.")}

-

- - - - -

- -

${_("Click to generate a ZIP file that contains all submission texts and attachments.")}

- -

- - %endif -

diff --git a/lms/templates/instructor/instructor_dashboard_2/student_admin.html b/lms/templates/instructor/instructor_dashboard_2/student_admin.html index ec21e3a99316..b7465269d297 100644 --- a/lms/templates/instructor/instructor_dashboard_2/student_admin.html +++ b/lms/templates/instructor/instructor_dashboard_2/student_admin.html @@ -1,5 +1,8 @@ <%page args="section_data" expression_filter="h"/> -<%! from django.utils.translation import ugettext as _ %> +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.markup import HTML, Text +%> %if section_data['access']['staff'] or section_data['access']['instructor']:
%if section_data.get('writable_gradebook_url') or section_data.get('is_small_course'): @@ -13,9 +16,12 @@

${_("View gradebook for enrolled learners")}



${_("View Gradebook")} %endif -

-
+

%endif + +

+ +
@@ -230,8 +236,32 @@
${_("Task Status")}
%endif %endif +
+
+
+
+

${_("Reports Available for Download")}

+

+ ${_("The reports listed below are available for download, identified by UTC date and time of generation.")} +

+ + ## Translators: a table of URL links to report files appears after this sentence. +

+ ${Text(_("{strong_start}Note{strong_end}: {ul_start}{li_start}To keep student data secure, you cannot save or email these links for direct access. Copies of links expire within 5 minutes.{li_end}{li_start}Report files are deleted 90 days after generation. If you will need access to old reports, download and store the files, in accordance with your institution's data security policies.{li_end}{ul_end}")).format( + strong_start=HTML(""), + strong_end=HTML(""), + ul_start=HTML("

"), + li_start=HTML("
  • "), + li_end=HTML("
  • "), + )} +


    + +
    +
    %if settings.FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'): +

    ${_("Pending Tasks")}

    diff --git a/openedx/features/wikimedia_features/admin_dashboard/admin_task/api.py b/openedx/features/wikimedia_features/admin_dashboard/admin_task/api.py index a30bc0d0142d..78c5536f1601 100644 --- a/openedx/features/wikimedia_features/admin_dashboard/admin_task/api.py +++ b/openedx/features/wikimedia_features/admin_dashboard/admin_task/api.py @@ -25,6 +25,7 @@ task_courses_enrollment_report, task_all_courses_enrollment_report, task_user_pref_lang_report, + task_users_enrollment_info_report, ) from openedx.features.wikimedia_features.admin_dashboard.course_versions import task_helper @@ -174,6 +175,7 @@ def all_courses_enrollment_report(request): "total_learners_enrolled", "total_learners_completed", "completed_percentage", + "total_cert_generated", ] submit_courses_enrollment_report( request, query_features, report_type, task_all_courses_enrollment_report @@ -235,6 +237,27 @@ def user_pref_lang_report(request): return JsonResponse({"status": success_status}) +@transaction.non_atomic_requests +@require_POST +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +def users_enrollment_report(request): + """ + Handles request to generate CSV of all users enrollments info. + """ + report_type = _("all_users_enrollment") + success_status = SUCCESS_MESSAGE_TEMPLATE.format( + report_type="Users Enrollments Report" + ) + task_input = { + 'features': ["username", "enrollments_count", "completions_count"], + 'csv_type': report_type, + } + + submit_task(request, report_type, task_users_enrollment_info_report, 'all_courses', task_input, "") + return JsonResponse({"status": success_status}) + + def submit_average_calculate_grades_csv(request, course_key): """ AlreadyRunningError is raised if the course's grades are already being updated. diff --git a/openedx/features/wikimedia_features/admin_dashboard/course_reports.py b/openedx/features/wikimedia_features/admin_dashboard/course_reports.py index 8467b5665f5e..0e17d5b753ed 100644 --- a/openedx/features/wikimedia_features/admin_dashboard/course_reports.py +++ b/openedx/features/wikimedia_features/admin_dashboard/course_reports.py @@ -11,7 +11,9 @@ from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.util.cache import cache_if_anonymous from lms.djangoapps.courseware.access import has_access -from lms.djangoapps.courseware.courses import get_course_by_id, get_courses +from lms.djangoapps.courseware.courses import get_course_by_id +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.lib.api.view_utils import LazySequence def require_user_permission(): @@ -42,6 +44,27 @@ def course_reports(request): courses_list = [] sections = {"key": {}} + def get_courses(user=None): + """ + Retrieve a list of courses that a user has access to based on their permissions. + + This function filters the list of all available courses to include only those + that the specified user has access to in the 'staff', 'instructor' or Global user role. + + Args: + user (optional): The user for whom the course access is being checked. + + Returns: + LazySequence: A lazily evaluated sequence of courses that the user has access to. + The sequence's length is estimated based on the total count of courses. + """ + permissions = ['staff', 'instructor'] + courses = CourseOverview.objects.all() + return LazySequence( + (c for c in courses if any(has_access(user, p, c) for p in permissions)), + est_len=courses.count() + ) + courses_list = get_courses(request.user) course = get_course_by_id(courses_list[0].id, depth=0) @@ -83,7 +106,8 @@ def section_data_download(course, access): 'courses_enrollments_csv_url': reverse('admin_dashboard:courses_enrollment_report'), 'all_courses_enrollments_csv_url': reverse('admin_dashboard:all_courses_enrollment_report'), 'user_pref_lang_csv_url': reverse('admin_dashboard:user_pref_lang_report'), + 'users_enrollment_url': reverse('admin_dashboard:users_enrollment_report'), } - if not access.get('data_researcher'): + if not (access.get('data_researcher') or access.get('staff') or access.get('instructor')): section_data['is_hidden'] = True return section_data diff --git a/openedx/features/wikimedia_features/admin_dashboard/course_versions/task_helper.py b/openedx/features/wikimedia_features/admin_dashboard/course_versions/task_helper.py index 45b65a0cc95d..4ccff4df716a 100644 --- a/openedx/features/wikimedia_features/admin_dashboard/course_versions/task_helper.py +++ b/openedx/features/wikimedia_features/admin_dashboard/course_versions/task_helper.py @@ -15,6 +15,7 @@ get_quarter_dates, list_all_courses_enrollment_data, list_user_pref_lang, + list_users_enrollments, list_version_report_info_per_course, list_version_report_info_total, list_quarterly_courses_enrollment_data, @@ -133,7 +134,7 @@ def upload_all_courses_enrollment_csv( csv_type = task_input.get("csv_type", "all_enrollments_stats") query_features_names = [ - 'Course URL', 'Course title', 'Course available since', 'Parent course URL', 'Parent course title', 'Total learners enrolled', 'Total learners completed', 'Percentage of learners who completed the course', + 'Course URL', 'Course title', 'Course available since', 'Parent course URL', 'Parent course title', 'Total learners enrolled', 'Total learners completed', 'Percentage of learners who completed the course', 'Certificates Generated', ] data = list_all_courses_enrollment_data() @@ -252,3 +253,43 @@ def upload_user_pref_lang_csv( report_store.store_rows(course_id_str, report_name, rows) return task_progress.update_task_state(extra_meta=current_step) + + +def upload_users_enrollment_info_csv( + _xmodule_instance_args, _entry_id, course_id_str, task_input, action_name, user_ids +): + """ + Generate a CSV file containing information of users enrollments and course completions. + """ + start_time = time() + start_date = datetime.now(UTC) + num_reports = 1 + task_progress = TaskProgress(action_name, num_reports, start_time) + current_step = {"step": "Getting Users profile info"} + task_progress.update_task_state(extra_meta=current_step) + + # Compute result table and format it + query_features = task_input.get("features") + + query_features_names = ["Username", "Enrollments", "Courses Completed"] + + data = list_users_enrollments() + __, rows = format_dictlist(data, query_features) + + task_progress.attempted = task_progress.succeeded = len(rows) + task_progress.skipped = task_progress.total - task_progress.attempted + + rows.insert(0, query_features_names) + + current_step = {"step": "Uploading CSV"} + task_progress.update_task_state(extra_meta=current_step) + + # Perform the upload + report_store = ReportStore.from_config("GRADES_DOWNLOAD") + csv_name = task_input.get("csv_type") + report_name = "{csv_name}_{timestamp_str}.csv".format( + csv_name=csv_name, timestamp_str=start_date.strftime("%Y-%m-%d-%H%M") + ) + report_store.store_rows(course_id_str, report_name, rows) + + return task_progress.update_task_state(extra_meta=current_step) diff --git a/openedx/features/wikimedia_features/admin_dashboard/course_versions/utils.py b/openedx/features/wikimedia_features/admin_dashboard/course_versions/utils.py index d03e8dda9196..533bfe2e4f61 100644 --- a/openedx/features/wikimedia_features/admin_dashboard/course_versions/utils.py +++ b/openedx/features/wikimedia_features/admin_dashboard/course_versions/utils.py @@ -6,6 +6,7 @@ from django.conf import settings from django.db.models import OuterRef, Subquery, Value, TextField from django.db.models.functions import Coalesce +from django.db.models import Prefetch from common.djangoapps.student.models import CourseEnrollment @@ -18,7 +19,7 @@ from openedx.core.djangoapps.user_api.models import UserPreference from openedx.features.wikimedia_features.meta_translations.models import CourseTranslation from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.features.wikimedia_features.wikimedia_general.utils import get_course_enrollment_and_completion_stats +from openedx.features.wikimedia_features.wikimedia_general.utils import get_course_enrollment_and_completion_stats, get_user_course_completions from edx_proctoring.api import get_last_exam_completion_date @@ -237,7 +238,7 @@ def list_all_courses_enrollment_data(): Get all courses enrollment report """ - courses = get_visible_courses() + courses = CourseOverview.objects.all() courses_data = [] for course in courses: @@ -257,7 +258,7 @@ def list_all_courses_enrollment_data(): continue try: - total_learners_completed, total_learners_enrolled, completed_percentage = \ + course_completion_stats = \ get_course_enrollment_and_completion_stats(course.id) except Exception as e: log.error(f"An error occurred while fetching enrollment data for course ID {course.id}: {e}") @@ -271,15 +272,15 @@ def list_all_courses_enrollment_data(): 'available_since': course.enrollment_start.strftime("%Y-%m-%d") if course.enrollment_start else '', "parent_course_url": parent_course_url, "parent_course_title": parent_course_title, - "total_learners_enrolled": total_learners_enrolled, - "total_learners_completed": total_learners_completed, - "completed_percentage": completed_percentage, + "total_learners_enrolled": course_completion_stats["total_learners_enrolled"], + "total_learners_completed": course_completion_stats["total_learners_completed"], + "completed_percentage": course_completion_stats["completed_percentage"], + "total_cert_generated": course_completion_stats["total_cert_generated"], }) return courses_data - def list_quarterly_courses_enrollment_data(quarter): """ Get course reports @@ -364,3 +365,27 @@ def list_user_pref_lang(): pref_lang_data.append(pref_lang) return pref_lang_data + + +def list_users_enrollments(): + + users_with_course_enrollments = User.objects.prefetch_related("courseenrollment_set__course").filter( + courseenrollment__is_active=1 + ).distinct() + + users_enrollments_data = [] + + for user in users_with_course_enrollments: + user_enrollments = user.courseenrollment_set.all() + user_completions = get_user_course_completions(user, user_enrollments) + user_enrollments_count = user_enrollments.count() + + users_enrollments_data.append( + { + "username": user.username, + "enrollments_count": user_enrollments_count, + "completions_count": user_completions, + } + ) + + return users_enrollments_data diff --git a/openedx/features/wikimedia_features/admin_dashboard/static/admin_dashboard/js/course_reports.js b/openedx/features/wikimedia_features/admin_dashboard/static/admin_dashboard/js/course_reports.js index 8fb277a5a4f1..5a1735029899 100644 --- a/openedx/features/wikimedia_features/admin_dashboard/static/admin_dashboard/js/course_reports.js +++ b/openedx/features/wikimedia_features/admin_dashboard/static/admin_dashboard/js/course_reports.js @@ -328,6 +328,10 @@ let data_url = $(this).attr('data-endpoint'); AjaxCall(data_url); }) + $("[name='users-enrollment-csv']").click(function() { + let data_url = $(this).attr('data-endpoint'); + AjaxCall(data_url); + }) $(document).ready(function() { endpoint = '/wikimedia/list_all_courses_report_downloads'; ReportDownloads(); diff --git a/openedx/features/wikimedia_features/admin_dashboard/tasks.py b/openedx/features/wikimedia_features/admin_dashboard/tasks.py index 6fc8176ab18b..365964cbc687 100644 --- a/openedx/features/wikimedia_features/admin_dashboard/tasks.py +++ b/openedx/features/wikimedia_features/admin_dashboard/tasks.py @@ -37,6 +37,7 @@ upload_quarterly_courses_enrollment_csv, upload_all_courses_enrollment_csv, upload_user_pref_lang_csv, + upload_users_enrollment_info_csv, ) from openedx.features.wikimedia_features.email.utils import send_notification @@ -130,7 +131,7 @@ def task_courses_enrollment_report(entry_id, xmodule_instance_args, user_id): @set_code_owner_attribute def task_user_pref_lang_report(entry_id, xmodule_instance_args, user_id): """ - Generate a course enrollment report for users preferred languages and push the results to an S3 bucket for download. + Generate a report for users preferred languages and push the results to an S3 bucket for download. """ # Translators: This is a past-tense verb that is inserted into task progress messages as {action}. action_name = ugettext_noop('generated') @@ -142,6 +143,24 @@ def task_user_pref_lang_report(entry_id, xmodule_instance_args, user_id): return run_main_task(entry_id, task_fn, action_name, user_id) +@shared_task(base=BaseAdminReportTask) +@set_code_owner_attribute +def task_users_enrollment_info_report(entry_id, xmodule_instance_args, user_id): + """ + Generate a report for users enrollments and course completions. + """ + # Translators: This is a past-tense verb that is inserted into task progress messages as {action}. + action_name = ugettext_noop("generated") + TASK_LOG.info( + "Task: %s, AdminReportTask ID: %s, Task type: %s, Preparing for task execution", + xmodule_instance_args.get("task_id"), + entry_id, + action_name, + ) + task_fn = partial(upload_users_enrollment_info_csv, xmodule_instance_args) + return run_main_task(entry_id, task_fn, action_name, user_id) + + @task(base=LoggedTask) def send_report_ready_email_task(msg_class_key, context, subject, email): TASK_LOG.info("Initiated task to send admin report ready notifications.") diff --git a/openedx/features/wikimedia_features/admin_dashboard/templates/course_report/course-reports.html b/openedx/features/wikimedia_features/admin_dashboard/templates/course_report/course-reports.html index 1115f6e4233a..df26fe2741a9 100644 --- a/openedx/features/wikimedia_features/admin_dashboard/templates/course_report/course-reports.html +++ b/openedx/features/wikimedia_features/admin_dashboard/templates/course_report/course-reports.html @@ -34,6 +34,78 @@ + + + + ${_("Quarterly courses enrollment report")} + ${_("Generate a report for last quarter enrollments of all courses.")} + + + + + + + + + + + + + ${_("All courses enrollment report")} + ${_("Generate an all time report of enrollment/completion for all courses.")} + + + + + + + + + ${_("Users preferred language report")} + ${_("Generate a report on users preferred lanugage. Displays N/A for users who have not selected a lanugage")} + + + + + + + + + ${_("All Users enrollment report")} + ${_("Generates a report on users enrollments and their completed courses")} + + + + + + ${_("List profile information for enrolled students")} @@ -167,62 +239,6 @@
    - - - ${_("Quarterly courses enrollment report")} - ${_("Generate a report for last quarter enrollments of all courses.")} - - - - - - - - - - - - - ${_("All courses enrollment report")} - ${_("Generate an all time report of enrollment/completion for all courses.")} - - - - - - - - - ${_("Users preferred language report")} - ${_("Generate a report on users preferred lanugage. Displays N/A for users who have not selected a lanugage")} - - - - - -