From c5c35d46199258974fcbaa9fd790508613874d51 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Mon, 4 Sep 2023 14:58:55 -0400 Subject: [PATCH 01/28] feat: add support to send email to cherry-picked students --- lms/djangoapps/bulk_email/data.py | 3 ++- .../0008_alter_target_target_type.py | 18 ++++++++++++++++++ lms/djangoapps/bulk_email/models.py | 14 +++++++++++--- lms/djangoapps/bulk_email/tasks.py | 2 +- lms/djangoapps/instructor/views/api.py | 2 +- lms/djangoapps/instructor_task/api.py | 2 +- 6 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 lms/djangoapps/bulk_email/migrations/0008_alter_target_target_type.py diff --git a/lms/djangoapps/bulk_email/data.py b/lms/djangoapps/bulk_email/data.py index 1173faa666e..6bc8cdb2742 100644 --- a/lms/djangoapps/bulk_email/data.py +++ b/lms/djangoapps/bulk_email/data.py @@ -20,8 +20,9 @@ class BulkEmailTargetChoices: SEND_TO_LEARNERS = "learners" SEND_TO_COHORT = "cohort" SEND_TO_TRACK = "track" + SEND_TO_INDIVIDUAL_STUDENTS = "individual-students" - TARGET_CHOICES = (SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_LEARNERS, SEND_TO_COHORT, SEND_TO_TRACK) + TARGET_CHOICES = (SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_LEARNERS, SEND_TO_COHORT, SEND_TO_TRACK, SEND_TO_INDIVIDUAL_STUDENTS) @classmethod def is_valid_target(cls, target): diff --git a/lms/djangoapps/bulk_email/migrations/0008_alter_target_target_type.py b/lms/djangoapps/bulk_email/migrations/0008_alter_target_target_type.py new file mode 100644 index 00000000000..9856d659eae --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0008_alter_target_target_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2023-09-04 14:11 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bulk_email', '0007_disabledcourse'), + ] + + operations = [ + migrations.AlterField( + model_name='target', + name='target_type', + field=models.CharField(choices=[('myself', 'Myself'), ('staff', 'Staff and instructors'), ('learners', 'All students'), ('cohort', 'Specific cohort'), ('track', 'Specific course mode'), ('individual-students', 'Specific list of students')], max_length=64), + ), + ] diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py index 37d9a6276f4..d560ed339a5 100644 --- a/lms/djangoapps/bulk_email/models.py +++ b/lms/djangoapps/bulk_email/models.py @@ -55,9 +55,10 @@ class Meta: SEND_TO_LEARNERS = 'learners' SEND_TO_COHORT = 'cohort' SEND_TO_TRACK = 'track' +SEND_TO_INDIVIDUAL_STUDENTS = 'individual-students' EMAIL_TARGET_CHOICES = list(zip( - [SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_LEARNERS, SEND_TO_COHORT, SEND_TO_TRACK], - ['Myself', 'Staff and instructors', 'All students', 'Specific cohort', 'Specific course mode'] + [SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_LEARNERS, SEND_TO_COHORT, SEND_TO_TRACK, SEND_TO_INDIVIDUAL_STUDENTS], + ['Myself', 'Staff and instructors', 'All students', 'Specific cohort', 'Specific course mode', 'Specific list of students'] )) EMAIL_TARGETS = {target[0] for target in EMAIL_TARGET_CHOICES} @@ -106,7 +107,7 @@ def long_display(self): else: return self.get_target_type_display() - def get_users(self, course_id, user_id=None): + def get_users(self, course_id, user_id=None, emails=None): """ Gets the users for a given target. @@ -148,6 +149,13 @@ def get_users(self, course_id, user_id=None): & enrollment_query ) ) + elif self.target_type == SEND_TO_INDIVIDUAL_STUDENTS: + return use_read_replica_if_available( + User.objects.filter( + models.Q(email__in=emails) + & enrollment_query + ) + ) else: raise ValueError(f"Unrecognized target type {self.target_type}") diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 4d86edcc103..d35cdb2f51f 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -192,7 +192,7 @@ def perform_delegate_email_batches(entry_id, course_id, task_input, action_name) global_email_context = _get_course_email_context(course) recipient_qsets = [ - target.get_users(course_id, user_id) + target.get_users(course_id, user_id, task_input.get("emails", [])) for target in targets ] # Use union here to combine the qsets instead of the | operator. This avoids generating an diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index eb0d6aa8a5a..7eaefec15c9 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -2701,7 +2701,7 @@ def send_email(request, course_id): log.warning(f"Email is not enabled for course {course_id}") return HttpResponseForbidden("Email is not enabled for this course.") - targets = json.loads(request.POST.get("send_to")) + targets = json.loads(request.POST.get("send_to")) + ["individual-students"] subject = request.POST.get("subject") message = request.POST.get("message") # optional, this is a date and time in the form of an ISO8601 string diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index bbf5bf7b287..9d89a9701ab 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -324,7 +324,7 @@ def submit_bulk_course_email(request, course_key, email_id, schedule=None): task_type = InstructorTaskTypes.BULK_COURSE_EMAIL task_class = send_bulk_course_email - task_input = {'email_id': email_id, 'to_option': targets} + task_input = {'email_id': email_id, 'to_option': targets, "emails": ["student@example.com"]} task_key_stub = str(email_id) # create the key value by using MD5 hash: task_key = hashlib.md5(task_key_stub.encode('utf-8')).hexdigest() From 80fec1dd411641a0f9038b277812ab01e73f8340 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 12 Sep 2023 09:38:14 -0400 Subject: [PATCH 02/28] refactor!: remove the hardcoded implementations --- lms/djangoapps/instructor/views/api.py | 5 +++-- lms/djangoapps/instructor_task/api.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 7eaefec15c9..5e6ab9fb49f 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -2701,7 +2701,8 @@ def send_email(request, course_id): log.warning(f"Email is not enabled for course {course_id}") return HttpResponseForbidden("Email is not enabled for this course.") - targets = json.loads(request.POST.get("send_to")) + ["individual-students"] + targets = json.loads(request.POST.get("send_to")) + emails = json.loads(request.POST.get("emails", [])) subject = request.POST.get("subject") message = request.POST.get("message") # optional, this is a date and time in the form of an ISO8601 string @@ -2744,7 +2745,7 @@ def send_email(request, course_id): return HttpResponseBadRequest(repr(err)) # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes) - task_api.submit_bulk_course_email(request, course_id, email.id, schedule_dt) + task_api.submit_bulk_course_email(request, course_id, email.id, schedule_dt, emails) response_payload = { 'course_id': str(course_id), diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index 9d89a9701ab..07b381d33b2 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -299,7 +299,7 @@ def submit_delete_entrance_exam_state_for_student(request, usage_key, student): return submit_task(request, task_type, task_class, usage_key.course_key, task_input, task_key) -def submit_bulk_course_email(request, course_key, email_id, schedule=None): +def submit_bulk_course_email(request, course_key, email_id, schedule=None, emails=[]): """ Request to have bulk email sent as a background task. @@ -324,7 +324,7 @@ def submit_bulk_course_email(request, course_key, email_id, schedule=None): task_type = InstructorTaskTypes.BULK_COURSE_EMAIL task_class = send_bulk_course_email - task_input = {'email_id': email_id, 'to_option': targets, "emails": ["student@example.com"]} + task_input = {'email_id': email_id, 'to_option': targets, 'emails': emails} task_key_stub = str(email_id) # create the key value by using MD5 hash: task_key = hashlib.md5(task_key_stub.encode('utf-8')).hexdigest() From 745fbc54142b97e1f98ece173408cc9597ff0958 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 12 Sep 2023 15:22:53 -0400 Subject: [PATCH 03/28] refactor: costume changes according MFE implementation --- lms/djangoapps/bulk_email/data.py | 4 ++-- .../0009_alter_target_target_type.py | 18 ++++++++++++++++++ lms/djangoapps/bulk_email/models.py | 6 +++--- lms/djangoapps/instructor/views/api.py | 2 +- 4 files changed, 24 insertions(+), 6 deletions(-) create mode 100644 lms/djangoapps/bulk_email/migrations/0009_alter_target_target_type.py diff --git a/lms/djangoapps/bulk_email/data.py b/lms/djangoapps/bulk_email/data.py index 6bc8cdb2742..99f97281f59 100644 --- a/lms/djangoapps/bulk_email/data.py +++ b/lms/djangoapps/bulk_email/data.py @@ -20,9 +20,9 @@ class BulkEmailTargetChoices: SEND_TO_LEARNERS = "learners" SEND_TO_COHORT = "cohort" SEND_TO_TRACK = "track" - SEND_TO_INDIVIDUAL_STUDENTS = "individual-students" + SEND_TO_INDIVIDUAL_LEARNERS = "individual-learners" - TARGET_CHOICES = (SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_LEARNERS, SEND_TO_COHORT, SEND_TO_TRACK, SEND_TO_INDIVIDUAL_STUDENTS) + TARGET_CHOICES = (SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_LEARNERS, SEND_TO_COHORT, SEND_TO_TRACK, SEND_TO_INDIVIDUAL_LEARNERS) @classmethod def is_valid_target(cls, target): diff --git a/lms/djangoapps/bulk_email/migrations/0009_alter_target_target_type.py b/lms/djangoapps/bulk_email/migrations/0009_alter_target_target_type.py new file mode 100644 index 00000000000..8036eff320f --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0009_alter_target_target_type.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2023-09-12 19:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bulk_email', '0008_alter_target_target_type'), + ] + + operations = [ + migrations.AlterField( + model_name='target', + name='target_type', + field=models.CharField(choices=[('myself', 'Myself'), ('staff', 'Staff and instructors'), ('learners', 'All students'), ('cohort', 'Specific cohort'), ('track', 'Specific course mode'), ('individual-learners', 'Specific list of students')], max_length=64), + ), + ] diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py index d560ed339a5..48e2634932c 100644 --- a/lms/djangoapps/bulk_email/models.py +++ b/lms/djangoapps/bulk_email/models.py @@ -55,9 +55,9 @@ class Meta: SEND_TO_LEARNERS = 'learners' SEND_TO_COHORT = 'cohort' SEND_TO_TRACK = 'track' -SEND_TO_INDIVIDUAL_STUDENTS = 'individual-students' +SEND_TO_INDIVIDUAL_LEARNERS = 'individual-learners' EMAIL_TARGET_CHOICES = list(zip( - [SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_LEARNERS, SEND_TO_COHORT, SEND_TO_TRACK, SEND_TO_INDIVIDUAL_STUDENTS], + [SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_LEARNERS, SEND_TO_COHORT, SEND_TO_TRACK, SEND_TO_INDIVIDUAL_LEARNERS], ['Myself', 'Staff and instructors', 'All students', 'Specific cohort', 'Specific course mode', 'Specific list of students'] )) EMAIL_TARGETS = {target[0] for target in EMAIL_TARGET_CHOICES} @@ -149,7 +149,7 @@ def get_users(self, course_id, user_id=None, emails=None): & enrollment_query ) ) - elif self.target_type == SEND_TO_INDIVIDUAL_STUDENTS: + elif self.target_type == SEND_TO_INDIVIDUAL_LEARNERS: return use_read_replica_if_available( User.objects.filter( models.Q(email__in=emails) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 5e6ab9fb49f..36ec9110afe 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -2702,7 +2702,7 @@ def send_email(request, course_id): return HttpResponseForbidden("Email is not enabled for this course.") targets = json.loads(request.POST.get("send_to")) - emails = json.loads(request.POST.get("emails", [])) + emails = json.loads(request.POST.get("individual_learners_emails", [])) subject = request.POST.get("subject") message = request.POST.get("message") # optional, this is a date and time in the form of an ISO8601 string From 8ae2d3fca53bf518bb97d1211257ebd24aeac66f Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Tue, 19 Sep 2023 16:12:22 -0400 Subject: [PATCH 04/28] refactor: remove ora from base requirements --- requirements/edx/base.txt | 2 +- requirements/edx/development.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 27737d29504..4ad2e2dd989 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -778,7 +778,7 @@ openedx-filters==1.2.0 # lti-consumer-xblock optimizely-sdk==4.1.1 # via -r requirements/edx/base.in -ora2==5.0.0 + # ora2==5.0.0 # via -r requirements/edx/base.in oscrypto==1.3.0 # via snowflake-connector-python diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 53d53ed50a7..3c1be15a66e 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -1037,7 +1037,7 @@ openedx-filters==1.2.0 # lti-consumer-xblock optimizely-sdk==4.1.1 # via -r requirements/edx/testing.txt -ora2==5.0.0 + # ora2==5.0.0 # via -r requirements/edx/testing.txt oscrypto==1.3.0 # via From 1342f3a640de4e5fd21abb05ffbb1436ba50e8e1 Mon Sep 17 00:00:00 2001 From: Maria Grimaldi Date: Thu, 7 Sep 2023 10:02:56 -0400 Subject: [PATCH 05/28] feat: add instructor dashboard filter integration (#32448) This PR adds a new filter to modify the instructor dashboard rendering process. For example: modify the section tabs of the instructor context --that specifies which tabs to render, adding a brand new one defined in a different plugin. The use case we're currently testing is to add a new tab to the instructor dashboard, which renders management information about an Xblock. (cherry picked from commit 750ee003537099d0514b1135053b8c0649be5229) --- .../instructor/tests/test_filters.py | 230 ++++++++++++++++++ .../instructor/views/instructor_dashboard.py | 22 +- 2 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 lms/djangoapps/instructor/tests/test_filters.py diff --git a/lms/djangoapps/instructor/tests/test_filters.py b/lms/djangoapps/instructor/tests/test_filters.py new file mode 100644 index 00000000000..19948da1449 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_filters.py @@ -0,0 +1,230 @@ +""" +Test that various filters are fired for models/views in the instructor app. +""" +import re +from django.http import HttpResponse +from django.test import override_settings +from django.urls import reverse +from openedx_filters import PipelineStep +from openedx_filters.learning.filters import InstructorDashboardRenderStarted +from rest_framework import status + +from common.djangoapps.student.tests.factories import AdminFactory, CourseAccessRoleFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class TestDashboardRenderPipelineStep(PipelineStep): + """ + Pipeline step for testing the instructor dashboard rendering process. + + This step is used to modify the dashboard data before it's rendered emptying + the sections list. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that modifies dashboard data.""" + context["sections"] = [] + return { + "context": context, + "template_name": template_name, + } + + +class TestRenderInvalidDashboard(PipelineStep): + """ + Pipeline step for testing the instructor dashboard rendering process. + + This step is used to modify the dashboard data before it's rendered by raising + an exception which will be caught by the instructor dashboard filter, rendering + a different template. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that stops the dashboard render process.""" + raise InstructorDashboardRenderStarted.RenderInvalidDashboard( + "You can't render this dashboard.", + instructor_template="static_templates/server-error.html" + ) + + +class TestRedirectDashboardPageStep(PipelineStep): + """ + Pipeline step for testing the instructor dashboard rendering process. + + This step is used to modify the dashboard data before it's rendered by raising + an exception which will be caught by the instructor dashboard filter and redirect + to a new page. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that redirects before the dashboard is rendered.""" + raise InstructorDashboardRenderStarted.RedirectToPage( + "You can't see this site's instructor dashboard, redirecting to the correct location.", + redirect_to="https://custom-dashboard.com", + ) + + +class TestRenderCustomResponse(PipelineStep): + """ + Pipeline step for testing the instructor dashboard rendering process. + + This step is used to modify the dashboard data before it's rendered by raising + an exception which will be caught by the instructor dashboard filter, returning + a custom response. + """ + + def run_filter(self, context, template_name): # pylint: disable=arguments-differ + """Pipeline step that changes dashboard view response before the dashboard is rendered.""" + response = HttpResponse("This is a custom response.") + raise InstructorDashboardRenderStarted.RenderCustomResponse( + "You can't see this site's dashboard.", + response=response, + ) + + +@skip_unless_lms +class InstructorDashboardFiltersTest(ModuleStoreTestCase): + """ + Tests for the Open edX Filters associated with the instructor dashboard rendering process. + + This class guarantees that the following filters are triggered during the instructor dashboard rendering: + - InstructorDashboardRenderStarted + """ + + def setUp(self): # pylint: disable=arguments-differ + """ + Setup the test suite. + """ + super().setUp() + self.instructor = AdminFactory.create() + self.client.login(username=self.instructor.username, password="test") + self.course = CourseFactory.create( + org="test1", course="course1", display_name="run1", + ) + self.dashboard_url = reverse("instructor_dashboard", kwargs={"course_id": str(self.course.id)}) + CourseAccessRoleFactory( + course_id=self.course.id, + user=self.instructor, + role="instructor", + org=self.course.id.org + ) + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.instructor.dashboard.render.started.v1": { + "pipeline": [ + "lms.djangoapps.instructor.tests.test_filters.TestDashboardRenderPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_dashboard_render_filter_executed(self): + """ + Test whether the instructor dashboard filter is triggered before the instructor's + dashboard rendering process. + + Expected result: + - InstructorDashboardRenderStarted is triggered and executes TestDashboardRenderPipelineStep. + - The dashboard is rendered using the empty sections list. + """ + response = self.client.get(self.dashboard_url) + + matches = re.findall( + rb'