diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 741cb2ef47..7f11af5d30 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,3 +1,10 @@ +## This configuration file overrides the inherited configuration file defined +## in openedx/.github/.github/ISSUE_TEMPLATE because this repo currently does +## not have Issues turned on, so we create this override to *only* show DEPR +## issues to users creating Issues. Once Issues are turned on and the repo is +## ready to accept Issues of all types, this file must be deleted so inheritance +## of standard openedx configuration works properly. + blank_issues_enabled: false contact_links: - name: Open edX Community Support diff --git a/.github/ISSUE_TEMPLATE/depr-ticket.yml b/.github/ISSUE_TEMPLATE/depr-ticket.yml index 977564ee47..93251af420 100644 --- a/.github/ISSUE_TEMPLATE/depr-ticket.yml +++ b/.github/ISSUE_TEMPLATE/depr-ticket.yml @@ -1,3 +1,10 @@ +## This configuration file overrides the inherited configuration file defined +## in openedx/.github/.github/ISSUE_TEMPLATE because this repo currently does +## not have Issues turned on, so we create this override to *only* show DEPR +## issues to users creating Issues. Once Issues are turned on and the repo is +## ready to accept Issues of all types, this file must be deleted so inheritance +## of standard openedx configuration works properly. + name: Deprecation (DEPR) Ticket description: Per OEP-21, use this template to begin the technology deprecation process. title: "[DEPR]: " diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml new file mode 100644 index 0000000000..71ca72a7da --- /dev/null +++ b/.github/actions/unit-tests/action.yml @@ -0,0 +1,34 @@ +name: 'Run unit tests' +description: 'shared steps to run unit tests on both Github hosted and self hosted runners.' +runs: + using: "composite" + steps: + - name: set settings path + shell: bash + run: | + echo "settings_path=$(python scripts/unit_test_shards_parser.py --shard-name=${{ matrix.shard_name }} --output settings )" >> $GITHUB_ENV + + - name: get unit tests for shard + shell: bash + run: | + echo "unit_test_paths=$(python scripts/unit_test_shards_parser.py --shard-name=${{ matrix.shard_name }} )" >> $GITHUB_ENV + + - name: run tests + shell: bash + run: | + python -Wd -m pytest -p no:randomly --ds=${{ env.settings_path }} ${{ env.unit_test_paths }} + + - name: rename warnings json file + if: success() + shell: bash + run: | + cd test_root/log + mv pytest_warnings.json pytest_warnings_${{ matrix.shard_name }}.json + + - name: save pytest warnings json file + if: success() + uses: actions/upload-artifact@v2 + with: + name: pytest-warnings-json + path: | + test_root/log/pytest_warnings*.json diff --git a/.github/actions/verify-tests-count/action.yml b/.github/actions/verify-tests-count/action.yml new file mode 100644 index 0000000000..6357a4158c --- /dev/null +++ b/.github/actions/verify-tests-count/action.yml @@ -0,0 +1,49 @@ +name: 'Verify unit tests count' +description: 'shared steps to verify unit tests count on both Github hosted and self hosted runners.' +runs: + using: "composite" + steps: + - name: collect tests from all modules + shell: bash + run: | + echo "root_cms_unit_tests_count=$(pytest --collect-only --ds=cms.envs.test cms/ -q | head -n -2 | wc -l)" >> $GITHUB_ENV + echo "root_lms_unit_tests_count=$(pytest --collect-only --ds=lms.envs.test lms/ openedx/ common/djangoapps/ common/lib/ -q | head -n -2 | wc -l)" >> $GITHUB_ENV + + - name: get GHA unit test paths + shell: bash + run: | + echo "cms_unit_test_paths=$(python scripts/gha_unit_tests_collector.py --cms-only)" >> $GITHUB_ENV + echo "lms_unit_test_paths=$(python scripts/gha_unit_tests_collector.py --lms-only)" >> $GITHUB_ENV + + + - name: collect tests from GHA unit test shards + shell: bash + run: | + echo "cms_unit_tests_count=$(pytest --collect-only --ds=cms.envs.test ${{ env.cms_unit_test_paths }} -q | head -n -2 | wc -l)" >> $GITHUB_ENV + echo "lms_unit_tests_count=$(pytest --collect-only --ds=lms.envs.test ${{ env.lms_unit_test_paths }} -q | head -n -2 | wc -l)" >> $GITHUB_ENV + + + - name: add unit tests count + shell: bash + run: | + echo "root_all_unit_tests_count=$((${{ env.root_cms_unit_tests_count }}+${{ env.root_lms_unit_tests_count }}))" >> $GITHUB_ENV + echo "shards_all_unit_tests_count=$((${{ env.cms_unit_tests_count }}+${{ env.lms_unit_tests_count }}))" >> $GITHUB_ENV + + - name: print unit tests count + shell: bash + run: | + echo CMS unit tests from root: ${{ env.root_cms_unit_tests_count }} + echo LMS unit tests from root: ${{ env.root_lms_unit_tests_count }} + echo CMS unit tests from shards: ${{ env.cms_unit_tests_count }} + echo LMS unit tests from shards: ${{ env.lms_unit_tests_count }} + echo All root unit tests count: ${{ env.root_all_unit_tests_count }} + echo All shards unit tests count: ${{ env.shards_all_unit_tests_count }} + + - name: fail the check + shell: bash + if: ${{ env.root_all_unit_tests_count != env.shards_all_unit_tests_count }} + run: | + echo "::error title='Unit test modules in unit-test-shards.json (unit-tests.yml workflow) are outdated'::unit tests running in unit-tests + workflow don't match the count for unit tests for entire edx-platform suite, please update the unit-test-shards.json under .github/workflows + to add any missing apps and match the count. for more details please take a look at scripts/gha-shards-readme.md" + exit 1 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4a81223141..bef36dd944 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,10 +1,10 @@ diff --git a/common/test/data/conditional_and_poll/course/2013_Spring.xml b/common/test/data/conditional_and_poll/course/2013_Spring.xml index 2eea422a2f..58076a7fad 100644 --- a/common/test/data/conditional_and_poll/course/2013_Spring.xml +++ b/common/test/data/conditional_and_poll/course/2013_Spring.xml @@ -2,7 +2,7 @@ Take note of this name exactly, you'll need to use it everywhere. --> - + diff --git a/common/test/data/course_ignore/course.xml b/common/test/data/course_ignore/course.xml index 7761c9811a..418ad0634c 100644 --- a/common/test/data/course_ignore/course.xml +++ b/common/test/data/course_ignore/course.xml @@ -1 +1 @@ - + diff --git a/common/test/data/manual-testing-complete/problem/45c317cb93d447f293ce982a2eccd77d.xml b/common/test/data/manual-testing-complete/problem/45c317cb93d447f293ce982a2eccd77d.xml index db34051c2b..4559484848 100644 --- a/common/test/data/manual-testing-complete/problem/45c317cb93d447f293ce982a2eccd77d.xml +++ b/common/test/data/manual-testing-complete/problem/45c317cb93d447f293ce982a2eccd77d.xml @@ -1,4 +1,4 @@ - + diff --git a/common/test/problem_ajax.html b/common/test/problem_ajax.html deleted file mode 100644 index bbe365bd92..0000000000 --- a/common/test/problem_ajax.html +++ /dev/null @@ -1,16 +0,0 @@ - -
-

- - Loading… -

-
diff --git a/common/test/video.html b/common/test/video.html deleted file mode 100644 index ea6a54d994..0000000000 --- a/common/test/video.html +++ /dev/null @@ -1,122 +0,0 @@ - -<%page expression_filter="h"/> - -<%! -from django.utils.translation import ugettext as _ -from openedx.core.djangolib.js_utils import ( - dump_js_escaped_json, js_escaped_string -) -%> -% if display_name is not UNDEFINED and display_name is not None: -

${display_name}

-% endif - -
-
- -
-
- - -
-
-
- - -
-
-
- -
-
- -
- - % if download_video_link or track or handout or branding_info: -

${_('Downloads and transcripts')}

-
- % if download_video_link: - - % endif - % if track: -
-

${_('Transcripts')}

- % if transcript_download_format: -
    - % for item in transcript_download_formats_list: -
  • - <% dname = _("Download {file}").format(file=item['display_name']) %> - ${dname} -
  • - % endfor -
- % else: - ${_('Download transcript')} - % endif -
- % endif - % if handout: -
-

${_('Handouts')}

- ${_('Download Handout')} -
- % endif - % if branding_info: -
- ${branding_info['logo_tag']} - -
- % endif -
- % endif -
-% if cdn_eval: - -% endif; diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 48ca6e9ac6..8f4a796af6 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2554,7 +2554,6 @@ paths: * `"empty"`: no start date is specified * pacing: Course pacing. Possible values: instructor, self * user_timezone: User's chosen timezone setting (or null for browser default) - * can_view_legacy_courseware: Indicates whether the user is able to see the legacy courseware view * user_has_passing_grade: Whether or not the effective user's grade is equal to or above the courses minimum passing grade * course_exit_page_is_active: Flag for the learning mfe on whether or not the course exit page should display @@ -7432,7 +7431,6 @@ definitions: - original_user_is_staff - tabs - title - - can_load_courseware - celebrations type: object properties: @@ -7480,9 +7478,6 @@ definitions: title: Title type: string minLength: 1 - can_load_courseware: - title: Can load courseware - type: boolean celebrations: title: Celebrations type: object diff --git a/lms/djangoapps/bulk_email/api.py b/lms/djangoapps/bulk_email/api.py index 5c2e40ee64..f89a27065b 100644 --- a/lms/djangoapps/bulk_email/api.py +++ b/lms/djangoapps/bulk_email/api.py @@ -4,11 +4,12 @@ Python APIs exposed by the bulk_email app to other in-process apps. """ # Public Bulk Email Functions - +import logging from django.conf import settings from django.urls import reverse +from lms.djangoapps.bulk_email.models import CourseEmail from lms.djangoapps.bulk_email.models_api import ( is_bulk_email_disabled_for_course, is_bulk_email_enabled_for_course, @@ -18,6 +19,8 @@ from lms.djangoapps.bulk_email.models_api import ( from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +log = logging.getLogger(__name__) + def get_emails_enabled(user, course_id): """ @@ -48,3 +51,39 @@ def get_unsubscribed_link(username, course_id): optout_url = reverse('bulk_email_opt_out', kwargs={'token': token, 'course_id': course_id}) url = f'{lms_root_url}{optout_url}' return url + + +def create_course_email(course_id, sender, targets, subject, html_message, text_message=None, template_name=None, + from_addr=None): + """ + Python API for creating a new CourseEmail instance. + + Args: + course_id (CourseKey): The CourseKey of the course. + sender (String): Email author. + targets (Target): Recipient groups the message should be sent to (e.g. SEND_TO_MYSELF) + subject (String)): Email subject. + html_message (String): Email body. Includes HTML markup. + text_message (String, optional): Plaintext version of email body. Defaults to None. + template_name (String, optional): Name of custom email template to use. Defaults to None. + from_addr (String, optional): Custom sending address, if desired. Defaults to None. + + Returns: + CourseEmail: Returns the created CourseEmail instance. + """ + try: + course_email = CourseEmail.create( + course_id, + sender, + targets, + subject, + html_message, + text_message=text_message, + template_name=template_name, + from_addr=from_addr + ) + + return course_email + except ValueError as err: + log.exception(f"Cannot create course email for {course_id} requested by user {sender} for targets {targets}") + raise ValueError from err diff --git a/lms/djangoapps/bulk_email/data.py b/lms/djangoapps/bulk_email/data.py new file mode 100644 index 0000000000..a0f50c6c6b --- /dev/null +++ b/lms/djangoapps/bulk_email/data.py @@ -0,0 +1,22 @@ +""" +Bulk Email Data + +This provides Data models to represent Bulk Email data. +""" + + +class BulkEmailTargetChoices: + """ + Enum for the available targets (recipient groups) of an email authored with the bulk course email tool. + + SEND_TO_MYSELF - Message intended for author of the message + SEND_TO_STAFF - Message intended for all course staff + SEND_TO_LEARNERS - Message intended for all enrolled learners + SEND_TO_COHORT - Message intended for a specific cohort + SEND_TO_TRACK - Message intended for all learners in a specific track (e.g. audit or verified) + """ + SEND_TO_MYSELF = "myself" + SEND_TO_STAFF = "staff" + SEND_TO_LEARNERS = "learners" + SEND_TO_COHORT = "cohort" + SEND_TO_TRACK = "track" diff --git a/lms/djangoapps/bulk_email/docs/decisions/001-bulk-email-content-sanitization.rst b/lms/djangoapps/bulk_email/docs/decisions/001-bulk-email-content-sanitization.rst index 7bc53a835a..4741225259 100644 --- a/lms/djangoapps/bulk_email/docs/decisions/001-bulk-email-content-sanitization.rst +++ b/lms/djangoapps/bulk_email/docs/decisions/001-bulk-email-content-sanitization.rst @@ -1,6 +1,6 @@ -==================================== -Bulk Email HTML Content Sanitization -==================================== +============================================= +Bulk Email HTML Content Will Not Be Sanitized +============================================= Status ------ @@ -17,16 +17,15 @@ It is considered good security practice to scan and sanitize user-provided conte Decision -------- -We will sanitize the HTML content received through the bulk course email tool before sending the messages to any recipients. +We will not sanitize the HTML content received through the bulk course email tool on the back end. The content that instructors create for courses also allows unfiltered html, which is arguably a larger risk than email, which will block the execution of certain types of code. In the future, if a standard is set for filtering course content, the same standard could be applied here. -We will use the `bleach`_ Python package to sanitize the data. -We will introduce a new configuration setting called ``BULK_COURSE_EMAIL_ALLOWED_HTML_TAGS`` that acts as an *allowlist* of HTML tags permitted for use within the body of a message authored by the bulk course email tool. A default list of options is provided in the ``lms/envs/common.py`` `configuration file`_. Offending data will be escaped (converted to plaintext) over being stripped out. +Rejected solutions +------------------ -Consequences ------------- +Bleach with allowlist +********************* -A message sent through the Bulk Course Email tool that includes any restricted HTML content will not appear as intended to the recipients of the message. The restricted HTML content will be converted to plaintext and will not render. +It has been standard practice for us to use `Bleach`_ with an allowlist to sanitize user provided content within the Open edX ecosystem. Santization using blocklists is vulnerable to obfuscation attacks, and the industry standard is to use an allowlist and explicitly enumerate all supported values. Given that this tool has been live for many years with no sanitization in place, a highly restrictive allowlist would be difficult to roll out to users, resulting in broken email templates and angry instructors who are used to having free rein. A permissive allowlist would potentially address this, but presents the non-trivial problem of assembling and maintaining a comprehensive list. .. _bleach: https://bleach.readthedocs.io/en/latest/ -.. _configuration file: https://github.com/openedx/edx-platform/blob/e608db847c39c2e3d723ef81f7dac66f63663a28/lms/envs/common.py#L4965 diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index d6b8393f47..4d86edcc10 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -10,7 +10,7 @@ import re import time from collections import Counter from datetime import datetime -from smtplib import SMTPConnectError, SMTPDataError, SMTPException, SMTPServerDisconnected +from smtplib import SMTPConnectError, SMTPDataError, SMTPException, SMTPServerDisconnected, SMTPSenderRefused from time import sleep from boto.exception import AWSConnectionError @@ -87,12 +87,13 @@ LIMITED_RETRY_ERRORS = ( # An example is if email is being sent too quickly, but may succeed if sent # more slowly. When caught by a task, it triggers an exponential backoff and retry. # Retries happen continuously until the email is sent. -# Note that the SMTPDataErrors here are only those within the 4xx range. +# Note that the (SMTPDataErrors and SMTPSenderRefused) here are only those within the 4xx range. # Those not in this range (i.e. in the 5xx range) are treated as hard failures # and thus like SINGLE_EMAIL_FAILURE_ERRORS. INFINITE_RETRY_ERRORS = ( SESMaxSendingRateExceededError, # Your account's requests/second limit has been exceeded. SMTPDataError, + SMTPSenderRefused, ) # Errors that are known to indicate an inability to send any more emails, @@ -565,11 +566,11 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas f"{recipient_num}/{total_recipients}, Recipient UserId: {current_recipient['pk']}" ) message.send() - except SMTPDataError as exc: + except (SMTPDataError, SMTPSenderRefused) as exc: # According to SMTP spec, we'll retry error codes in the 4xx range. 5xx range indicates hard failure. total_recipients_failed += 1 log.exception( - f"BulkEmail ==> Status: Failed(SMTPDataError), Task: {parent_task_id}, SubTask: {task_id}, " + f"BulkEmail ==> Status: Failed({exc.smtp_error}), Task: {parent_task_id}, SubTask: {task_id}, " f"EmailId: {email_id}, Recipient num: {recipient_num}/{total_recipients}, Recipient UserId: " f"{current_recipient['pk']}" ) diff --git a/lms/djangoapps/bulk_email/tests/test_api.py b/lms/djangoapps/bulk_email/tests/test_api.py new file mode 100644 index 0000000000..f6c0804724 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_api.py @@ -0,0 +1,96 @@ +""" +Tests for the public Python API functions of the Bulk Email app. +""" +from testfixtures import LogCapture + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from common.djangoapps.student.tests.factories import InstructorFactory +from lms.djangoapps.bulk_email.api import create_course_email +from lms.djangoapps.bulk_email.data import BulkEmailTargetChoices +from openedx.core.lib.html_to_text import html_to_text + + +class CreateCourseEmailTests(ModuleStoreTestCase): + """ + Tests for the `create_course_email` function of the bulk email app's public Python API. + """ + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + self.instructor = InstructorFactory(course_key=self.course.id) + self.target = [BulkEmailTargetChoices.SEND_TO_MYSELF] + self.subject = "email subject" + self.html_message = "

test message

" + + def test_create_course_email(self): + """ + Happy path test for the `create_course_email` function. Verifies the creation of a CourseEmail instance with + the bare minimum information required for the function call. + """ + course_email = create_course_email( + self.course.id, + self.instructor, + self.target, + self.subject, + self.html_message, + ) + + assert course_email.sender.id == self.instructor.id + assert course_email.subject == self.subject + assert course_email.html_message == self.html_message + assert course_email.course_id == self.course.id + assert course_email.text_message == html_to_text(self.html_message) + + def test_create_course_email_with_optional_args(self): + """ + Additional testing to verify that optional data is used as expected when passed into the `create_course_email` + function. + """ + text_message = "everything is awesome!" + template_name = "gnarly_template" + from_addr = "blub@noreply.fish.com" + + course_email = create_course_email( + self.course.id, + self.instructor, + self.target, + self.subject, + self.html_message, + text_message=text_message, + template_name=template_name, + from_addr=from_addr + ) + + assert course_email.sender.id == self.instructor.id + assert course_email.subject == self.subject + assert course_email.html_message == self.html_message + assert course_email.course_id == self.course.id + assert course_email.text_message == text_message + assert course_email.template_name == template_name + assert course_email.from_addr == from_addr + + def test_create_course_email_expect_exception(self): + """ + Test to verify behavior when an exception occurs when calling teh `create_course_email` function. + """ + targets = ["humpty dumpty"] + + expected_messages = [ + f"Cannot create course email for {self.course.id} requested by user {self.instructor} for targets " + f"{targets}", + ] + + with self.assertRaises(ValueError): + with LogCapture() as log: + create_course_email( + self.course.id, + self.instructor, + targets, + self.subject, + self.html_message + ) + + for index, message in enumerate(expected_messages): + assert message in log.records[index].getMessage() diff --git a/lms/djangoapps/bulk_email/tests/test_tasks.py b/lms/djangoapps/bulk_email/tests/test_tasks.py index 51f7b4161c..e73db046aa 100644 --- a/lms/djangoapps/bulk_email/tests/test_tasks.py +++ b/lms/djangoapps/bulk_email/tests/test_tasks.py @@ -11,7 +11,7 @@ from datetime import datetime from dateutil.relativedelta import relativedelta import json # lint-amnesty, pylint: disable=wrong-import-order from itertools import chain, cycle, repeat # lint-amnesty, pylint: disable=wrong-import-order -from smtplib import SMTPAuthenticationError, SMTPConnectError, SMTPDataError, SMTPServerDisconnected # lint-amnesty, pylint: disable=wrong-import-order +from smtplib import SMTPAuthenticationError, SMTPConnectError, SMTPDataError, SMTPServerDisconnected, SMTPSenderRefused # lint-amnesty, pylint: disable=wrong-import-order from unittest.mock import Mock, patch # lint-amnesty, pylint: disable=wrong-import-order from uuid import uuid4 # lint-amnesty, pylint: disable=wrong-import-order import pytest @@ -411,6 +411,11 @@ class TestBulkEmailInstructorTask(InstructorTaskCourseTestCase): def test_retry_after_smtp_throttling_error(self): self._test_retry_after_unlimited_retry_error(SMTPDataError(455, "Throttling: Sending rate exceeded")) + def test_retry_after_smtp_sender_refused_error(self): + self._test_retry_after_unlimited_retry_error( + SMTPSenderRefused(421, "Throttling: Sending rate exceeded", self.instructor.email) + ) + def test_retry_after_ses_throttling_error(self): self._test_retry_after_unlimited_retry_error( SESMaxSendingRateExceededError(455, "Throttling: Sending rate exceeded") diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py index 8d717bcc17..723fba2406 100644 --- a/lms/djangoapps/ccx/tests/test_views.py +++ b/lms/djangoapps/ccx/tests/test_views.py @@ -17,7 +17,6 @@ from django.test.utils import override_settings from django.urls import resolve, reverse from django.utils.translation import gettext as _ from edx_django_utils.cache import RequestCache -from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey from pytz import UTC from xmodule.modulestore import ModuleStoreEnum @@ -41,7 +40,6 @@ from lms.djangoapps.courseware.tabs import get_course_tab_list from lms.djangoapps.courseware.tests.factories import StudentModuleFactory from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase from lms.djangoapps.courseware.testutils import FieldOverrideTestMixin -from lms.djangoapps.courseware.toggles import COURSEWARE_USE_LEGACY_FRONTEND from lms.djangoapps.discussion.django_comment_client.utils import has_forum_access from lms.djangoapps.grades.api import task_compute_all_grades_for_course from lms.djangoapps.instructor.access import allow_access, list_with_level @@ -1219,12 +1217,8 @@ class TestStudentViewsWithCCX(ModuleStoreTestCase): assert response.status_code == 200 assert re.search('Test CCX', response.content.decode('utf-8')) - @override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True) def test_load_courseware(self): self.client.login(username=self.student.username, password=self.student_password) - response = self.client.get(reverse('courseware_section', kwargs={ - 'course_id': str(self.ccx_course_key), - 'chapter': 'chapter_x', - 'section': 'sequential_x1', - })) + sequence_key = self.ccx_course_key.make_usage_key('sequential', 'sequential_x1') + response = self.client.get(reverse('render_xblock', args=[str(sequence_key)])) assert response.status_code == 200 diff --git a/lms/djangoapps/certificates/generation_handler.py b/lms/djangoapps/certificates/generation_handler.py index d2641e3abf..8b3834f528 100644 --- a/lms/djangoapps/certificates/generation_handler.py +++ b/lms/djangoapps/certificates/generation_handler.py @@ -53,7 +53,7 @@ def generate_allowlist_certificate_task(user, course_key, generation_mode=None, Create a task to generate an allowlist certificate for this user in this course run. """ enrollment_mode = _get_enrollment_mode(user, course_key) - course_grade = _get_course_grade(user, course_key) + course_grade = _get_course_grade(user, course_key, send_course_grade_signals=False) if _can_generate_allowlist_certificate(user, course_key, enrollment_mode): return _generate_certificate_task(user=user, course_key=course_key, enrollment_mode=enrollment_mode, course_grade=course_grade, generation_mode=generation_mode, @@ -72,7 +72,7 @@ def _generate_regular_certificate_task(user, course_key, generation_mode=None, d eligible and a certificate can be generated. """ enrollment_mode = _get_enrollment_mode(user, course_key) - course_grade = _get_course_grade(user, course_key) + course_grade = _get_course_grade(user, course_key, send_course_grade_signals=False) if _can_generate_regular_certificate(user, course_key, enrollment_mode, course_grade): return _generate_certificate_task(user=user, course_key=course_key, enrollment_mode=enrollment_mode, course_grade=course_grade, generation_mode=generation_mode, @@ -377,11 +377,14 @@ def _get_grade_value(course_grade): return '' -def _get_course_grade(user, course_key): +def _get_course_grade(user, course_key, send_course_grade_signals=True): """ Get the user's course grade in this course run. Note that this may be None. + + Use send_course_grade_signals=False to avoid firing the course grade signals recursively. + See details in lms/djangoapps/grades/course_grade_factory.py _update method. """ - return CourseGradeFactory().read(user, course_key=course_key) + return CourseGradeFactory().read(user, course_key=course_key, send_course_grade_signals=send_course_grade_signals) def _get_enrollment_mode(user, course_key): diff --git a/lms/djangoapps/certificates/migrations/0034_auto_20220401_1213.py b/lms/djangoapps/certificates/migrations/0034_auto_20220401_1213.py new file mode 100644 index 0000000000..c2a1bb73a5 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0034_auto_20220401_1213.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.12 on 2022-04-01 12:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('certificates', '0033_auto_20220307_1100'), + ] + + operations = [ + migrations.AlterField( + model_name='certificatetemplate', + name='mode', + field=models.CharField(blank=True, choices=[('verified', 'verified'), ('honor', 'honor'), ('audit', 'audit'), ('professional', 'professional'), ('no-id-professional', 'no-id-professional'), ('masters', 'masters'), ('executive-education', 'executive-education'), ('paid-executive-education', 'paid-executive-education'), ('paid-bootcamp', 'paid-bootcamp')], default='honor', help_text='The course mode for this template.', max_length=125, null=True), + ), + migrations.AlterField( + model_name='generatedcertificate', + name='mode', + field=models.CharField(choices=[('verified', 'verified'), ('honor', 'honor'), ('audit', 'audit'), ('professional', 'professional'), ('no-id-professional', 'no-id-professional'), ('masters', 'masters'), ('executive-education', 'executive-education'), ('paid-executive-education', 'paid-executive-education'), ('paid-bootcamp', 'paid-bootcamp')], default='honor', max_length=32), + ), + migrations.AlterField( + model_name='historicalgeneratedcertificate', + name='mode', + field=models.CharField(choices=[('verified', 'verified'), ('honor', 'honor'), ('audit', 'audit'), ('professional', 'professional'), ('no-id-professional', 'no-id-professional'), ('masters', 'masters'), ('executive-education', 'executive-education'), ('paid-executive-education', 'paid-executive-education'), ('paid-bootcamp', 'paid-bootcamp')], default='honor', max_length=32), + ), + ] diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 35b14d17fd..dd6be3ccbd 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -213,11 +213,12 @@ class GeneratedCertificate(models.Model): 'masters', 'executive-education', 'paid-executive-education', + 'paid-bootcamp', ) VERIFIED_CERTS_MODES = [ CourseMode.VERIFIED, CourseMode.CREDIT_MODE, CourseMode.MASTERS, CourseMode.EXECUTIVE_EDUCATION, - CourseMode.PAID_EXECUTIVE_EDUCATION + CourseMode.PAID_EXECUTIVE_EDUCATION, CourseMode.PAID_BOOTCAMP ] user = models.ForeignKey(User, on_delete=models.CASCADE) diff --git a/lms/djangoapps/course_api/api.py b/lms/djangoapps/course_api/api.py index 4b5eeb942f..bf3520031b 100644 --- a/lms/djangoapps/course_api/api.py +++ b/lms/djangoapps/course_api/api.py @@ -11,6 +11,7 @@ from django.urls import reverse from edx_django_utils.monitoring import function_trace from edx_when.api import get_dates_for_course from opaque_keys.edx.django.models import CourseKeyField +from opaque_keys.edx.keys import CourseKey from rest_framework.exceptions import PermissionDenied from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment @@ -25,6 +26,7 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi from openedx.core.djangoapps.content.learning_sequences.api import get_course_outline from openedx.core.djangoapps.content.learning_sequences.data import CourseOutlineData from openedx.core.lib.api.view_utils import LazySequence +from openedx.features.course_experience import course_home_url from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -285,8 +287,8 @@ def get_course_run_url(request, course_id): Returns: (string): the URL to the course run associated with course_id """ - course_run_url = reverse('openedx.course_experience.course_home', args=[course_id]) - return request.build_absolute_uri(course_run_url) + course_key = CourseKey.from_string(str(course_id)) + return request.build_absolute_uri(course_home_url(course_key)) def get_course_members(course_key): diff --git a/lms/djangoapps/course_api/tests/test_api.py b/lms/djangoapps/course_api/tests/test_api.py index 8de1b78755..481afc412d 100644 --- a/lms/djangoapps/course_api/tests/test_api.py +++ b/lms/djangoapps/course_api/tests/test_api.py @@ -9,7 +9,7 @@ from unittest import mock import pytest from django.contrib.auth.models import AnonymousUser from django.http import Http404 -from django.test import override_settings +from django.test import TestCase, override_settings from opaque_keys.edx.keys import CourseKey from rest_framework.exceptions import PermissionDenied from rest_framework.request import Request @@ -20,7 +20,9 @@ from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, py from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import ItemFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order -from ..api import UNKNOWN_BLOCK_DISPLAY_NAME, course_detail, get_due_dates, list_courses, get_course_members +from ..api import ( + UNKNOWN_BLOCK_DISPLAY_NAME, course_detail, get_due_dates, list_courses, get_course_members, get_course_run_url, +) from ..exceptions import OverEnrollmentLimitException from .mixins import CourseApiFactoryMixin @@ -426,3 +428,13 @@ class TestGetCourseMembers(CourseApiTestMixin, SharedModuleStoreTestCase): """ with self.assertRaises(OverEnrollmentLimitException): get_course_members(self.course.id) + + +class TestGetCourseRunUrl(TestCase): + """ + Tests of get_course_run_url. + """ + def test_simple_lookup(self): + request = Request(APIRequestFactory().get('/')) + url = get_course_run_url(request, 'course-v1:org+course+run') + assert url == 'http://learning-mfe/course/course-v1:org+course+run/home' diff --git a/lms/djangoapps/course_goals/api.py b/lms/djangoapps/course_goals/api.py index 414eda0ba7..079dfc46b7 100644 --- a/lms/djangoapps/course_goals/api.py +++ b/lms/djangoapps/course_goals/api.py @@ -2,31 +2,9 @@ Course Goals Python API """ - from opaque_keys.edx.keys import CourseKey -from rest_framework.reverse import reverse -from common.djangoapps.course_modes.models import CourseMode -from lms.djangoapps.course_goals.models import CourseGoal, GOAL_KEY_CHOICES -from openedx.features.course_experience import ENABLE_COURSE_GOALS - - -def add_course_goal_deprecated(user, course_id, goal_key): - """ - Add a new course goal for the provided user and course. If the goal - already exists, simply update and save the goal. - This method is for the deprecated version of course goals and will be removed as soon - as the newer number of days version of course goals is fully implemented. - - Arguments: - user: The user that is setting the goal - course_id (string): The id for the course the goal refers to - goal_key (string): The goal key for the new goal. - """ - course_key = CourseKey.from_string(str(course_id)) - CourseGoal.objects.update_or_create( - user=user, course_key=course_key, defaults={'goal_key': goal_key} - ) +from lms.djangoapps.course_goals.models import CourseGoal def add_course_goal(user, course_id, subscribed_to_reminders, days_per_week=None): @@ -61,58 +39,3 @@ def get_course_goal(user, course_key): return None return CourseGoal.objects.filter(user=user, course_key=course_key).first() - - -def get_goal_api_url(request): - """ - Returns the endpoint for accessing REST API. - """ - return reverse('course_goals_api:v0:course_goal-list', request=request) - - -def has_course_goal_permission(request, course_id, user_access): - """ - Returns whether the user can access the course goal functionality. - - Only authenticated users that are enrolled in a verifiable course - can use this feature. - """ - course_key = CourseKey.from_string(course_id) - has_verified_mode = CourseMode.has_verified_mode(CourseMode.modes_for_course_dict(course_key)) - return user_access['is_enrolled'] and has_verified_mode and ENABLE_COURSE_GOALS.is_enabled(course_key) - - -def get_course_goal_options(): - """ - Returns the valid options for goal keys, mapped to their translated - strings, as defined by theCourseGoal model. - """ - return dict(GOAL_KEY_CHOICES) - - -def get_course_goal_text(goal_key): - """ - Returns the translated string for the given goal key - """ - goal_options = get_course_goal_options() - return goal_options[goal_key] - - -def valid_course_goals_ordered(include_unsure=False): - """ - Returns a list of the valid options for goal keys ordered by the level of commitment. - Each option is represented as a tuple, with (goal_key, goal_string). - - This list does not return the unsure option by default since it does not have a relevant commitment level. - """ - goal_options = get_course_goal_options() - - ordered_goal_options = [] - ordered_goal_options.append((GOAL_KEY_CHOICES.certify, goal_options[GOAL_KEY_CHOICES.certify])) - ordered_goal_options.append((GOAL_KEY_CHOICES.complete, goal_options[GOAL_KEY_CHOICES.complete])) - ordered_goal_options.append((GOAL_KEY_CHOICES.explore, goal_options[GOAL_KEY_CHOICES.explore])) - - if include_unsure: - ordered_goal_options.append((GOAL_KEY_CHOICES.unsure, goal_options[GOAL_KEY_CHOICES.unsure])) - - return ordered_goal_options diff --git a/lms/djangoapps/course_goals/handlers.py b/lms/djangoapps/course_goals/handlers.py index 661d3220f5..0c25746958 100644 --- a/lms/djangoapps/course_goals/handlers.py +++ b/lms/djangoapps/course_goals/handlers.py @@ -17,7 +17,6 @@ def emit_course_goal_event(sender, instance, **kwargs): # lint-amnesty, pylint: name = 'edx.course.goal.added' if kwargs.get('created', False) else 'edx.course.goal.updated' properties = { 'courserun_key': str(instance.course_key), - 'goal_key': instance.goal_key, 'days_per_week': instance.days_per_week, 'subscribed_to_reminders': instance.subscribed_to_reminders, } diff --git a/lms/djangoapps/course_goals/models.py b/lms/djangoapps/course_goals/models.py index f2dfc8e4ef..73d69d6bd6 100644 --- a/lms/djangoapps/course_goals/models.py +++ b/lms/djangoapps/course_goals/models.py @@ -8,7 +8,6 @@ from datetime import datetime, timedelta from django.contrib.auth import get_user_model from django.db import models -from django.utils.translation import gettext_lazy as _ from edx_django_utils.cache import TieredCache from model_utils import Choices from model_utils.models import TimeStampedModel @@ -20,12 +19,11 @@ from lms.djangoapps.courseware.context_processor import get_user_timezone_or_las from openedx.core.lib.mobile_utils import is_request_from_mobile_app from openedx.features.course_experience import ENABLE_COURSE_GOALS -# Each goal is represented by a goal key and a string description. -GOAL_KEY_CHOICES = Choices( - ('certify', _('Earn a certificate')), - ('complete', _('Complete the course')), - ('explore', _('Explore the course')), - ('unsure', _('Not sure yet')), +_GOAL_KEY_CHOICES = Choices( + ('certify', 'Earn a certificate'), + ('complete', 'Complete the course'), + ('explore', 'Explore the course'), + ('unsure', 'Not sure yet'), ) User = get_user_model() @@ -57,7 +55,9 @@ class CourseGoal(models.Model): unsubscribe_token = models.UUIDField(null=True, blank=True, unique=True, editable=False, default=uuid.uuid4, help_text='Used to validate unsubscribe requests without requiring a login') - goal_key = models.CharField(max_length=100, choices=GOAL_KEY_CHOICES, default=GOAL_KEY_CHOICES.unsure) + # Deprecated and unused - replaced by days_per_week and its subscription-based approach to goals + goal_key = models.CharField(max_length=100, choices=_GOAL_KEY_CHOICES, default=_GOAL_KEY_CHOICES.unsure) + history = HistoricalRecords() def __str__(self): diff --git a/lms/djangoapps/course_goals/tests/test_views.py b/lms/djangoapps/course_goals/tests/test_views.py deleted file mode 100644 index a5fd30c6d2..0000000000 --- a/lms/djangoapps/course_goals/tests/test_views.py +++ /dev/null @@ -1,114 +0,0 @@ -""" -Unit tests for course_goals.views methods. -""" - - -from unittest import mock - -from django.test.utils import override_settings -from django.urls import reverse -from rest_framework.test import APIClient - -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.course_goals.models import CourseGoal -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order - -EVENT_NAME_ADDED = 'edx.course.goal.added' -EVENT_NAME_UPDATED = 'edx.course.goal.updated' - - -class TestCourseGoalsAPI(SharedModuleStoreTestCase): - """ - Testing the Course Goals API. - """ - - def setUp(self): - # Create a course with a verified track - super().setUp() - self.course = CourseFactory.create(emit_signals=True) - - self.user = UserFactory.create(username='john', email='lennon@thebeatles.com', password='password') - CourseEnrollment.enroll(self.user, self.course.id) - - self.client = APIClient(enforce_csrf_checks=True) - self.client.login(username=self.user.username, password=self.user.password) - self.client.force_authenticate(user=self.user) - - self.apiUrl = reverse('course_goals_api:v0:course_goal-list') - - @mock.patch('lms.djangoapps.course_goals.handlers.segment.track') - @override_settings(LMS_SEGMENT_KEY="foobar") - def test_add_valid_goal(self, segment_call): - """ Ensures a correctly formatted post succeeds.""" - response = self.post_course_goal(valid=True, goal_key='certify') - segment_call.assert_called_once_with(self.user.id, EVENT_NAME_ADDED, { - 'courserun_key': str(self.course.id), - 'goal_key': 'certify', - 'days_per_week': 0, - 'subscribed_to_reminders': False, - }) - assert response.status_code == 201 - - current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id) - assert len(current_goals) == 1 - assert current_goals[0].goal_key == 'certify' - - def test_add_invalid_goal(self): - """ Ensures an incorrectly formatted post does not succeed. """ - response = self.post_course_goal(valid=False) - assert response.status_code == 400 - assert len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)) == 0 - - def test_add_without_goal_key(self): - """ Ensures if no goal key provided, post does not succeed. """ - response = self.post_course_goal(goal_key=None) - assert len(CourseGoal.objects.filter(user=self.user, course_key=self.course.id)) == 0 - self.assertContains( - response=response, - text='Please provide a valid goal key from following options.', - status_code=400 - ) - - @mock.patch('lms.djangoapps.course_goals.handlers.segment.track') - @override_settings(LMS_SEGMENT_KEY="foobar") - def test_update_goal(self, segment_call): - """ Ensures that repeated course goal post events do not create new instances of the goal. """ - self.post_course_goal(valid=True, goal_key='explore') - self.post_course_goal(valid=True, goal_key='certify') - self.post_course_goal(valid=True, goal_key='unsure') - - segment_call.assert_any_call(self.user.id, EVENT_NAME_ADDED, { - 'courserun_key': str(self.course.id), 'goal_key': 'explore', - 'days_per_week': 0, - 'subscribed_to_reminders': False, - }) - segment_call.assert_any_call(self.user.id, EVENT_NAME_UPDATED, { - 'courserun_key': str(self.course.id), 'goal_key': 'certify', - 'days_per_week': 0, - 'subscribed_to_reminders': False, - }) - segment_call.assert_any_call(self.user.id, EVENT_NAME_UPDATED, { - 'courserun_key': str(self.course.id), 'goal_key': 'unsure', - 'days_per_week': 0, - 'subscribed_to_reminders': False, - }) - current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id) - assert len(current_goals) == 1 - assert current_goals[0].goal_key == 'unsure' - - def post_course_goal(self, valid=True, goal_key='certify'): - """ - Sends a post request to set a course goal and returns the response. - """ - goal_key = goal_key if valid else 'invalid' - post_data = { - 'course_key': self.course.id, - 'user': self.user.username, - } - if goal_key: - post_data['goal_key'] = goal_key - - response = self.client.post(self.apiUrl, post_data) - return response diff --git a/lms/djangoapps/course_goals/urls.py b/lms/djangoapps/course_goals/urls.py deleted file mode 100644 index fd55934ef6..0000000000 --- a/lms/djangoapps/course_goals/urls.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Course Goals URLs -""" - - -from django.urls import include, path -from rest_framework import routers - -from .views import CourseGoalViewSet - -router = routers.DefaultRouter() -router.register(r'course_goals', CourseGoalViewSet, basename='course_goal') - -urlpatterns = [ - path('v0/', include((router.urls, "api"), namespace='v0')), -] diff --git a/lms/djangoapps/course_goals/views.py b/lms/djangoapps/course_goals/views.py deleted file mode 100644 index a2992a64c5..0000000000 --- a/lms/djangoapps/course_goals/views.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Course Goals Views - includes REST API -""" - - -from django.contrib.auth import get_user_model -from django.http import JsonResponse -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication -from opaque_keys.edx.keys import CourseKey -from rest_framework import permissions, serializers, status, viewsets -from rest_framework.authentication import SessionAuthentication -from rest_framework.response import Response - -from lms.djangoapps.course_goals.api import get_course_goal_options -from lms.djangoapps.course_goals.models import GOAL_KEY_CHOICES, CourseGoal -from openedx.core.lib.api.permissions import IsStaffOrOwner - -User = get_user_model() - - -class CourseGoalSerializer(serializers.ModelSerializer): - """ - Serializes CourseGoal models. - """ - user = serializers.SlugRelatedField(slug_field='username', queryset=User.objects.all()) - - class Meta: - model = CourseGoal - fields = ('user', 'course_key', 'goal_key') - - -class CourseGoalViewSet(viewsets.ModelViewSet): - """ - API calls to create and update a course goal. - - Validates incoming data to ensure that course_key maps to an actual - course and that the goal_key is a valid option. - - **Use Case** - * Create a new goal for a user. - * Update an existing goal for a user - - **Example Requests** - POST /api/course_goals/v0/course_goals/ - Request data: {"course_key": , "goal_key": "", "user": ""} - - Returns Http400 response if the course_key does not map to a known - course or if the goal_key does not map to a valid goal key. - """ - authentication_classes = (JwtAuthentication, SessionAuthentication,) - permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner,) - queryset = CourseGoal.objects.all() - serializer_class = CourseGoalSerializer - - # Another version of this endpoint exists in ../course_home_api/outline/views.py - # This version is used by the legacy frontend and is deprecated - def create(self, post_data): # lint-amnesty, pylint: disable=arguments-differ - """ Create a new goal if one does not exist, otherwise update the existing goal. """ - # Ensure goal_key is valid - goal_options = get_course_goal_options() - goal_key = post_data.data.get('goal_key') - if not goal_key: - return Response( - 'Please provide a valid goal key from following options. (options= {goal_options}).'.format( - goal_options=goal_options, - ), - status=status.HTTP_400_BAD_REQUEST, - ) - elif goal_key not in goal_options: - return Response( - 'Provided goal key, {goal_key}, is not a valid goal key (options= {goal_options}).'.format( - goal_key=goal_key, - goal_options=goal_options, - ), - status=status.HTTP_400_BAD_REQUEST, - ) - - # Ensure course key is valid - course_key = CourseKey.from_string(post_data.data['course_key']) - if not course_key: - return Response( - 'Provided course_key ({course_key}) does not map to a course.'.format( - course_key=course_key - ), - status=status.HTTP_400_BAD_REQUEST, - ) - - user = post_data.user - goal = CourseGoal.objects.filter(user=user.id, course_key=course_key).first() - if goal: - goal.goal_key = goal_key - goal.save(update_fields=['goal_key']) - else: - CourseGoal.objects.create( - user=user, - course_key=course_key, - goal_key=goal_key, - ) - data = { - 'goal_key': str(goal_key), - 'goal_text': str(goal_options[goal_key]), - 'is_unsure': goal_key == GOAL_KEY_CHOICES.unsure, - } - return JsonResponse(data, content_type="application/json", status=(200 if goal else 201)) # lint-amnesty, pylint: disable=redundant-content-type-for-json-response diff --git a/lms/djangoapps/course_home_api/course_metadata/serializers.py b/lms/djangoapps/course_home_api/course_metadata/serializers.py index cef6292cf3..b140938f7e 100644 --- a/lms/djangoapps/course_home_api/course_metadata/serializers.py +++ b/lms/djangoapps/course_home_api/course_metadata/serializers.py @@ -33,7 +33,6 @@ class CourseHomeMetadataSerializer(VerifiedModeSerializer): """ Serializer for the Course Home Course Metadata """ - can_load_courseware = serializers.BooleanField() celebrations = serializers.DictField() course_access = serializers.DictField() course_id = serializers.CharField() @@ -48,3 +47,4 @@ class CourseHomeMetadataSerializer(VerifiedModeSerializer): title = serializers.CharField() username = serializers.CharField() user_timezone = serializers.CharField() + can_view_certificate = serializers.BooleanField() diff --git a/lms/djangoapps/course_home_api/course_metadata/views.py b/lms/djangoapps/course_home_api/course_metadata/views.py index 4132b5f9c3..2e570bc399 100644 --- a/lms/djangoapps/course_home_api/course_metadata/views.py +++ b/lms/djangoapps/course_home_api/course_metadata/views.py @@ -8,6 +8,7 @@ from rest_framework.response import Response from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from lms.djangoapps.certificates.api import certificates_viewable_for_course from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.djangoapps.courseware_api.utils import get_celebrations_dict @@ -20,7 +21,6 @@ from lms.djangoapps.courseware.context_processor import user_timezone_locale_pre from lms.djangoapps.courseware.courses import check_course_access from lms.djangoapps.courseware.masquerade import setup_masquerade from lms.djangoapps.courseware.tabs import get_course_tab_list -from lms.djangoapps.courseware.toggles import courseware_mfe_is_visible class CourseHomeMetadataView(RetrieveAPIView): @@ -55,6 +55,7 @@ class CourseHomeMetadataView(RetrieveAPIView): title: (str) The Course's display title celebrations: (dict) a dict of celebration data user_timezone: (str) The timezone of the given user + can_view_certificate: Flag to determine whether or not the learner can view their course certificate. **Returns** @@ -100,12 +101,6 @@ class CourseHomeMetadataView(RetrieveAPIView): enrollment = CourseEnrollment.get_enrollment(request.user, course_key_string) user_is_enrolled = bool(enrollment and enrollment.is_active) - can_load_courseware = courseware_mfe_is_visible( - course_key=course_key, - is_global_staff=original_user_is_global_staff, - is_course_staff=original_user_is_staff - ) - # User locale settings user_timezone_locale = user_timezone_locale_prefs(request) user_timezone = user_timezone_locale['user_timezone'] @@ -131,9 +126,9 @@ class CourseHomeMetadataView(RetrieveAPIView): 'is_self_paced': getattr(course, 'self_paced', False), 'is_enrolled': user_is_enrolled, 'course_access': load_access.to_json(), - 'can_load_courseware': can_load_courseware, 'celebrations': celebrations, 'user_timezone': user_timezone, + 'can_view_certificate': certificates_viewable_for_course(course), } context = self.get_serializer_context() context['course'] = course diff --git a/lms/djangoapps/course_home_api/dates/views.py b/lms/djangoapps/course_home_api/dates/views.py index f7f7fb3209..3ca59b6bdc 100644 --- a/lms/djangoapps/course_home_api/dates/views.py +++ b/lms/djangoapps/course_home_api/dates/views.py @@ -2,7 +2,6 @@ Dates Tab Views """ -from django.http.response import Http404 from edx_django_utils import monitoring as monitoring_utils from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -14,7 +13,6 @@ from rest_framework.response import Response from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.course_goals.models import UserActivity from lms.djangoapps.course_home_api.dates.serializers import DatesTabSerializer -from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_with_access @@ -76,9 +74,6 @@ class DatesTabView(RetrieveAPIView): course_key_string = kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) - if course_home_legacy_is_active(course_key): - raise Http404 - # Enable NR tracing for this view based on course monitoring_utils.set_custom_attribute('course_id', course_key_string) monitoring_utils.set_custom_attribute('user_id', request.user.id) diff --git a/lms/djangoapps/course_home_api/outline/serializers.py b/lms/djangoapps/course_home_api/outline/serializers.py index 922a64b494..20b205e304 100644 --- a/lms/djangoapps/course_home_api/outline/serializers.py +++ b/lms/djangoapps/course_home_api/outline/serializers.py @@ -51,7 +51,6 @@ class CourseBlockSerializer(serializers.Serializer): 'icon': icon, 'id': block_key, 'lms_web_url': block['lms_web_url'] if enable_links else None, - 'legacy_web_url': block['legacy_web_url'] if enable_links else None, 'resume_block': block.get('resume_block', False), 'type': block_type, 'has_scheduled_content': block.get('has_scheduled_content'), diff --git a/lms/djangoapps/course_home_api/outline/tests/test_goals.py b/lms/djangoapps/course_home_api/outline/tests/test_goals.py index aa7a97585f..699531c93d 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_goals.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_goals.py @@ -72,7 +72,6 @@ class TestCourseGoalsAPI(SharedModuleStoreTestCase): 'courserun_key': str(self.course.id), 'days_per_week': 1, 'subscribed_to_reminders': True, - 'goal_key': 'unsure', }) current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id) @@ -89,7 +88,6 @@ class TestCourseGoalsAPI(SharedModuleStoreTestCase): 'courserun_key': str(self.course.id), 'days_per_week': 1, 'subscribed_to_reminders': True, - 'goal_key': 'unsure', }) self.save_course_goal(3, True) @@ -97,7 +95,6 @@ class TestCourseGoalsAPI(SharedModuleStoreTestCase): 'courserun_key': str(self.course.id), 'days_per_week': 3, 'subscribed_to_reminders': True, - 'goal_key': 'unsure', }) self.save_course_goal(5, False) @@ -105,7 +102,6 @@ class TestCourseGoalsAPI(SharedModuleStoreTestCase): 'courserun_key': str(self.course.id), 'days_per_week': 5, 'subscribed_to_reminders': False, - 'goal_key': 'unsure', }) current_goals = CourseGoal.objects.filter(user=self.user, course_key=self.course.id) diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py index 2714f3ece5..c37788e9ee 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -3,7 +3,7 @@ Tests for Outline Tab API in the Course Home API """ import itertools -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from unittest.mock import Mock, patch # lint-amnesty, pylint: disable=wrong-import-order @@ -15,11 +15,12 @@ from edx_toggles.toggles.testutils import override_waffle_flag # lint-amnesty, from cms.djangoapps.contentstore.outlines import update_outline_from_modulestore from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import CourseInstructorRole from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests -from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.learning_sequences.api import replace_course_outline from openedx.core.djangoapps.content.learning_sequences.data import CourseOutlineData, CourseVisibility from openedx.core.djangoapps.course_date_signals.utils import MIN_DURATION @@ -46,6 +47,10 @@ class OutlineTabTestViews(BaseCourseHomeTests): super().setUp() self.url = reverse('course-home:outline-tab', args=[self.course.id]) + def update_course_and_overview(self): + self.update_course(self.course, self.user.id) + CourseOverview.load_from_module_store(self.course.id) + @override_waffle_flag(ENABLE_COURSE_GOALS, active=True) @ddt.data(CourseMode.AUDIT, CourseMode.VERIFIED) def test_get_authenticated_enrolled_user(self, enrollment_mode): @@ -148,13 +153,6 @@ class OutlineTabTestViews(BaseCourseHomeTests): response = self.client.get(url) assert response.status_code == 404 - @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) - @ddt.data(CourseMode.AUDIT, CourseMode.VERIFIED) - def test_legacy_view_enabled(self, enrollment_mode): - CourseEnrollment.enroll(self.user, self.course.id, enrollment_mode) - response = self.client.get(self.url) - assert response.status_code == 404 - @ddt.data(True, False) def test_welcome_message(self, welcome_message_is_dismissed): CourseEnrollment.enroll(self.user, self.course.id) @@ -319,7 +317,7 @@ class OutlineTabTestViews(BaseCourseHomeTests): self.user.save() if course_visibility: self.course.course_visibility = course_visibility - self.course = self.update_course(self.course, self.user.id) + self.update_course_and_overview() self.store.create_item( self.user.id, self.course.id, 'course_info', 'handouts', fields={'data': '

Handouts

'} @@ -400,8 +398,42 @@ class OutlineTabTestViews(BaseCourseHomeTests): def test_user_has_passing_grade(self): CourseEnrollment.enroll(self.user, self.course.id) self.course._grading_policy['GRADE_CUTOFFS']['Pass'] = 0 # pylint: disable=protected-access - self.update_course(self.course, self.user.id) + self.update_course_and_overview() CourseGradeFactory().update(self.user, self.course) response = self.client.get(self.url) assert response.status_code == 200 assert response.data['user_has_passing_grade'] is True + + def assert_can_enroll(self, can_enroll): + response = self.client.get(self.url) + assert response.status_code == 200 + assert response.data['enroll_alert']['can_enroll'] == can_enroll + + def test_can_enroll_basic(self): + self.assert_can_enroll(True) + + def test_cannot_enroll_invitation_only(self): + self.course.invitation_only = True + self.update_course_and_overview() + self.assert_can_enroll(False) + + def test_cannot_enroll_masters_only(self): + CourseMode.objects.all().delete() + CourseModeFactory(course_id=self.course.id, mode_slug=CourseMode.MASTERS) + self.assert_can_enroll(False) + + def test_cannot_enroll_before_enrollment(self): + self.course.enrollment_start = datetime.now(timezone.utc) + timedelta(days=1) + self.update_course_and_overview() + self.assert_can_enroll(False) + + def test_cannot_enroll_after_enrollment(self): + self.course.enrollment_end = datetime.now(timezone.utc) - timedelta(days=1) + self.update_course_and_overview() + self.assert_can_enroll(False) + + def test_cannot_enroll_if_full(self): + self.course.max_student_enrollments_allowed = 1 + self.update_course_and_overview() + CourseEnrollment.enroll(UserFactory(), self.course.id) # grr, some rando took our spot! + self.assert_can_enroll(False) diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index f17d9b0f46..c9c88c52ca 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -6,7 +6,6 @@ from datetime import datetime, timezone from completion.exceptions import UnavailableCompletionData # lint-amnesty, pylint: disable=wrong-import-order from completion.utilities import get_key_to_last_completed_block # lint-amnesty, pylint: disable=wrong-import-order from django.conf import settings # lint-amnesty, pylint: disable=wrong-import-order -from django.http.response import Http404 # lint-amnesty, pylint: disable=wrong-import-order from django.shortcuts import get_object_or_404 # lint-amnesty, pylint: disable=wrong-import-order from django.urls import reverse # lint-amnesty, pylint: disable=wrong-import-order from django.utils.translation import gettext as _ # lint-amnesty, pylint: disable=wrong-import-order @@ -29,15 +28,11 @@ from lms.djangoapps.course_goals.api import ( ) from lms.djangoapps.course_goals.models import CourseGoal from lms.djangoapps.course_home_api.outline.serializers import OutlineTabSerializer -from lms.djangoapps.course_home_api.toggles import ( - course_home_legacy_is_active, -) from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_info_section, get_course_with_access from lms.djangoapps.courseware.date_summary import TodaysDate from lms.djangoapps.courseware.masquerade import is_masquerading, setup_masquerade -from lms.djangoapps.courseware.toggles import course_is_invitation_only from lms.djangoapps.courseware.views.views import get_cert_data from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from openedx.core.djangoapps.content.learning_sequences.api import get_user_course_outline @@ -54,7 +49,6 @@ from openedx.features.course_experience.url_helpers import get_learning_mfe_home from openedx.features.course_experience.utils import get_course_outline_block_tree, get_start_block from openedx.features.discounts.utils import generate_offer_data from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order class UnableToDismissWelcomeMessage(APIException): @@ -166,10 +160,6 @@ class OutlineTabView(RetrieveAPIView): def get(self, request, *args, **kwargs): # pylint: disable=too-many-statements course_key_string = kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) - course_usage_key = modulestore().make_course_usage_key(course_key) # pylint: disable=unused-variable - - if course_home_legacy_is_active(course_key): - raise Http404 # Enable NR tracing for this view based on course monitoring_utils.set_custom_attribute('course_id', course_key_string) @@ -198,10 +188,7 @@ class OutlineTabView(RetrieveAPIView): user_timezone_locale = user_timezone_locale_prefs(request) user_timezone = user_timezone_locale['user_timezone'] - if course_home_legacy_is_active(course.id): - dates_tab_link = request.build_absolute_uri(reverse('dates', args=[course.id])) - else: - dates_tab_link = get_learning_mfe_home_url(course_key=course.id, url_fragment='dates') + dates_tab_link = get_learning_mfe_home_url(course_key=course.id, url_fragment='dates') # Set all of the defaults access_expiration = None @@ -280,8 +267,11 @@ class OutlineTabView(RetrieveAPIView): 'Please contact your degree administrator or ' '{platform_name} Support if you have questions.' ).format(platform_name=settings.PLATFORM_NAME) - elif course_is_invitation_only(course): + elif CourseEnrollment.is_enrollment_closed(request.user, course_overview): enroll_alert['can_enroll'] = False + elif CourseEnrollment.objects.is_course_full(course_overview): + enroll_alert['can_enroll'] = False + enroll_alert['extra_text'] = _('Course is full') # Sometimes there are sequences returned by Course Blocks that we # don't actually want to show to the user, such as when a sequence is @@ -387,7 +377,6 @@ def dismiss_welcome_message(request): # pylint: disable=missing-function-docstr @permission_classes((IsAuthenticated,)) def save_course_goal(request): # pylint: disable=missing-function-docstring course_id = request.data.get('course_id') - goal_key = request.data.get('goal_key') days_per_week = request.data.get('days_per_week') subscribed_to_reminders = request.data.get('subscribed_to_reminders') diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index 8c8b8ad215..bc47acf30b 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -11,29 +11,12 @@ WAFFLE_FLAG_NAMESPACE = LegacyWaffleFlagNamespace(name='course_home') COURSE_HOME_MICROFRONTEND_PROGRESS_TAB = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'course_home_mfe_progress_tab', # lint-amnesty, pylint: disable=toggle-missing-annotation __name__) -# .. toggle_name: course_home.course_home_use_legacy_frontend -# .. toggle_implementation: CourseWaffleFlag -# .. toggle_default: False -# .. toggle_description: This flag enables the use of the legacy view of course home as the default course frontend. -# .. Learning microfrontend (frontend-app-learning) is now an opt-out view, where if this flag is -# .. enabled the default changes from the learning microfrontend to legacy. -# .. toggle_warnings: None -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2021-06-11 -# .. toggle_target_removal_date: 2022-05-15 -# .. toggle_tickets: https://openedx.atlassian.net/browse/AA-797 -COURSE_HOME_USE_LEGACY_FRONTEND = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'course_home_use_legacy_frontend', __name__) - - -def course_home_legacy_is_active(course_key): - return COURSE_HOME_USE_LEGACY_FRONTEND.is_enabled(course_key) or course_key.deprecated - def course_home_mfe_progress_tab_is_active(course_key): # Avoiding a circular dependency from .models import DisableProgressPageStackedConfig return ( - (not course_home_legacy_is_active(course_key)) and + not course_key.deprecated and COURSE_HOME_MICROFRONTEND_PROGRESS_TAB.is_enabled(course_key) and not DisableProgressPageStackedConfig.current(course_key=course_key).disabled ) diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index b03144888f..46668f1aa8 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -27,6 +27,7 @@ from lms.djangoapps.courseware.access_response import ( MilestoneAccessError, MobileAvailabilityError, NoAllowedPartitionGroupsError, + OldMongoAccessError, VisibilityError ) from lms.djangoapps.courseware.access_utils import ( @@ -329,6 +330,9 @@ def _has_access_course(user, action, courselike): # ).or( # _has_staff_access_to_descriptor, user, courselike, courselike.id # ) + if courselike.id.deprecated: # we no longer support accessing Old Mongo courses + return OldMongoAccessError(courselike) + visible_to_nonstaff = _visible_to_nonstaff_users(courselike) if not visible_to_nonstaff: staff_access = _has_staff_access_to_descriptor(user, courselike, courselike.id) diff --git a/lms/djangoapps/courseware/access_response.py b/lms/djangoapps/courseware/access_response.py index e88b58d205..674003d6c8 100644 --- a/lms/djangoapps/courseware/access_response.py +++ b/lms/djangoapps/courseware/access_response.py @@ -236,3 +236,16 @@ class AuthenticationRequiredAccessError(AccessError): developer_message = "User must be authenticated to view the course" user_message = _("You must be logged in to see this course") super().__init__(error_code, developer_message, user_message) + + +class OldMongoAccessError(AccessError): + """ + Access denied because the course is in Old Mongo and we no longer support them. See DEPR-58. + """ + def __init__(self, courselike): + error_code = 'old_mongo' + developer_message = 'Access to Old Mongo courses is unsupported' + user_message = _('{course_name} is no longer available.').format( + course_name=courselike.display_name_with_default, + ) + super().__init__(error_code, developer_message, user_message) diff --git a/lms/djangoapps/courseware/admin.py b/lms/djangoapps/courseware/admin.py index a7bfb2a512..573286ccc2 100644 --- a/lms/djangoapps/courseware/admin.py +++ b/lms/djangoapps/courseware/admin.py @@ -8,6 +8,8 @@ from django.contrib import admin from lms.djangoapps.courseware import models + +admin.site.register(models.FinancialAssistanceConfiguration, ConfigurationModelAdmin) admin.site.register(models.DynamicUpgradeDeadlineConfiguration, ConfigurationModelAdmin) admin.site.register(models.OfflineComputedGrade) admin.site.register(models.OfflineComputedGradeLog) diff --git a/lms/djangoapps/courseware/config.py b/lms/djangoapps/courseware/config.py new file mode 100644 index 0000000000..34766d0271 --- /dev/null +++ b/lms/djangoapps/courseware/config.py @@ -0,0 +1,18 @@ +""" +Waffle flags and switches +""" + +from edx_toggles.toggles import WaffleSwitch + +WAFFLE_NAMESPACE = 'courseware' + +# .. toggle_name: courseware.enable_new_financial_assistance_flow +# .. toggle_implementation: WaffleSwitch +# .. toggle_default: False +# .. toggle_description: enables new internal only financial assistance flow, when active. +# .. toggle_use_cases: opt_in +# .. toggle_creation_date: 2022-03-25 +# .. toggle_tickets: https://openedx.atlassian.net/browse/PROD-2588 +ENABLE_NEW_FINANCIAL_ASSISTANCE_FLOW = WaffleSwitch( + f"{WAFFLE_NAMESPACE}.enable_new_financial_assistance_flow", __name__ +) diff --git a/lms/djangoapps/courseware/constants.py b/lms/djangoapps/courseware/constants.py new file mode 100644 index 0000000000..dcde5f7024 --- /dev/null +++ b/lms/djangoapps/courseware/constants.py @@ -0,0 +1,8 @@ +""" +Constants for courseware app. +""" +UNEXPECTED_ERROR_IS_ELIGIBLE = "An unexpected error occurred while fetching " \ + "financial assistance eligibility criteria for a course" +UNEXPECTED_ERROR_APPLICATION_STATUS = "An unexpected error occurred while getting " \ + "financial assistance application status" +UNEXPECTED_ERROR_CREATE_APPLICATION = "An unexpected error occurred while creating financial assistance application" diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 89fa7c0596..cd755a8d47 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -27,6 +27,7 @@ from lms.djangoapps.courseware.access_response import ( AuthenticationRequiredAccessError, EnrollmentRequiredAccessError, MilestoneAccessError, + OldMongoAccessError, StartDateError, ) from lms.djangoapps.courseware.date_summary import ( @@ -213,6 +214,15 @@ def check_course_access_with_redirect(course, user, action, check_if_enrolled=Fa params=params.urlencode() ), access_response) + # Redirect if trying to access an Old Mongo course + if isinstance(access_response, OldMongoAccessError): + params = QueryDict(mutable=True) + params['access_response_error'] = access_response.user_message + raise CourseAccessRedirect('{dashboard_url}?{params}'.format( + dashboard_url=reverse('dashboard'), + params=params.urlencode(), + ), access_response) + # Redirect if the user must answer a survey before entering the course. if isinstance(access_response, MilestoneAccessError): raise CourseAccessRedirect('{dashboard_url}'.format( diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index d9d20018f0..4123da5f38 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -10,8 +10,6 @@ import datetime import crum from babel.dates import format_timedelta from django.conf import settings -from django.urls import reverse -from django.utils.formats import date_format from django.utils.functional import cached_property from django.utils.translation import get_language, to_locale from django.utils.translation import gettext as _ @@ -19,14 +17,14 @@ from django.utils.translation import gettext_lazy from lazy import lazy from pytz import utc -from common.djangoapps.course_modes.models import CourseMode, get_cosmetic_verified_display_price +from common.djangoapps.course_modes.models import CourseMode from lms.djangoapps.certificates.api import get_active_web_certificate, can_show_certificate_available_date_field from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link, can_show_verified_upgrade from lms.djangoapps.verify_student.models import VerificationDeadline from lms.djangoapps.verify_student.services import IDVerificationService -from openedx.core.djangolib.markup import HTML, Text +from openedx.core.djangolib.markup import HTML from openedx.features.course_duration_limits.access import get_user_course_expiration_date -from openedx.features.course_experience import RELATIVE_DATES_FLAG, UPGRADE_DEADLINE_MESSAGE, CourseHomeMessages +from openedx.features.course_experience import RELATIVE_DATES_FLAG from common.djangoapps.student.models import CourseEnrollment from .context_processor import user_timezone_locale_prefs @@ -79,12 +77,6 @@ class DateSummary: """Extra detail to display as a tooltip.""" return None - def register_alerts(self, request, course): - """ - Registers any relevant course alerts given the current request. - """ - pass # lint-amnesty, pylint: disable=unnecessary-pass - @property def date(self): """This summary's date.""" @@ -280,35 +272,6 @@ class CourseStartDate(DateSummary): return gettext_lazy('Enrollment Date') return gettext_lazy('Course starts') - def register_alerts(self, request, course): - """ - Registers an alert if the course has not started yet. - """ - is_enrolled = CourseEnrollment.get_enrollment(request.user, course.id) - if not course.start or not is_enrolled: - return - days_until_start = (course.start - self.current_time).days - if course.start > self.current_time: - if days_until_start > 0: - CourseHomeMessages.register_info_message( - request, - Text(_( - "Don't forget to add a calendar reminder!" - )), - title=Text(_("Course starts in {time_remaining_string} on {course_start_date}.")).format( - time_remaining_string=self.time_remaining_string, - course_start_date=self.long_date_html, - ) - ) - else: - CourseHomeMessages.register_info_message( - request, - Text(_("Course starts in {time_remaining_string} at {course_start_time}.")).format( - time_remaining_string=self.time_remaining_string, - course_start_time=self.short_time_html, - ) - ) - class CourseEndDate(DateSummary): """ @@ -361,34 +324,6 @@ class CourseEndDate(DateSummary): def date_type(self): return 'course-end-date' - def register_alerts(self, request, course): - """ - Registers an alert if the end date is approaching. - """ - is_enrolled = CourseEnrollment.get_enrollment(request.user, course.id) - if not course.start or not course.end or self.current_time < course.start or not is_enrolled: - return - days_until_end = (course.end - self.current_time).days - if course.end > self.current_time and days_until_end <= settings.COURSE_MESSAGE_ALERT_DURATION_IN_DAYS: - if days_until_end > 0: - CourseHomeMessages.register_info_message( - request, - Text(self.description), - title=Text(_('This course is ending in {time_remaining_string} on {course_end_date}.')).format( - time_remaining_string=self.time_remaining_string, - course_end_date=self.long_date_html, - ) - ) - else: - CourseHomeMessages.register_info_message( - request, - Text(self.description), - title=Text(_('This course is ending in {time_remaining_string} at {course_end_time}.')).format( - time_remaining_string=self.time_remaining_string, - course_end_time=self.short_time_html, - ) - ) - class CourseAssignmentDate(DateSummary): """ @@ -512,31 +447,6 @@ class CertificateAvailableDate(DateSummary): ) if mode.slug != CourseMode.AUDIT ) - def register_alerts(self, request, course): - """ - Registers an alert close to the certificate delivery date. - """ - is_enrolled = CourseEnrollment.get_enrollment(request.user, course.id) - if not is_enrolled or not self.is_enabled or (course.end and course.end > self.current_time): - return - if self.date > self.current_time: - CourseHomeMessages.register_info_message( - request, - Text(_( - 'If you have earned a certificate, you will be able to access it {time_remaining_string}' - ' from now. You will also be able to view your certificates on your {learner_profile_link}.' - )).format( - time_remaining_string=self.time_remaining_string, - learner_profile_link=HTML( - '{learner_profile_name}' - ).format( - learner_profile_url=reverse('learner_profile', kwargs={'username': request.user.username}), - learner_profile_name=_('Learner Profile'), - ), - ), - title=Text(_('We are working on generating course certificates.')) - ) - class VerifiedUpgradeDeadlineDate(DateSummary): """ @@ -608,44 +518,6 @@ class VerifiedUpgradeDeadlineDate(DateSummary): # according to their locale. return _('by {date}') - def register_alerts(self, request, course): - """ - Registers an alert if the verification deadline is approaching. - """ - upgrade_price = get_cosmetic_verified_display_price(course) - if not UPGRADE_DEADLINE_MESSAGE.is_enabled(course.id) or not self.is_enabled or not upgrade_price: - return - days_left_to_upgrade = (self.date - self.current_time).days - if self.date > self.current_time and days_left_to_upgrade <= settings.COURSE_MESSAGE_ALERT_DURATION_IN_DAYS: - upgrade_message = _( - "Don't forget, you have {time_remaining_string} left to upgrade to a Verified Certificate." - ).format(time_remaining_string=self.time_remaining_string) - if self._dynamic_deadline() is not None: - upgrade_message = _( - "Don't forget to upgrade to a verified certificate by {localized_date}." - ).format(localized_date=date_format(self.date)) - CourseHomeMessages.register_info_message( - request, - Text(_( - 'In order to qualify for a certificate, you must meet all course grading ' - 'requirements, upgrade before the course deadline, and successfully verify ' - 'your identity on {platform_name} if you have not done so already.{button_panel}' - )).format( - platform_name=settings.PLATFORM_NAME, - button_panel=HTML( - '
' - '{upgrade_label}' - '
' - ).format( - upgrade_url=self.link, - upgrade_label=Text(_('Upgrade ({upgrade_price})')).format(upgrade_price=upgrade_price), - ) - ), - title=Text(upgrade_message) - ) - class VerificationDeadlineDate(DateSummary): """ diff --git a/lms/djangoapps/courseware/management/commands/dump_course_ids_with_filter.py b/lms/djangoapps/courseware/management/commands/dump_course_ids_with_filter.py new file mode 100644 index 0000000000..c4ed14f431 --- /dev/null +++ b/lms/djangoapps/courseware/management/commands/dump_course_ids_with_filter.py @@ -0,0 +1,35 @@ +""" +Dump the course_ids available to the lms, excluding courses +that have ended prior to the given date. + +Output is UTF-8 encoded by default. +The output format is one course_id per line. +""" + +import datetime + +from textwrap import dedent + +from django.core.management.base import BaseCommand +from django.db.models import Q +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + +class Command(BaseCommand): # lint-amnesty, pylint: disable=missing-class-docstring + help = dedent(__doc__).strip() + + def add_arguments(self, parser): + parser.add_argument('--end', + default=None, + help='Date to filter out courses that have ended before the provided date') + + def handle(self, *args, **options): + course_overviews = CourseOverview.objects.all() + courseoverview_filter = Q() + if options['end']: + courseoverview_filter |= Q(end__gte=datetime.datetime.strptime(options['end'], "%Y-%m-%d")) + courseoverview_filter |= Q(end=None) + course_overviews = course_overviews.filter(courseoverview_filter) + + output = '\n'.join(str(course_overview.id) for course_overview in course_overviews) + '\n' + return output diff --git a/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py b/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py index 18e29123b2..9be179bf4a 100644 --- a/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py +++ b/lms/djangoapps/courseware/management/commands/tests/test_dump_course.py @@ -2,11 +2,12 @@ Tests for Django management commands """ - +import datetime import json from io import StringIO import factory +import pytz from django.conf import settings from django.core.management import call_command @@ -22,6 +23,8 @@ from xmodule.modulestore.xml_importer import import_course_from_xml DATA_DIR = settings.COMMON_TEST_DATA_ROOT XML_COURSE_DIRS = ['simple'] +TEST_COURSE_START = datetime.datetime(2012, 7, 1, tzinfo=pytz.UTC) +TEST_COURSE_END = datetime.datetime(2012, 12, 31, tzinfo=pytz.UTC) class CommandsTestBase(SharedModuleStoreTestCase): @@ -56,7 +59,9 @@ class CommandsTestBase(SharedModuleStoreTestCase): course='simple', run="run", display_name='2012_Fáĺĺ', - modulestore=store + modulestore=store, + start=TEST_COURSE_START, + end=TEST_COURSE_END, ) cls.discussion = ItemFactory.create( @@ -81,6 +86,25 @@ class CommandsTestBase(SharedModuleStoreTestCase): out.seek(0) return out.read() + def test_dump_course_ids_with_filter(self): + """ + Test that `dump_course_ids_with_filter` works correctly by + only returning courses that have not ended before the provided `end` data. + + `load_courses` method creates two courses first by calling CourseFactory.create + which creates a course with end=2012-12-31. Then it creates a second course + by calling import_course_from_xml which creates a course with end=None. + + This test makes sure that only the second course is returned when + `end`=2013-01-01 is passed to `dump_course_ids_with_filter`. + """ + args = [] + kwargs = {'end': '2013-01-01'} # exclude any courses which have ended before 2013-01-01 + output = self.call_command('dump_course_ids_with_filter', *args, **kwargs) + dumped_courses = output.strip().split('\n') + dumped_ids = set(dumped_courses) + assert {str(self.test_course_key)} == dumped_ids + def test_dump_course_ids(self): output = self.call_command('dump_course_ids') dumped_courses = output.strip().split('\n') diff --git a/lms/djangoapps/courseware/migrations/0017_financialassistanceconfiguration.py b/lms/djangoapps/courseware/migrations/0017_financialassistanceconfiguration.py new file mode 100644 index 0000000000..857258f5b9 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0017_financialassistanceconfiguration.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2.12 on 2022-04-11 19:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('courseware', '0016_lastseencoursewaretimezone'), + ] + + operations = [ + migrations.CreateModel( + name='FinancialAssistanceConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('api_base_url', models.URLField(help_text='Financial Assistance Backend API Base URL.', verbose_name='Internal API Base URL')), + ('service_username', models.CharField(default='financial_assistance_service_user', help_text='Username created for Financial Assistance Backend, e.g. financial_assistance_service_user.', max_length=100)), + ('fa_backend_enabled_courses_percentage', models.IntegerField(default=0, help_text='Percentage of courses allowed to use edx-financial-assistance')), + ('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')), + ], + options={ + 'ordering': ('-change_date',), + 'abstract': False, + }, + ), + ] diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 5aa20a6616..05020a811d 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -18,6 +18,7 @@ import itertools import logging from config_models.models import ConfigurationModel +from django.contrib.auth import get_user_model from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db import models @@ -500,3 +501,33 @@ class LastSeenCoursewareTimezone(models.Model): class Meta: app_label = "courseware" + + +class FinancialAssistanceConfiguration(ConfigurationModel): + """ + Manages configuration for connecting to Financial Assistance backend service and using its API. + """ + + api_base_url = models.URLField( + verbose_name=_('Internal API Base URL'), + help_text=_('Financial Assistance Backend API Base URL.') + ) + + service_username = models.CharField( + max_length=100, + default='financial_assistance_service_user', + null=False, + blank=False, + help_text=_('Username created for Financial Assistance Backend, e.g. financial_assistance_service_user.') + ) + + fa_backend_enabled_courses_percentage = models.IntegerField( + default=0, + help_text=_('Percentage of courses allowed to use edx-financial-assistance') + ) + + def get_service_user(self): + """ + Getter function to get service user for Financial Assistance backend. + """ + return get_user_model().objects.get(username=self.service_username) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index f9ee3c8d21..62157ec648 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -727,8 +727,6 @@ def get_module_system_for_user( system = LmsModuleSystem( track_function=track_function, static_url=settings.STATIC_URL, - # TODO (cpennington): Figure out how to share info between systems - filestore=descriptor.runtime.resources_fs, get_module=inner_get_module, user=user, debug=settings.DEBUG, diff --git a/lms/djangoapps/courseware/student_field_overrides.py b/lms/djangoapps/courseware/student_field_overrides.py index 31b0b8b774..4eceebf9fc 100644 --- a/lms/djangoapps/courseware/student_field_overrides.py +++ b/lms/djangoapps/courseware/student_field_overrides.py @@ -22,7 +22,7 @@ class IndividualStudentOverrideProvider(FieldOverrideProvider): return get_override_for_user(self.user, block, name, default) @classmethod - def enabled_for(cls, course): + def enabled_for(cls, course): # pylint: disable=arguments-differ """This simple override provider is always enabled""" return True diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index f599e9b3df..7f774faf62 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -7,15 +7,15 @@ perform some LMS-specific tab display gymnastics for the Entrance Exams feature from django.conf import settings from django.utils.translation import gettext as _ from django.utils.translation import gettext_noop +from xmodule.tabs import CourseTab, CourseTabList, key_checker from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.entrance_exams import user_can_skip_entrance_exam -from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active, course_home_mfe_progress_tab_is_active +from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active from openedx.core.lib.course_tabs import CourseTabPluginManager -from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG, default_course_url_name +from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG, default_course_url from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url from common.djangoapps.student.models import CourseEnrollment -from xmodule.tabs import CourseTab, CourseTabList, course_reverse_func_from_name_func, key_checker # lint-amnesty, pylint: disable=wrong-import-order class EnrolledTab(CourseTab): @@ -41,13 +41,8 @@ class CoursewareTab(EnrolledTab): supports_preview_menu = True def __init__(self, tab_dict): - def link_func(course, reverse_func): - if course_home_legacy_is_active(course.id): - reverse_name_func = lambda course: default_course_url_name(course.id) - url_func = course_reverse_func_from_name_func(reverse_name_func) - return url_func(course, reverse_func) - else: - return get_learning_mfe_home_url(course_key=course.id, url_fragment='home') + def link_func(course, _reverse_func): + return default_course_url(course.id) tab_dict['link_func'] = link_func super().__init__(tab_dict) @@ -334,11 +329,8 @@ class DatesTab(EnrolledTab): view_name = "dates" def __init__(self, tab_dict): - def link_func(course, reverse_func): - if course_home_legacy_is_active(course.id): - return reverse_func(self.view_name, args=[str(course.id)]) - else: - return get_learning_mfe_home_url(course_key=course.id, url_fragment=self.view_name) + def link_func(course, _reverse_func): + return get_learning_mfe_home_url(course_key=course.id, url_fragment='dates') tab_dict['link_func'] = link_func super().__init__(tab_dict) diff --git a/lms/djangoapps/courseware/tests/factories.py b/lms/djangoapps/courseware/tests/factories.py index e699db9c72..a7343528df 100644 --- a/lms/djangoapps/courseware/tests/factories.py +++ b/lms/djangoapps/courseware/tests/factories.py @@ -17,7 +17,8 @@ from lms.djangoapps.courseware.models import ( StudentModule, XModuleStudentInfoField, XModuleStudentPrefsField, - XModuleUserStateSummaryField + XModuleUserStateSummaryField, + FinancialAssistanceConfiguration ) COURSE_KEY = CourseKey.from_string('edX/test_course/test') @@ -75,3 +76,12 @@ class StudentInfoFactory(DjangoModelFactory): field_name = 'existing_field' value = json.dumps('old_value') student = factory.SubFactory(UserFactory) + + +class FinancialAssistanceConfigurationFactory(DjangoModelFactory): + """ + Factory for FinancialAssistanceConfiguration model. + """ + + class Meta: + model = FinancialAssistanceConfiguration diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index 5247f71c3b..f5818f7734 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -7,8 +7,9 @@ import ast import json from collections import OrderedDict from datetime import timedelta -from unittest.mock import Mock +from unittest.mock import Mock, patch +from django.conf import settings from django.contrib import messages from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.test import TestCase @@ -18,6 +19,7 @@ from django.utils.timezone import now from xblock.field_data import DictFieldData from common.djangoapps.edxmako.shortcuts import render_to_string +from lms.djangoapps.courseware import access_utils from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link from lms.djangoapps.courseware.masquerade import MasqueradeView @@ -451,3 +453,11 @@ def get_context_dict_from_string(data): sorted(json.loads(cleaned_data['metadata']).items(), key=lambda t: t[0]) ) return cleaned_data + + +def set_preview_mode(preview_mode: bool): + """ + A decorator to force the preview mode on or off. + """ + hostname = settings.FEATURES.get('PREVIEW_LMS_BASE') if preview_mode else None + return patch.object(access_utils, 'get_current_request_hostname', new=lambda: hostname) diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py index 4b863fbc8e..244a5fd2b3 100644 --- a/lms/djangoapps/courseware/tests/test_about.py +++ b/lms/djangoapps/courseware/tests/test_about.py @@ -9,7 +9,6 @@ from unittest import mock from unittest.mock import patch import ddt import pytz -from ccx_keys.locator import CCXLocator from django.conf import settings from django.test.utils import override_settings from django.urls import reverse @@ -29,13 +28,11 @@ from xmodule.modulestore.tests.utils import TEST_DATA_DIR from xmodule.modulestore.xml_importer import import_course_from_xml from common.djangoapps.course_modes.models import CourseMode -from lms.djangoapps.ccx.tests.factories import CcxFactory from openedx.core.djangoapps.models.course_details import CourseDetails -from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG +from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, course_home_url from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML from openedx.features.course_experience.waffle import WAFFLE_NAMESPACE as COURSE_EXPERIENCE_WAFFLE_NAMESPACE -from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND -from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentAllowedFactory, UserFactory +from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory from common.djangoapps.track.tests import EventTrackingTestCase from common.djangoapps.util.milestones_helpers import get_prerequisite_courses_display, set_prerequisite_courses @@ -47,7 +44,6 @@ SHIB_ERROR_STR = "The currently logged-in user account does not have permission @ddt.ddt -@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTrackingTestCase, MilestonesTestCaseMixin): """ Tests about xblock. @@ -124,13 +120,7 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra self.setup_user() url = reverse('about_course', args=[str(self.course.id)]) resp = self.client.get(url) - # should be redirected - assert resp.status_code == 302 - # follow this time, and check we're redirected to the course home page - resp = self.client.get(url, follow=True) - target_url = resp.redirect_chain[-1][0] - course_home_url = reverse('openedx.course_experience.course_home', args=[str(self.course.id)]) - assert target_url.endswith(course_home_url) + self.assertRedirects(resp, course_home_url(self.course.id), fetch_redirect_response=False) @patch.dict(settings.FEATURES, {'ENABLE_COURSE_HOME_REDIRECT': False}) @patch.dict(settings.FEATURES, {'ENABLE_MKTG_SITE': True}) @@ -229,7 +219,6 @@ class AboutTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase, EventTra self.assertContains(resp, "Enroll Now") -@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) class AboutTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase): """ Tests for the course about page @@ -273,7 +262,6 @@ class AboutTestCaseXML(LoginEnrollmentTestCase, ModuleStoreTestCase): self.assertContains(resp, self.xml_data) -@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) class AboutWithCappedEnrollmentsTestCase(LoginEnrollmentTestCase, SharedModuleStoreTestCase): """ This test case will check the About page when a course has a capped enrollment @@ -316,7 +304,6 @@ class AboutWithCappedEnrollmentsTestCase(LoginEnrollmentTestCase, SharedModuleSt self.assertNotContains(resp, REG_STR) -@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) class AboutWithInvitationOnly(SharedModuleStoreTestCase): """ This test case will check the About page when a course is invitation only. @@ -356,7 +343,6 @@ class AboutWithInvitationOnly(SharedModuleStoreTestCase): self.assertContains(resp, REG_STR) -@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) class AboutWithClosedEnrollment(ModuleStoreTestCase): """ This test case will check the About page for a course that has enrollment start/end @@ -393,7 +379,6 @@ class AboutWithClosedEnrollment(ModuleStoreTestCase): @ddt.ddt -@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) class AboutSidebarHTMLTestCase(SharedModuleStoreTestCase): """ This test case will check the About page for the content in the HTML sidebar. @@ -433,38 +418,3 @@ class AboutSidebarHTMLTestCase(SharedModuleStoreTestCase): self.assertContains(resp, itemfactory_data) else: self.assertNotContains(resp, '
') - - -class CourseAboutTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase): - """ - Test for unenrolled student tries to access ccx. - Note: Only CCX coach can enroll a student in CCX. In sum self-registration not allowed. - """ - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - def setUp(self): - super().setUp() - - # Create ccx coach account - self.coach = coach = AdminFactory.create(password="test") - self.client.login(username=coach.username, password="test") - - @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) - def test_redirect_to_dashboard_unenrolled_ccx(self): - """ - Assert that when unenrolled user tries to access CCX do not allow the user to self-register. - Redirect them to their student dashboard - """ - - # create ccx - ccx = CcxFactory(course_id=self.course.id, coach=self.coach) - ccx_locator = CCXLocator.from_course_locator(self.course.id, str(ccx.id)) - - self.setup_user() - url = reverse('openedx.course_experience.course_home', args=[ccx_locator]) - response = self.client.get(url) - expected = reverse('dashboard') - self.assertRedirects(response, expected, status_code=302, target_status_code=200) diff --git a/lms/djangoapps/courseware/tests/test_course_info.py b/lms/djangoapps/courseware/tests/test_course_info.py index 88db559336..635b845665 100644 --- a/lms/djangoapps/courseware/tests/test_course_info.py +++ b/lms/djangoapps/courseware/tests/test_course_info.py @@ -293,7 +293,7 @@ class CourseInfoTitleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): assert expected_title == content('.page-title').contents()[0].strip() if expected_subtitle is None: - assert [] == content('.page-subtitle') + assert not content('.page-subtitle') else: assert expected_subtitle == content('.page-subtitle').contents()[0].strip() diff --git a/lms/djangoapps/courseware/tests/test_course_survey.py b/lms/djangoapps/courseware/tests/test_course_survey.py index 5cfae9047c..d729add1b6 100644 --- a/lms/djangoapps/courseware/tests/test_course_survey.py +++ b/lms/djangoapps/courseware/tests/test_course_survey.py @@ -5,20 +5,19 @@ Python tests for the Survey workflows from collections import OrderedDict from copy import deepcopy +from urllib.parse import quote from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from common.test.utils import XssTestMixin -from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase from lms.djangoapps.survey.models import SurveyAnswer, SurveyForm +from openedx.features.course_experience import course_home_url -@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTestMixin): """ All tests for the views.py file @@ -78,7 +77,7 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe """ Helper method to assert that all known redirect points do redirect as expected """ - for view_name in ['courseware', 'openedx.course_experience.course_home', 'progress']: + for view_name in ['courseware', 'progress']: resp = self.client.get( reverse( view_name, @@ -95,7 +94,7 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe Helper method to asswer that all known conditionally redirect points do not redirect as expected """ - for view_name in ['courseware', 'openedx.course_experience.course_home', 'progress']: + for view_name in ['courseware', 'progress']: resp = self.client.get( reverse( view_name, @@ -119,17 +118,20 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe def test_anonymous_user_visiting_course_with_survey(self): """ - Verifies that anonymous user going to the courseware home with an unanswered survey is not - redirected to survey and home page renders without server error. + Verifies that anonymous user going to the course with an unanswered survey is not + redirected to survey. """ self.logout() resp = self.client.get( reverse( - 'openedx.course_experience.course_home', + 'courseware', kwargs={'course_id': str(self.course.id)} ) ) - assert resp.status_code == 200 + self.assertRedirects( + resp, + f'/login?next=/courses/{quote(str(self.course.id))}/courseware' + ) def test_visiting_course_with_existing_answers(self): """ @@ -206,10 +208,10 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe kwargs={'course_id': str(self.course_with_bogus_survey.id)} ) ) - course_home_path = 'openedx.course_experience.course_home' self.assertRedirects( resp, - reverse(course_home_path, kwargs={'course_id': str(self.course_with_bogus_survey.id)}) + course_home_url(self.course_with_bogus_survey.id), + fetch_redirect_response=False, ) def test_visiting_survey_with_no_course_survey(self): @@ -223,10 +225,10 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe kwargs={'course_id': str(self.course_without_survey.id)} ) ) - course_home_path = 'openedx.course_experience.course_home' self.assertRedirects( resp, - reverse(course_home_path, kwargs={'course_id': str(self.course_without_survey.id)}) + course_home_url(self.course_without_survey.id), + fetch_redirect_response=False, ) def test_survey_xss(self): diff --git a/lms/djangoapps/courseware/tests/test_courses.py b/lms/djangoapps/courseware/tests/test_courses.py index df85d58ff6..7dd231b1eb 100644 --- a/lms/djangoapps/courseware/tests/test_courses.py +++ b/lms/djangoapps/courseware/tests/test_courses.py @@ -39,6 +39,7 @@ from lms.djangoapps.courseware.courses import ( get_courses, get_current_child ) +from lms.djangoapps.courseware.exceptions import CourseAccessRedirect from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.module_render import get_module_for_descriptor from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException @@ -88,6 +89,18 @@ class CoursesTest(ModuleStoreTestCase): assert error.value.access_response.error_code == 'not_visible_to_user' assert not error.value.access_response.has_access + @ddt.data(GET_COURSE_WITH_ACCESS, GET_COURSE_OVERVIEW_WITH_ACCESS) + def test_old_mongo_access_error(self, course_access_func_name): + course_access_func = self.COURSE_ACCESS_FUNCS[course_access_func_name] + user = UserFactory.create() + with self.store.default_store(ModuleStoreEnum.Type.mongo): + course = CourseFactory.create() + + with pytest.raises(CourseAccessRedirect) as error: + course_access_func(user, 'load', course.id) + assert error.value.access_error.error_code == 'old_mongo' + assert not error.value.access_error.has_access + @ddt.data( (GET_COURSE_WITH_ACCESS, 2), (GET_COURSE_OVERVIEW_WITH_ACCESS, 0), diff --git a/lms/djangoapps/courseware/tests/test_date_summary.py b/lms/djangoapps/courseware/tests/test_date_summary.py index 2ea337d639..741a02bea4 100644 --- a/lms/djangoapps/courseware/tests/test_date_summary.py +++ b/lms/djangoapps/courseware/tests/test_date_summary.py @@ -9,7 +9,6 @@ import crum import ddt import waffle # lint-amnesty, pylint: disable=invalid-django-waffle-import from django.conf import settings -from django.contrib.messages.middleware import MessageMiddleware from django.test import RequestFactory from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag @@ -22,7 +21,6 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory from lms.djangoapps.commerce.models import CommerceConfiguration -from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND from lms.djangoapps.courseware.courses import get_course_date_blocks from lms.djangoapps.courseware.date_summary import ( CertificateAvailableDate, @@ -45,14 +43,8 @@ from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVer from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration -from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.features.course_duration_limits.models import CourseDurationLimitConfig -from openedx.features.course_experience import ( - DISABLE_UNIFIED_COURSE_TAB_FLAG, - RELATIVE_DATES_FLAG, - UPGRADE_DEADLINE_MESSAGE, - CourseHomeMessages -) +from openedx.features.course_experience import RELATIVE_DATES_FLAG from common.djangoapps.student.tests.factories import TEST_PASSWORD, CourseEnrollmentFactory, UserFactory @@ -82,13 +74,6 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): response = self.client.get(url) self.assertNotContains(response, 'date-summary', status_code=302) - @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) - def test_course_home_logged_out(self): - course = create_course_run() - url = reverse('openedx.course_experience.course_home', args=(course.id,)) - response = self.client.get(url) - assert 200 == response.status_code - # Tests for which blocks are enabled def assert_block_types(self, course, user, expected_blocks): """Assert that the enabled block types for this course are as expected.""" @@ -424,53 +409,6 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): assert block.date == datetime.now(utc) assert block.title == 'current_datetime' - @ddt.data( - 'info', - 'openedx.course_experience.course_home', - ) - @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) - @override_waffle_flag(DISABLE_UNIFIED_COURSE_TAB_FLAG, active=False) - def test_todays_date_no_timezone(self, url_name): - with freeze_time('2015-01-02'): - course = create_course_run() - user = create_user() - self.client.login(username=user.username, password=TEST_PASSWORD) - - html_elements = [ - '

Upcoming Dates

', - '
Upcoming Dates', - '
', - ] - # The url should change based on the mfe being active. - if legacy_active: - html_elements.append('/courses/' + str(course.id) + '/dates') - else: - html_elements.append('/course/' + str(course.id) + '/dates') - url = reverse(url_name, args=(course.id,)) - - def assert_html_elements(assert_function, user): - self.client.login(username=user.username, password=TEST_PASSWORD) - with override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=legacy_active): - response = self.client.get(url, follow=True) - if legacy_active or user.is_staff: - for html in html_elements: - assert_function(response, html) - else: - assert 404 == response.status_code - self.client.logout() - - with freeze_time('2015-01-02'): - unenrolled_user = create_user() - assert_html_elements(self.assertNotContains, unenrolled_user) - - staff_user = create_user() - staff_user.is_staff = True - staff_user.save() - assert_html_elements(self.assertContains, staff_user) - - enrolled_user = create_user() - CourseEnrollmentFactory(course_id=course.id, user=enrolled_user, mode=CourseMode.VERIFIED) - assert_html_elements(self.assertContains, enrolled_user) - - -@ddt.ddt -class TestDateAlerts(SharedModuleStoreTestCase): - """ - Unit tests for date alerts. - """ - def setUp(self): - super().setUp() - with freeze_time('2017-07-01 09:00:00'): - self.course = create_course_run(days_till_start=0) - self.course.certificate_available_date = self.course.start + timedelta(days=21) - enable_course_certificates(self.course) - self.enrollment = CourseEnrollmentFactory(course_id=self.course.id, mode=CourseMode.AUDIT) - self.request = RequestFactory().request() - self.request.session = {} - self.request.user = self.enrollment.user - MessageMiddleware().process_request(self.request) - - @ddt.data( - ['2017-01-01 09:00:00', 'in 6 months on - """, - html, - ) - @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_discussion_render_successfully_with_orphan_parent(self, default_store): """ @@ -431,14 +400,13 @@ class TestXBlockQueryLoad(SharedModuleStoreTestCase): discussion_target='Target Discussion', )) - # 7 queries are required to do first discussion xblock render: + # 4 queries are required to do first discussion xblock render: # * split_modulestore_django_splitmodulestorecourseindex x2 - # * waffle_utils_wafflecourseoverridemodel # * waffle_utils_waffleorgoverridemodel - # * waffle_flag # * django_comment_client_role - # * lms_xblock_xblockasidesconfig - num_queries = 7 + + num_queries = 4 + for discussion in discussions: discussion_xblock = get_module_for_descriptor_internal( user=user, @@ -451,9 +419,9 @@ class TestXBlockQueryLoad(SharedModuleStoreTestCase): with self.assertNumQueries(num_queries): fragment = discussion_xblock.render('student_view') - # Permissions are cached, so only 1 query required for subsequent renders - # to check the waffle flag - num_queries = 1 + # Permissions are cached, so no queries required for subsequent renders + + num_queries = 0 html = fragment.content assert 'data-user-create-comment="false"' in html diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index 0dd7976c11..ee07b3aa5f 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -3,10 +3,9 @@ Tests use cases related to LMS Entrance Exam behavior, such as gated content acc """ -from unittest.mock import Mock, patch +from unittest.mock import patch from crum import set_current_request from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag from milestones.tests.utils import MilestonesTestCaseMixin from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -21,11 +20,9 @@ from lms.djangoapps.courseware.entrance_exams import ( from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.module_render import get_module, handle_xblock_callback, toc_for_course from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase -from lms.djangoapps.courseware.toggles import COURSEWARE_USE_LEGACY_FRONTEND from openedx.core.djangolib.testing.utils import get_mock_request -from openedx.features.course_experience import DISABLE_COURSE_OUTLINE_PAGE_FLAG, DISABLE_UNIFIED_COURSE_TAB_FLAG from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.tests.factories import AnonymousUserFactory, CourseEnrollmentFactory +from common.djangoapps.student.tests.factories import AnonymousUserFactory from common.djangoapps.student.tests.factories import InstructorFactory from common.djangoapps.student.tests.factories import RequestFactoryNoCsrf from common.djangoapps.student.tests.factories import StaffFactory @@ -40,7 +37,6 @@ from common.djangoapps.util.milestones_helpers import ( ) -@override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True) @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': True}) class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTestCaseMixin): """ @@ -216,54 +212,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest ] ) - def test_view_redirect_if_entrance_exam_required(self): - """ - Unit Test: if entrance exam is required. Should return a redirect. - """ - url = reverse('courseware', kwargs={'course_id': str(self.course.id)}) - expected_url = reverse('courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.entrance_exam.location.block_id, - 'section': self.exam_1.location.block_id - }) - resp = self.client.get(url) - self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) - - @patch.dict('django.conf.settings.FEATURES', {'ENTRANCE_EXAMS': False}) - def test_entrance_exam_content_absence(self): - """ - Unit Test: If entrance exam is not enabled then page should be redirected with chapter contents. - """ - url = reverse('courseware', kwargs={'course_id': str(self.course.id)}) - expected_url = reverse('courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.chapter.location.block_id, - 'section': self.welcome.location.block_id - }) - resp = self.client.get(url) - self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) - resp = self.client.get(expected_url) - self.assertNotContains(resp, 'Exam Vertical - Unit 1') - - def test_entrance_exam_content_presence(self): - """ - Unit Test: If entrance exam is enabled then its content e.g. problems should be loaded and redirection will - occur with entrance exam contents. - """ - url = reverse('courseware', kwargs={'course_id': str(self.course.id)}) - expected_url = reverse('courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.entrance_exam.location.block_id, - 'section': self.exam_1.location.block_id - }) - resp = self.client.get(url) - self.assertRedirects(resp, expected_url, status_code=302, target_status_code=200) - resp = self.client.get(expected_url) - self.assertContains(resp, 'Exam Vertical - Unit 1') - def test_get_entrance_exam_content(self): """ test get entrance exam content method @@ -279,95 +227,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest assert exam_chapter is None assert user_has_passed_entrance_exam(self.request.user, self.course) - def test_entrance_exam_requirement_message(self): - """ - Unit Test: entrance exam requirement message should be present in response - """ - url = reverse( - 'courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.entrance_exam.location.block_id, - 'section': self.exam_1.location.block_id, - } - ) - resp = self.client.get(url) - self.assertContains(resp, 'To access course materials, you must score') - - def test_entrance_exam_requirement_message_with_correct_percentage(self): - """ - Unit Test: entrance exam requirement message should be present in response - and percentage of required score should be rounded as expected - """ - minimum_score_pct = 29 - self.course.entrance_exam_minimum_score_pct = float(minimum_score_pct) / 100 - self.update_course(self.course, self.request.user.id) - - # answer the problem so it results in only 20% correct. - answer_entrance_exam_problem(self.course, self.request, self.problem_1, value=1, max_value=5) - - url = reverse( - 'courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.entrance_exam.location.block_id, - 'section': self.exam_1.location.block_id - } - ) - resp = self.client.get(url) - self.assertContains( - resp, - f'To access course materials, you must score {minimum_score_pct}% or higher', - ) - assert 'Your current score is 20%.' in resp.content.decode(resp.charset) - - def test_entrance_exam_requirement_message_hidden(self): - """ - Unit Test: entrance exam message should not be present outside the context of entrance exam subsection. - """ - # Login as staff to avoid redirect to entrance exam - self.client.logout() - staff_user = StaffFactory(course_key=self.course.id) - self.client.login(username=staff_user.username, password='test') - CourseEnrollment.enroll(staff_user, self.course.id) - - url = reverse( - 'courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.chapter.location.block_id, - 'section': self.chapter_subsection.location.block_id - } - ) - resp = self.client.get(url) - assert resp.status_code == 200 - self.assertNotContains(resp, 'To access course materials, you must score') - self.assertNotContains(resp, 'You have passed the entrance exam.') - - # TODO: LEARNER-71: Do we need to adjust or remove this test? - @override_waffle_flag(DISABLE_COURSE_OUTLINE_PAGE_FLAG, active=True) - def test_entrance_exam_passed_message_and_course_content(self): - """ - Unit Test: exam passing message and rest of the course section should be present - when user achieves the entrance exam milestone/pass the exam. - """ - url = reverse( - 'courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.entrance_exam.location.block_id, - 'section': self.exam_1.location.block_id - } - ) - - answer_entrance_exam_problem(self.course, self.request, self.problem_1) - answer_entrance_exam_problem(self.course, self.request, self.problem_2) - - resp = self.client.get(url) - self.assertNotContains(resp, 'To access course materials, you must score') - self.assertContains(resp, 'Your score is 100%. You have passed the entrance exam.') - self.assertContains(resp, 'Lesson 1') - def test_entrance_exam_gating(self): """ Unit Test: test_entrance_exam_gating @@ -427,71 +286,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest for toc_section in self.expected_unlocked_toc: assert toc_section in unlocked_toc - def test_courseware_page_access_without_passing_entrance_exam(self): - """ - Test courseware access page without passing entrance exam - """ - url = reverse( - 'courseware_chapter', - kwargs={'course_id': str(self.course.id), 'chapter': self.chapter.url_name} - ) - response = self.client.get(url) - expected_url = reverse('courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.entrance_exam.location.block_id, - 'section': self.exam_1.location.block_id - }) - self.assertRedirects(response, expected_url, status_code=302, target_status_code=200) - - @override_waffle_flag(DISABLE_UNIFIED_COURSE_TAB_FLAG, active=True) - def test_courseinfo_page_access_without_passing_entrance_exam(self): - """ - Test courseware access page without passing entrance exam - """ - url = reverse('info', args=[str(self.course.id)]) - response = self.client.get(url) - redirect_url = reverse('courseware', args=[str(self.course.id)]) - self.assertRedirects(response, redirect_url, status_code=302, target_status_code=302) - response = self.client.get(redirect_url) - exam_url = response.get('Location') - self.assertRedirects(response, exam_url) - - @patch('lms.djangoapps.courseware.entrance_exams.get_entrance_exam_content', Mock(return_value=None)) - def test_courseware_page_access_after_passing_entrance_exam(self): - """ - Test courseware access page after passing entrance exam - """ - self._assert_chapter_loaded(self.course, self.chapter) - - @patch('common.djangoapps.util.milestones_helpers.get_required_content', Mock(return_value=['a value'])) - def test_courseware_page_access_with_staff_user_without_passing_entrance_exam(self): - """ - Test courseware access page without passing entrance exam but with staff user - """ - self.logout() - staff_user = StaffFactory.create(course_key=self.course.id) - self.login(staff_user.email, 'test') - CourseEnrollmentFactory(user=staff_user, course_id=self.course.id) - self._assert_chapter_loaded(self.course, self.chapter) - - def test_courseware_page_access_with_staff_user_after_passing_entrance_exam(self): - """ - Test courseware access page after passing entrance exam but with staff user - """ - self.logout() - staff_user = StaffFactory.create(course_key=self.course.id) - self.login(staff_user.email, 'test') - CourseEnrollmentFactory(user=staff_user, course_id=self.course.id) - self._assert_chapter_loaded(self.course, self.chapter) - - @patch.dict("django.conf.settings.FEATURES", {'ENTRANCE_EXAMS': False}) - def test_courseware_page_access_when_entrance_exams_disabled(self): - """ - Test courseware page access when ENTRANCE_EXAMS feature is disabled - """ - self._assert_chapter_loaded(self.course, self.chapter) - def test_can_skip_entrance_exam_with_anonymous_user(self): """ Test can_skip_entrance_exam method with anonymous user @@ -540,17 +334,6 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest assert response.status_code == 200 self.assertContains(response, 'entrance_exam_passed') - def _assert_chapter_loaded(self, course, chapter): - """ - Asserts courseware chapter load successfully. - """ - url = reverse( - 'courseware_chapter', - kwargs={'course_id': str(course.id), 'chapter': chapter.url_name} - ) - response = self.client.get(url) - assert response.status_code == 200 - def _return_table_of_contents(self): """ Returns table of content for the entrance exam specific to this test diff --git a/lms/djangoapps/courseware/tests/test_field_overrides.py b/lms/djangoapps/courseware/tests/test_field_overrides.py index a58dbe5050..34efe70686 100644 --- a/lms/djangoapps/courseware/tests/test_field_overrides.py +++ b/lms/djangoapps/courseware/tests/test_field_overrides.py @@ -39,7 +39,7 @@ class TestOverrideProvider(FieldOverrideProvider): return default @classmethod - def enabled_for(cls, course): + def enabled_for(cls, course): # pylint: disable=arguments-differ return True diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py index 2e34ebed79..6c10cc38db 100644 --- a/lms/djangoapps/courseware/tests/test_masquerade.py +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -26,9 +26,10 @@ from lms.djangoapps.courseware.masquerade import ( setup_masquerade, ) -from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase, MasqueradeMixin, masquerade_as_group_member +from lms.djangoapps.courseware.tests.helpers import ( + LoginEnrollmentTestCase, MasqueradeMixin, masquerade_as_group_member, set_preview_mode, +) from lms.djangoapps.courseware.tests.test_submitting_problems import ProblemSubmissionTestMixin -from lms.djangoapps.courseware.toggles import COURSEWARE_USE_LEGACY_FRONTEND from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration from openedx.core.djangoapps.user_api.preferences.api import get_user_preference, set_user_preference @@ -42,7 +43,6 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory # li from xmodule.partitions.partitions import Group, UserPartition # lint-amnesty, pylint: disable=wrong-import-order -@override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True) class MasqueradeTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase, MasqueradeMixin): """ Base class for masquerade tests that sets up a test course and enrolls a user in the course. @@ -189,32 +189,6 @@ class MasqueradeTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase, Mas assert 200 == masquerade_as_group_member(self.test_user, self.course, partition_id, group_id) -class NormalStudentVisibilityTest(MasqueradeTestCase): - """ - Verify the course displays as expected for a "normal" student (to ensure test setup is correct). - """ - - def create_user(self): - """ - Creates a normal student user. - """ - return UserFactory() - - @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) - def test_staff_debug_not_visible(self): - """ - Tests that staff debug control is not present for a student. - """ - self.verify_staff_debug_present(False) - - @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) - def test_show_answer_not_visible(self): - """ - Tests that "Show Answer" is not visible for a student. - """ - self.verify_show_answer_present(False) - - class StaffMasqueradeTestCase(MasqueradeTestCase): """ Base class for tests of the masquerade behavior for a staff member. @@ -285,6 +259,7 @@ class TestMasqueradeOptionsNoContentGroups(StaffMasqueradeTestCase): assert is_target_available == expected +@set_preview_mode(True) class TestStaffMasqueradeAsStudent(StaffMasqueradeTestCase): """ Check for staff being able to masquerade as student. diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index b7ea203fb9..1dd954f5e0 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -15,19 +15,15 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from common.djangoapps.student.tests.factories import GlobalStaffFactory -from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase -from lms.djangoapps.courseware.toggles import COURSEWARE_USE_LEGACY_FRONTEND +from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase, set_preview_mode from openedx.features.course_experience import DISABLE_COURSE_OUTLINE_PAGE_FLAG -from common.djangoapps.student.tests.factories import UserFactory -@override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True) +@set_preview_mode(True) class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Check that navigation state is saved properly. """ - STUDENT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')] - @classmethod def setUpClass(cls): # pylint: disable=super-method-not-called @@ -71,18 +67,11 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): display_name='pdf_textbooks_tab', default_tab='progress') - cls.staff_user = GlobalStaffFactory() - cls.user = UserFactory() + cls.user = GlobalStaffFactory(password='test') def setUp(self): super().setUp() - - # Create student accounts and activate them. - for i in range(len(self.STUDENT_INFO)): - email, password = self.STUDENT_INFO[i] - username = f'u{i}' - self.create_account(username, email, password) - self.activate_user(email) + self.login(self.user.email, 'test') def assertTabActive(self, tabname, response): ''' Check if the progress tab is active in the tab set ''' @@ -106,10 +95,6 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): - Accordion enabled, or disabled - Navigation tabs enabled, disabled, or redirected ''' - email, password = self.STUDENT_INFO[0] - self.login(email, password) - self.enroll(self.course, True) - test_data = ( ('tabs', False, True), ('none', False, False), @@ -126,7 +111,7 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): assert ('course-navigation' in response.content.decode('utf-8')) == accordion self.assertTabInactive('progress', response) - self.assertTabActive('home', response) + self.assertTabActive('courseware', response) response = self.client.get(reverse('courseware_section', kwargs={ 'course_id': str(self.course.id), @@ -135,7 +120,7 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): })) self.assertTabActive('progress', response) - self.assertTabInactive('home', response) + self.assertTabInactive('courseware', response) @override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=1) def test_inactive_session_timeout(self): @@ -143,9 +128,6 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): Verify that an inactive session times out and redirects to the login page """ - email, password = self.STUDENT_INFO[0] - self.login(email, password) - # make sure we can access courseware immediately resp = self.client.get(reverse('dashboard')) assert resp.status_code == 200 @@ -163,11 +145,6 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): Verify that the first time we click on the courseware tab we are redirected to the 'Welcome' section. """ - email, password = self.STUDENT_INFO[0] - self.login(email, password) - self.enroll(self.course, True) - self.enroll(self.test_course, True) - resp = self.client.get(reverse('courseware', kwargs={'course_id': str(self.course.id)})) self.assertRedirects(resp, reverse( @@ -180,11 +157,6 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): Verify the accordion remembers we've already visited the Welcome section and redirects correspondingly. """ - email, password = self.STUDENT_INFO[0] - self.login(email, password) - self.enroll(self.course, True) - self.enroll(self.test_course, True) - section_url = reverse( 'courseware_section', kwargs={ @@ -203,11 +175,6 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Verify the accordion remembers which chapter you were last viewing. """ - email, password = self.STUDENT_INFO[0] - self.login(email, password) - self.enroll(self.course, True) - self.enroll(self.test_course, True) - # Now we directly navigate to a section in a chapter other than 'Overview'. section_url = reverse( 'courseware_section', @@ -230,11 +197,6 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): # TODO: LEARNER-71: Do we need to adjust or remove this test? @override_waffle_flag(DISABLE_COURSE_OUTLINE_PAGE_FLAG, active=True) def test_incomplete_course(self): - email = self.staff_user.email - password = "test" - self.login(email, password) - self.enroll(self.test_course, True) - test_course_id = str(self.test_course.id) url = reverse( @@ -284,11 +246,6 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): courseware pages if either the FEATURE flag is turned off or the course is not proctored enabled """ - - email, password = self.STUDENT_INFO[0] - self.login(email, password) - self.enroll(self.test_course_proctored, True) - test_course_id = str(self.test_course_proctored.id) with patch.dict(settings.FEATURES, {'ENABLE_SPECIAL_EXAMS': False}): diff --git a/lms/djangoapps/courseware/tests/test_split_module.py b/lms/djangoapps/courseware/tests/test_split_module.py index fb447dcca1..2c06f03ec4 100644 --- a/lms/djangoapps/courseware/tests/test_split_module.py +++ b/lms/djangoapps/courseware/tests/test_split_module.py @@ -5,19 +5,16 @@ Test for split test XModule from unittest.mock import MagicMock from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.partitions.partitions import Group, UserPartition from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.module_render import get_module_for_descriptor -from lms.djangoapps.courseware.toggles import COURSEWARE_USE_LEGACY_FRONTEND from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactory from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory -@override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=True) class SplitTestBase(ModuleStoreTestCase): """ Sets up a basic course and user for split test testing. @@ -118,12 +115,7 @@ class SplitTestBase(ModuleStoreTestCase): value=str(user_tag) ) - resp = self.client.get(reverse( - 'courseware_section', - kwargs={'course_id': str(self.course.id), - 'chapter': self.chapter.url_name, - 'section': self.sequential.url_name} - )) + resp = self.client.get(reverse('render_xblock', args=[str(self.sequential.location)])) unicode_content = resp.content.decode(resp.charset) # Assert we see the proper icon in the top display diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index b159c20b84..bc4ce6ffdc 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -3,6 +3,8 @@ Test cases for tabs. """ from unittest.mock import MagicMock, Mock, patch + +import ddt import pytest from crum import set_current_request from django.contrib.auth.models import AnonymousUser @@ -22,6 +24,7 @@ from lms.djangoapps.courseware.tabs import ( ) from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase from lms.djangoapps.courseware.views.views import StaticCourseTabView, get_static_tab_fragment +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider from openedx.core.djangolib.testing.utils import get_mock_request from openedx.core.lib.courses import get_course_by_id from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG @@ -35,6 +38,7 @@ from common.djangoapps.util.milestones_helpers import ( add_milestone, get_milestone_relationship_types ) +from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url from xmodule import tabs as xmodule_tabs # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.django_utils import ( # lint-amnesty, pylint: disable=wrong-import-order TEST_DATA_MIXED_MODULESTORE, @@ -72,7 +76,7 @@ class TabTestCase(SharedModuleStoreTestCase): """ Returns true if the specified tab is enabled. """ - return tab.is_enabled(course, user=user) + return tab is not None and tab.is_enabled(course, user=user) def set_up_books(self, num_books): """Initializes the textbooks in the course and adds the given number of books to each textbook""" @@ -773,6 +777,7 @@ class CourseInfoTabTestCase(TabTestCase): assert tab.type == 'course_info' +@ddt.ddt class DiscussionLinkTestCase(TabTestCase): """Test cases for discussion link tab.""" @@ -834,11 +839,19 @@ class DiscussionLinkTestCase(TabTestCase): ) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def test_tabs_with_discussion(self): + @ddt.data(Provider.OPEN_EDX, Provider.LEGACY) + def test_tabs_with_discussion(self, provider): """Test a course with a discussion tab configured""" + config = DiscussionsConfiguration.get(self.course.id) + config.provider_type = provider + config.save() + if provider == Provider.OPEN_EDX: + expected_link = get_learning_mfe_home_url(course_key=self.course.id, url_fragment="discussion") + else: + expected_link = "default_discussion_link" self.check_discussion( tab_list=self.tabs_with_discussion, - expected_discussion_link="default_discussion_link", + expected_discussion_link=expected_link, expected_can_display_value=True, ) @@ -852,11 +865,19 @@ class DiscussionLinkTestCase(TabTestCase): ) @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def test_tabs_enrolled_or_staff(self): + @ddt.data(Provider.OPEN_EDX, Provider.LEGACY) + def test_tabs_enrolled_or_staff(self, provider): + config = DiscussionsConfiguration.get(self.course.id) + config.provider_type = provider + config.save() for is_enrolled, is_staff in [(True, False), (False, True)]: + if provider == Provider.OPEN_EDX: + expected_link = get_learning_mfe_home_url(course_key=self.course.id, url_fragment="discussion") + else: + expected_link = "default_discussion_link" self.check_discussion( tab_list=self.tabs_with_discussion, - expected_discussion_link="default_discussion_link", + expected_discussion_link=expected_link, expected_can_display_value=True, is_enrolled=is_enrolled, is_staff=is_staff diff --git a/lms/djangoapps/courseware/tests/test_user_state_client.py b/lms/djangoapps/courseware/tests/test_user_state_client.py index 84cb7e8616..89071d2c7f 100644 --- a/lms/djangoapps/courseware/tests/test_user_state_client.py +++ b/lms/djangoapps/courseware/tests/test_user_state_client.py @@ -27,7 +27,7 @@ class TestDjangoUserStateClient(UserStateClientTestBase, ModuleStoreTestCase): def _user(self, user_idx): # lint-amnesty, pylint: disable=arguments-differ return self.users[user_idx].username - def _block_type(self, block): + def _block_type(self, block): # pylint: disable=arguments-differ # We only record block state history in DjangoUserStateClient # when the block type is 'problem' return 'problem' diff --git a/lms/djangoapps/courseware/tests/test_utils.py b/lms/djangoapps/courseware/tests/test_utils.py new file mode 100644 index 0000000000..81841f99ad --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_utils.py @@ -0,0 +1,160 @@ +""" +Unit test for various Utility functions +""" +import json +from unittest.mock import patch + +import ddt +from django.test import TestCase +from edx_rest_api_client.client import OAuthAPIClient +from oauth2_provider.models import Application +from requests.models import Response +from rest_framework import status + +from common.djangoapps.student.tests.factories import GlobalStaffFactory, UserFactory +from lms.djangoapps.courseware.constants import UNEXPECTED_ERROR_IS_ELIGIBLE +from lms.djangoapps.courseware.tests.factories import FinancialAssistanceConfigurationFactory +from lms.djangoapps.courseware.utils import ( + create_financial_assistance_application, + get_financial_assistance_application_status, + is_eligible_for_financial_aid +) + + +@ddt.ddt +class TestFinancialAssistanceViews(TestCase): + """ + Tests new financial assistance views that communicate with edx-financial-assistance backend. + """ + + def setUp(self) -> None: + super().setUp() + self.test_course_id = 'course-v1:edX+Test+1' + self.user = UserFactory() + self.global_staff = GlobalStaffFactory.create() + _ = FinancialAssistanceConfigurationFactory( + api_base_url='http://financial.assistance.test:1234', + service_username=self.global_staff.username, + fa_backend_enabled_courses_percentage=100, + enabled=True + ) + _ = Application.objects.create( + name='Test Application', + user=self.global_staff, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + ) + + def _mock_response(self, status_code, content=None): + """ + Generates a python core response which is used as a default response in edx-rest-api-client. + """ + mock_response = Response() + mock_response.status_code = status_code + mock_response._content = json.dumps(content).encode('utf-8') # pylint: disable=protected-access + return mock_response + + @ddt.data( + {'is_eligible': True, 'reason': None}, + {'is_eligible': False, 'reason': 'This course is not eligible for financial aid'} + ) + def test_is_eligible_for_financial_aid(self, response_data): + """ + Tests the functionality of is_eligible_for_financial_aid which calls edx-financial-assistance backend + to return eligibility status for financial assistance for a given course. + """ + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response(status.HTTP_200_OK, response_data) + is_eligible, reason = is_eligible_for_financial_aid(self.test_course_id) + assert is_eligible is response_data.get('is_eligible') + assert reason == response_data.get('reason') + + def test_is_eligible_for_financial_aid_invalid_course_id(self): + """ + Tests the functionality of is_eligible_for_financial_aid for an invalid course id. + """ + error_message = f"Invalid course id {self.test_course_id} provided" + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response( + status.HTTP_400_BAD_REQUEST, {"message": error_message} + ) + is_eligible, reason = is_eligible_for_financial_aid(self.test_course_id) + assert is_eligible is False + assert reason == error_message + + def test_is_eligible_for_financial_aid_invalid_unexpected_error(self): + """ + Tests the functionality of is_eligible_for_financial_aid for an unexpected error + """ + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response( + status.HTTP_500_INTERNAL_SERVER_ERROR, {'error': 'unexpected error occurred'} + ) + is_eligible, reason = is_eligible_for_financial_aid(self.test_course_id) + assert is_eligible is False + assert reason == UNEXPECTED_ERROR_IS_ELIGIBLE + + def test_get_financial_assistance_application_status(self): + """ + Tests the functionality of get_financial_assistance_application_status against a user id and a course id + edx-financial-assistance backend to return status of a financial assistance application. + """ + test_response = {'id': 123, 'status': 'ACCEPTED', 'coupon_code': 'ABCD..'} + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response(status.HTTP_200_OK, test_response) + has_application, reason = get_financial_assistance_application_status(self.user.id, self.test_course_id) + assert has_application is True + assert reason == test_response + + @ddt.data( + { + 'status': status.HTTP_400_BAD_REQUEST, + 'content': {'message': 'Invalid course id provided'} + }, + { + 'status': status.HTTP_404_NOT_FOUND, + 'content': {'message': 'Application details not found'} + } + ) + def test_get_financial_assistance_application_status_unsuccessful(self, response_data): + """ + Tests unsuccessful scenarios of get_financial_assistance_application_status + against a user id and a course id edx-financial-assistance backend. + """ + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response(response_data.get('status'), response_data.get('content')) + has_application, reason = get_financial_assistance_application_status(self.user.id, self.test_course_id) + assert has_application is False + assert reason == response_data.get('content').get('message') + + def test_create_financial_assistance_application(self): + """ + Tests the functionality of create_financial_assistance_application which calls edx-financial-assistance backend + to create a new financial assistance application given a form data. + """ + test_form_data = { + 'lms_user_id': self.user.id, + 'course_id': self.test_course_id, + 'income': '$85,000 - $100,000' + } + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response(status.HTTP_200_OK, {'success': True}) + response = create_financial_assistance_application(form_data=test_form_data) + assert response.status_code == status.HTTP_204_NO_CONTENT + + def test_create_financial_assistance_application_bad_request(self): + """ + Tests the functionality of create_financial_assistance_application which calls edx-financial-assistance backend + to create a new financial assistance application given a form data. + """ + test_form_data = { + 'lms_user_id': self.user.id, + 'course_id': 'invalid_course_id', + 'income': '$85,000 - $100,000' + } + error_response = {'message': 'Invalid course id provided'} + with patch.object(OAuthAPIClient, 'request') as oauth_mock: + oauth_mock.return_value = self._mock_response(status.HTTP_400_BAD_REQUEST, error_response) + response = create_financial_assistance_application(form_data=test_form_data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert json.loads(response.content.decode('utf-8')) == error_response diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 1265ed19f1..36b8bca2f9 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -2,24 +2,22 @@ Tests courseware views.py """ - import html import itertools import json import re from datetime import datetime, timedelta -from unittest.mock import MagicMock, PropertyMock, call, create_autospec, patch -from urllib.parse import urlencode +from unittest.mock import MagicMock, PropertyMock, create_autospec, patch +from urllib.parse import quote, urlencode from uuid import uuid4 import ddt -from capa.tests.response_xml_factory import \ - MultipleChoiceResponseXMLFactory +from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory from completion.test_utils import CompletionWaffleTestMixin from crum import set_current_request from django.conf import settings from django.contrib.auth.models import AnonymousUser -from django.http import Http404, HttpResponseBadRequest +from django.http import Http404, HttpResponse, HttpResponseBadRequest from django.http.request import QueryDict from django.test import RequestFactory, TestCase from django.test.client import Client @@ -27,28 +25,18 @@ from django.test.utils import override_settings from django.urls import reverse, reverse_lazy from edx_toggles.toggles.testutils import override_waffle_flag, override_waffle_switch from freezegun import freeze_time -from markupsafe import escape -from milestones.tests.utils import MilestonesTestCaseMixin from opaque_keys.edx.keys import CourseKey, UsageKey -from pytz import UTC, utc +from pytz import UTC +from rest_framework import status from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Scope, String -from xmodule.course_module import ( - COURSE_VISIBILITY_PRIVATE, - COURSE_VISIBILITY_PUBLIC, - COURSE_VISIBILITY_PUBLIC_OUTLINE -) from xmodule.data import CertificatesDisplayBehaviors from xmodule.graders import ShowCorrectness from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import ( - CourseFactory, - ItemFactory, - check_mongo_calls -) +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls import lms.djangoapps.courseware.views.views as views from common.djangoapps.course_modes.models import CourseMode @@ -79,25 +67,17 @@ from lms.djangoapps.certificates.tests.factories import ( ) from lms.djangoapps.commerce.models import CommerceConfiguration from lms.djangoapps.commerce.utils import EcommerceService -from lms.djangoapps.course_home_api.toggles import COURSE_HOME_USE_LEGACY_FRONTEND -from lms.djangoapps.courseware import access_utils from lms.djangoapps.courseware.access_utils import check_course_open_for_learner from lms.djangoapps.courseware.model_data import FieldDataCache, set_score from lms.djangoapps.courseware.module_render import get_module, handle_xblock_callback from lms.djangoapps.courseware.tests.factories import StudentModuleFactory -from lms.djangoapps.courseware.tests.helpers import get_expiration_banner_text +from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin, get_expiration_banner_text, set_preview_mode from lms.djangoapps.courseware.testutils import RenderXBlockTestMixin -from lms.djangoapps.courseware.toggles import ( - COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW, - COURSEWARE_OPTIMIZED_RENDER_XBLOCK, - COURSEWARE_USE_LEGACY_FRONTEND, - courseware_mfe_is_advertised -) +from lms.djangoapps.courseware.toggles import COURSEWARE_OPTIMIZED_RENDER_XBLOCK from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient from lms.djangoapps.grades.config.waffle import ASSUME_ZERO_GRADE_IF_ABSENT from lms.djangoapps.grades.config.waffle import waffle_switch as grades_waffle_switch from lms.djangoapps.instructor.access import allow_access -from lms.djangoapps.verify_student.models import VerificationDeadline from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory, ProgramFactory @@ -107,19 +87,15 @@ from openedx.core.djangoapps.credit.api import set_credit_requirements from openedx.core.djangoapps.credit.models import CreditCourse, CreditProvider from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from openedx.core.djangolib.testing.utils import get_mock_request -from openedx.core.lib.gating import api as gating_api from openedx.core.lib.url_utils import quote_slashes from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.course_duration_limits.models import CourseDurationLimitConfig from openedx.features.course_experience import ( - COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, DISABLE_COURSE_OUTLINE_PAGE_FLAG, DISABLE_UNIFIED_COURSE_TAB_FLAG, - RELATIVE_DATES_FLAG ) from openedx.features.course_experience.tests.views.helpers import add_course_mode from openedx.features.course_experience.url_helpers import ( - ExperienceOption, get_courseware_url, get_learning_mfe_home_url, make_learning_mfe_courseware_url @@ -132,68 +108,38 @@ FEATURES_WITH_DISABLE_HONOR_CERTIFICATE = settings.FEATURES.copy() FEATURES_WITH_DISABLE_HONOR_CERTIFICATE['DISABLE_HONOR_CERTIFICATES'] = True -def _set_mfe_flag(activate_mfe: bool): - """ - A decorator/contextmanager to force the base courseware MFE flag on or off. - """ - return override_waffle_flag(COURSEWARE_USE_LEGACY_FRONTEND, active=(not activate_mfe)) - - -def _set_preview_mfe_flag(active: bool): - """ - A decorator/contextmanager to force the courseware MFE educator preview flag on or off. - """ - return override_waffle_flag(COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW, active=active) - - -def _set_course_home_mfe_flag(activate_mfe: bool): - """ - A decorator/contextmanager to force the courseware home MFE flag on or off. - """ - return override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=(not activate_mfe)) - - @ddt.ddt class TestJumpTo(ModuleStoreTestCase): """ Check the jumpto link for a course. """ @ddt.data( - (False, None, False), # not provided -> Active experience - (False, "blarfingar", False), # nonsense -> Active experience - (False, "legacy", False), # "legacy" -> Legacy experience - (False, "new", True), # "new" -> MFE experience - (True, None, True), # not provided -> Active experience - (True, "blarfingar", True), # nonsense -> Active experience - (True, "legacy", False), # "legacy" -> Legacy experience - (True, "new", True), # "new" -> MFE experience + (True, False), # preview -> Legacy experience + (False, True), # no preview -> MFE experience ) @ddt.unpack - def test_jump_to_legacy_vs_mfe(self, activate_mfe, experience_param, expect_mfe): + def test_jump_to_legacy_vs_mfe(self, preview_mode, expect_mfe): """ - Test that jump_to and jump_to_id correctly choose which courseware - frontend to redirect to, taking into account the '?experience=' query - param. + Test that jump_to and jump_to_id correctly choose which courseware frontend to redirect to. - Will be removed along with DEPR-109. + Can be removed when the MFE supports a preview mode. """ course = CourseFactory.create() chapter = ItemFactory.create(category='chapter', parent_location=course.location) - querystring = f"experience={experience_param}" if experience_param else "" if expect_mfe: expected_url = f'http://learning-mfe/course/{course.id}/{chapter.location}' else: expected_url = f'/courses/{course.id}/courseware/{chapter.url_name}/' - jumpto_url = f'/courses/{course.id}/jump_to/{chapter.location}?{querystring}' - with _set_mfe_flag(activate_mfe): + jumpto_url = f'/courses/{course.id}/jump_to/{chapter.location}' + with set_preview_mode(preview_mode): response = self.client.get(jumpto_url) assert response.status_code == 302 # Check the response URL, but chop off the querystring; we don't care here. assert response.url.split('?')[0] == expected_url - jumpto_id_url = f'/courses/{course.id}/jump_to_id/{chapter.url_name}?{querystring}' - with _set_mfe_flag(activate_mfe): + jumpto_id_url = f'/courses/{course.id}/jump_to_id/{chapter.url_name}' + with set_preview_mode(preview_mode): response = self.client.get(jumpto_id_url) assert response.status_code == 302 # Check the response URL, but chop off the querystring; we don't care here. @@ -205,25 +151,25 @@ class TestJumpTo(ModuleStoreTestCase): (True, ModuleStoreEnum.Type.split), ) @ddt.unpack - def test_jump_to_invalid_location(self, activate_mfe, store_type): + def test_jump_to_invalid_location(self, preview_mode, store_type): """Confirm that invalid locations redirect back to a general course URL""" with self.store.default_store(store_type): course = CourseFactory.create() location = course.id.make_usage_key(None, 'NoSuchPlace') expected_redirect_url = ( - f'http://learning-mfe/course/{course.id}' - ) if activate_mfe else ( f'/courses/{course.id}/courseware?' + urlencode({'activate_block_id': str(course.location)}) + ) if preview_mode else ( + f'http://learning-mfe/course/{course.id}' ) # This is fragile, but unfortunately the problem is that within the LMS we # can't use the reverse calls from the CMS jumpto_url = f'/courses/{course.id}/jump_to/{location}' - with _set_mfe_flag(activate_mfe): + with set_preview_mode(preview_mode): response = self.client.get(jumpto_url) assert response.status_code == 302 assert response.url == expected_redirect_url - @_set_mfe_flag(activate_mfe=False) + @set_preview_mode(True) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_jump_to_legacy_from_sequence(self, store_type): with self.store.default_store(store_type): @@ -238,7 +184,7 @@ class TestJumpTo(ModuleStoreTestCase): response = self.client.get(jumpto_url) self.assertRedirects(response, expected_redirect_url, status_code=302, target_status_code=302) - @_set_mfe_flag(activate_mfe=True) + @set_preview_mode(False) def test_jump_to_mfe_from_sequence(self): course = CourseFactory.create() chapter = ItemFactory.create(category='chapter', parent_location=course.location) @@ -251,7 +197,7 @@ class TestJumpTo(ModuleStoreTestCase): assert response.status_code == 302 assert response.url == expected_redirect_url - @_set_mfe_flag(activate_mfe=False) + @set_preview_mode(True) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_jump_to_legacy_from_module(self, store_type): with self.store.default_store(store_type): @@ -279,7 +225,7 @@ class TestJumpTo(ModuleStoreTestCase): response = self.client.get(jumpto_url) self.assertRedirects(response, expected_redirect_url, status_code=302, target_status_code=302) - @_set_mfe_flag(activate_mfe=True) + @set_preview_mode(False) def test_jump_to_mfe_from_module(self): course = CourseFactory.create() chapter = ItemFactory.create(category='chapter', parent_location=course.location) @@ -307,7 +253,7 @@ class TestJumpTo(ModuleStoreTestCase): # The new courseware experience does not support this sort of course structure; # it assumes a simple course->chapter->sequence->unit->component tree. - @_set_mfe_flag(activate_mfe=False) + @set_preview_mode(True) @ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split) def test_jump_to_legacy_from_nested_module(self, store_type): with self.store.default_store(store_type): @@ -337,15 +283,15 @@ class TestJumpTo(ModuleStoreTestCase): (True, ModuleStoreEnum.Type.split), ) @ddt.unpack - def test_jump_to_id_invalid_location(self, activate_mfe, store_type): + def test_jump_to_id_invalid_location(self, preview_mode, store_type): with self.store.default_store(store_type): course = CourseFactory.create() jumpto_url = f'/courses/{course.id}/jump_to/NoSuchPlace' - with _set_mfe_flag(activate_mfe): + with set_preview_mode(preview_mode): response = self.client.get(jumpto_url) assert response.status_code == 404 - @_set_mfe_flag(activate_mfe=False) + @set_preview_mode(True) @ddt.data( (ModuleStoreEnum.Type.mongo, False, '1'), (ModuleStoreEnum.Type.mongo, True, '2'), @@ -384,16 +330,14 @@ class TestJumpTo(ModuleStoreTestCase): } ) expected_url += "?{}".format(urlencode({'activate_block_id': str(staff_only_vertical.location)})) - assert expected_url == get_courseware_url(usage_key, request, ExperienceOption.LEGACY) + assert expected_url == get_courseware_url(usage_key, request) -@ddt.ddt -@_set_mfe_flag(activate_mfe=False) +@set_preview_mode(True) class IndexQueryTestCase(ModuleStoreTestCase): """ Tests for query count. """ - CREATE_USER = False NUM_PROBLEMS = 20 def test_index_query_counts(self): @@ -408,11 +352,10 @@ class IndexQueryTestCase(ModuleStoreTestCase): for _ in range(self.NUM_PROBLEMS): ItemFactory.create(category='problem', parent_location=vertical.location) - self.user = UserFactory() - self.client.login(username=self.user.username, password=TEST_PASSWORD) + self.client.login(username=self.user.username, password=self.user_password) CourseEnrollment.enroll(self.user, course.id) - with self.assertNumQueries(206, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): + with self.assertNumQueries(203, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): with check_mongo_calls(3): url = reverse( 'courseware_section', @@ -426,7 +369,10 @@ class IndexQueryTestCase(ModuleStoreTestCase): assert response.status_code == 200 -class BaseViewsTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring +class BaseViewsTestCase(ModuleStoreTestCase, MasqueradeMixin): + """Base class for courseware tests""" + CREATE_USER = False + def setUp(self): super().setUp() self.course = CourseFactory.create(display_name='teꜱᴛ course', run="Testing_course") @@ -515,16 +461,14 @@ class BaseViewsTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=m @ddt.ddt -@_set_mfe_flag(activate_mfe=False) -class ViewsTestCase(BaseViewsTestCase): +@set_preview_mode(True) +class CoursewareIndexTestCase(BaseViewsTestCase): """ - Tests for views.py methods. + Tests for the courseware index view, used for instructor previews. """ - YESTERDAY = 'yesterday' - DATES = { - YESTERDAY: datetime.now(UTC) - timedelta(days=1), - None: None, - } + def setUp(self): + super().setUp() + self._create_global_staff_user() # this view needs staff permission def test_index_success(self): response = self._verify_index_response() @@ -538,23 +482,20 @@ class ViewsTestCase(BaseViewsTestCase): self.assertNotContains(response, self.problem.location.replace(branch=None, version_guid=None)) self.assertContains(response, self.problem2.location.replace(branch=None, version_guid=None)) + @set_preview_mode(True) def test_index_nonexistent_chapter(self): self._verify_index_response(expected_response_code=404, chapter_name='non-existent') def test_index_nonexistent_chapter_masquerade(self): - with patch('lms.djangoapps.courseware.views.index.setup_masquerade') as patch_masquerade: - masquerade = MagicMock(role='student') - patch_masquerade.return_value = (masquerade, self.user) - self._verify_index_response(expected_response_code=302, chapter_name='non-existent') + self.update_masquerade(username=self.user.username) + self._verify_index_response(expected_response_code=302, chapter_name='non-existent') def test_index_nonexistent_section(self): self._verify_index_response(expected_response_code=404, section_name='non-existent') def test_index_nonexistent_section_masquerade(self): - with patch('lms.djangoapps.courseware.views.index.setup_masquerade') as patch_masquerade: - masquerade = MagicMock(role='student') - patch_masquerade.return_value = (masquerade, self.user) - self._verify_index_response(expected_response_code=302, section_name='non-existent') + self.update_masquerade(username=self.user.username) + self._verify_index_response(expected_response_code=302, section_name='non-existent') def _verify_index_response(self, expected_response_code=200, chapter_name=None, section_name=None): """ @@ -573,66 +514,95 @@ class ViewsTestCase(BaseViewsTestCase): assert response.status_code == expected_response_code return response - def test_index_no_visible_section_in_chapter(self): + def test_get_redirect_url(self): + # test the course location + assert '/courses/{course_key}/courseware?{activate_block_id}'.format( + course_key=str(self.course_key), + activate_block_id=urlencode({'activate_block_id': str(self.course.location)}) + ) == get_courseware_url(self.course.location) + # test a section location + assert '/courses/{course_key}/courseware/Chapter_1/Sequential_1/?{activate_block_id}'.format( + course_key=str(self.course_key), + activate_block_id=urlencode({'activate_block_id': str(self.section.location)}) + ) == get_courseware_url(self.section.location) - # reload the chapter from the store so its children information is updated - self.chapter = self.store.get_item(self.chapter.location) + def test_index_invalid_position(self): + request_url = '/'.join([ + '/courses', + str(self.course.id), + 'courseware', + self.chapter.location.block_id, + self.section.location.block_id, + 'f' + ]) + response = self.client.get(request_url) + assert response.status_code == 404 - # disable the visibility of the sections in the chapter - for section in self.chapter.get_children(): - section.visible_to_staff_only = True - self.store.update_item(section, ModuleStoreEnum.UserID.test) + def test_unicode_handling_in_url(self): + url_parts = [ + '/courses', + str(self.course.id), + 'courseware', + self.chapter.location.block_id, + self.section.location.block_id, + '1' + ] + for idx, val in enumerate(url_parts): + url_parts_copy = url_parts[:] + url_parts_copy[idx] = val + 'χ' + request_url = '/'.join(url_parts_copy) + response = self.client.get(request_url) + assert response.status_code == 404 - url = reverse( - 'courseware_chapter', - kwargs={'course_id': str(self.course.id), - 'chapter': str(self.chapter.location.block_id)}, - ) - response = self.client.get(url) - assert response.status_code == 200 - self.assertNotContains(response, 'Problem 1') - self.assertNotContains(response, 'Problem 2') - - @ddt.data(False, True) - def test_mfe_link_from_about_page(self, activate_mfe): + # TODO: TNL-6387: Remove test + @override_waffle_flag(DISABLE_COURSE_OUTLINE_PAGE_FLAG, active=True) + def test_accordion(self): """ - Verify course about page links to the MFE when enabled. + This needs a response_context, which is not included in the render_accordion's main method + returning a render_to_string, so we will render via the courseware URL in order to include + the needed context + """ + response = self.client.get( + reverse('courseware', args=[str(self.course.id)]), + follow=True + ) + test_responses = [ + '

Sequential 1 current section

', + '

Sequential 2

' + ] + for test in test_responses: + self.assertContains(response, test) + + +@ddt.ddt +class ViewsTestCase(BaseViewsTestCase): + """ + Tests for views.py methods. + """ + YESTERDAY = 'yesterday' + DATES = { + YESTERDAY: datetime.now(UTC) - timedelta(days=1), + None: None, + } + + def test_mfe_link_from_about_page(self): + """ + Verify course about page links to the MFE. """ with self.store.default_store(ModuleStoreEnum.Type.split): course = CourseFactory.create() CourseEnrollment.enroll(self.user, course.id) - assert self.client.login(username=self.user.username, password=TEST_PASSWORD) - legacy_url = reverse( - 'openedx.course_experience.course_home', - kwargs={ - 'course_id': str(course.id), - } - ) - mfe_url = get_learning_mfe_home_url(course_key=course.id, url_fragment='home') - - with _set_course_home_mfe_flag(activate_mfe): - response = self.client.get(reverse('about_course', args=[str(course.id)])) - if activate_mfe: - self.assertContains(response, mfe_url) - self.assertNotContains(response, legacy_url) - else: - self.assertNotContains(response, mfe_url) - self.assertContains(response, legacy_url) + response = self.client.get(reverse('about_course', args=[str(course.id)])) + self.assertContains(response, get_learning_mfe_home_url(course_key=course.id, url_fragment='home')) def _create_url_for_enroll_staff(self): """ creates the courseware url and enroll staff url """ # create the _next parameter - courseware_url = reverse( - 'courseware_section', - kwargs={ - 'course_id': str(self.course_key), - 'chapter': str(self.chapter.location.block_id), - 'section': str(self.section.location.block_id), - } - ) + courseware_url = make_learning_mfe_courseware_url(self.course.id, self.chapter.location, self.section.location) + courseware_url = quote(courseware_url, safe=':/') # create the url for enroll_staff view enroll_url = "{enroll_url}?next={courseware_url}".format( enroll_url=reverse('enroll_staff', kwargs={'course_id': str(self.course.id)}), @@ -650,14 +620,11 @@ class ViewsTestCase(BaseViewsTestCase): """ self._create_global_staff_user() courseware_url, enroll_url = self._create_url_for_enroll_staff() - response = self.client.post(enroll_url, data=data, follow=True) - assert response.status_code == 200 + response = self.client.post(enroll_url, data=data) # we were redirected to our current location - assert 302 in response.redirect_chain[0] - assert len(response.redirect_chain) == 1 if enrollment: - self.assertRedirects(response, courseware_url) + self.assertRedirects(response, courseware_url, fetch_redirect_response=False) else: self.assertRedirects(response, f'/courses/{str(self.course_key)}/about') @@ -714,22 +681,6 @@ class ViewsTestCase(BaseViewsTestCase): type(mock_user).is_authenticated = PropertyMock(return_value=False) assert views.user_groups(mock_user) == [] - def test_get_redirect_url(self): - # test the course location - assert '/courses/{course_key}/courseware?{activate_block_id}'.format( - course_key=str(self.course_key), - activate_block_id=urlencode({'activate_block_id': str(self.course.location)}) - ) == get_courseware_url( - self.course.location, experience=ExperienceOption.LEGACY - ) - # test a section location - assert '/courses/{course_key}/courseware/Chapter_1/Sequential_1/?{activate_block_id}'.format( - course_key=str(self.course_key), - activate_block_id=urlencode({'activate_block_id': str(self.section.location)}) - ) == get_courseware_url( - self.section.location, experience=ExperienceOption.LEGACY - ) - def test_invalid_course_id(self): response = self.client.get('/courses/MITx/3.091X/') assert response.status_code == 404 @@ -738,36 +689,6 @@ class ViewsTestCase(BaseViewsTestCase): response = self.client.get('/courses/MITx/') assert response.status_code == 404 - def test_index_invalid_position(self): - request_url = '/'.join([ - '/courses', - str(self.course.id), - 'courseware', - self.chapter.location.block_id, - self.section.location.block_id, - 'f' - ]) - assert self.client.login(username=self.user.username, password=TEST_PASSWORD) - response = self.client.get(request_url) - assert response.status_code == 404 - - def test_unicode_handling_in_url(self): - url_parts = [ - '/courses', - str(self.course.id), - 'courseware', - self.chapter.location.block_id, - self.section.location.block_id, - '1' - ] - assert self.client.login(username=self.user.username, password=TEST_PASSWORD) - for idx, val in enumerate(url_parts): - url_parts_copy = url_parts[:] - url_parts_copy[idx] = val + 'χ' - request_url = '/'.join(url_parts_copy) - response = self.client.get(request_url) - assert response.status_code == 404 - def test_jump_to_invalid(self): # TODO add a test for invalid location # TODO add a test for no data * @@ -982,9 +903,9 @@ class ViewsTestCase(BaseViewsTestCase): self.assertContains(response, str(course)) - def _submit_financial_assistance_form(self, data): + def _submit_financial_assistance_form(self, data, submit_url='submit_financial_assistance_request'): """Submit a financial assistance request.""" - url = reverse('submit_financial_assistance_request') + url = reverse(submit_url) return self.client.post(url, json.dumps(data), content_type='application/json') @patch.object(views, 'create_zendesk_ticket', return_value=200) @@ -1045,15 +966,33 @@ class ViewsTestCase(BaseViewsTestCase): }) assert response.status_code == 500 + @patch.object( + views, 'create_financial_assistance_application', return_value=HttpResponse(status=status.HTTP_204_NO_CONTENT) + ) + def test_submit_financial_assistance_request_v2(self, create_application_mock): + form_data = { + 'username': self.user.username, + 'course': 'course-v1:test+TestX+Test_Course', + 'income': '$25,000 - $40,000', + 'reason_for_applying': "It's just basic chemistry, yo.", + 'goals': "I don't know if it even matters, but... work with my hands, I guess.", + 'effort': "I'm done, okay? You just give me my money, and you and I, we're done.", + 'mktg-permission': False + } + response = self._submit_financial_assistance_form( + form_data, submit_url='submit_financial_assistance_request_v2' + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + @ddt.data( ({}, 400), ({'username': 'wwhite'}, 403), ({'username': 'dummy', 'course': 'bad course ID'}, 400) ) @ddt.unpack - def test_submit_financial_assistance_errors(self, data, status): + def test_submit_financial_assistance_errors(self, data, response_status): response = self._submit_financial_assistance_form(data) - assert response.status_code == status + assert response.status_code == response_status def test_financial_assistance_login_required(self): for url in ( @@ -1078,25 +1017,6 @@ class ViewsTestCase(BaseViewsTestCase): response = self.client.get(reverse('info', args=[course_id]), HTTP_REFERER='foo') assert response.status_code == 200 - # TODO: TNL-6387: Remove test - @override_waffle_flag(DISABLE_COURSE_OUTLINE_PAGE_FLAG, active=True) - def test_accordion(self): - """ - This needs a response_context, which is not included in the render_accordion's main method - returning a render_to_string, so we will render via the courseware URL in order to include - the needed context - """ - response = self.client.get( - reverse('courseware', args=[str(self.course.id)]), - follow=True - ) - test_responses = [ - '

Sequential 1 current section

', - '

Sequential 2

' - ] - for test in test_responses: - self.assertContains(response, test) - # Patching 'lms.djangoapps.courseware.views.views.get_programs' would be ideal, # but for some unknown reason that patch doesn't seem to be applied. @@ -1141,7 +1061,6 @@ class TestProgramMarketingView(SharedModuleStoreTestCase): # setting TIME_ZONE_DISPLAYED_FOR_DEADLINES explicitly @override_settings(TIME_ZONE_DISPLAYED_FOR_DEADLINES="UTC") -@_set_mfe_flag(activate_mfe=False) class BaseDueDateTests(ModuleStoreTestCase): """ Base class that verifies that due dates are rendered correctly on a page @@ -1177,8 +1096,7 @@ class BaseDueDateTests(ModuleStoreTestCase): def setUp(self): super().setUp() - self.user = UserFactory.create() - assert self.client.login(username=self.user.username, password='test') + assert self.client.login(username=self.user.username, password=self.user_password) self.time_with_tz = "2013-09-18 11:30:00+00:00" @@ -1231,6 +1149,7 @@ class TestProgressDueDate(BaseDueDateTests): # TODO: LEARNER-71: Delete entire TestAccordionDueDate class +@set_preview_mode(True) class TestAccordionDueDate(BaseDueDateTests): """ Test that the accordion page displays due dates correctly @@ -2519,7 +2438,7 @@ class ViewCheckerBlock(XBlock): @ddt.ddt -@_set_mfe_flag(activate_mfe=False) +@set_preview_mode(True) class TestIndexView(ModuleStoreTestCase): """ Tests of the courseware.views.index view. @@ -2529,8 +2448,6 @@ class TestIndexView(ModuleStoreTestCase): """ Verify that saved student state is loaded for xblocks rendered in the index view. """ - user = UserFactory() - with modulestore().default_store(ModuleStoreEnum.Type.split): course = CourseFactory.create() chapter = ItemFactory.create(parent_location=course.location, category='chapter') @@ -2543,16 +2460,16 @@ class TestIndexView(ModuleStoreTestCase): for item in (section, vertical, block): StudentModuleFactory.create( - student=user, + student=self.user, course_id=course.id, module_state_key=item.scope_ids.usage_id, state=json.dumps({'state': str(item.scope_ids.usage_id)}) ) CourseOverview.load_from_module_store(course.id) - CourseEnrollmentFactory(user=user, course_id=course.id) + CourseEnrollmentFactory(user=self.user, course_id=course.id) - assert self.client.login(username=user.username, password='test') + assert self.client.login(username=self.user.username, password=self.user_password) response = self.client.get( reverse( 'courseware_section', @@ -2568,8 +2485,6 @@ class TestIndexView(ModuleStoreTestCase): @XBlock.register_temp_plugin(ActivateIDCheckerBlock, 'id_checker') def test_activate_block_id(self): - user = UserFactory() - course = CourseFactory.create() with self.store.bulk_operations(course.id): chapter = ItemFactory.create(parent=course, category='chapter') @@ -2578,9 +2493,9 @@ class TestIndexView(ModuleStoreTestCase): ItemFactory.create(parent=vertical, category='id_checker', display_name="ID Checker") CourseOverview.load_from_module_store(course.id) - CourseEnrollmentFactory(user=user, course_id=course.id) + CourseEnrollmentFactory(user=self.user, course_id=course.id) - assert self.client.login(username=user.username, password='test') + assert self.client.login(username=self.user.username, password=self.user_password) response = self.client.get( reverse( 'courseware_section', @@ -2593,83 +2508,6 @@ class TestIndexView(ModuleStoreTestCase): ) self.assertContains(response, "Activate Block ID: test_block_id") - @ddt.data( - [False, COURSE_VISIBILITY_PRIVATE, CourseUserType.ANONYMOUS, False], - [False, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ANONYMOUS, False], - [False, COURSE_VISIBILITY_PUBLIC, CourseUserType.ANONYMOUS, False], - [True, COURSE_VISIBILITY_PRIVATE, CourseUserType.ANONYMOUS, False], - [True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ANONYMOUS, False], - [True, COURSE_VISIBILITY_PUBLIC, CourseUserType.ANONYMOUS, True], - - [False, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED, False], - [False, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED, False], - [False, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED, False], - [True, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED, False], - [True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED, False], - [True, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED, True], - - [False, COURSE_VISIBILITY_PRIVATE, CourseUserType.ENROLLED, True], - [True, COURSE_VISIBILITY_PRIVATE, CourseUserType.ENROLLED, True], - [True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.ENROLLED, True], - [True, COURSE_VISIBILITY_PUBLIC, CourseUserType.ENROLLED, True], - - [False, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED_STAFF, True], - [True, COURSE_VISIBILITY_PRIVATE, CourseUserType.UNENROLLED_STAFF, True], - [True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.UNENROLLED_STAFF, True], - [True, COURSE_VISIBILITY_PUBLIC, CourseUserType.UNENROLLED_STAFF, True], - - [False, COURSE_VISIBILITY_PRIVATE, CourseUserType.GLOBAL_STAFF, True], - [True, COURSE_VISIBILITY_PRIVATE, CourseUserType.GLOBAL_STAFF, True], - [True, COURSE_VISIBILITY_PUBLIC_OUTLINE, CourseUserType.GLOBAL_STAFF, True], - [True, COURSE_VISIBILITY_PUBLIC, CourseUserType.GLOBAL_STAFF, True], - ) - @ddt.unpack - def test_courseware_access(self, waffle_override, course_visibility, user_type, expected_course_content): - - course = CourseFactory(course_visibility=course_visibility) - with self.store.bulk_operations(course.id): - chapter = ItemFactory(parent=course, category='chapter') - section = ItemFactory(parent=chapter, category='sequential') - vertical = ItemFactory.create(parent=section, category='vertical', display_name="Vertical") - ItemFactory.create(parent=vertical, category='html', display_name='HTML block') - ItemFactory.create(parent=vertical, category='video', display_name='Video') - - self.create_user_for_course(course, user_type) - - url = reverse( - 'courseware_section', - kwargs={ - 'course_id': str(course.id), - 'chapter': chapter.url_name, # lint-amnesty, pylint: disable=no-member - 'section': section.url_name, # lint-amnesty, pylint: disable=no-member - } - ) - - with override_waffle_flag(COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, active=waffle_override): - - response = self.client.get(url, follow=False) - assert response.status_code == (200 if expected_course_content else 302) - unicode_content = response.content.decode('utf-8') - if expected_course_content: - if user_type in (CourseUserType.ANONYMOUS, CourseUserType.UNENROLLED): - assert 'data-save-position="false"' in unicode_content - assert 'data-show-completion="false"' in unicode_content - assert 'xblock-public_view-sequential' in unicode_content - assert 'xblock-public_view-vertical' in unicode_content - assert 'xblock-public_view-html' in unicode_content - assert 'xblock-public_view-video' in unicode_content - if user_type == CourseUserType.ANONYMOUS and course_visibility == COURSE_VISIBILITY_PRIVATE: - assert 'To see course content' in unicode_content - if user_type == CourseUserType.UNENROLLED and course_visibility == COURSE_VISIBILITY_PRIVATE: - assert 'You must be enrolled' in unicode_content - else: - assert 'data-save-position="true"' in unicode_content - assert 'data-show-completion="true"' in unicode_content - assert 'xblock-student_view-sequential' in unicode_content - assert 'xblock-student_view-vertical' in unicode_content - assert 'xblock-student_view-html' in unicode_content - assert 'xblock-student_view-video' in unicode_content - @patch('lms.djangoapps.courseware.views.views.CourseTabView.course_open_for_learner_enrollment') @patch('openedx.core.djangoapps.util.user_messages.PageLevelMessages.register_warning_message') def test_courseware_messages_differentiate_for_anonymous_users( @@ -2754,7 +2592,7 @@ class TestIndexView(ModuleStoreTestCase): @ddt.ddt -@_set_mfe_flag(activate_mfe=False) +@set_preview_mode(True) class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin): """ Tests CompleteOnView is set up correctly in CoursewareIndex. @@ -2767,7 +2605,6 @@ class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin # pylint:disable=attribute-defined-outside-init self.request_factory = RequestFactoryNoCsrf() - self.user = UserFactory() with modulestore().default_store(default_store): self.course = CourseFactory.create() @@ -2826,11 +2663,11 @@ class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin CourseOverview.load_from_module_store(self.course.id) CourseEnrollmentFactory(user=self.user, course_id=self.course.id) + assert self.client.login(username=self.user.username, password=self.user_password) def test_completion_service_disabled(self): self.setup_course(ModuleStoreEnum.Type.split) - assert self.client.login(username=self.user.username, password='test') response = self.client.get(self.section_1_url) self.assertNotContains(response, 'data-mark-completed-on-view-after-delay') @@ -2843,7 +2680,6 @@ class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin self.override_waffle_switch(True) self.setup_course(ModuleStoreEnum.Type.split) - assert self.client.login(username=self.user.username, password='test') response = self.client.get(self.section_1_url) self.assertContains(response, 'data-mark-completed-on-view-after-delay') @@ -2855,6 +2691,7 @@ class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin content_type='application/json', ) request.user = self.user + request.session = {} response = handle_xblock_callback( request, str(self.course.id), @@ -2873,6 +2710,7 @@ class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin content_type='application/json', ) request.user = self.user + request.session = {} response = handle_xblock_callback( request, str(self.course.id), @@ -2889,7 +2727,7 @@ class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin @ddt.ddt -@_set_mfe_flag(activate_mfe=False) +@set_preview_mode(True) class TestIndexViewWithVerticalPositions(ModuleStoreTestCase): """ Test the index view to handle vertical positions. Confirms that first position is loaded @@ -2902,8 +2740,6 @@ class TestIndexViewWithVerticalPositions(ModuleStoreTestCase): """ super().setUp() - self.user = UserFactory() - # create course with 3 positions self.course = CourseFactory.create() with self.store.bulk_operations(self.course.id): @@ -2916,7 +2752,7 @@ class TestIndexViewWithVerticalPositions(ModuleStoreTestCase): CourseOverview.load_from_module_store(self.course.id) - self.client.login(username=self.user, password='test') + self.client.login(username=self.user, password=self.user_password) CourseEnrollmentFactory(user=self.user, course_id=self.course.id) def _get_course_vertical_by_position(self, input_position): @@ -2955,130 +2791,6 @@ class TestIndexViewWithVerticalPositions(ModuleStoreTestCase): self._assert_correct_position(resp, expected_position) -@_set_mfe_flag(activate_mfe=False) -class TestIndexViewWithGating(ModuleStoreTestCase, MilestonesTestCaseMixin): - """ - Test the index view for a course with gated content - """ - - def setUp(self): - """ - Set up the initial test data - """ - super().setUp() - - self.user = UserFactory() - self.course = CourseFactory.create() - with self.store.bulk_operations(self.course.id): - self.course.enable_subsection_gating = True - self.course.save() - self.course = self.update_course(self.course, 0) - self.chapter = ItemFactory.create( - parent_location=self.course.location, category="chapter", display_name="Chapter", - ) - self.open_seq = ItemFactory.create( - parent_location=self.chapter.location, category='sequential', display_name="Open Sequential" - ) - ItemFactory.create(parent_location=self.open_seq.location, category='problem', display_name="Problem 1") - self.gated_seq = ItemFactory.create( - parent_location=self.chapter.location, category='sequential', display_name="Gated Sequential" - ) - ItemFactory.create(parent_location=self.gated_seq.location, category='problem', display_name="Problem 2") - - gating_api.add_prerequisite(self.course.id, self.open_seq.location) - gating_api.set_required_content(self.course.id, self.gated_seq.location, self.open_seq.location, 100) - - CourseEnrollmentFactory(user=self.user, course_id=self.course.id) - - def test_index_with_gated_sequential(self): - """ - Test index view with a gated sequential raises Http404 - """ - assert self.client.login(username=self.user.username, password='test') - response = self.client.get( - reverse( - 'courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.chapter.url_name, - 'section': self.gated_seq.url_name, - } - ) - ) - assert response.status_code == 200 - self.assertContains(response, "Content Locked") - - -@_set_mfe_flag(activate_mfe=False) -class TestIndexViewWithCourseDurationLimits(ModuleStoreTestCase): - """ - Test the index view for a course with course duration limits enabled. - """ - - def setUp(self): - """ - Set up the initial test data. - """ - super().setUp() - - self.user = UserFactory() - self.course = CourseFactory.create(start=datetime.now() - timedelta(weeks=1)) - with self.store.bulk_operations(self.course.id): - self.chapter = ItemFactory.create(parent_location=self.course.location, category="chapter") - self.sequential = ItemFactory.create(parent_location=self.chapter.location, category='sequential') - self.vertical = ItemFactory.create(parent_location=self.sequential.location, category="vertical") - - CourseEnrollmentFactory(user=self.user, course_id=self.course.id) - - def test_index_with_course_duration_limits(self): - """ - Test that the courseware contains the course expiration banner - when course_duration_limits are enabled. - """ - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) - assert self.client.login(username=self.user.username, password='test') - add_course_mode(self.course, mode_slug=CourseMode.AUDIT) - add_course_mode(self.course) - response = self.client.get( - reverse( - 'courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.chapter.url_name, - 'section': self.sequential.url_name, - } - ) - ) - bannerText = get_expiration_banner_text(self.user, self.course) - # Banner is XBlock wrapper, so it is escaped in raw response. Since - # it's escaped, ignoring the whitespace with assertContains doesn't - # work. Instead we remove all whitespace to verify content is correct. - bannerText_no_spaces = escape(bannerText).replace(' ', '') - response_no_spaces = response.content.decode('utf-8').replace(' ', '') - assert bannerText_no_spaces in response_no_spaces - - def test_index_without_course_duration_limits(self): - """ - Test that the courseware does not contain the course expiration banner - when course_duration_limits are disabled. - """ - CourseDurationLimitConfig.objects.create(enabled=False) - assert self.client.login(username=self.user.username, password='test') - add_course_mode(self.course, upgrade_deadline_expired=False) - response = self.client.get( - reverse( - 'courseware_section', - kwargs={ - 'course_id': str(self.course.id), - 'chapter': self.chapter.url_name, - 'section': self.sequential.url_name, - } - ) - ) - bannerText = get_expiration_banner_text(self.user, self.course) - self.assertNotContains(response, bannerText, html=True) - - class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaffleTestMixin): """ Tests for the courseware.render_xblock endpoint. @@ -3282,6 +2994,79 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaf self.assertNotContains(response, banner_text, html=True) +class TestRenderPublicVideoXBlock(ModuleStoreTestCase): + """ + Tests for the courseware.render_public_video_xblock endpoint. + """ + def setup_course(self): + """ + Helper method to create the course. + """ + with self.store.default_store(self.store.default_modulestore.get_modulestore_type()): + course = CourseFactory.create(**{'start': datetime.now() - timedelta(days=1)}) + chapter = ItemFactory.create(parent=course, category='chapter') + vertical_block = ItemFactory.create( + parent_location=chapter.location, + category='vertical', + display_name="Vertical" + ) + self.html_block = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=vertical_block, + category='html', + data="

Test HTML Content

" + ) + self.video_block_public = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=vertical_block, + category='video', + display_name='Video with public access', + metadata={'public_access': True} + ) + self.video_block_not_public = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=vertical_block, + category='video', + display_name='Video with private access' + ) + CourseOverview.load_from_module_store(course.id) + + def get_response(self, usage_key): + """ + Overridable method to get the response from the endpoint that is being tested. + """ + url = reverse('render_public_video_xblock', kwargs={'usage_key_string': str(usage_key)}) + return self.client.get(url) + + def test_render_xblock_with_invalid_usage_key(self): + """ + Verify that endpoint returns expected response with invalid usage key + """ + response = self.get_response(usage_key='some_invalid_usage_key') + self.assertContains(response, 'Page not found', status_code=404) + + def test_render_xblock_with_non_video_usage_key(self): + """ + Verify that endpoint returns expected response if usage key block type is not `video` + """ + self.setup_course() + response = self.get_response(usage_key=self.html_block.location) + self.assertContains(response, 'Page not found', status_code=404) + + def test_render_xblock_with_video_usage_key_with_public_access(self): + """ + Verify that endpoint returns expected response if usage key block type is `video` and video has public access + """ + self.setup_course() + response = self.get_response(usage_key=self.video_block_public.location) + self.assertContains(response, 'Play video', status_code=200) + + def test_render_xblock_with_video_usage_key_with_non_public_access(self): + """ + Verify that endpoint returns expected response if usage key block type is `video` and video has private access + """ + self.setup_course() + response = self.get_response(usage_key=self.video_block_not_public.location) + self.assertContains(response, 'Page not found', status_code=404) + + class TestRenderXBlockSelfPaced(TestRenderXBlock): # lint-amnesty, pylint: disable=test-inherits-tests """ Test rendering XBlocks for a self-paced course. Relies on the query @@ -3296,7 +3081,7 @@ class TestRenderXBlockSelfPaced(TestRenderXBlock): # lint-amnesty, pylint: disa return options -@_set_mfe_flag(activate_mfe=False) +@set_preview_mode(True) class TestIndexViewCrawlerStudentStateWrites(SharedModuleStoreTestCase): """ Ensure that courseware index requests do not trigger student state writes. @@ -3319,7 +3104,7 @@ class TestIndexViewCrawlerStudentStateWrites(SharedModuleStoreTestCase): @classmethod def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called """Set up and enroll our fake user in the course.""" - cls.user = UserFactory() + cls.user = UserFactory(is_staff=True) CourseEnrollment.enroll(cls.user, cls.course.id) def setUp(self): @@ -3423,233 +3208,23 @@ class AccessUtilsTestCase(ModuleStoreTestCase): assert bool(check_course_open_for_learner(staff_user, course)) == expected_value -@ddt.ddt -@override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=True) -class DatesTabTestCase(ModuleStoreTestCase): +class DatesTabTestCase(TestCase): """ - Ensure that the dates page renders with the correct data for both a verified and audit learner + Ensure that the legacy dates view redirects appropriately (it no longer exists). """ - def setUp(self): - super().setUp() - - now = datetime.now(utc) - self.course = CourseFactory.create(start=now + timedelta(days=-1), self_paced=True) - self.course.end = now + timedelta(days=3) - - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) - CourseModeFactory(course_id=self.course.id, mode_slug=CourseMode.AUDIT) - CourseModeFactory( - course_id=self.course.id, - mode_slug=CourseMode.VERIFIED, - expiration_datetime=now + timedelta(days=1) - ) - VerificationDeadline.objects.create( - course_key=self.course.id, - deadline=now + timedelta(days=2) - ) - - self.user = UserFactory() - self.client.login(username=self.user.username, password=TEST_PASSWORD) - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2017, 1, 1)) - - def _get_response(self, course): - """ Returns the HTML for the dates page """ - return self.client.get(reverse('dates', args=[str(course.id)])) - - def test_tab_redirects_if_not_logged_in(self): - self.client.logout() - response = self._get_response(self.course) - assert response.status_code == 302 - assert '/login?next=/courses/' in response.url - - def test_tab_redirects_if_not_enrolled_and_not_staff(self): - response = self._get_response(self.course) - assert response.status_code == 302 - # Beginning of redirect URL - assert '/courses/' in response.url - # End of redirect URL - assert '/course/' in response.url - - # Now check staff users can see - self.user.is_staff = True - self.user.save() - response = self._get_response(self.course) - assert response.status_code == 200 - - # Enrolled users can also see - self.client.logout() - enrolled_user = UserFactory() - CourseEnrollmentFactory(course_id=self.course.id, user=enrolled_user, mode=CourseMode.VERIFIED) - self.client.login(username=enrolled_user.username, password=TEST_PASSWORD) - response = self._get_response(self.course) - assert response.status_code == 200 - - @override_waffle_flag(RELATIVE_DATES_FLAG, active=True) - @patch('edx_django_utils.monitoring.set_custom_attribute') - def test_defaults(self, mock_set_custom_attribute): - enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user, mode=CourseMode.VERIFIED) - now = datetime.now(utc) - with self.store.bulk_operations(self.course.id): - section = ItemFactory.create(category='chapter', parent_location=self.course.location) - subsection = ItemFactory.create( - category='sequential', - display_name='Released', - parent_location=section.location, - start=now - timedelta(days=1), - due=now + timedelta(days=1), # Setting this to tomorrow so it'll show the 'Due Next' pill - graded=True, - format='Homework', - ) - vertical = ItemFactory.create(category='vertical', parent_location=subsection.location) - ItemFactory.create(category='problem', parent_location=vertical.location, has_score=True) - - response = self._get_response(self.course) - self.assertContains(response, subsection.display_name) - # Show the Verification Deadline for verified only - self.assertContains(response, 'Verification Deadline') - # Make sure pill exists for today's date - self.assertContains(response, '

') - # Make sure pill exists for next due assignment - self.assertContains(response, '
') - # No pills for verified enrollments - self.assertNotContains(response, '
') - # Make sure the assignment type is rendered - self.assertContains(response, 'Homework:') - - enrollment.delete() - enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user, mode=CourseMode.AUDIT) - - expected_calls = [ - call('course_id', str(self.course.id)), - call('user_id', self.user.id), - call('is_staff', self.user.is_staff), - ] - - response = self._get_response(self.course) - - mock_set_custom_attribute.assert_has_calls(expected_calls, any_order=True) - self.assertContains(response, subsection.display_name) - # Don't show the Verification Deadline for audit - self.assertNotContains(response, 'Verification Deadline') - # Pill doesn't exist for assignment due tomorrow - self.assertNotContains(response, '
') - # Should have verified pills for audit enrollments - self.assertContains(response, '
') - # Make sure the assignment type is rendered - self.assertContains(response, 'Homework:') - - @override_waffle_flag(RELATIVE_DATES_FLAG, active=True) - def test_reset_deadlines_banner_displays(self): - CourseEnrollmentFactory(course_id=self.course.id, user=self.user, mode=CourseMode.VERIFIED) - now = datetime.now(utc) - with self.store.bulk_operations(self.course.id): - section = ItemFactory.create(category='chapter', parent_location=self.course.location) - ItemFactory.create( - category='sequential', - display_name='Released', - parent_location=section.location, - start=now - timedelta(days=1), - due=now - timedelta(days=1), # Setting this to tomorrow so it'll show the 'Due Next' pill - graded=True, - ) - response = self._get_response(self.course) - self.assertContains(response, 'div class="banner-cta-text"') - - @override_waffle_flag(COURSE_HOME_USE_LEGACY_FRONTEND, active=False) def test_legacy_redirect(self): """ Verify that the legacy dates page redirects to the MFE correctly. """ - response = self.client.get(reverse('dates', args=[str(self.course.id)]) + '?foo=b$r') + response = self.client.get('/courses/course-v1:Org+Course+Run/dates?foo=b$r') assert response.status_code == 302 - assert response.get('Location') == f'http://learning-mfe/course/{self.course.id}/dates?foo=b%24r' + assert response.get('Location') == 'http://learning-mfe/course/course-v1:Org+Course+Run/dates?foo=b%24r' -class TestShowCoursewareMFE(TestCase): +class MFEUrlTests(TestCase): """ - Make sure we're showing the Courseware MFE link when appropriate. - - There are an unfortunate number of state permutations here since we have - the product of the following binary states: - - * user is global staff member - * user is member of the course team - * whether the course_key is an old Mongo style of key - * the COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW CourseWaffleFlag - * the COURSEWARE_USE_LEGACY_FRONTEND opt-out CourseWaffleFlag - - Giving us theoretically 2^5 = 32 states. >_< + Test url utility method """ - def test_permutations(self): - """Test every permutation""" - old_course_key = CourseKey.from_string("OpenEdX/Old/2020") - new_course_key = CourseKey.from_string("course-v1:OpenEdX+New+2020") - - # Old style course keys are never supported and should always return false... - old_mongo_combos = itertools.product( - [True, False], # is_global_staff - [True, False], # is_course_staff - [True, False], # preview_active (COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW) - [True, False], # redirect_active (not COURSEWARE_USE_LEGACY_FRONTEND) - ) - for is_global_staff, is_course_staff, preview_active, redirect_active in old_mongo_combos: - with _set_preview_mfe_flag(preview_active): - with _set_mfe_flag(redirect_active): - assert not courseware_mfe_is_advertised( - is_global_staff=is_global_staff, - is_course_staff=is_course_staff, - course_key=old_course_key, - ) - - # We've checked all old-style course keys now, so we can test only the - # new ones going forward. Now we check combinations of waffle flags and - # user permissions... - with _set_preview_mfe_flag(True): - with _set_mfe_flag(activate_mfe=True): - # (preview=on, redirect=on) - # Global and Course Staff can see the link. - assert courseware_mfe_is_advertised(new_course_key, True, True) - assert courseware_mfe_is_advertised(new_course_key, True, False) - assert courseware_mfe_is_advertised(new_course_key, False, True) - - # (Regular users would see the link, but they can't see the Legacy - # experience, so it doesn't matter.) - - with _set_mfe_flag(activate_mfe=False): - # (preview=on, redirect=off) - # Global and Course Staff can see the link. - assert courseware_mfe_is_advertised(new_course_key, True, True) - assert courseware_mfe_is_advertised(new_course_key, True, False) - assert courseware_mfe_is_advertised(new_course_key, False, True) - - # Regular users don't see the link. - assert not courseware_mfe_is_advertised(new_course_key, False, False) - - with _set_preview_mfe_flag(False): - with _set_mfe_flag(activate_mfe=True): - # (preview=off, redirect=on) - # Global staff see the link anyway - assert courseware_mfe_is_advertised(new_course_key, True, True) - assert courseware_mfe_is_advertised(new_course_key, True, False) - - # If redirect is active for their students, course staff see the link even - # if preview=off. - assert courseware_mfe_is_advertised(new_course_key, False, True) - - # (Regular users would see the link, but they can't see the Legacy - # experience, so it doesn't matter.) - - with _set_mfe_flag(activate_mfe=False): - # (preview=off, redirect=off) - # Global staff and course teams can NOT see the link - # because both rollout waffle flags are false. - assert not courseware_mfe_is_advertised(new_course_key, True, True) - assert not courseware_mfe_is_advertised(new_course_key, True, False) - assert not courseware_mfe_is_advertised(new_course_key, False, True) - - # Regular users don't see the link. - assert not courseware_mfe_is_advertised(new_course_key, False, False) - @override_settings(LEARNING_MICROFRONTEND_URL='https://learningmfe.openedx.org') def test_url_generation(self): course_key = CourseKey.from_string("course-v1:OpenEdX+MFE+2020") @@ -3677,82 +3252,24 @@ class TestShowCoursewareMFE(TestCase): ) -@ddt.ddt -class MFERedirectTests(BaseViewsTestCase): # lint-amnesty, pylint: disable=missing-class-docstring +class PreviewTests(BaseViewsTestCase): + """ + Make sure we allow the Legacy view for course previews. + """ def test_learner_redirect(self): - # learners will be redirected when the waffle flag is set + # learners will be redirected by default lms_url, mfe_url, __ = self._get_urls() - assert self.client.get(lms_url).url == mfe_url - def test_staff_no_redirect(self): - lms_url, __, __ = self._get_urls() - - # course staff will redirect in an MFE-enabled course - and not redirect otherwise. - course_staff = UserFactory.create(is_staff=False) - CourseStaffRole(self.course_key).add_users(course_staff) - self.client.login(username=course_staff.username, password='test') - - with _set_mfe_flag(activate_mfe=False): - assert self.client.get(lms_url).status_code == 200 - assert self.client.get(lms_url).status_code == 302 - - # global staff will never be redirected - self._create_global_staff_user() - - with _set_mfe_flag(activate_mfe=False): - assert self.client.get(lms_url).status_code == 200 - assert self.client.get(lms_url).status_code == 200 - - def test_exam_no_redirect(self): - # exams will not redirect to the mfe, for the time being - self.section2.is_time_limited = True - self.store.update_item(self.section2, self.user.id) - - lms_url, __, __ = self._get_urls() - - assert self.client.get(lms_url).status_code == 200 - - -class PreviewRedirectTests(BaseViewsTestCase): - """ - Make sure we're redirecting to the Legacy view for course previews. - - The user should always be redirected to the Legacy view as long as they are - part of the two following groups: - - * user is global staff member - * user is member of the course team - """ - def test_staff_no_redirect(self): + def test_preview_no_redirect(self): __, __, preview_url = self._get_urls() - with patch.object(access_utils, 'get_current_request_hostname', - return_value=settings.FEATURES.get('PREVIEW_LMS_BASE', None)): - - # Previews will not redirect to the mfe,, for the time being. + with set_preview_mode(True): + # Previews will not redirect to the mfe course_staff = UserFactory.create(is_staff=False) CourseStaffRole(self.course_key).add_users(course_staff) self.client.login(username=course_staff.username, password='test') - - with _set_mfe_flag(activate_mfe=False): - assert self.client.get(preview_url).status_code == 200 assert self.client.get(preview_url).status_code == 200 - # global staff will never be redirected - self._create_global_staff_user() - with _set_mfe_flag(activate_mfe=False): - assert self.client.get(preview_url).status_code == 200 - assert self.client.get(preview_url).status_code == 200 - - def test_exam_no_redirect(self): - # exams will not redirect to the mfe, for the time being - self.section2.is_time_limited = True - self.store.update_item(self.section2, self.user.id) - - __, __, preview_url = self._get_urls() - - assert self.client.get(preview_url).status_code == 200 - class ContentOptimizationTestCase(ModuleStoreTestCase): """ @@ -3863,7 +3380,6 @@ class TestCourseWideResources(ModuleStoreTestCase): @ddt.data( ('courseware', 'course_id', False, True), - ('dates', 'course_id', False, False), ('progress', 'course_id', False, False), ('instructor_dashboard', 'course_id', True, False), ('forum_form_discussion', 'course_id', False, False), diff --git a/lms/djangoapps/courseware/testutils.py b/lms/djangoapps/courseware/testutils.py index 864e18200c..fe510799d2 100644 --- a/lms/djangoapps/courseware/testutils.py +++ b/lms/djangoapps/courseware/testutils.py @@ -11,8 +11,9 @@ from urllib.parse import urlencode import ddt from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from lms.djangoapps.courseware.tests.helpers import set_preview_mode from lms.djangoapps.courseware.utils import is_mode_upsellable -from openedx.features.course_experience.url_helpers import get_courseware_url, ExperienceOption +from openedx.features.course_experience.url_helpers import get_courseware_url from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory @@ -116,6 +117,11 @@ class RenderXBlockTestMixin(MasqueradeMixin, metaclass=ABCMeta): category='problem', display_name='Problem' ) + self.video_block = ItemFactory.create( + parent=self.vertical_block, + category='video', + display_name='Video' + ) CourseOverview.load_from_module_store(self.course.id) # block_name_to_be_tested can be `html_block` or `vertical_block`. @@ -165,6 +171,7 @@ class RenderXBlockTestMixin(MasqueradeMixin, metaclass=ABCMeta): ('html_block', 4), ) @ddt.unpack + @set_preview_mode(True) def test_courseware_html(self, block_name, mongo_calls): """ To verify that the removal of courseware chrome elements is working, @@ -179,10 +186,7 @@ class RenderXBlockTestMixin(MasqueradeMixin, metaclass=ABCMeta): self.setup_user(admin=True, enroll=True, login=True) with check_mongo_calls(mongo_calls): - url = get_courseware_url( - self.block_to_be_tested.location, - experience=ExperienceOption.LEGACY, - ) + url = get_courseware_url(self.block_to_be_tested.location) response = self.client.get(url) expected_elements = self.block_specific_chrome_html_elements + self.COURSEWARE_CHROME_HTML_ELEMENTS for chrome_element in expected_elements: diff --git a/lms/djangoapps/courseware/toggles.py b/lms/djangoapps/courseware/toggles.py index 4cea39e09c..63babaceac 100644 --- a/lms/djangoapps/courseware/toggles.py +++ b/lms/djangoapps/courseware/toggles.py @@ -3,7 +3,6 @@ Toggles for courseware in-course experience. """ from edx_toggles.toggles import LegacyWaffleFlagNamespace, SettingToggle -from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag @@ -11,35 +10,6 @@ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag WAFFLE_FLAG_NAMESPACE = LegacyWaffleFlagNamespace(name='courseware') -# .. toggle_name: courseware.use_legacy_frontend -# .. toggle_implementation: CourseWaffleFlag -# .. toggle_default: False -# .. toggle_description: Waffle flag to direct learners to the legacy courseware experience - the default behavior -# directs to the new MFE-based courseware in frontend-app-learning. Supports the ability to globally flip back to -# the legacy courseware experience. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2021-06-03 -# .. toggle_target_removal_date: 2021-10-09 -# .. toggle_tickets: DEPR-109 -COURSEWARE_USE_LEGACY_FRONTEND = CourseWaffleFlag( - WAFFLE_FLAG_NAMESPACE, 'use_legacy_frontend', __name__ -) - -# .. toggle_name: courseware.microfrontend_course_team_preview -# .. toggle_implementation: CourseWaffleFlag -# .. toggle_default: False -# .. toggle_description: Waffle flag to display a link for the new learner experience to course teams without -# redirecting students. Supports staged rollout to course teams of a new micro-frontend-based implementation of the -# courseware page. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2020-03-09 -# .. toggle_target_removal_date: 2020-12-31 -# .. toggle_warnings: Also set settings.LEARNING_MICROFRONTEND_URL. -# .. toggle_tickets: DEPR-109 -COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW = CourseWaffleFlag( - WAFFLE_FLAG_NAMESPACE, 'microfrontend_course_team_preview', __name__ -) - # Waffle flag to enable the course exit page in the learning MFE. # # .. toggle_name: courseware.microfrontend_course_exit_page @@ -128,118 +98,25 @@ COURSEWARE_OPTIMIZED_RENDER_XBLOCK = CourseWaffleFlag( COURSES_INVITE_ONLY = SettingToggle('COURSES_INVITE_ONLY', default=False) -def courseware_mfe_is_active(course_key: CourseKey) -> bool: +def courseware_mfe_is_active() -> bool: """ Should we serve the Learning MFE as the canonical courseware experience? """ - #Avoid circular imports. - from lms.djangoapps.courseware.access_utils import in_preview_mode - # NO: Old Mongo courses are always served in the Legacy frontend, - # regardless of configuration. - if course_key.deprecated: - return False - # NO: MFE courseware can be disabled for users/courses/globally via this - # Waffle flag. - if COURSEWARE_USE_LEGACY_FRONTEND.is_enabled(course_key): - return False - # NO: Course preview doesn't work in the MFE - if in_preview_mode(): - return False - # OTHERWISE: MFE courseware experience is active by default. - return True - - -def courseware_mfe_is_visible( - course_key: CourseKey, - is_global_staff=False, - is_course_staff=False, -) -> bool: - """ - Can we see a course run's content in the Learning MFE? - """ - #Avoid circular imports. - from lms.djangoapps.courseware.access_utils import in_preview_mode - # DENY: Old Mongo courses don't work in the MFE. - if course_key.deprecated: - return False - # DENY: Course preview doesn't work in the MFE - if in_preview_mode(): - return False - # ALLOW: Where techincally possible, global staff may always see the MFE. - if is_global_staff: - return True - # ALLOW: If course team preview is enabled, then course staff may see their - # course in the MFE. - if is_course_staff and COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW.is_enabled(course_key): - return True - # OTHERWISE: The MFE is only visible if it's the active (ie canonical) experience. - return courseware_mfe_is_active(course_key) - - -def courseware_mfe_is_advertised( - course_key: CourseKey, - is_global_staff=False, - is_course_staff=False, -) -> bool: - """ - Should we invite the user to view a course run's content in the Learning MFE? - - This check is slightly different than `courseware_mfe_is_visible`, in that - we always *permit* global staff to view MFE content (assuming it's deployed), - but we do not shove the New Experience in their face if the preview isn't - enabled. - """ - #Avoid circular imports. - from lms.djangoapps.courseware.access_utils import in_preview_mode - # DENY: Old Mongo courses don't work in the MFE. - if course_key.deprecated: - return False - # DENY: Course preview doesn't work in the MFE - if in_preview_mode(): - return False - # ALLOW: Both global and course staff can see the MFE link if the course team - # preview is enabled. - is_staff = is_global_staff or is_course_staff - if is_staff and COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW.is_enabled(course_key): - return True - # OTHERWISE: The MFE is only advertised if it's the active (ie canonical) experience. - return courseware_mfe_is_active(course_key) - - -def courseware_legacy_is_visible( - course_key: CourseKey, - is_global_staff=False, -) -> bool: - """ - Can we see a course run's content in the Legacy frontend? - - Note: This function will always return True for Old Mongo courses, - since `courseware_mfe_is_active` will always return False for them. - """ - #Avoid circular imports. - from lms.djangoapps.courseware.access_utils import in_preview_mode - # ALLOW: Global staff may always see the Legacy experience. - if is_global_staff: - return True - # ALLOW: All course previews will be shown in Legacy experience - if in_preview_mode(): - return True - # OTHERWISE: Legacy is only visible if it's the active (ie canonical) experience. - # Note that Old Mongo courses are never the active experience, - # so we effectively always ALLOW them to be viewed in Legacy. - return not courseware_mfe_is_active(course_key) + from lms.djangoapps.courseware.access_utils import in_preview_mode # avoid a circular import + # We only use legacy views for the Studio "preview mode" feature these days, while everyone else gets the MFE + return not in_preview_mode() def course_exit_page_is_active(course_key): return ( - courseware_mfe_is_active(course_key) and + courseware_mfe_is_active() and COURSEWARE_MICROFRONTEND_COURSE_EXIT_PAGE.is_enabled(course_key) ) def courseware_mfe_progress_milestones_are_active(course_key): return ( - courseware_mfe_is_active(course_key) and + courseware_mfe_is_active() and COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES.is_enabled(course_key) ) diff --git a/lms/djangoapps/courseware/utils.py b/lms/djangoapps/courseware/utils.py index 655a78deb6..9eafc2a775 100644 --- a/lms/djangoapps/courseware/utils.py +++ b/lms/djangoapps/courseware/utils.py @@ -2,14 +2,29 @@ import datetime +import hashlib +import logging from django.conf import settings -from lms.djangoapps.commerce.utils import EcommerceService +from django.http import HttpResponse, HttpResponseBadRequest +from edx_rest_api_client.client import OAuthAPIClient +from oauth2_provider.models import Application from pytz import utc # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework import status +from xmodule.partitions.partitions import \ + ENROLLMENT_TRACK_PARTITION_ID # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.partitions.partitions_service import PartitionService # lint-amnesty, pylint: disable=wrong-import-order from common.djangoapps.course_modes.models import CourseMode -from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions_service import PartitionService # lint-amnesty, pylint: disable=wrong-import-order +from lms.djangoapps.commerce.utils import EcommerceService +from lms.djangoapps.courseware.constants import ( + UNEXPECTED_ERROR_APPLICATION_STATUS, + UNEXPECTED_ERROR_CREATE_APPLICATION, + UNEXPECTED_ERROR_IS_ELIGIBLE +) +from lms.djangoapps.courseware.models import FinancialAssistanceConfiguration + +log = logging.getLogger(__name__) def verified_upgrade_deadline_link(user, course=None, course_id=None): @@ -95,3 +110,103 @@ def can_show_verified_upgrade(user, enrollment, course=None): # Show the summary if user enrollment is in which allow user to upsell return enrollment.is_active and enrollment.mode in CourseMode.UPSELL_TO_VERIFIED_MODES + + +def _request_financial_assistance(method, url, params=None, data=None): + """ + An internal function containing common functionality among financial assistance utility function to call + edx-financial-assistance backend with appropriate method, url, params and data. + """ + financial_assistance_configuration = FinancialAssistanceConfiguration.current() + if financial_assistance_configuration.enabled: + oauth_application = Application.objects.get(user=financial_assistance_configuration.get_service_user()) + client = OAuthAPIClient( + settings.LMS_ROOT_URL, + oauth_application.client_id, + oauth_application.client_secret + ) + return client.request( + method, f"{financial_assistance_configuration.api_base_url}{url}", params=params, data=data + ) + else: + return False, 'Financial Assistance configuration is not enabled' + + +def is_eligible_for_financial_aid(course_id): + """ + Sends a get request to edx-financial-assistance to retrieve financial assistance eligibility criteria for a course. + + Returns either True if course is eligible for financial aid or vice versa. + Also returns the reason why the course isn't eligible. + In case of a bad request, returns an error message. + """ + response = _request_financial_assistance('GET', f"{settings.IS_ELIGIBLE_FOR_FINANCIAL_ASSISTANCE_URL}{course_id}/") + if response.status_code == status.HTTP_200_OK: + return response.json().get('is_eligible'), response.json().get('reason') + elif response.status_code == status.HTTP_400_BAD_REQUEST: + return False, response.json().get('message') + else: + log.error('%s %s', UNEXPECTED_ERROR_IS_ELIGIBLE, str(response.content)) + return False, UNEXPECTED_ERROR_IS_ELIGIBLE + + +def get_financial_assistance_application_status(user_id, course_id): + """ + Given the course_id, sends a get request to edx-financial-assistance to retrieve + financial assistance application(s) status for the logged-in user. + """ + request_params = { + 'course_id': course_id, + 'lms_user_id': user_id + } + response = _request_financial_assistance( + 'GET', f"{settings.FINANCIAL_ASSISTANCE_APPLICATION_STATUS_URL}", params=request_params + ) + if response.status_code == status.HTTP_200_OK: + return True, response.json() + elif response.status_code in (status.HTTP_400_BAD_REQUEST, status.HTTP_404_NOT_FOUND): + return False, response.json().get('message') + else: + log.error('%s %s', UNEXPECTED_ERROR_APPLICATION_STATUS, response.content) + return False, UNEXPECTED_ERROR_APPLICATION_STATUS + + +def create_financial_assistance_application(form_data): + """ + Sends a post request to edx-financial-assistance to create a new application for financial assistance application. + The incoming form_data must have data as given in the example below: + { + "lms_user_id": , + "course_id": , + "income": , + "learner_reasons": , + "learner_goals": , + "learner_plans": , + "allow_for_marketing": + } + """ + response = _request_financial_assistance( + 'POST', f"{settings.CREATE_FINANCIAL_ASSISTANCE_APPLICATION_URL}/", data=form_data + ) + if response.status_code == status.HTTP_200_OK: + return HttpResponse(status=status.HTTP_204_NO_CONTENT) + elif response.status_code == status.HTTP_400_BAD_REQUEST: + log.error(response.json().get('message')) + return HttpResponseBadRequest(response.content) + else: + log.error('%s %s', UNEXPECTED_ERROR_CREATE_APPLICATION, response.content) + return HttpResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +def get_course_hash_value(course_key): + """ + Returns a hash value for the given course key. + If course key is None, function returns an out of bound value which will + never satisfy the fa_backend_enabled_courses_percentage condition + """ + out_of_bound_value = 100 + if course_key: + m = hashlib.md5(str(course_key).encode()) + return int(m.hexdigest(), base=16) % 100 + + return out_of_bound_value diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index 3a2d178dd6..b789a86598 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -40,7 +40,7 @@ from openedx.core.djangolib.markup import HTML, Text from openedx.features.course_experience import ( COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, DISABLE_COURSE_OUTLINE_PAGE_FLAG, - default_course_url_name + default_course_url ) from openedx.features.course_experience.views.course_sock import CourseSockFragmentView from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url @@ -64,10 +64,7 @@ from ..masquerade import check_content_start_date_for_masquerade_user, setup_mas from ..model_data import FieldDataCache from ..module_render import get_module_for_descriptor, toc_for_course from ..permissions import MASQUERADE_AS_STUDENT -from ..toggles import ( - courseware_legacy_is_visible, - courseware_mfe_is_advertised -) +from ..toggles import courseware_mfe_is_active from .views import CourseTabView log = logging.getLogger("edx.courseware.views.index") @@ -172,23 +169,11 @@ class CoursewareIndex(View): def _redirect_to_learning_mfe(self): """ - Can the user access this sequence in Legacy courseware? If not, redirect to MFE. - - We specifically allow users to stay in the Legacy frontend for special - (ie timed/proctored) exams since they're not yet supported by the MFE. + Can the user access this sequence in the courseware MFE? If so, redirect to MFE. """ - # STAY: if the course run as a whole is visible in the Legacy experience. - if courseware_legacy_is_visible( - course_key=self.course_key, - is_global_staff=self.request.user.is_staff, - ): - return - # STAY: if we are in a special (ie proctored/timed) exam, which isn't yet - # supported on the MFE. - if getattr(self.section, 'is_time_limited', False): - return - # REDIRECT otherwise. - raise Redirect(self.microfrontend_url) + # If the MFE is active, prefer that + if courseware_mfe_is_active(): + raise Redirect(self.microfrontend_url) @property def microfrontend_url(self): @@ -417,8 +402,7 @@ class CoursewareIndex(View): Also returns the table of contents for the courseware. """ - course_url_name = default_course_url_name(self.course.id) - course_url = reverse(course_url_name, kwargs={'course_id': str(self.course.id)}) + course_url = default_course_url(self.course.id) show_search = ( settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH') or (settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH_FOR_COURSE_STAFF') and self.is_staff) @@ -498,16 +482,6 @@ class CoursewareIndex(View): if self.section.position and self.section.has_children: self._add_sequence_title_to_context(courseware_context) - # Courseware MFE link - if courseware_mfe_is_advertised( - is_global_staff=request.user.is_staff, - is_course_staff=staff_access, - course_key=self.course.id, - ): - courseware_context['microfrontend_link'] = self.microfrontend_url - else: - courseware_context['microfrontend_link'] = None - return courseware_context def _add_sequence_title_to_context(self, courseware_context): diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 0db5e27957..e5028a3d67 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -32,7 +32,6 @@ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_GET, require_http_methods, require_POST from django.views.generic import View -from edx_django_utils import monitoring as monitoring_utils from edx_django_utils.monitoring import set_custom_attribute, set_custom_attributes_for_course_key from ipware.ip import get_client_ip from markupsafe import escape @@ -45,15 +44,9 @@ from rest_framework.decorators import api_view, throttle_classes from rest_framework.response import Response from rest_framework.throttling import UserRateThrottle from web_fragments.fragment import Fragment -from xmodule.course_module import ( - COURSE_VISIBILITY_PUBLIC, - COURSE_VISIBILITY_PUBLIC_OUTLINE -) +from xmodule.course_module import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import ( - ItemNotFoundError, - NoPathToItem -) +from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem from xmodule.tabs import CourseTabList from xmodule.x_module import STUDENT_VIEW @@ -70,14 +63,14 @@ from lms.djangoapps.certificates import api as certs_api from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.course_goals.models import UserActivity -from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active, course_home_mfe_progress_tab_is_active +from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active from lms.djangoapps.courseware.access import has_access, has_ccx_coach_role from lms.djangoapps.courseware.access_utils import check_course_open_for_learner, check_public_access +from lms.djangoapps.courseware.config import ENABLE_NEW_FINANCIAL_ASSISTANCE_FLOW from lms.djangoapps.courseware.courses import ( can_self_enroll_in_course, course_open_for_self_enrollment, get_course, - get_course_date_blocks, get_course_overview_with_access, get_course_with_access, get_courses, @@ -92,13 +85,10 @@ from lms.djangoapps.courseware.exceptions import CourseAccessRedirect, Redirect from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_student, setup_masquerade from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule -from lms.djangoapps.courseware.permissions import ( - MASQUERADE_AS_STUDENT, - VIEW_COURSE_HOME, - VIEW_COURSEWARE, -) +from lms.djangoapps.courseware.permissions import MASQUERADE_AS_STUDENT, VIEW_COURSE_HOME, VIEW_COURSEWARE from lms.djangoapps.courseware.toggles import course_is_invitation_only from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient +from lms.djangoapps.courseware.utils import create_financial_assistance_application from lms.djangoapps.edxnotes.helpers import is_feature_enabled from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context from lms.djangoapps.grades.api import CourseGradeFactory @@ -123,13 +113,12 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_ from openedx.core.djangoapps.util.user_messages import PageLevelMessages from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket from openedx.core.djangolib.markup import HTML, Text +from openedx.core.lib.courses import get_course_by_id from openedx.core.lib.mobile_utils import is_request_from_mobile_app -from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.course_duration_limits.access import generate_course_expired_fragment -from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG, course_home_url_name +from openedx.features.course_experience import DISABLE_UNIFIED_COURSE_TAB_FLAG, course_home_url from openedx.features.course_experience.course_tools import CourseToolsPluginManager from openedx.features.course_experience.url_helpers import ( - ExperienceOption, get_courseware_url, get_learning_mfe_home_url, is_request_from_learning_mfe @@ -140,7 +129,6 @@ from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBA from openedx.features.course_experience.waffle import waffle as course_experience_waffle from openedx.features.enterprise_support.api import data_sharing_consent_required -from ..context_processor import user_timezone_locale_prefs from ..entrance_exams import user_can_skip_entrance_exam from ..module_render import get_module, get_module_by_usage_id, get_module_for_descriptor from ..tabs import _get_dynamic_tabs @@ -421,19 +409,10 @@ def jump_to(request, course_id, location): except InvalidKeyError as exc: raise Http404("Invalid course_key or usage_key") from exc - experience_param = request.GET.get("experience", "").lower() - if experience_param == "new": - experience = ExperienceOption.NEW - elif experience_param == "legacy": - experience = ExperienceOption.LEGACY - else: - experience = ExperienceOption.ACTIVE - try: redirect_url = get_courseware_url( usage_key=usage_key, request=request, - experience=experience, ) except (ItemNotFoundError, NoPathToItem): # We used to 404 here, but that's ultimately a bad experience. There are real world use cases where a user @@ -443,7 +422,6 @@ def jump_to(request, course_id, location): redirect_url = get_courseware_url( usage_key=course_location_from_key(course_key), request=request, - experience=experience, ) return redirect(redirect_url) @@ -491,7 +469,7 @@ def course_info(request, course_id): # If the unified course experience is enabled, redirect to the "Course" tab if not DISABLE_UNIFIED_COURSE_TAB_FLAG.is_enabled(course_key): - return redirect(reverse(course_home_url_name(course_key), args=[course_id])) + return redirect(course_home_url(course_key)) with modulestore().bulk_operations(course_key): course = get_course_with_access(request.user, 'load', course_key) @@ -925,7 +903,7 @@ def course_about(request, course_id): # If user needs to be redirected to course home then redirect if _course_home_redirect_enabled(): - return redirect(reverse(course_home_url_name(course_key), args=[str(course_key)])) + return redirect(course_home_url(course_key)) with modulestore().bulk_operations(course_key): permission = get_permission_for_course_about() @@ -938,10 +916,7 @@ def course_about(request, course_id): studio_url = get_studio_url(course, 'settings/details') if request.user.has_perm(VIEW_COURSE_HOME, course): - if course_home_legacy_is_active(course.id): - course_target = reverse(course_home_url_name(course.id), args=[str(course.id)]) - else: - course_target = get_learning_mfe_home_url(course_key=course.id, url_fragment='home') + course_target = course_home_url(course.id) else: course_target = reverse('about_course', args=[str(course.id)]) @@ -1053,82 +1028,12 @@ def program_marketing(request, program_uuid): return render_to_response('courseware/program_marketing.html', context) -@login_required -@ensure_csrf_cookie @ensure_valid_course_key def dates(request, course_id): """ - Display the course's dates.html, or 404 if there is no such course. - Assumes the course_id is in a valid format. + Simply redirects to the MFE dates tab, as this legacy view for dates no longer exists. """ - from lms.urls import COURSE_DATES_NAME, RESET_COURSE_DEADLINES_NAME - - course_key = CourseKey.from_string(course_id) - if not (course_home_legacy_is_active(course_key) or request.user.is_staff): - raise Redirect(get_learning_mfe_home_url( - course_key=course_key, url_fragment=COURSE_DATES_NAME, params=request.GET, - )) - - # Enable NR tracing for this view based on course - monitoring_utils.set_custom_attribute('course_id', str(course_key)) - monitoring_utils.set_custom_attribute('user_id', request.user.id) - monitoring_utils.set_custom_attribute('is_staff', request.user.is_staff) - - course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=False) - - masquerade = None - can_masquerade = request.user.has_perm(MASQUERADE_AS_STUDENT, course) - if can_masquerade: - masquerade, masquerade_user = setup_masquerade( - request, - course.id, - can_masquerade, - reset_masquerade_data=True, - ) - request.user = masquerade_user - - user_is_enrolled = CourseEnrollment.is_enrolled(request.user, course_key) - user_is_staff = bool(has_access(request.user, 'staff', course_key)) - - # Render the full content to enrolled users, as well as to course and global staff. - # Unenrolled users who are not course or global staff are redirected to the Outline Tab. - if not user_is_enrolled and not user_is_staff: - raise CourseAccessRedirect(reverse('openedx.course_experience.course_home', args=[course_id])) - - course_date_blocks = get_course_date_blocks(course, request.user, request, - include_access=True, include_past_dates=True) - - learner_is_full_access = not ContentTypeGatingConfig.enabled_for_enrollment(request.user, course_key) - - # User locale settings - user_timezone_locale = user_timezone_locale_prefs(request) - user_timezone = user_timezone_locale['user_timezone'] - user_language = user_timezone_locale['user_language'] - - missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, request.user) - - context = { - 'course': course, - 'course_date_blocks': course_date_blocks, - 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course), - 'learner_is_full_access': learner_is_full_access, - 'user_timezone': user_timezone, - 'user_language': user_language, - 'supports_preview_menu': True, - 'can_masquerade': can_masquerade, - 'masquerade': masquerade, - 'on_dates_tab': True, - 'content_type_gating_enabled': ContentTypeGatingConfig.enabled_for_enrollment( - user=request.user, - course_key=course_key, - ), - 'missed_deadlines': missed_deadlines, - 'missed_gated_content': missed_gated_content, - 'reset_deadlines_url': reverse(RESET_COURSE_DEADLINES_NAME), - 'has_ended': course.has_ended(), - } - - return render_to_response('courseware/dates.html', context) + raise Redirect(get_learning_mfe_home_url(course_key=course_id, url_fragment='dates', params=request.GET)) @transaction.non_atomic_requests @@ -1562,7 +1467,7 @@ def course_survey(request, course_id): course_key = CourseKey.from_string(course_id) course = get_course_with_access(request.user, 'load', course_key, check_survey_complete=False) - redirect_url = reverse(course_home_url_name(course.id), args=[course_id]) + redirect_url = course_home_url(course_key) # if there is no Survey associated with this course, # then redirect to the course instead @@ -1702,9 +1607,6 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): Returns an HttpResponse with HTML content for the xBlock with the given usage_key. The returned HTML is a chromeless rendering of the xBlock (excluding content of the containing courseware). """ - from lms.urls import RESET_COURSE_DEADLINES_NAME - from openedx.features.course_experience.urls import COURSE_HOME_VIEW_NAME - usage_key = UsageKey.from_string(usage_key_string) usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) @@ -1811,12 +1713,11 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): 'missed_deadlines': missed_deadlines, 'missed_gated_content': missed_gated_content, 'has_ended': course.has_ended(), - 'web_app_course_url': reverse(COURSE_HOME_VIEW_NAME, args=[course.id]), + 'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'), 'on_courseware_page': True, 'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course), 'is_learning_mfe': is_learning_mfe, 'is_mobile_app': is_mobile_app, - 'reset_deadlines_url': reverse(RESET_COURSE_DEADLINES_NAME), 'render_course_wide_assets': True, **optimization_flags, @@ -1824,6 +1725,58 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True): return render_to_response('courseware/courseware-chromeless.html', context) +@require_http_methods(["GET"]) +@ensure_valid_usage_key +@xframe_options_exempt +@transaction.non_atomic_requests +def render_public_video_xblock(request, usage_key_string): + """ + Returns an HttpResponse with HTML content for the Video xBlock with the given usage_key. + The returned HTML is a chromeless rendering of the Video xBlock (excluding content of the containing courseware). + """ + view = 'public_view' + + usage_key = UsageKey.from_string(usage_key_string) + usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) + course_key = usage_key.course_key + + # usage key block type must be `video` else raise 404 + if usage_key.block_type != 'video': + raise Http404("Video not found.") + + with modulestore().bulk_operations(course_key): + course = get_course_by_id(course_key, 0) + + block, _ = get_module_by_usage_id( + request, + str(course_key), + str(usage_key), + disable_staff_debug_info=True, + course=course, + will_recheck_access=False + ) + + # video must be public (`Public Access` field set to True) by course author in studio in video advanced settings + if not block.public_access: + raise Http404("Video not found.") + + fragment = block.render(view, context={}) + + context = { + 'fragment': fragment, + 'course': course, + 'disable_accordion': False, + 'allow_iframing': True, + 'disable_header': False, + 'disable_footer': False, + 'disable_window_wrap': True, + 'edx_notes_enabled': False, + 'is_learning_mfe': True, + 'is_mobile_app': False, + } + return render_to_response('courseware/courseware-chromeless.html', context) + + def get_optimization_flags_for_content(block, fragment): """ Return a dict with a set of display options appropriate for the block. @@ -1998,6 +1951,48 @@ def financial_assistance_request(request): return HttpResponse(status=status.HTTP_204_NO_CONTENT) +@login_required +@require_POST +def financial_assistance_request_v2(request): + """ + Uses the new financial assistance application flow. + Creates a post request to edx-financial-assistance backend. + """ + try: + data = json.loads(request.body.decode('utf8')) + username = data['username'] + # Simple sanity check that the session belongs to the user + # submitting an FA request + if request.user.username != username: + return HttpResponseForbidden() + + lms_user_id = request.user.id + course_id = data['course'] + income = data['income'] + learner_reasons = data['reason_for_applying'] + learner_goals = data['goals'] + learner_plans = data['effort'] + allowed_for_marketing = data['mktg-permission'] + + except ValueError: + # Thrown if JSON parsing fails + return HttpResponseBadRequest('Could not parse request JSON.') + except KeyError as err: + # Thrown if fields are missing + return HttpResponseBadRequest(f'The field {str(err)} is required.') + + form_data = { + 'lms_user_id': lms_user_id, + 'course_id': course_id, + 'income': income, + 'learner_reasons': learner_reasons, + 'learner_goals': learner_goals, + 'learner_plans': learner_plans, + 'allowed_for_marketing': allowed_for_marketing + } + return create_financial_assistance_application(form_data) + + @login_required def financial_assistance_form(request): """Render the financial assistance application form page.""" @@ -2010,6 +2005,11 @@ def financial_assistance_form(request): annual_incomes = [ {'name': _(income), 'value': income} for income in incomes # lint-amnesty, pylint: disable=translation-of-non-string ] + if ENABLE_NEW_FINANCIAL_ASSISTANCE_FLOW.is_enabled(): + submit_url = 'submit_financial_assistance_request_v2' + else: + submit_url = 'submit_financial_assistance_request' + return render_to_response('financial-assistance/apply.html', { 'header_text': _get_fa_header(FINANCIAL_ASSISTANCE_HEADER), 'student_faq_url': marketing_link('FAQ'), @@ -2022,7 +2022,7 @@ def financial_assistance_form(request): 'name': user.profile.name, 'country': str(user.profile.country.name), }, - 'submit_url': reverse('submit_financial_assistance_request'), + 'submit_url': reverse(submit_url), 'fields': [ { 'name': 'course', diff --git a/lms/djangoapps/discussion/django_comment_client/tests/utils.py b/lms/djangoapps/discussion/django_comment_client/tests/utils.py index 1e762feeec..4f5fa72ef3 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/utils.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/utils.py @@ -83,7 +83,8 @@ def config_course_discussions( course, discussion_topics={}, divided_discussions=[], - always_divide_inline_discussions=False + always_divide_inline_discussions=False, + reported_content_email_notifications=False, ): """ Set discussions and configure divided discussions for a course. @@ -96,6 +97,8 @@ def config_course_discussions( list to use the same ids as discussion topic names. always_divide_inline_discussions (bool): Whether inline discussions should be divided by default. + reported_content_email_notifications (bool): Whether email notifications + are enabled for reported content for moderators. Returns: Nothing -- modifies course in place. @@ -113,6 +116,7 @@ def config_course_discussions( ], 'always_divide_inline_discussions': always_divide_inline_discussions, 'division_scheme': CourseDiscussionSettings.COHORT, + 'reported_content_email_notifications': reported_content_email_notifications, }) course.discussion_topics = {name: {"sort_key": "A", "id": to_id(name)} diff --git a/lms/djangoapps/discussion/management/commands/update_user_discussion_stats.py b/lms/djangoapps/discussion/management/commands/update_user_discussion_stats.py new file mode 100644 index 0000000000..ee2509f90f --- /dev/null +++ b/lms/djangoapps/discussion/management/commands/update_user_discussion_stats.py @@ -0,0 +1,30 @@ +""" +Management command to update user stats for all users in a course. +""" +import logging + +from django.core.management.base import BaseCommand +from opaque_keys.edx.keys import CourseKey + +import openedx.core.djangoapps.django_comment_common.comment_client.course as cc + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Invoke with: + + python manage.py lms update_user_discussion_stats + + """ + help = 'Update the user stats for all users for a particular course.' + + def add_arguments(self, parser): + parser.add_argument('course_id', help="ID of the Course to update user stats for") + + def handle(self, *args, **options): + course_id = options['course_id'] + course_key = CourseKey.from_string(course_id) + data = cc.update_course_users_stats(course_key) + log.info(f"Updated user stats for {data['user_count']} users in {course_key}") diff --git a/lms/djangoapps/discussion/plugins.py b/lms/djangoapps/discussion/plugins.py index b97cd49e5d..12ad5061f6 100644 --- a/lms/djangoapps/discussion/plugins.py +++ b/lms/djangoapps/discussion/plugins.py @@ -9,6 +9,8 @@ from xmodule.tabs import TabFragmentViewMixin import lms.djangoapps.discussion.django_comment_client.utils as utils from lms.djangoapps.courseware.tabs import EnrolledTab +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider +from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url from openedx.features.lti_course_tab.tab import DiscussionLtiCourseTab @@ -35,3 +37,15 @@ class DiscussionTab(TabFragmentViewMixin, EnrolledTab): if DiscussionLtiCourseTab.is_enabled(course, user): return False return utils.is_discussion_enabled(course.id) + + @property + def link_func(self): + legacy_link_func = super().link_func + + def _link_func(course, reverse_func): + config = DiscussionsConfiguration.get(course.id) + if config.provider_type == Provider.OPEN_EDX: + return get_learning_mfe_home_url(course_key=course.id, url_fragment=self.type) + else: + return legacy_link_func(course, reverse_func) + return _link_func diff --git a/lms/djangoapps/discussion/rest_api/render.py b/lms/djangoapps/discussion/rest_api/render.py index 303b26299c..fb302423c8 100644 --- a/lms/djangoapps/discussion/rest_api/render.py +++ b/lms/djangoapps/discussion/rest_api/render.py @@ -13,7 +13,7 @@ ALLOWED_TAGS = bleach.ALLOWED_TAGS + [ ] ALLOWED_PROTOCOLS = ["http", "https", "ftp", "mailto"] ALLOWED_ATTRIBUTES = { - "a": ["href", "title"], + "a": ["href", "title", "target", "rel"], "img": ["src", "alt", "title", "width", "height"], } diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index d890046474..13f691209a 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -434,6 +434,11 @@ class ThreadSerializer(_ContentSerializer): def update(self, instance, validated_data): for key, val in validated_data.items(): instance[key] = val + requesting_user_id = self.context["cc_requester"]["id"] + if key == "closed" and val: + instance["closing_user_id"] = requesting_user_id + if key == "body" and val: + instance["editing_user_id"] = requesting_user_id instance.save() return instance @@ -573,8 +578,11 @@ class CommentSerializer(_ContentSerializer): # TODO: The comments service doesn't populate the endorsement # field on comment creation, so we only provide # endorsement_user_id on update + requesting_user_id = self.context["cc_requester"]["id"] if key == "endorsed": - instance["endorsement_user_id"] = self.context["cc_requester"]["id"] + instance["endorsement_user_id"] = requesting_user_id + if key == "body" and val: + instance["editing_user_id"] = requesting_user_id instance.save() return instance diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index cd8425bd28..efc95084cd 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -2558,7 +2558,8 @@ class UpdateThreadTest( 'anonymous_to_peers': ['False'], 'closed': ['False'], 'pinned': ['False'], - 'read': ['False'] + 'read': ['False'], + 'editing_user_id': [str(self.user.id)], } def test_nonexistent_thread(self): @@ -2859,6 +2860,38 @@ class UpdateThreadTest( assert role_name == FORUM_ROLE_STUDENT assert error.message_dict == {"edit_reason_code": ["This field is not editable."]} + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ) + @mock.patch("lms.djangoapps.discussion.rest_api.serializers.CLOSE_REASON_CODES", { + "test-close-reason": "Test Close Reason", + }) + def test_update_thread_with_close_reason_code(self, role_name): + """ + Test editing comments, specifying and retrieving edit reason codes. + """ + _assign_role_to_user(user=self.user, course_id=self.course.id, role=role_name) + self.register_thread() + try: + result = update_thread(self.request, "test_thread", { + "closed": True, + "close_reason_code": "test-close-reason", + }) + assert role_name != FORUM_ROLE_STUDENT + assert result["closed"] + request_body = httpretty.last_request().parsed_body # pylint: disable=no-member + assert request_body["close_reason_code"] == ["test-close-reason"] + assert request_body["closing_user_id"] == [str(self.user.id)] + except ValidationError as error: + assert role_name == FORUM_ROLE_STUDENT + assert error.message_dict == { + "closed": ["This field is not editable."], + "close_reason_code": ["This field is not editable."], + } + @ddt.ddt @disable_signal(api, 'comment_edited') @@ -2966,7 +2999,8 @@ class UpdateCommentTest( 'user_id': [str(self.user.id)], 'anonymous': ['False'], 'anonymous_to_peers': ['False'], - 'endorsed': ['False'] + 'endorsed': ['False'], + 'editing_user_id': [str(self.user.id)], } def test_nonexistent_comment(self): diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index a2d11548d4..6fbedb4ff4 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -592,7 +592,8 @@ class ThreadSerializerDeserializationTest( 'closed': ['False'], 'pinned': ['False'], 'user_id': [str(self.user.id)], - 'read': [str(read)] + 'read': [str(read)], + 'editing_user_id': [str(self.user.id)], } for key in data: assert saved[key] == data[key] @@ -877,7 +878,8 @@ class CommentSerializerDeserializationTest(ForumsEnableMixin, CommentsServiceMoc 'anonymous': ['False'], 'anonymous_to_peers': ['False'], 'endorsed': ['True'], - 'endorsement_user_id': [str(self.user.id)] + 'endorsement_user_id': [str(self.user.id)], + 'editing_user_id': [str(self.user.id)], } for key in data: assert saved[key] == data[key] diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 37e97daa8f..529ae16c89 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -1306,7 +1306,7 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest 'updated_at': 'Test Updated Date', 'comment_count': 1, 'read': True, - 'response_count': 2 + 'response_count': 2, }) assert parsed_body(httpretty.last_request()) == { 'course_id': [str(self.course.id)], @@ -1319,7 +1319,8 @@ class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTest 'anonymous_to_peers': ['False'], 'closed': ['False'], 'pinned': ['False'], - 'read': ['True'] + 'read': ['True'], + 'editing_user_id': [str(self.user.id)], } def test_error(self): @@ -2022,7 +2023,8 @@ class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTes 'user_id': [str(self.user.id)], 'anonymous': ['False'], 'anonymous_to_peers': ['False'], - 'endorsed': ['False'] + 'endorsed': ['False'], + 'editing_user_id': [str(self.user.id)], } def test_error(self): @@ -2313,7 +2315,9 @@ class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStor 'divided_course_wide_discussions': [], 'id': 1, 'division_scheme': 'cohort', - 'available_division_schemes': ['cohort'] + 'available_division_schemes': ['cohort'], + 'reported_content_email_notifications': False, + 'reported_content_email_notifications_flag': False, } def patch_request(self, data, headers=None): @@ -2491,6 +2495,15 @@ class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStor expected_response['division_scheme'] = 'none' self._assert_patched_settings({'division_scheme': 'none'}, expected_response) + def test_update_reported_content_email_notifications(self): + """Test whether the 'reported_content_email_notifications' setting is updated.""" + config_course_cohorts(self.course, is_cohorted=True) + config_course_discussions(self.course, reported_content_email_notifications=True) + expected_response = self._get_expected_response() + expected_response['reported_content_email_notifications'] = True + self._login_as_staff() + self._assert_current_settings(expected_response) + @ddt.ddt class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTestCase): diff --git a/lms/djangoapps/discussion/templates/discussion/discussion_mfe_embed.html b/lms/djangoapps/discussion/templates/discussion/discussion_mfe_embed.html index 54159e75c1..69dcc60774 100644 --- a/lms/djangoapps/discussion/templates/discussion/discussion_mfe_embed.html +++ b/lms/djangoapps/discussion/templates/discussion/discussion_mfe_embed.html @@ -7,10 +7,33 @@ <%! import json from django.utils.translation import ugettext as _ +from openedx.core.djangolib.js_utils import js_escaped_string %> +<%block name="js_extra"> + + +
+ data-course-id="${course_key}"> <%include file="_switch_experience_fragment.html" /> - +
diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index 0cec540d3c..a44bd19775 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -2287,7 +2287,7 @@ class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase): self.staff_user = AdminFactory.create() CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - @ddt.data(*itertools.product(("http://test.url", None), (True, False), (True, False))) + @ddt.data(*itertools.product(("http://test.url", None), (True, False), (True, True))) @ddt.unpack def test_staff_user(self, mfe_url, toggle_enabled, is_staff): """ diff --git a/lms/djangoapps/discussion/toggles.py b/lms/djangoapps/discussion/toggles.py index 6d2f8e3909..b22151cd15 100644 --- a/lms/djangoapps/discussion/toggles.py +++ b/lms/djangoapps/discussion/toggles.py @@ -14,6 +14,17 @@ WAFFLE_FLAG_NAMESPACE = "discussions" # .. toggle_target_removal_date: 2022-03-05 ENABLE_DISCUSSIONS_MFE = CourseWaffleFlag(WAFFLE_FLAG_NAMESPACE, 'enable_discussions_mfe', __name__) +# .. toggle_name: discussions.enable_discussions_mfe_for_everyone +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to use the new MFE experience for discussions in the course tab and in-context +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2021-04-21 +# .. toggle_target_removal_date: 2022-03-05 +ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE = CourseWaffleFlag( + WAFFLE_FLAG_NAMESPACE, 'enable_discussions_mfe_for_everyone', __name__ +) + # .. toggle_name: discussions.enable_new_structure_discussions # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False @@ -45,3 +56,16 @@ ENABLE_DISCUSSION_MODERATION_REASON_CODES = CourseWaffleFlag( 'enable_moderation_reason_codes', __name__, ) + +# .. toggle_name: discussions.enable_reported_content_email_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to toggle email notifications for reported content for moderators +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2022-03-08 +# .. toggle_target_removal_date: 2022-12-31 +ENABLE_REPORTED_CONTENT_EMAIL_NOTIFICATIONS = CourseWaffleFlag( + WAFFLE_FLAG_NAMESPACE, + 'enable_reported_content_email_notifications', + __name__, +) diff --git a/lms/djangoapps/discussion/views.py b/lms/djangoapps/discussion/views.py index 84cf8ce119..9c4b9b6630 100644 --- a/lms/djangoapps/discussion/views.py +++ b/lms/djangoapps/discussion/views.py @@ -47,7 +47,7 @@ from lms.djangoapps.discussion.django_comment_client.utils import ( strip_none, ) from lms.djangoapps.discussion.exceptions import TeamDiscussionHiddenFromUserException -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE, ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context from lms.djangoapps.teams import api as team_api from openedx.core.djangoapps.discussions.url_helpers import get_discussions_mfe_url @@ -61,6 +61,7 @@ from openedx.core.djangoapps.django_comment_common.models import CourseDiscussio from openedx.core.djangoapps.django_comment_common.utils import ThreadContext from openedx.core.djangoapps.plugin_api.views import EdxFragmentView from openedx.features.course_duration_limits.access import generate_course_expired_fragment +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, GlobalStaff User = get_user_model() log = logging.getLogger("edx.discussions") @@ -703,7 +704,10 @@ def followed_threads(request, course_key, user_id): raise Http404 # lint-amnesty, pylint: disable=raise-missing-from -def _discussions_mfe_context(query_params: Dict, course_key: CourseKey, legacy_only_view=False) -> Optional[Dict]: +def _discussions_mfe_context(query_params: Dict, + course_key: CourseKey, + is_educator_or_staff=False, + legacy_only_view=False) -> Optional[Dict]: """ Returns the context for rendering the MFE banner and MFE. @@ -718,12 +722,15 @@ def _discussions_mfe_context(query_params: Dict, course_key: CourseKey, legacy_o if not mfe_url: return {"show_banner": False, "show_mfe": False} discussions_mfe_enabled = ENABLE_DISCUSSIONS_MFE.is_enabled(course_key) + discussions_mfe_enabled_for_everyone = ENABLE_DISCUSSIONS_MFE_FOR_EVERYONE.is_enabled(course_key) + enabled_for_educator_or_staff = is_educator_or_staff and discussions_mfe_enabled + enable_mfe = enabled_for_educator_or_staff or discussions_mfe_enabled_for_everyone # Show the MFE if the new MFE is enabled, # and if the legacy experience is not requested via query param # and if the current view isn't only that's only supported by the legacy view show_mfe = ( query_params.get("discussions_experience", "").lower() != "legacy" - and discussions_mfe_enabled + and enable_mfe and not legacy_only_view ) forum_url = reverse("forum_form_discussion", args=[course_key]) @@ -733,11 +740,18 @@ def _discussions_mfe_context(query_params: Dict, course_key: CourseKey, legacy_o "mfe_url": f"{forum_url}?discussions_experience=new", "share_feedback_url": settings.DISCUSSIONS_MFE_FEEDBACK_URL, "course_key": course_key, - "show_banner": discussions_mfe_enabled, + "show_banner": enable_mfe, "discussions_mfe_url": mfe_url, } +def is_course_staff(course_key: CourseKey, user: User): + """ + Check if user has course instructor or course staff role. + """ + return CourseInstructorRole(course_key).has_user(user) or CourseStaffRole(course_key).has_user(user) + + class DiscussionBoardFragmentView(EdxFragmentView): """ Component implementation of the discussion board. @@ -767,7 +781,8 @@ class DiscussionBoardFragmentView(EdxFragmentView): course_key = CourseKey.from_string(course_id) # Force using the legacy view if a user profile is requested or the URL contains a specific topic or thread force_legacy_view = (profile_page_context or thread_id or discussion_id) - mfe_context = _discussions_mfe_context(request.GET, course_key, force_legacy_view) + is_educator_or_staff = is_course_staff(course_key, request.user) or GlobalStaff().has_user(request.user) + mfe_context = _discussions_mfe_context(request.GET, course_key, is_educator_or_staff, force_legacy_view) if mfe_context["show_mfe"]: fragment = Fragment(render_to_string('discussion/discussion_mfe_embed.html', mfe_context)) fragment.add_css( diff --git a/lms/djangoapps/grades/course_grade_factory.py b/lms/djangoapps/grades/course_grade_factory.py index 1c64f97589..65f3a9c4cc 100644 --- a/lms/djangoapps/grades/course_grade_factory.py +++ b/lms/djangoapps/grades/course_grade_factory.py @@ -33,6 +33,7 @@ class CourseGradeFactory: course_structure=None, course_key=None, create_if_needed=True, + send_course_grade_signals=True, ): """ Returns the CourseGrade for the given user in the course. @@ -51,7 +52,7 @@ class CourseGradeFactory: if assume_zero_if_absent(course_data.course_key): return self._create_zero(user, course_data) elif create_if_needed: - return self._update(user, course_data) + return self._update(user, course_data, send_course_grade_signals=send_course_grade_signals) else: return None @@ -160,13 +161,16 @@ class CourseGradeFactory: ) @staticmethod - def _update(user, course_data, force_update_subsections=False): + def _update(user, course_data, force_update_subsections=False, send_course_grade_signals=True): """ - Computes, saves, and returns a CourseGrade object for the - given user and course. - Sends a COURSE_GRADE_CHANGED signal to listeners and - COURSE_GRADE_NOW_PASSED if learner has passed course or - COURSE_GRADE_NOW_FAILED if learner is now failing course + Computes, saves, and returns a CourseGrade object for the given user and course. + + send_course_grade_signals defines if signals should be sent. Use it to avoid recursion issues in + cases when the signal listener trying to get grades but Persistent Grades are disabled. + If True - sends: + COURSE_GRADE_CHANGED signal to listeners and + COURSE_GRADE_NOW_PASSED if learner has passed course or + COURSE_GRADE_NOW_FAILED if learner is now failing course """ should_persist = should_persist_grades(course_data.course_key) if should_persist and force_update_subsections: @@ -193,26 +197,27 @@ class CourseGradeFactory: passed=course_grade.passed, ) - COURSE_GRADE_CHANGED.send_robust( - sender=None, - user=user, - course_grade=course_grade, - course_key=course_data.course_key, - deadline=course_data.course.end, - ) - if course_grade.passed: - COURSE_GRADE_NOW_PASSED.send( - sender=CourseGradeFactory, + if send_course_grade_signals: + COURSE_GRADE_CHANGED.send_robust( + sender=None, user=user, - course_id=course_data.course_key, - ) - else: - COURSE_GRADE_NOW_FAILED.send( - sender=CourseGradeFactory, - user=user, - course_id=course_data.course_key, - grade=course_grade, + course_grade=course_grade, + course_key=course_data.course_key, + deadline=course_data.course.end, ) + if course_grade.passed: + COURSE_GRADE_NOW_PASSED.send( + sender=CourseGradeFactory, + user=user, + course_id=course_data.course_key, + ) + else: + COURSE_GRADE_NOW_FAILED.send( + sender=CourseGradeFactory, + user=user, + course_id=course_data.course_key, + grade=course_grade, + ) log.info( 'Grades: Update, %s, User: %s, %s, persisted: %s', diff --git a/lms/djangoapps/grades/tests/test_course_grade_factory.py b/lms/djangoapps/grades/tests/test_course_grade_factory.py index 91fea2f1f5..880b31f0d2 100644 --- a/lms/djangoapps/grades/tests/test_course_grade_factory.py +++ b/lms/djangoapps/grades/tests/test_course_grade_factory.py @@ -2,16 +2,19 @@ Tests for the CourseGradeFactory class. """ import itertools -from unittest.mock import patch +from unittest.mock import patch, Mock import ddt from django.conf import settings from edx_toggles.toggles.testutils import override_waffle_switch +import pytest from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION from lms.djangoapps.courseware.access import has_access from lms.djangoapps.grades.config.tests.utils import persistent_grades_feature_flags from openedx.core.djangoapps.content.block_structure.factory import BlockStructureFactory +from openedx.core.djangoapps.signals.signals import COURSE_GRADE_NOW_PASSED from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -73,6 +76,35 @@ class TestCourseGradeFactory(GradeTestBase): grade_factory.read(self.request.user, self.course) assert mock_read_grade.called == (feature_flag and course_setting) + @patch.dict(settings.FEATURES, {'PERSISTENT_GRADES_ENABLED_FOR_ALL_TESTS': False}) + def test_no_recursion_without_persistent_grades(self): + """ + Course grade signals should not be fired recursively when persistent grades are disabled. + """ + self.mock_process_signal = Mock() # pylint: disable=attribute-defined-outside-init + + def handler(**kwargs): + """ + Mock signal receiver. + """ + self.mock_process_signal() + + with persistent_grades_feature_flags( + global_flag=False, + enabled_for_all_courses=False, + course_id=self.course.id, + enabled_for_course=False + ): + with override_waffle_switch(AUTO_CERTIFICATE_GENERATION, active=True), mock_get_score(2, 2): + COURSE_GRADE_NOW_PASSED.connect(handler) + try: + CourseGradeFactory().update(self.request.user, self.course) + except RecursionError: + pytest.fail("The COURSE_GRADE_NOW_PASSED signal fired recursively.") + + self.mock_process_signal.assert_called_once() + COURSE_GRADE_NOW_PASSED.disconnect(handler) + def test_read_and_update(self): grade_factory = CourseGradeFactory() diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 77bef6407a..e79397c262 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -10,8 +10,10 @@ import shutil import tempfile from unittest.mock import Mock, NonCallableMock, patch +import dateutil import ddt import pytest +import pytz from boto.exception import BotoServerError from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user @@ -27,6 +29,7 @@ from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import UsageKey from pytz import UTC +from testfixtures import LogCapture from xmodule.fields import Date from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import ( @@ -91,12 +94,14 @@ from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMM from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin +from openedx.core.djangoapps.user_api.preferences.api import set_user_preference, delete_user_preference from openedx.core.lib.teams_config import TeamsConfig from openedx.core.lib.xblock_utils import grade_histogram from openedx.features.course_experience import RELATIVE_DATES_FLAG from .test_tools import msk_from_problem_urlname +LOG_PATH = "lms.djangoapps.instructor.views.api" DATE_FIELD = Date() EXPECTED_CSV_HEADER = ( '"code","redeem_code_url","course_id","company_name","created_by","redeemed_by","invoice_id","purchaser",' @@ -2808,32 +2813,24 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment response = self.client.post(url, {}) self.assertContains(response, success_status) - @ddt.data( - True, - False - ) @valid_problem_location - def test_idv_retirement_student_features_report(self, enable_integrity_signature): - with patch.dict(settings.FEATURES, {'ENABLE_INTEGRITY_SIGNATURE': enable_integrity_signature}): - kwargs = {'course_id': str(self.course.id)} - kwargs.update({'csv': '/csv'}) - url = reverse('get_students_features', kwargs=kwargs) - success_status = 'The enrolled learner profile report is being created.' - with patch('lms.djangoapps.instructor_task.api.submit_calculate_students_features_csv') \ - as mock_task_endpoint: - CourseFinanceAdminRole(self.course.id).add_users(self.instructor) - response = self.client.post(url, {}) - self.assertContains(response, success_status) + def test_idv_retirement_student_features_report(self): + kwargs = {'course_id': str(self.course.id)} + kwargs.update({'csv': '/csv'}) + url = reverse('get_students_features', kwargs=kwargs) + success_status = 'The enrolled learner profile report is being created.' + with patch('lms.djangoapps.instructor_task.api.submit_calculate_students_features_csv') as mock_task_endpoint: + CourseFinanceAdminRole(self.course.id).add_users(self.instructor) + response = self.client.post(url, {}) + self.assertContains(response, success_status) - # assert that if the integrity signature is enabled, the verification - # status is not included as a query feature - args = mock_task_endpoint.call_args.args - self.assertEqual(len(args), 3) - query_features = args[2] - if not enable_integrity_signature: - self.assertIn('verification_status', query_features) - else: - self.assertNotIn('verification_status', query_features) + # assert that if the integrity signature is enabled, the verification + # status is not included as a query feature + args = mock_task_endpoint.call_args.args + self.assertEqual(len(args), 3) + query_features = args[2] + + self.assertNotIn('verification_status', query_features) def test_get_ora2_responses_success(self): url = reverse('export_ora2_data', kwargs={'course_id': str(self.course.id)}) @@ -3377,13 +3374,6 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm def setUpClass(cls): super().setUpClass() cls.course = CourseFactory.create() - test_subject = '\u1234 test subject' - test_message = '\u6824 test message' - cls.full_test_message = { - 'send_to': '["myself", "staff"]', - 'subject': test_subject, - 'message': test_message, - } BulkEmailFlag.objects.create(enabled=True, require_course_email_auth=False) @classmethod @@ -3393,10 +3383,26 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm def setUp(self): super().setUp() + test_subject = '\u1234 test subject' + test_message = '\u6824 test message' + self.full_test_message = { + 'send_to': '["myself", "staff"]', + 'subject': test_subject, + 'message': test_message, + } self.instructor = InstructorFactory(course_key=self.course.id) self.client.login(username=self.instructor.username, password='test') + def tearDown(self): + super().tearDown() + delete_user_preference(self.instructor, 'time_zone', username=self.instructor.username) + + def _get_expected_schedule(self, schedule, timezone): + local_tz = dateutil.tz.gettz(timezone) + local_dt = dateutil.parser.parse(schedule).replace(tzinfo=local_tz) + return local_dt.astimezone(pytz.utc) + def test_send_email_as_logged_in_instructor(self): url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, self.full_test_message) @@ -3483,6 +3489,122 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm html_message=self.full_test_message['message'], template_name=org_template, from_addr=org_email).count() + @patch("lms.djangoapps.instructor.views.api.task_api.submit_bulk_course_email") + def test_send_email_with_schedule_and_timezone(self, mock_task_api): + """ + Test for the new scheduling logic added to the `send_email` function. + """ + schedule = "2030-05-02T14:00:00.000Z" + timezone = "America/New_York" + self.full_test_message['schedule'] = schedule + self.full_test_message['browser_timezone'] = timezone + expected_schedule = self._get_expected_schedule(schedule, timezone) + expected_messages = [ + f"Converting requested schedule from local time '{schedule}' with the timezone '{timezone}' to UTC", + ] + + url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) + with LogCapture() as log: + response = self.client.post(url, self.full_test_message) + + assert response.status_code == 200 + _, _, _, arg_schedule = mock_task_api.call_args.args + assert arg_schedule == expected_schedule + log.check_present( + (LOG_PATH, "INFO", expected_messages[0]), + ) + + @patch("lms.djangoapps.instructor.views.api.task_api.submit_bulk_course_email") + def test_send_email_with_schedule_and_no_browser_timezone(self, mock_task_api): + """ + Test that verifies we will retrieve and use the preferred time zone if possible. + """ + schedule = "2030-05-02T14:00:00.000Z" + timezone = "America/New_York" + self.full_test_message['schedule'] = schedule + self.full_test_message['browser_timezone'] = "" + expected_schedule = self._get_expected_schedule(schedule, timezone) + set_user_preference(self.instructor, 'time_zone', timezone) + + url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, self.full_test_message) + + assert response.status_code == 200 + _, _, _, arg_schedule = mock_task_api.call_args.args + assert arg_schedule == expected_schedule + + @patch("lms.djangoapps.instructor.views.api.task_api.submit_bulk_course_email") + def test_send_email_with_schedule_and_preferred_timezone(self, mock_task_api): + """ + Test that verifies we will use the preferred timezone over the browser timezone if possible. + """ + schedule = "2030-05-02T14:00:00.000Z" + preferred_timezone = "America/Anchorage" + self.full_test_message['schedule'] = schedule + self.full_test_message['browser_timezone'] = "America/New_York" + expected_schedule = self._get_expected_schedule(schedule, preferred_timezone) + set_user_preference(self.instructor, 'time_zone', preferred_timezone) + + url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, self.full_test_message) + + assert response.status_code == 200 + _, _, _, arg_schedule = mock_task_api.call_args.args + assert arg_schedule == expected_schedule + + def test_send_email_with_malformed_schedule_expect_error(self): + self.full_test_message['schedule'] = "Blub Glub" + self.full_test_message['browser_timezone'] = "America/New_York" + expected_messages = [ + "Error occurred while attempting to create a scheduled bulk email task: unknown string format", + ] + + url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) + with LogCapture() as log: + response = self.client.post(url, self.full_test_message) + + assert response.status_code == 400 + log.check_present( + (LOG_PATH, "ERROR", expected_messages[0]), + ) + + def test_send_email_with_malformed_timezone_expect_error(self): + self.full_test_message['schedule'] = "2030-05-02T14:00:00.000Z" + self.full_test_message['browser_timezone'] = "Flim/Flam" + expected_messages = [ + "Error occurred while attempting to create a scheduled bulk email task: Unable to determine the time zone " + "to use to convert the schedule to UTC", + ] + + url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) + with LogCapture() as log: + response = self.client.post(url, self.full_test_message) + + assert response.status_code == 400 + log.check_present( + (LOG_PATH, "ERROR", expected_messages[0]), + ) + + def test_send_email_with_lapsed_date_expect_error(self): + schedule = "2020-01-01T00:00:00.000Z" + timezone = "America/New_York" + self.full_test_message['schedule'] = schedule + self.full_test_message['browser_timezone'] = timezone + expected_schedule = self._get_expected_schedule(schedule, timezone) + expected_messages = [ + "Error occurred while attempting to create a scheduled bulk email task: The requested schedule " + f"'{expected_schedule}' is in the past" + ] + + url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) + with LogCapture() as log: + response = self.client.post(url, self.full_test_message) + + assert response.status_code == 400 + log.check_present( + (LOG_PATH, "ERROR", expected_messages[0]), + ) + class MockCompletionInfo: """Mock for get_task_completion_info""" @@ -3725,7 +3847,9 @@ class TestInstructorEmailContentList(SharedModuleStoreTestCase, LoginEnrollmentT self.setup_fake_email_info(num_emails, with_failures) task_history_request.return_value = list(self.tasks.values()) url = reverse('list_email_content', kwargs={'course_id': str(self.course.id)}) - with patch('lms.djangoapps.instructor.views.api.CourseEmail.objects.get') as mock_email_info: + with patch( + 'lms.djangoapps.instructor.views.instructor_task_helpers.CourseEmail.objects.get' + ) as mock_email_info: mock_email_info.side_effect = self.get_matching_mock_email response = self.client.post(url, {}) assert response.status_code == 200 @@ -3802,7 +3926,9 @@ class TestInstructorEmailContentList(SharedModuleStoreTestCase, LoginEnrollmentT email_info = FakeEmailInfo(email, 0, 10) task_history_request.return_value = [task_info] url = reverse('list_email_content', kwargs={'course_id': str(self.course.id)}) - with patch('lms.djangoapps.instructor.views.api.CourseEmail.objects.get') as mock_email_info: + with patch( + 'lms.djangoapps.instructor.views.instructor_task_helpers.CourseEmail.objects.get' + ) as mock_email_info: mock_email_info.return_value = email response = self.client.post(url, {}) assert response.status_code == 200 diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 67cc72b68b..9651c0b397 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -7,12 +7,15 @@ Many of these GETs may become PUTs in the future. """ import csv +import datetime import json import logging import string import random import re +import dateutil +import pytz import edx_api_doc_tools as apidocs from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user @@ -71,8 +74,7 @@ from common.djangoapps.util.file import ( ) from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest from common.djangoapps.util.views import require_global_staff -from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled -from lms.djangoapps.bulk_email.models import CourseEmail +from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled, create_course_email from lms.djangoapps.certificates import api as certs_api from lms.djangoapps.certificates.models import ( CertificateStatuses @@ -963,9 +965,9 @@ def bulk_beta_modify_access(request, course_id): # Tabulate the action result of this email address results.append({ 'identifier': identifier, - 'error': error, - 'userDoesNotExist': user_does_not_exist, - 'is_active': user_active + 'error': error, # pylint: disable=used-before-assignment + 'userDoesNotExist': user_does_not_exist, # pylint: disable=used-before-assignment + 'is_active': user_active # pylint: disable=used-before-assignment }) response_payload = { @@ -1425,8 +1427,7 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red 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' + 'goals', 'enrollment_mode', 'last_login', 'date_joined', 'external_user_key' ] # Provide human-friendly and translatable names for these features. These names @@ -1445,7 +1446,6 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red 'mailing_address': _('Mailing Address'), 'goals': _('Goals'), 'enrollment_mode': _('Enrollment Mode'), - 'verification_status': _('Verification Status'), 'last_login': _('Last Login'), 'date_joined': _('Date Joined'), 'external_user_key': _('External User Key'), @@ -1460,11 +1460,6 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red query_features.append('team') query_features_names['team'] = _('Team') - if settings.FEATURES.get('ENABLE_INTEGRITY_SIGNATURE'): - if 'verification_status' in query_features: - query_features.remove('verification_status') - query_features_names.pop('verification_status') - # For compatibility reasons, city and country should always appear last. query_features.append('city') query_features_names['city'] = _('City') @@ -2699,6 +2694,7 @@ def send_email(request, course_id): - 'message' specifies email's content """ course_id = CourseKey.from_string(course_id) + course_overview = CourseOverview.get_from_id(course_id) if not is_bulk_email_feature_enabled(course_id): log.warning('Email is not enabled for course %s', course_id) @@ -2707,48 +2703,45 @@ def send_email(request, course_id): targets = json.loads(request.POST.get("send_to")) subject = request.POST.get("subject") message = request.POST.get("message") + # optional, this is a date and time in the form of an ISO8601 string + schedule = request.POST.get("schedule", "") + # optional, this is the timezone captured from the author's browser when requesting a scheduled email + browser_timezone = request.POST.get("browser_timezone", "") - # allow two branding points to come from Site Configuration: which CourseEmailTemplate should be used - # and what the 'from' field in the email should be - # - # If these are None (there is no site configuration enabled for the current site) than - # the system will use normal system defaults - course_overview = CourseOverview.get_from_id(course_id) - from_addr = configuration_helpers.get_value('course_email_from_addr') - if isinstance(from_addr, dict): - # If course_email_from_addr is a dict, we are customizing - # the email template for each organization that has courses - # on the site. The dict maps from addresses by org allowing - # us to find the correct from address to use here. - from_addr = from_addr.get(course_overview.display_org_with_default) + # If this is a scheduled bulk email request then we try to convert the requested schedule date to UTC. We do this + # before we attempt to create the email object instance in case there is an issue as we don't want to have an + # orphaned email object that will never be sent. + if schedule: + try: + schedule = _convert_schedule_to_utc_from_local(schedule, browser_timezone, request.user) + _determine_valid_schedule(schedule) + except ValueError as error: + error_message = f"Error occurred while attempting to create a scheduled bulk email task: {error}" + log.error(f"{error_message}") + return HttpResponseBadRequest(repr(error_message)) - template_name = configuration_helpers.get_value('course_email_template_name') - if isinstance(template_name, dict): - # If course_email_template_name is a dict, we are customizing - # the email template for each organization that has courses - # on the site. The dict maps template names by org allowing - # us to find the correct template to use here. - template_name = template_name.get(course_overview.display_org_with_default) + # Retrieve the customized email "from address" and email template from site configuration for the course/partner. If + # there is no site configuration enabled for the current site then we use system defaults for both. + from_addr = _get_branded_email_from_address(course_overview) + template_name = _get_branded_email_template(course_overview) - # Create the CourseEmail object. This is saved immediately, so that - # any transaction that has been pending up to this point will also be - # committed. + # Create the CourseEmail object. This is saved immediately so that any transaction that has been pending up to this + # point will also be committed. try: - email = CourseEmail.create( + email = create_course_email( course_id, request.user, targets, - subject, message, + subject, + message, template_name=template_name, - from_addr=from_addr + from_addr=from_addr, ) except ValueError as err: - log.exception('Cannot create course email for course %s requested by user %s for targets %s', - course_id, request.user, targets) 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) + task_api.submit_bulk_course_email(request, course_id, email.id, schedule) response_payload = { 'course_id': str(course_id), @@ -3564,3 +3557,91 @@ def _get_certificate_for_user(course_key, student): ) return certificate + + +def _get_branded_email_from_address(course_overview): + """ + Checks and retrieves a customized "from address", if one exists for the course/org. This is the email address that + learners will see the message coming from. + + Args: + course_overview (CourseOverview): The course overview instance for the course-run. + + Returns: + String: The customized "from address" to be used in messages sent by the bulk course email tool for this + course or org. + """ + from_addr = configuration_helpers.get_value('course_email_from_addr') + if isinstance(from_addr, dict): + # If course_email_from_addr is a dict, we are customizing the email template for each organization that has + # courses on the site. The dict maps from addresses by org allowing us to find the correct from address to use + # here. + from_addr = from_addr.get(course_overview.display_org_with_default) + + return from_addr + + +def _get_branded_email_template(course_overview): + """ + Checks and retrieves the custom email template, if one exists for the course/org, to style messages sent by the bulk + course email tool. + + Args: + course_overview (CourseOverview): The course overview instance for the course-run. + + Returns: + String: The name of the custom email template to use for this course or org. + """ + template_name = configuration_helpers.get_value('course_email_template_name') + if isinstance(template_name, dict): + # If course_email_template_name is a dict, we are customizing the email template for each organization that has + # courses on the site. The dict maps template names by org allowing us to find the correct template to use here. + template_name = template_name.get(course_overview.display_org_with_default) + + return template_name + + +def _convert_schedule_to_utc_from_local(schedule, browser_timezone, user): + """ + Utility function to help convert the schedule of an instructor task from the requesters local time and timezone + (taken from the request) to a UTC datetime. + + Args: + schedule (String): The desired time to execute a scheduled task, in local time, in the form of an ISOString. + timezone (String): The time zone, as captured by the user's web browser, in the form of a string. + user (User): The user requesting the action, captured from the originating web request. Used to lookup the + the time zone preference as set in the user's account settings. + + Returns: + DateTime: A datetime instance describing when to execute this schedule task converted to the UTC timezone. + """ + # look up the requesting user's timezone from their account settings + preferred_timezone = get_user_preference(user, 'time_zone', username=user.username) + # use the user's preferred timezone (if available), otherwise use the browser timezone. + timezone = preferred_timezone if preferred_timezone else browser_timezone + + # convert the schedule to UTC + log.info(f"Converting requested schedule from local time '{schedule}' with the timezone '{timezone}' to UTC") + + local_tz = dateutil.tz.gettz(timezone) + if local_tz is None: + raise ValueError( + "Unable to determine the time zone to use to convert the schedule to UTC" + ) + local_dt = dateutil.parser.parse(schedule).replace(tzinfo=local_tz) + schedule_utc = local_dt.astimezone(pytz.utc) + + return schedule_utc + + +def _determine_valid_schedule(schedule): + """ + Utility function that determines if the requested schedule is in the future. Raises ValueError if the schedule time + has already lapsed. + + Args: + schedule (DateTime): UTC DateTime representing the desired date and time to process a scheduled instructor task. + """ + now = datetime.datetime.now(pytz.utc) + if schedule < now: + raise ValueError(f"The requested schedule '{schedule}' is in the past") diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index d10c4f80b6..2a6f315d68 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -749,6 +749,7 @@ def _section_open_response_assessment(request, course, openassessment_blocks, ac 'parent_name': parents[block_parent_id].display_name, 'staff_assessment': 'staff-assessment' in block.assessment_steps, 'peer_assessment': 'peer-assessment' in block.assessment_steps, + 'team_assignment': block.teams_enabled, 'url_base': reverse('xblock_view', args=[course.id, block.location, 'student_view']), 'url_grade_available_responses': reverse('xblock_view', args=[course.id, block.location, 'grade_available_responses_view']), diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index c43ca04931..587781ab82 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -8,23 +8,29 @@ arguments. """ +import datetime import hashlib +import logging from collections import Counter +import pytz from celery.states import READY_STATES from common.djangoapps.util import milestones_helpers from lms.djangoapps.bulk_email.models import CourseEmail from lms.djangoapps.certificates.models import CertificateGenerationHistory from lms.djangoapps.instructor_task.api_helper import ( + QueueConnectionError, check_arguments_for_overriding, check_arguments_for_rescoring, check_entrance_exam_problems_for_rescoring, encode_entrance_exam_and_student_input, encode_problem_and_student_input, - submit_task + schedule_task, + submit_task, + submit_scheduled_task, ) -from lms.djangoapps.instructor_task.models import InstructorTask +from lms.djangoapps.instructor_task.models import InstructorTask, InstructorTaskSchedule, SCHEDULED from lms.djangoapps.instructor_task.tasks import ( calculate_grades_csv, calculate_may_enroll_csv, @@ -47,6 +53,8 @@ from lms.djangoapps.instructor_task.tasks import ( ) from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +log = logging.getLogger(__name__) + class SpecificStudentIdMissingError(Exception): """ @@ -292,7 +300,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): +def submit_bulk_course_email(request, course_key, email_id, schedule=None): """ Request to have bulk email sent as a background task. @@ -321,6 +329,10 @@ def submit_bulk_course_email(request, course_key, email_id): 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() + + if schedule: + return schedule_task(request, task_type, course_key, task_input, task_key, schedule) + return submit_task(request, task_type, task_class, course_key, task_input, task_key) @@ -560,3 +572,18 @@ def generate_anonymous_ids(request, course_key): task_key = "" return submit_task(request, task_type, task_class, course_key, task_input, task_key) + + +def process_scheduled_tasks(): + """ + Utility function that retrieves tasks whose schedules have elapsed and should be processed. Only retrieves + instructor tasks that are in the `SCHEDULED` state. Then submits these tasks for processing by Celery. + """ + now = datetime.datetime.now(pytz.utc) + due_schedules = InstructorTaskSchedule.objects.filter(task__task_state=SCHEDULED).filter(task_due__lte=now) + for schedule in due_schedules: + try: + log.info(f"Attempting to queue scheduled task with id '{schedule.task.id}'") + submit_scheduled_task(schedule) + except QueueConnectionError as exc: + log.error(f"Error processing scheduled task with task id '{schedule.task.id}': {exc}") diff --git a/lms/djangoapps/instructor_task/api_helper.py b/lms/djangoapps/instructor_task/api_helper.py index 59096b91d0..06e09372a0 100644 --- a/lms/djangoapps/instructor_task/api_helper.py +++ b/lms/djangoapps/instructor_task/api_helper.py @@ -14,11 +14,14 @@ from celery.result import AsyncResult from celery.states import FAILURE, READY_STATES, REVOKED, SUCCESS from django.utils.translation import gettext as _ from opaque_keys.edx.keys import UsageKey +from xmodule.modulestore.django import modulestore from common.djangoapps.util.db import outer_atomic from lms.djangoapps.courseware.courses import get_problems_in_section -from lms.djangoapps.instructor_task.models import PROGRESS, InstructorTask -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from lms.djangoapps.instructor_task.data import InstructorTaskTypes +from lms.djangoapps.instructor_task.models import PROGRESS, SCHEDULED, InstructorTask, InstructorTaskSchedule + +from lms.djangoapps.instructor_task.tasks import send_bulk_course_email log = logging.getLogger(__name__) @@ -88,7 +91,7 @@ def _reserve_task(course_id, task_type, task_key, task_input, requester): "No duplicate tasks found: task_type %s, task_key %s, and most recent task_id = %s", task_type, task_key, - most_recent_id + most_recent_id # pylint: disable=used-before-assignment ) # Create log entry now, so that future requests will know it's running. @@ -266,6 +269,23 @@ def _get_async_result(task_id): return AsyncResult(task_id) +def _determine_task_class(task_type): + """ + Utility function used when processing scheduled instructor tasks. This function uses the type of an instructor task + to determine the associated Celery task function that will be used when processing the task via Celery. + + Args: + task_type (String): A string describing the type of task. + + Returns: + A Python function associated with the scheduled instructor task used during task execution. + """ + if task_type == InstructorTaskTypes.BULK_COURSE_EMAIL: + return send_bulk_course_email + + return None + + def get_updated_instructor_task(task_id): """ Returns InstructorTask object corresponding to a given `task_id`. @@ -448,3 +468,73 @@ def submit_task(request, task_type, task_class, course_key, task_input, task_key _handle_instructor_task_failure(instructor_task, error) return instructor_task + + +def schedule_task(request, task_type, course_key, task_input, task_key, schedule): + """ + Helper function to schedule a background task. + + Reserves the requested task and stores it until the task is ready for execution. We also create an instance of a + InstructorTaskSchedule object responsible for maintaining the details of _when_ a task should be executed. Extracts + arguments important to the task from the originating server request and stores them as part of the schedule object. + Sets the `task_status` to SCHEDULED to indicate this task will be executed in the future. + + Args: + request (WSGIRequest): The originating web request associated with this task request. + task_type (String): Text describing the type of task (e.g. 'bulk_course_email' or 'grade_course') + course_key (CourseKey): The CourseKey of the course-run the task belongs to. + task_input (dict): Task input arguments stores as JSON-serialized dictionary. + task_key (String): Encoded input arguments used during task execution. + schedule (DateTime): DateTime (in UTC) describing when the task should be executed. + """ + instructor_task = None + try: + log.info(f"Creating a scheduled instructor task of type '{task_type}' for course '{course_key}' requested by " + f"user with id '{request.user.id}'") + instructor_task = InstructorTask.create(course_key, task_type, task_key, task_input, request.user) + + task_id = instructor_task.task_id + task_args = _get_xmodule_instance_args(request, task_id) + log.info(f"Creating a task schedule associated with instructor task '{instructor_task.id}' and due after " + f"'{schedule}'") + InstructorTaskSchedule.objects.create( + task=instructor_task, + task_args=json.dumps(task_args), + task_due=schedule, + ) + + log.info(f"Updating task state of instructor task '{instructor_task.id}' to '{SCHEDULED}'") + instructor_task.task_state = SCHEDULED + instructor_task.save() + except Exception as error: # pylint: disable=broad-except + log.error(f"Error occurred during task or schedule creation: {error}") + # Set any orphaned instructor tasks to the FAILURE state. + if instructor_task: + _handle_instructor_task_failure(instructor_task, error) + + +def submit_scheduled_task(schedule): + """ + Helper function for submitting a scheduled task due for execution to Celery. + """ + # determine the task_class needed based off the task_type + task_class = _determine_task_class(schedule.task.task_type) + if task_class: + try: + # convert the stored argument data back into a dict from text + task_arguments = json.loads(schedule.task_args) + # turn this into the format Celery expects + task_args = [schedule.task.id, task_arguments] + # submit the task + log.info(f"Submitting scheduled task {schedule.task.id} for processing") + task_class.apply_async(task_args, task_id=schedule.task.task_id) + except Exception as error: # pylint: disable=broad-except + # broad except here to make sure we cast a wide net for tasks with issues that can't be processed + log.error(f"Error submitting scheduled task '{schedule.task.id}' to Celery: {error}") + # handle task failure + _handle_instructor_task_failure(schedule.task, error) + else: + log.warning( + f"Could not submit scheduled instructor task with id '{schedule.task.id}' and task type " + f"'{schedule.task.task_type}'. Could not determine the task class for the request." + ) diff --git a/lms/djangoapps/instructor_task/data.py b/lms/djangoapps/instructor_task/data.py new file mode 100644 index 0000000000..c9da5eda7d --- /dev/null +++ b/lms/djangoapps/instructor_task/data.py @@ -0,0 +1,34 @@ +""" +Public data structures for the instructor_task app. +""" +from enum import Enum + + +class InstructorTaskTypes(str, Enum): + """ + Enum describing the assortment of instructor tasks supported by edx-platform. + """ + BULK_COURSE_EMAIL = "bulk_course_email" + COHORT_STUDENTS = "cohort_students" + COURSE_SURVEY_REPORT = "course_survey_report" + DELETE_PROBLEM_STATE = "delete_problem_state" + DETAILED_ENROLLMENT_REPORT = "detailed_enrollment_report" + EXEC_SUMMARY_REPORT = "exec_summary_report" + EXPORT_ORA2_DATA = "export_ora2_data" + EXPORT_ORA2_SUBMISSION_FILES = "export_ora2_submission_files" + EXPORT_ORA2_SUMMARY = "export_ora2_summary" + GENERATE_ANONYMOUS_IDS_FOR_COURSE = "generate_anonymous_ids_for_course" + GENERATE_CERTIFICATES_ALL_STUDENT = "generate_certificates_all_student" + GENERATE_CERTIFICATES_CERTAIN_STUDENT = "generate_certificates_certain_student" + GENERATE_CERTIFICATES_STUDENT_SET = "generate_certificates_student_set" + GRADE_COURSE = "grade_course" + GRADE_PROBLEMS = "grade_problems" + MAY_ENROLL_INFO_CSV = "may_enroll_info_csv" + OVERRIDE_PROBLEM_SCORE = "override_problem_score" + PROBLEM_RESPONSES_CSV = "problem_responses_csv" + PROCTORED_EXAM_RESULTS_REPORT = "proctored_exam_results_report" + PROFILE_INFO_CSV = "profile_info_csv" + REGENERATE_CERTIFICATES_ALL_STUDENT = "regenerate_certificates_all_student" + RESCORE_PROBLEM = "rescore_problem" + RESCORE_PROBLEM_IF_HIGHER = "rescore_problem_if_higher" + RESET_PROBLEM_ATTEMPTS = "reset_problem_attempts" diff --git a/lms/djangoapps/instructor_task/docs/decisions/001-scheduling-instructor-tasks.rst b/lms/djangoapps/instructor_task/docs/decisions/001-scheduling-instructor-tasks.rst new file mode 100644 index 0000000000..4e539cd5de --- /dev/null +++ b/lms/djangoapps/instructor_task/docs/decisions/001-scheduling-instructor-tasks.rst @@ -0,0 +1,61 @@ +========================== +Scheduled Instructor Tasks +========================== + +Status +------ + +Accepted + +Background +---------- + +One of the most requested features in edx-platform has been the ability to schedule an email message to be sent at a specified date and time through the Instructor Dashboard's Bulk Course Email tool. These email messages are sent as an instructor task. Today, edx-platform does not have the ability to support scheduling of instructor tasks. When an instructor task is created we attempt to execute the task immediately. + +This ADR covers details on the proposed modeling of a scheduled instructor task. A subsequent ADR will focus on the processing of scheduled instructor tasks. + +Decision +-------- + +* We will introduce a new ``InstructorTaskSchedule`` model to track the (optional) schedule of an instructor task. +* We will introduce a new **SCHEDULED** state that will be used by the ``InstructorTask`` model to denote a task that has been scheduled for execution at a later date and time. +* Instructor tasks without a schedule will continue to be executed immediately. +* A scheduled instructor task will be reserved in the LMS database but will *not* be submitted to Celery for execution until it is due. + +The ``InstructorTaskSchedule`` model +==================================== + +This new model that will be responsible for tracking the due date of an instructor task, as well as some information needed to process the tasks later. The model will have two fields: + +* **due** (DateTime): The date and time (in UTC) to execute the associated task. +* **task_args** (TextField): This will store information required to execute the task when due. The source data is in the form of a dictionary that will be converted to text for storage in the database. +* **task** (OneToOneField, **InstructorTask**): Allows us to link the related **InstructorTask** instance to its schedule. + +A OneToOne relationship is specified on the ``InstructorTaskSchedule`` model in order to avoid modifying the core ``InstructorTask`` model. An instructor task is a one-time task that should only ever have a single schedule attributed to it. + +New **SCHEDULED** Status +======================== + +The ``InstructorTask`` model currently uses two custom states (**QUEUEING** and **PROGRESS**) to help describe the current status of a task. We will introduce a new state, **SCHEDULED**, to represent the status of an instructor task that has been created but hasn't been executed yet. + +Scheduled tasks will be periodically retrieved by use of this status while determining if they should be executed. + +Rejected Solutions +------------------ + +Celery: the ``eta`` and ``countdown`` arguments +=============================================== + +Celery provides two `optional arguments`_ (**eta** and **countdown**) that can delay the execution of a task. + +* **eta**: A specific date and time describing the earliest moment a task should be executed. +* **countdown**: How many seconds Celery should wait before a task should be executed. + +The Celery worker process holds these delayed tasks in memory aside from any non-delayed tasks. This can be memory intensive. We found several well documented accounts of performance issues related to using these options. Unless the tasks are only delayed for just a few minutes, it seems best to avoid this solution. + +Object Inheritance +================== + +We considered creating a new ``ScheduledInstructorTask`` model that inherits from the existing ``InstructorTask`` model. After reading about how Django treats model inheritance, we decided against this route. Using (single-table or multi-table) inheritance didn't provide any clear or discernable advantages (and there were plenty of documented reasons *not* to use a multi-table inheritance approach). + +.. _optional arguments: https://docs.celeryproject.org/en/latest/userguide/calling.html?highlight=countdown#eta-and-countdown diff --git a/lms/djangoapps/instructor_task/docs/decisions/002-processing-scheduled-instructor-tasks.rst b/lms/djangoapps/instructor_task/docs/decisions/002-processing-scheduled-instructor-tasks.rst new file mode 100644 index 0000000000..870a036544 --- /dev/null +++ b/lms/djangoapps/instructor_task/docs/decisions/002-processing-scheduled-instructor-tasks.rst @@ -0,0 +1,31 @@ +===================================== +Processing Scheduled Instructor Tasks +===================================== + +Status +------ + +Accepted + +Background +---------- +The previous ADR (``Scheduled Instructor Tasks``) explains the motivation and approach for scheduling instructor tasks. + +This ADR will cover our approach for the execution of scheduled instructor tasks. + +Decision +-------- + +A management command will be introduced to assist in processing scheduled instructor tasks. This management command will rely on new utility functions in the **instructor_task** app of edx-platform to query tasks that have the **SCHEDULED** status and then determine if these tasks are due for execution. + +This management command will be invoked by a Jenkins job running on a (cron) schedule to process scheduled tasks due for execution. The job will run every fifteen minutes. + +Rejected Solutions +------------------ + +Celery Beat +=========== + +`Celery Beat`_ is a scheduler for periodic tasks. When seeking feedback on using Celery Beat for this project we were warned to stay away. We are under the impression that there have been several attempts to utilize Celery Beat in edx-platform over the past few years that haven't been successful. For this reason we have decided not to explore its use. + +.. _Celery Beat: https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html#introduction \ No newline at end of file diff --git a/lms/djangoapps/instructor_task/migrations/0004_historicalinstructortaskschedule_instructortaskschedule.py b/lms/djangoapps/instructor_task/migrations/0004_historicalinstructortaskschedule_instructortaskschedule.py new file mode 100644 index 0000000000..36b6ddb186 --- /dev/null +++ b/lms/djangoapps/instructor_task/migrations/0004_historicalinstructortaskschedule_instructortaskschedule.py @@ -0,0 +1,55 @@ +# Generated by Django 3.2.12 on 2022-03-22 18:49 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields +import simple_history.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('instructor_task', '0003_alter_task_input_field'), + ] + + operations = [ + migrations.CreateModel( + name='InstructorTaskSchedule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('task_args', models.TextField()), + ('task_due', models.DateTimeField()), + ('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='instructor_task.instructortask')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='HistoricalInstructorTaskSchedule', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('task_args', models.TextField()), + ('task_due', models.DateTimeField()), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('task', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='instructor_task.instructortask')), + ], + options={ + 'verbose_name': 'historical instructor task schedule', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/lms/djangoapps/instructor_task/models.py b/lms/djangoapps/instructor_task/models.py index 1309ac3283..92268c3090 100644 --- a/lms/djangoapps/instructor_task/models.py +++ b/lms/djangoapps/instructor_task/models.py @@ -20,13 +20,16 @@ import os.path from uuid import uuid4 from boto.exception import BotoServerError +from django.apps import apps from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.files.base import ContentFile from django.db import models, transaction from django.utils.translation import gettext as _ +from model_utils.models import TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField +from simple_history.models import HistoricalRecords from openedx.core.storage import get_storage @@ -35,6 +38,7 @@ logger = logging.getLogger(__name__) # define custom states used by InstructorTask QUEUING = 'QUEUING' PROGRESS = 'PROGRESS' +SCHEDULED = 'SCHEDULED' TASK_INPUT_LENGTH = 10000 @@ -187,6 +191,26 @@ class InstructorTask(models.Model): return json.dumps({'message': 'Task revoked before running'}) +class InstructorTaskSchedule(TimeStampedModel): + """ + A database model to store information about _when_ to execute a scheduled background task. + + The primary use case is to allow instructors to schedule their email messages (authored with the bulk course email + tool) to be sent at a later date and time. + + .. no_pii: + """ + class Meta: + app_label = "instructor_task" + + task = models.OneToOneField(InstructorTask, on_delete=models.CASCADE) + task_args = models.TextField(null=False, blank=False) + task_due = models.DateTimeField(null=False) + + if 'instructor_task' in apps.app_configs: + history = HistoricalRecords() + + class ReportStore: """ Simple abstraction layer that can fetch and store CSV files for reports diff --git a/lms/djangoapps/instructor_task/tests/factories.py b/lms/djangoapps/instructor_task/tests/factories.py index 1cb5b9d881..b69a2a5667 100644 --- a/lms/djangoapps/instructor_task/tests/factories.py +++ b/lms/djangoapps/instructor_task/tests/factories.py @@ -2,7 +2,7 @@ Instructor Task Factory """ - +import datetime import json import factory @@ -11,10 +11,13 @@ from factory.django import DjangoModelFactory from opaque_keys.edx.locator import CourseLocator from common.djangoapps.student.tests.factories import UserFactory as StudentUserFactory -from lms.djangoapps.instructor_task.models import InstructorTask +from lms.djangoapps.instructor_task.models import InstructorTask, InstructorTaskSchedule -class InstructorTaskFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing-class-docstring +class InstructorTaskFactory(DjangoModelFactory): + """ + Factory used to create InstructorTask instances in unit tests. + """ class Meta: model = InstructorTask @@ -26,3 +29,15 @@ class InstructorTaskFactory(DjangoModelFactory): # lint-amnesty, pylint: disabl task_state = PENDING task_output = None requester = factory.SubFactory(StudentUserFactory) + + +class InstructorTaskScheduleFactory(DjangoModelFactory): + """ + Factory used to create InstructorTaskSchedule instances in unit tests. + """ + class Meta: + model = InstructorTaskSchedule + + task = factory.SubFactory(InstructorTaskFactory) + task_args = "{}" + task_due = datetime.datetime.now().replace(tzinfo=datetime.timezone.utc) - datetime.timedelta(days=1) diff --git a/lms/djangoapps/instructor_task/tests/test_api.py b/lms/djangoapps/instructor_task/tests/test_api.py index 678291cc28..9b8f02699a 100644 --- a/lms/djangoapps/instructor_task/tests/test_api.py +++ b/lms/djangoapps/instructor_task/tests/test_api.py @@ -2,11 +2,17 @@ Test for LMS instructor background task queue management """ +import datetime +import json from unittest.mock import MagicMock, Mock, patch +from uuid import uuid4 import pytest +import pytz import ddt -from celery.states import FAILURE +from testfixtures import LogCapture +from celery.states import FAILURE, SUCCESS +from xmodule.modulestore.exceptions import ItemNotFoundError from common.djangoapps.student.tests.factories import UserFactory from common.test.utils import normalize_repr @@ -18,6 +24,7 @@ from lms.djangoapps.instructor_task.api import ( generate_certificates_for_students, get_instructor_task_history, get_running_instructor_tasks, + process_scheduled_tasks, regenerate_certificates, submit_bulk_course_email, submit_calculate_may_enroll_csv, @@ -38,9 +45,13 @@ from lms.djangoapps.instructor_task.api import ( generate_anonymous_ids ) from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, QueueConnectionError -from lms.djangoapps.instructor_task.models import PROGRESS, InstructorTask -from lms.djangoapps.instructor_task.tasks import export_ora2_data, export_ora2_submission_files, \ - generate_anonymous_ids_for_course +from lms.djangoapps.instructor_task.models import PROGRESS, SCHEDULED, InstructorTask +from lms.djangoapps.instructor_task.tasks import ( + export_ora2_data, + export_ora2_submission_files, + generate_anonymous_ids_for_course, +) +from lms.djangoapps.instructor_task.tests.factories import InstructorTaskFactory, InstructorTaskScheduleFactory from lms.djangoapps.instructor_task.tests.test_base import ( TEST_COURSE_KEY, InstructorTaskCourseTestCase, @@ -48,7 +59,8 @@ from lms.djangoapps.instructor_task.tests.test_base import ( InstructorTaskTestCase, TestReportMixin ) -from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order + +LOG_PATH = 'lms.djangoapps.instructor_task.api' class InstructorTaskReportTest(InstructorTaskTestCase): @@ -205,6 +217,8 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa def _define_course_email(self): """Create CourseEmail object for testing.""" + # TODO: convert to use bulk_email app's `create_course_email` API function and remove direct import and use of + # bulk_email model course_email = CourseEmail.create( self.course.id, self.instructor, @@ -229,6 +243,41 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa with pytest.raises(AlreadyRunningError): api_call() + def _generate_scheduled_task(self, task_state=None): + return InstructorTaskFactory.create( + task_type="bulk_course_email", + course_id=self.course.id, + task_input="{'email_id': 1, 'to_option': ['myself']}", + task_key="3416a75f4cea9109507cacd8e2f2aefc", + task_id=str(uuid4()), + task_state=task_state if task_state else SCHEDULED, + task_output=None, + requester=self.instructor + ) + + def _generate_scheduled_task_schedule(self, task, due_date): + return InstructorTaskScheduleFactory.create( + task=task, + task_args=json.dumps(self._generate_task_args()), + task_due=due_date + ) + + def _generate_task_args(self): + """ + Utility function that creates a sample `task_args` value for a scheduled task. + """ + task_args = { + "request_info": { + "username": self.instructor.username, + "user_id": self.instructor.id, + "ip": "127.0.0.1", + "agent": "Mozilla", + "host": "localhost:18000" + }, + "task_id": "622748b3-2831-432e-b519-4fde2706ca59" + } + return task_args + def test_submit_bulk_email_all(self): email_id = self._define_course_email() api_call = lambda: submit_bulk_course_email( @@ -387,3 +436,74 @@ class InstructorTaskCourseSubmitTest(TestReportMixin, InstructorTaskCourseTestCa {}, '' ) + + @patch("lms.djangoapps.instructor_task.api.schedule_task") + @patch("lms.djangoapps.instructor_task.api.submit_task") + def test_submit_bulk_course_email_with_schedule(self, mock_submit_task, mock_schedule_task): + """ + A test to determine if the right helper function is being called when a scheduled task is being processed. + """ + email_id = self._define_course_email() + schedule = datetime.datetime(2030, 8, 15, 8, 15, 12, 0, pytz.utc) + submit_bulk_course_email( + self.create_task_request(self.instructor), + self.course.id, + email_id, + schedule + ) + mock_schedule_task.assert_called_once() + mock_submit_task.assert_not_called() + + @patch("lms.djangoapps.instructor_task.api.submit_scheduled_task") + def test_process_scheduled_tasks(self, mock_submit_scheduled_task): + """ + A test to verify the functionality of the `process_scheduled_tasks` function. This function determines which + scheduled instructor tasks are due for execution. + + This test generates three scheduled tasks; one that has been processed, one that is due for processing, and one + that is due in the future. In this test, one only of these tasks should be eligible for processing. + """ + base_date = datetime.datetime.now().replace(tzinfo=datetime.timezone.utc) + executed_instructor_task = self._generate_scheduled_task(task_state=SUCCESS) + executed_instructor_task_due_date = base_date - datetime.timedelta(days=5) + self._generate_scheduled_task_schedule(executed_instructor_task, executed_instructor_task_due_date) + + due_instructor_task = self._generate_scheduled_task() + due_instructor_task_due_date = base_date - datetime.timedelta(days=1) + due_instructor_task_schedule = self._generate_scheduled_task_schedule( + due_instructor_task, + due_instructor_task_due_date + ) + + future_instructor_task = self._generate_scheduled_task() + future_instructor_task_due_date = base_date + datetime.timedelta(days=15) + self._generate_scheduled_task_schedule(future_instructor_task, future_instructor_task_due_date) + + expected_messages = [ + f"Attempting to queue scheduled task with id '{due_instructor_task.id}'" + ] + + with LogCapture() as log: + process_scheduled_tasks() + + mock_submit_scheduled_task.assert_called_once_with(due_instructor_task_schedule) + log.check_present((LOG_PATH, "INFO", expected_messages[0]),) + + @patch("lms.djangoapps.instructor_task.api.submit_scheduled_task", side_effect=QueueConnectionError("blammo!")) + def test_process_scheduled_tasks_expect_error(self, mock_scheduled_task): + """ + A test that verifies the behavior of the `process_scheduled_tasks` function when there is an error processing + the request. + """ + base_date = datetime.datetime.now().replace(tzinfo=datetime.timezone.utc) + due_instructor_task = self._generate_scheduled_task() + due_instructor_task_due_date = base_date - datetime.timedelta(days=1) + self._generate_scheduled_task_schedule(due_instructor_task, due_instructor_task_due_date) + expected_messages = [ + f"Error processing scheduled task with task id '{due_instructor_task.id}': blammo!", + ] + + with LogCapture() as log: + process_scheduled_tasks() + + log.check_present((LOG_PATH, "ERROR", expected_messages[0]),) diff --git a/lms/djangoapps/instructor_task/tests/test_api_helper.py b/lms/djangoapps/instructor_task/tests/test_api_helper.py new file mode 100644 index 0000000000..cbc566944b --- /dev/null +++ b/lms/djangoapps/instructor_task/tests/test_api_helper.py @@ -0,0 +1,234 @@ +""" +Tests for the Instructor Task `api_helper.py` functions. +""" +import datetime +import hashlib +import json +from unittest.mock import patch +from uuid import uuid4 + +from testfixtures import LogCapture +from celery.states import FAILURE + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.bulk_email.api import create_course_email +from lms.djangoapps.bulk_email.data import BulkEmailTargetChoices +from lms.djangoapps.instructor_task.api_helper import QueueConnectionError, schedule_task, submit_scheduled_task +from lms.djangoapps.instructor_task.models import SCHEDULED, InstructorTask, InstructorTaskSchedule +from lms.djangoapps.instructor_task.tests.factories import InstructorTaskFactory, InstructorTaskScheduleFactory +from lms.djangoapps.instructor_task.tests.test_base import InstructorTaskCourseTestCase + +LOG_PATH = "lms.djangoapps.instructor_task.api_helper" + + +class ScheduledBulkEmailInstructorTaskTests(InstructorTaskCourseTestCase): + """ + Tests for the `schedule_task` functionality, with a focus on the scheduled bulk email tasks. + """ + class FakeRequest: + """ + Test class reflecting a portion of the properties expected in a WSGIRequest. We use data from the originating + web request during execution of Instructor Tasks. + """ + def __init__(self, user): + self.user = user + self.META = { + "REMOTE_ADDR": "127.0.0.1", + 'HTTP_USER_AGENT': 'test_agent', + 'SERVER_NAME': 'test_server_name', + } + + def setUp(self): + super().setUp() + + self.initialize_course() + self.instructor = UserFactory.create(username="instructor", email="instructor@edx.org") + self.request = self.FakeRequest(self.instructor) + self.targets = [BulkEmailTargetChoices.SEND_TO_MYSELF] + self.course_email = self._create_course_email(self.targets) + self.schedule = datetime.datetime.now(datetime.timezone.utc) + self.task_type = "bulk_course_email" + self.task_input = json.dumps(self._generate_bulk_email_task_input(self.course_email, self.targets)) + self.task_key = hashlib.md5(str(self.course_email.id).encode('utf-8')).hexdigest() + + def _create_course_email(self, targets): + """ + Create CourseEmail object for testing. + """ + course_email = create_course_email( + self.course.id, + self.instructor, + targets, + "Test Subject", + "

Test message.

" + ) + + return course_email + + def _generate_bulk_email_task_input(self, course_email, targets): + return { + "email_id": course_email.id, + "to_option": targets + } + + def _verify_log_messages(self, expected_messages, log): + for index, message in enumerate(expected_messages): + assert message in log.records[index].getMessage() + + def test_create_scheduled_instructor_task(self): + """ + Happy path test for the `schedule_task` function. Verifies that we create an InstructorTask instance and an + associated InstructorTaskSchedule instance as expected. + """ + with LogCapture() as log: + schedule_task(self.request, self.task_type, self.course.id, self.task_input, self.task_key, self.schedule) + + # get the task instance and its associated schedule for verifications + task = InstructorTask.objects.get(course_id=self.course.id, task_key=self.task_key) + task_schedule = InstructorTaskSchedule.objects.get(task=task) + expected_task_args = { + "request_info": { + "username": self.instructor.username, + "user_id": self.instructor.id, + "ip": "127.0.0.1", + "agent": "test_agent", + "host": "test_server_name", + }, + "task_id": task.task_id + } + expected_messages = [ + f"Creating a scheduled instructor task of type '{self.task_type}' for course '{self.course.id}' requested " + f"by user with id '{self.request.user.id}'", + f"Creating a task schedule associated with instructor task '{task.id}' and due after '{self.schedule}'", + f"Updating task state of instructor task '{task.id}' to '{SCHEDULED}'" + ] + # convert from text back to JSON before comparison + actual_task_args = json.loads(task_schedule.task_args) + + # verify the task has the correct state + assert task.task_state == SCHEDULED + # verify that the schedule is associated with the correct task_id (UUID) + assert task_schedule.task_id == task.id + # verify that the schedule is the expected date and time + assert task_schedule.task_due == self.schedule + # verify the task_arguments are as expected + assert expected_task_args == actual_task_args + self._verify_log_messages(expected_messages, log) + + @patch("lms.djangoapps.instructor_task.api_helper._get_xmodule_instance_args", side_effect=Exception("boom!")) + def test_create_scheduled_instructor_task_expect_failure(self, mock_get_xmodule_instance_args): + """ + A test to verify that we will mark a task as `FAILED` if a failure occurs during the creation of the task + schedule. + """ + expected_messages = [ + f"Creating a scheduled instructor task of type '{self.task_type}' for course '{self.course.id}' requested " + f"by user with id '{self.request.user.id}'", + "Error occurred during task or schedule creation: boom!", + ] + + with self.assertRaises(QueueConnectionError): + with LogCapture() as log: + schedule_task( + self.request, self.task_type, self.course.id, self.task_input, self.task_key, self.schedule + ) + + task = InstructorTask.objects.get(course_id=self.course.id, task_key=self.task_key) + assert task.task_state == FAILURE + self._verify_log_messages(expected_messages, log) + + +class ScheduledInstructorTaskSubmissionTests(InstructorTaskCourseTestCase): + """ + Unit tests scheduled instructor task functionality. Verifies behavior around retrieving and submission of instructor + tasks due for execution. + """ + def setUp(self): + super().setUp() + self.initialize_course() + self.instructor = UserFactory.create(username="instructor", email="instructor@edx.org") + # create an instructor task instance + task_id = str(uuid4()) + self.task = InstructorTaskFactory.create( + task_type="bulk_course_email", + course_id=self.course.id, + task_input="{'email_id': 41, 'to_option': ['myself']}", + task_key="3416a75f4cea9109507cacd8e2f2aefc", + task_id=task_id, + task_state=SCHEDULED, + task_output=None, + requester=self.instructor + ) + # associate the task with a instructor task schedule instance + task_args = { + "request_info": { + "username": self.instructor.username, + "user_id": self.instructor.id, + "ip": "192.168.1.100", + "agent": "Mozilla", + "host": "localhost:18000" + }, + "task_id": self.task.task_id + } + self.task_schedule = InstructorTaskScheduleFactory.create( + task=self.task, + task_args=json.dumps(task_args), + ) + + @patch("lms.djangoapps.instructor_task.api_helper.send_bulk_course_email.apply_async") + def test_submit_scheduled_instructor_task(self, mock_task_execution): + """ + A test that verifies the behavior of submitting a scheduled instructor task for execution. + """ + schedule = InstructorTaskSchedule.objects.get(task=self.task.id) + expected_task_arguments = json.loads(schedule.task_args) + expected_task_args = [schedule.task.id, expected_task_arguments] + expected_messages = [ + f"Submitting scheduled task {schedule.task.id} for processing", + ] + + with LogCapture() as log: + submit_scheduled_task(schedule) + + mock_task_execution.assert_called_once() + mock_task_execution.assert_called_with(expected_task_args, task_id=schedule.task.task_id) + log.check_present((LOG_PATH, "INFO", expected_messages[0])) + + def test_submit_scheduled_instructor_task_bad_task_class(self): + """ + A test that verifies behavior when we can't determine the task class to use when submitting our scheduled task + for execution. + """ + schedule = InstructorTaskSchedule.objects.get(task=self.task.id) + task = schedule.task + task.task_type = "task_without_scheduling_support" + task.save() + expected_messages = [ + f"Could not submit scheduled instructor task with id '{schedule.task.id}' and task type " + f"'{task.task_type}'. Could not determine the task class for the request.", + ] + + with LogCapture() as log: + submit_scheduled_task(schedule) + + log.check_present((LOG_PATH, "WARNING", expected_messages[0])) + + def test_submit_scheduled_instructor_task_with_error(self): + """ + A test that verifies our task handling if an error occurs during task submission. Verifies that the task failure + is correctly handled. + """ + schedule = InstructorTaskSchedule.objects.get(task=self.task.id) + schedule.task_args = "{malformed JSON data}" + schedule.save() + expected_messages = [ + f"Error submitting scheduled task '{schedule.task.id}' to Celery: Expecting property name enclosed in " + "double quotes: line 1 column 2 (char 1)", + ] + + with self.assertRaises(QueueConnectionError): + with LogCapture() as log: + submit_scheduled_task(schedule) + + assert schedule.task.task_state == FAILURE + log.check_present((LOG_PATH, "ERROR", expected_messages[0])) diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py index ad94339d67..7072235ef1 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py @@ -166,7 +166,8 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): title='Journey to cooking', type='MicroMasters', authoring_organizations=[{ - 'key': 'MAX' + 'key': 'MAX', + 'logo_image_url': 'http://test.org/media/organization/logos/test-logo.png' }], ) cls.site = SiteFactory(domain='test.localhost') diff --git a/lms/djangoapps/learner_dashboard/api/v0/views.py b/lms/djangoapps/learner_dashboard/api/v0/views.py index 256ba7295b..7a26adc201 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/views.py @@ -134,7 +134,12 @@ class Programs(APIView): """ transformed_authoring_organizations = [] for authoring_organization in authoring_organizations: - transformed_authoring_organizations.append({'key': authoring_organization['key']}) + transformed_authoring_organizations.append( + { + 'key': authoring_organization['key'], + 'logo_image_url': authoring_organization['logo_image_url'] + } + ) return transformed_authoring_organizations diff --git a/lms/djangoapps/ora_staff_grader/ora_api.py b/lms/djangoapps/ora_staff_grader/ora_api.py index 9adcfd0646..852f1a707b 100644 --- a/lms/djangoapps/ora_staff_grader/ora_api.py +++ b/lms/djangoapps/ora_staff_grader/ora_api.py @@ -154,3 +154,19 @@ def delete_submission_lock(request, usage_id, submission_uuid): raise XBlockInternalError(context={"handler": handler_name}) return json.loads(response.content) + + +def batch_delete_submission_locks(request, usage_id, submission_uuids): + """ + Batch delete a list of submission locks. Limited only to those in the list the user owns. + + Returns: none + """ + handler_name = "batch_delete_submission_lock" + body = {"submission_uuids": submission_uuids} + + response = call_xblock_json_handler(request, usage_id, handler_name, body) + + # Errors should raise a blanket exception. Otherwise body is empty, 200 is implicit success + if response.status_code != 200: + raise XBlockInternalError(context={"handler": handler_name}) diff --git a/lms/djangoapps/ora_staff_grader/ora_staff_grader.postman_collection.json b/lms/djangoapps/ora_staff_grader/ora_staff_grader.postman_collection.json index 34b0fa027e..22a6573a99 100644 --- a/lms/djangoapps/ora_staff_grader/ora_staff_grader.postman_collection.json +++ b/lms/djangoapps/ora_staff_grader/ora_staff_grader.postman_collection.json @@ -1454,6 +1454,173 @@ } ] }, + { + "name": "Bulk Unlock", + "event": [ + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "X-CSRFToken", + "value": "{{csrftoken}}", + "type": "text" + } + ], + "url": { + "raw": "{{protocol}}://{{lms_url}}/api/ora_staff_grader{{mock}}/submission/lock?oraLocation={{block_id_encoded}}&submissionUUID={{submission_id}}", + "protocol": "{{protocol}}", + "host": [ + "{{lms_url}}" + ], + "path": [ + "api", + "ora_staff_grader{{mock}}", + "submission", + "lock" + ], + "query": [ + { + "key": "oraLocation", + "value": "{{block_id_encoded}}", + "description": "ORA location" + }, + { + "key": "submissionUUID", + "value": "{{submission_id}}", + "description": "Individual submission UUID" + }, + { + "key": "oraLocation", + "value": "{{team_block_id_encoded}}", + "description": "Team ORA location", + "disabled": true + }, + { + "key": "submissionUUID", + "value": "{{team_submission_id}}", + "description": "Team submission UUID", + "disabled": true + } + ] + } + }, + "response": [ + { + "name": "Bulk Unlock Success", + "originalRequest": { + "method": "POST", + "header": [ + { + "key": "X-CSRFToken", + "value": "{{csrftoken}}", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"submissionUUIDs\": [{{submission_id}}]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{protocol}}://{{lms_url}}/api/ora_staff_grader{{mock}}/submission/batch/unlock?oraLocation={{block_id_encoded}}", + "protocol": "{{protocol}}", + "host": [ + "{{lms_url}}" + ], + "path": [ + "api", + "ora_staff_grader{{mock}}", + "submission", + "batch", + "unlock" + ], + "query": [ + { + "key": "oraLocation", + "value": "{{block_id_encoded}}", + "description": "ORA location" + }, + { + "key": "oraLocation", + "value": "{{team_block_id_encoded}}", + "description": "Team ORA location", + "disabled": true + } + ] + } + }, + "status": "OK", + "code": 200, + "_postman_previewlanguage": "json", + "header": [ + { + "key": "Date", + "value": "Thu, 31 Mar 2022 20:52:57 GMT" + }, + { + "key": "Server", + "value": "WSGIServer/0.2 CPython/3.8.10" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "WWW-Authenticate", + "value": "JWT realm=\"api\"" + }, + { + "key": "Vary", + "value": "Accept, Accept-Language, Origin, Cookie" + }, + { + "key": "Allow", + "value": "GET, POST, HEAD, OPTIONS" + }, + { + "key": "Server-Timing", + "value": "TimerPanel_utime;dur=37.91900000000048;desc=\"User CPU time\", TimerPanel_stime;dur=1.1920000000000819;desc=\"System CPU time\", TimerPanel_total;dur=39.11100000000056;desc=\"Total CPU time\", TimerPanel_total_time;dur=65.8259391784668;desc=\"Elapsed time\", SQLPanel_sql_time;dur=0;desc=\"SQL 0 queries\"" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + }, + { + "key": "Content-Language", + "value": "en" + }, + { + "key": "Content-Length", + "value": "58" + }, + { + "key": "Set-Cookie", + "value": "lms_sessionid=\"\"; expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0; Path=/; SameSite=Lax" + } + ], + "cookie": [ + { + "expires": "Invalid Date" + } + ], + "body": "{}" + } + ] + }, { "name": "Update Grade Data", "event": [ diff --git a/lms/djangoapps/ora_staff_grader/serializers.py b/lms/djangoapps/ora_staff_grader/serializers.py index bf34897c6d..4b0df31021 100644 --- a/lms/djangoapps/ora_staff_grader/serializers.py +++ b/lms/djangoapps/ora_staff_grader/serializers.py @@ -167,15 +167,24 @@ class InitializeSerializer(serializers.Serializer): courseMetadata = CourseMetadataSerializer() oraMetadata = OpenResponseMetadataSerializer() submissions = serializers.DictField(child=SubmissionMetadataSerializer()) + isEnabled = serializers.SerializerMethodField() class Meta: fields = [ "courseMetadata", "oraMetadata", "submissions", + "isEnabled" ] read_only_fields = fields + def get_isEnabled(self, obj): + """ + Only enable ESG if the flag is enabled and also this is not a Team ORA + Revert back to BooleanField in AU-617 when ESG officially supports team ORAs + """ + return obj['isEnabled'] and not obj['oraMetadata'].teams_enabled + class UploadedFileSerializer(serializers.Serializer): """Serializer for a file uploaded as a part of a response""" diff --git a/lms/djangoapps/ora_staff_grader/tests/test_serializers.py b/lms/djangoapps/ora_staff_grader/tests/test_serializers.py index 648f053772..b5bc979c1f 100644 --- a/lms/djangoapps/ora_staff_grader/tests/test_serializers.py +++ b/lms/djangoapps/ora_staff_grader/tests/test_serializers.py @@ -347,6 +347,7 @@ class TestInitializeSerializer(TestCase): "courseMetadata": self.mock_course_metadata, "oraMetadata": self.mock_ora_instance, "submissions": self.mock_submissions_data, + "isEnabled": True, } output_data = InitializeSerializer(input_data).data @@ -364,6 +365,7 @@ class TestInitializeSerializer(TestCase): assert output_data["courseMetadata"] == expected_course_data assert output_data["oraMetadata"] == expected_ora_data assert output_data["submissions"] == expected_submissions_data + assert output_data["isEnabled"] is True class TestRubricConfigSerializer(TestCase): diff --git a/lms/djangoapps/ora_staff_grader/tests/test_views.py b/lms/djangoapps/ora_staff_grader/tests/test_views.py index e50c56a1fb..d9ad6c4a4f 100644 --- a/lms/djangoapps/ora_staff_grader/tests/test_views.py +++ b/lms/djangoapps/ora_staff_grader/tests/test_views.py @@ -116,7 +116,7 @@ class TestInitializeView(BaseViewTest): self.api_url, {PARAM_ORA_LOCATION: self.ora_usage_key} ) - expected_keys = set(["courseMetadata", "oraMetadata", "submissions"]) + expected_keys = set(["courseMetadata", "oraMetadata", "submissions", "isEnabled"]) assert response.status_code == 200 assert response.data.keys() == expected_keys @@ -548,6 +548,99 @@ class TestSubmissionLockView(BaseViewTest): assert json.loads(response.content) == {"error": ERR_UNKNOWN} +class TestBatchSubmissionLockView(BaseViewTest): + """ + Tests for the /lock view, locking or unlocking a submission for grading + """ + + view_name = "ora-staff-grader:batch-unlock" + + test_submission_uuids = [str(uuid4()) for _ in range(3)] + test_anon_user_id = "anon-user-id" + test_other_anon_user_id = "anon-user-id-2" + test_timestamp = "2020-08-29T02:14:00-04:00" + + def setUp(self): + super().setUp() + + # Batch unlock includes the ORA location in the params... + self.test_request_params = { + PARAM_ORA_LOCATION: self.ora_usage_key, + } + + # and a list of submission UUIDs in the body + self.test_request_body = { + "submissionUUIDs": self.test_submission_uuids + } + + self.log_in() + + def batch_unlock(self, params, body): + """Wrapper for easier calling of 'batch_unlock'""" + return self.client.post(self.url_with_params(params), body, format="json") + + @patch("lms.djangoapps.ora_staff_grader.views.batch_delete_submission_locks") + def test_batch_unlock_invalid_ora(self, mock_batch_delete): + """An invalid ORA returns a 400""" + self.test_request_params[PARAM_ORA_LOCATION] = "not_a_real_location" + + response = self.batch_unlock(self.test_request_params, self.test_request_body) + + assert response.status_code == 400 + assert json.loads(response.content) == {"error": ERR_BAD_ORA_LOCATION} + mock_batch_delete.assert_not_called() + + @patch("lms.djangoapps.ora_staff_grader.views.batch_delete_submission_locks") + def test_batch_unlock_missing_submisison_list(self, mock_batch_delete): + """An invalid ORA returns a 400""" + + response = self.batch_unlock(self.test_request_params, {}) + + assert response.status_code == 400 + assert json.loads(response.content) == {"error": ERR_MISSING_PARAM} + mock_batch_delete.assert_not_called() + + @patch("lms.djangoapps.ora_staff_grader.views.batch_delete_submission_locks") + def test_batch_unlock(self, mock_batch_delete): + """POST tries to delete a group of submission locks. Success returns empty 200""" + mock_batch_delete.return_value = None + + response = self.batch_unlock(self.test_request_params, self.test_request_body) + + assert response.status_code == 200 + assert json.loads(response.content) == {} + mock_batch_delete.assert_called() + + @patch("lms.djangoapps.ora_staff_grader.views.batch_delete_submission_locks") + def test_batch_unlock_internal_error(self, mock_batch_delete): + """Any internal errors to this API get surfaced as an internal error""" + mock_batch_delete.side_effect = XBlockInternalError( + context={"handler": "batch_delete_submission_locks"} + ) + + response = self.batch_unlock(self.test_request_params, self.test_request_body) + + assert response.status_code == 500 + assert json.loads(response.content) == { + "error": ERR_INTERNAL, + "handler": "batch_delete_submission_locks", + } + + @patch("lms.djangoapps.ora_staff_grader.views.batch_delete_submission_locks") + def test_batch_unlock_generic_exception( + self, + mock_batch_delete, + ): + """In the even more unlikely event of an unhandled error, shrug exuberantly""" + # Mock a generic error inside the API + mock_batch_delete.side_effect = Exception() + + response = self.batch_unlock(self.test_request_params, self.test_request_body) + + assert response.status_code == 500 + assert json.loads(response.content) == {"error": ERR_UNKNOWN} + + class TestUpdateGradeView(BaseViewTest): """ Tests for updating a grade for a submission diff --git a/lms/djangoapps/ora_staff_grader/urls.py b/lms/djangoapps/ora_staff_grader/urls.py index ed100114e0..c5e3a2a837 100644 --- a/lms/djangoapps/ora_staff_grader/urls.py +++ b/lms/djangoapps/ora_staff_grader/urls.py @@ -7,6 +7,7 @@ from django.urls import path from lms.djangoapps.ora_staff_grader.views import ( InitializeView, + SubmissionBatchUnlockView, SubmissionFetchView, SubmissionLockView, SubmissionStatusFetchView, @@ -20,6 +21,7 @@ app_name = "ora-staff-grader" urlpatterns += [ path("mock/", include("lms.djangoapps.ora_staff_grader.mock.urls")), path("initialize", InitializeView.as_view(), name="initialize"), + path("submission/batch/unlock", SubmissionBatchUnlockView.as_view(), name="batch-unlock"), path( "submission/status", SubmissionStatusFetchView.as_view(), diff --git a/lms/djangoapps/ora_staff_grader/views.py b/lms/djangoapps/ora_staff_grader/views.py index bdad314699..c14a208c31 100644 --- a/lms/djangoapps/ora_staff_grader/views.py +++ b/lms/djangoapps/ora_staff_grader/views.py @@ -14,6 +14,7 @@ from edx_rest_framework_extensions.auth.session.authentication import ( ) from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey +from openassessment.xblock.config_mixin import WAFFLE_NAMESPACE, ENHANCED_STAFF_GRADER from rest_framework.generics import RetrieveAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -30,10 +31,12 @@ from lms.djangoapps.ora_staff_grader.errors import ( InternalErrorResponse, LockContestedError, LockContestedResponse, + MissingParamResponse, UnknownErrorResponse, XBlockInternalError, ) from lms.djangoapps.ora_staff_grader.ora_api import ( + batch_delete_submission_locks, check_submission_lock, claim_submission_lock, delete_submission_lock, @@ -53,6 +56,7 @@ from lms.djangoapps.ora_staff_grader.utils import require_params from openedx.core.djangoapps.content.course_overviews.api import ( get_course_overview_or_none, ) +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser log = logging.getLogger(__name__) @@ -80,6 +84,7 @@ class InitializeView(StaffGraderBaseView): courseMetadata oraMetadata submissions + isEnabled } Errors: @@ -89,6 +94,17 @@ class InitializeView(StaffGraderBaseView): - UnknownError (HTTP 500) for other errors """ + def _is_staff_grader_enabled(self, course_key): + """ Helper to evaluate if the staff grader flag / overrides are enabled """ + # This toggle is documented on the edx-ora2 repo in openassessment/xblock/config_mixin.py + # pylint: disable=toggle-missing-annotation + enhanced_staff_grader_flag = CourseWaffleFlag( + WAFFLE_NAMESPACE, + ENHANCED_STAFF_GRADER, + module_name='openassessment.xblock.config_mixin' + ) + return enhanced_staff_grader_flag.is_enabled(course_key) + @require_params([PARAM_ORA_LOCATION]) def get(self, request, ora_location, *args, **kwargs): try: @@ -105,6 +121,9 @@ class InitializeView(StaffGraderBaseView): # Get list of submissions for this ORA init_data["submissions"] = get_submissions(request, ora_location) + # Is the Staff Grader enabled for this course? + init_data["isEnabled"] = self._is_staff_grader_enabled(ora_usage_key.course_key) + response_data = InitializeSerializer(init_data).data log.info(response_data) return Response(response_data) @@ -428,3 +447,52 @@ class SubmissionLockView(StaffGraderBaseView): except Exception as ex: log.exception(ex) return UnknownErrorResponse() + + +class SubmissionBatchUnlockView(StaffGraderBaseView): + """ + POST delete a group of submission locks, limited to just those in the list that the user owns. + + Params: + - ora_location (str/UsageID): ORA location for XBlock handling + + Body: + - submissionUUIDs (UUID): A list of submission/team submission UUIDS to lock/unlock + + Response: None + + Errors: + - MissingParamResponse (HTTP 400) for missing params + - XBlockInternalError (HTTP 500) for an issue within ORA + """ + + @require_params([PARAM_ORA_LOCATION]) + def post(self, request, ora_location, *args, **kwargs): + """Batch delete submission locks""" + try: + # Validate ORA location + UsageKey.from_string(ora_location) + + # Pull submission UUIDs list from request body + submission_uuids = request.data.get('submissionUUIDs') + if not isinstance(submission_uuids, list): + return MissingParamResponse() + batch_delete_submission_locks(request, ora_location, submission_uuids) + + # Return empty response + return Response({}) + + # Catch bad ORA location + except (InvalidKeyError, ItemNotFoundError): + log.error(f"Bad ORA location provided: {ora_location}") + return BadOraLocationResponse() + + # Issues with the XBlock handlers + except XBlockInternalError as ex: + log.error(ex) + return InternalErrorResponse(context=ex.context) + + # Blanket exception handling + except Exception as ex: + log.exception(ex) + return UnknownErrorResponse() diff --git a/lms/djangoapps/program_enrollments/management/commands/send_program_course_nudge_email.py b/lms/djangoapps/program_enrollments/management/commands/send_program_course_nudge_email.py new file mode 100644 index 0000000000..f9d4a969c7 --- /dev/null +++ b/lms/djangoapps/program_enrollments/management/commands/send_program_course_nudge_email.py @@ -0,0 +1,197 @@ +""" +Django management command for sending nudge emails to learners after they complete once course in a program, to suggest +to complete possible next course from same program. +""" + +import logging +from collections import defaultdict +from datetime import timedelta +from operator import itemgetter +from urllib.parse import urljoin + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.sites.models import Site +from django.core.management import BaseCommand +from django.utils import timezone + +from common.djangoapps.track import segment +from lms.djangoapps.grades.models import PersistentCourseGrade +from openedx.core.constants import COURSE_PUBLISHED +from openedx.core.djangoapps.catalog.utils import get_programs +from openedx.core.djangoapps.programs.utils import ProgramProgressMeter + +User = get_user_model() + +LOGGER = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Django management command for sending nudge emails to learners + + This command sends nudge emails to learners after they complete once course in a program, to suggest to complete + possible next course from same program. + + Example usage: + $ ./manage.py lms send_program_course_nudge_email + $ ./manage.py lms send_program_course_nudge_email --no-commit + """ + + def get_passed_course_to_users_maps(self): + """ + Returns mapping between course passed yesterday with passing users. + """ + passed_course_to_users_maps = defaultdict(list) + yesterday = timezone.now().date() - timedelta(days=1) + passed_grades = PersistentCourseGrade.objects.filter( + passed_timestamp__date=yesterday, + ) + + passing_user_ids = list(passed_grades.values_list('user_id', flat=True).distinct()) + passing_users = User.objects.filter(id__in=passing_user_ids) + user_id_to_user_map = {user.id: user for user in passing_users} + + for passing_grade in passed_grades: + user = user_id_to_user_map[passing_grade.user_id] + passed_course_to_users_maps[str(passing_grade.course_id)].append(user) + + LOGGER.info( + '[Program Course Nudge Email] Found [%s] passing grades on [%s] date with [%s] distinct users ' + 'and [%s] distinct courses', + passed_grades.count(), + yesterday, + len(passing_user_ids), + len(passed_course_to_users_maps.keys()), + ) + + return passed_course_to_users_maps + + def valid_course_run(self, course_run): + """ + Check if a course run is in enrollable state. + """ + return course_run['is_enrollable'] \ + and course_run['is_marketable'] \ + and course_run['marketing_url'] \ + and course_run['image'] \ + and course_run['status'] == COURSE_PUBLISHED + + def get_course_run_to_suggest(self, programs_progress, completed_course_id): + """ + Finds out enrollable course run from programs Generated by ProgramProgressMeter. + + Returns: Suggested program and course_run dicts + """ + for program in programs_progress: + for not_started_courses in program['not_started']: + for course_run in not_started_courses['course_runs']: + if self.valid_course_run(course_run) and course_run['key'] != completed_course_id: + return program, course_run + return None, None + + def sort_programs(self, programs): + """ + Sorts programs based on their revenue ranking. + """ + sort_revenue_order = { + 'MicroMasters': 1, + 'Professional Program': 2, + 'Professional Certificate': 3, + 'XSeries': 4, + 'Masters': 5, + 'MicroBachelors': 6, + } + for program in programs: + program['sort_revenue_order'] = sort_revenue_order.get(program['type'], 7) + + return sorted(programs, key=itemgetter('sort_revenue_order')) + + def get_course_run(self, program, course_run_id): + """ + get course run from a program. + """ + for course in program['courses']: + for course_run in course['course_runs']: + if course_run['key'] == course_run_id: + return course_run + + def get_program(self, programs, program_progress): + """ + get detailed program. + """ + for program in programs: + if program['uuid'] == program_progress['uuid']: + return program + + def emit_event(self, user, program, suggested_course_run, completed_course_run): + """ + Emit the Segment event which will be used by Braze to send the email + """ + event_properties = { + 'COURSE_ONE_NAME': completed_course_run['title'], + 'PROGRAM_TYPE': program['type'], + 'PROGRAM_TITLE': program['title'], + 'COURSE_TWO_NAME': suggested_course_run['title'], + 'COURSE_TWO_SHORT_DESCRIPTION': suggested_course_run['short_description'], + 'COURSE_TWO_LINK': urljoin(settings.MKTG_URLS.get('ROOT'), suggested_course_run['marketing_url']), + 'COURSE_TWO_IMAGE_LINK': suggested_course_run['image'].get('src'), + } + segment.track(user.id, 'edx.bi.program.course-enrollment.nudge', event_properties) + + LOGGER.info( + '[Program Course Nudge Email] Segment event fired to suggested. ' + 'Completed Course: [%s], Program: [%s], Suggested Course: [%s], User: [%s].', + completed_course_run['key'], + program['uuid'], + suggested_course_run['key'], + user.username, + ) + + def add_arguments(self, parser): + """ + Entry point to add arguments. + """ + parser.add_argument( + '--no-commit', + action='store_true', + dest='no_commit', + default=False, + help='Dry Run, print log messages without committing anything.', + ) + + def handle(self, *args, **options): + """ + Command's entry point. + """ + should_commit = not options['no_commit'] + + email_sent_records = [] + site = Site.objects.get_current() + course_to_users_maps = self.get_passed_course_to_users_maps() + + for completed_course_id, users in course_to_users_maps.items(): + course_linked_programs = get_programs(course=completed_course_id) + course_linked_programs = self.sort_programs(course_linked_programs) + if course_linked_programs: + for user in users: + meter = ProgramProgressMeter(site=site, user=user, include_course_entitlements=False) + programs_progress = meter.progress(programs=course_linked_programs, count_only=False) + suggested_program_progress, suggested_course_run = self.get_course_run_to_suggest( + programs_progress, completed_course_id + ) + if suggested_course_run: + suggested_program = self.get_program(course_linked_programs, suggested_program_progress) + completed_course_run = self.get_course_run(suggested_program, completed_course_id) + if should_commit: + self.emit_event(user, suggested_program, suggested_course_run, completed_course_run) + email_sent_records.append( + f'User: {user.username}, Completed Course: {completed_course_id}, ' + f'Suggested Course: {suggested_course_run["key"]}' + ) + + LOGGER.info( + '[Program Course Nudge Email] %s Emails sent. Records: %s', + len(email_sent_records), + email_sent_records, + ) diff --git a/lms/djangoapps/program_enrollments/management/commands/tests/test_send_program_course_nudge_email.py b/lms/djangoapps/program_enrollments/management/commands/tests/test_send_program_course_nudge_email.py new file mode 100644 index 0000000000..9b04c04170 --- /dev/null +++ b/lms/djangoapps/program_enrollments/management/commands/tests/test_send_program_course_nudge_email.py @@ -0,0 +1,102 @@ +""" +Tests for the send_program_course_nudge_email management command. +""" +from datetime import timedelta +from unittest.mock import patch + +import ddt +from django.core.management import call_command +from django.test import TestCase +from django.utils import timezone +from testfixtures import LogCapture + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.grades.models import PersistentCourseGrade +from openedx.core.djangoapps.catalog.tests.factories import CourseFactory, CourseRunFactory, ProgramFactory + +LOG_PATH = 'lms.djangoapps.program_enrollments.management.commands.send_program_course_nudge_email' + + +@ddt.ddt +class TestSendProgramCourseNudgeEmailCommand(TestCase): + """ + Test send_program_course_nudge_email command. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.command = 'send_program_course_nudge_email' + + def setUp(self): + super().setUp() + self.user_1 = UserFactory() + self.user_2 = UserFactory() + + self.enrolled_course_run = CourseRunFactory() + self.course_run_1 = CourseRunFactory() + self.course_run_2 = CourseRunFactory() + self.enrolled_course = CourseFactory(course_runs=[self.enrolled_course_run]) + self.unenrolled_course_1 = CourseFactory(course_runs=[self.course_run_1]) + self.unenrolled_course_2 = CourseFactory(course_runs=[self.course_run_2]) + + self.enrolled_program_1 = ProgramFactory( + courses=[self.enrolled_course, self.unenrolled_course_1], + type='MicroBachelors' + ) + self.enrolled_program_2 = ProgramFactory( + courses=[self.enrolled_course, self.unenrolled_course_2], + type='MicroMasters' + ) + self.unenrolled_program = ProgramFactory() + self.create_grade(user_id=self.user_1.id, course_id=self.enrolled_course_run['key']) + self.create_grade(user_id=self.user_2.id, course_id=self.enrolled_course_run['key']) + + self.all_programs = [self.enrolled_program_1, self.enrolled_program_2, self.unenrolled_program] + + def create_grade(self, user_id, course_id): + """ + Create PersistentCourseGrade records for given user and course + """ + params = { + "user_id": user_id, + "course_id": course_id, + "course_version": "JoeMcEwing", + "percent_grade": 77.7, + "letter_grade": "Great job", + "passed_timestamp": timezone.now() - timedelta(days=1), + } + PersistentCourseGrade.objects.create(**params) + + @ddt.data( + False, True + ) + @patch('common.djangoapps.student.models.segment.track') + @patch('lms.djangoapps.program_enrollments.management.commands.send_program_course_nudge_email.get_programs') + def test_email_send(self, add_no_commit, get_programs_mock, mock_track): + """ + Test Segment fired as expected. + """ + get_programs_mock.return_value = [self.enrolled_program_1, self.enrolled_program_2] + with LogCapture() as logger: + if add_no_commit: + call_command(self.command, '--no-commit') + assert mock_track.call_count == 0 + else: + call_command(self.command) + assert mock_track.call_count == 2 + logger.check_present( + ( + LOG_PATH, + 'INFO', + f"[Program Course Nudge Email] 2 Emails sent. Records: [" + f"'User: {self.user_1.username}, Completed Course: {self.enrolled_course_run['key']}," + f" Suggested Course: {self.course_run_2['key']}', " + f"'User: {self.user_2.username}, Completed Course: {self.enrolled_course_run['key']}," + f" Suggested Course: {self.course_run_2['key']}']" + ) + ) + if add_no_commit: + assert mock_track.call_count == 0 + else: + assert mock_track.call_count == 2 diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py b/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py index e7131c23f4..a6158cc0d0 100644 --- a/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py +++ b/lms/djangoapps/program_enrollments/rest_api/v1/tests/test_views.py @@ -2098,7 +2098,7 @@ class ProgramCourseEnrollmentOverviewGetTests( def test_course_run_url(self): self.log_in() - course_run_url = f'http://testserver/courses/{str(self.course_id)}/course/' + course_run_url = f'http://learning-mfe/course/{str(self.course_id)}/home' response_status_code, response_course_runs = self.get_status_and_course_runs() assert status.HTTP_200_OK == response_status_code diff --git a/lms/djangoapps/program_enrollments/rest_api/v1/utils.py b/lms/djangoapps/program_enrollments/rest_api/v1/utils.py index 1b62654827..e72cb76ce8 100644 --- a/lms/djangoapps/program_enrollments/rest_api/v1/utils.py +++ b/lms/djangoapps/program_enrollments/rest_api/v1/utils.py @@ -217,7 +217,8 @@ def get_enrollments_for_courses_in_program(user, program): user=user, course_id__in=course_keys, mode__in=[ - CourseMode.VERIFIED, CourseMode.MASTERS, CourseMode.EXECUTIVE_EDUCATION, CourseMode.PAID_EXECUTIVE_EDUCATION + CourseMode.VERIFIED, CourseMode.MASTERS, CourseMode.EXECUTIVE_EDUCATION, + CourseMode.PAID_EXECUTIVE_EDUCATION, CourseMode.PAID_BOOTCAMP ], is_active=True, ) diff --git a/lms/djangoapps/save_for_later/admin.py b/lms/djangoapps/save_for_later/admin.py new file mode 100644 index 0000000000..9c94e5e94b --- /dev/null +++ b/lms/djangoapps/save_for_later/admin.py @@ -0,0 +1,29 @@ +""" Django admin pages for save_for_later app """ + +from django.contrib import admin + +from lms.djangoapps.save_for_later.models import SavedCourse, SavedProgram + + +class SavedCourseAdmin(admin.ModelAdmin): + """ + Admin for the Saved Course table. + """ + + list_display = ['email', 'course_id', 'email_sent_count', 'reminder_email_sent'] + + search_fields = ['email', 'course_id'] + + +class SavedProgramAdmin(admin.ModelAdmin): + """ + Admin for the Saved Program table. + """ + + list_display = ['email', 'program_uuid', 'email_sent_count', 'reminder_email_sent'] + + search_fields = ['email', 'program_uuid'] + + +admin.site.register(SavedCourse, SavedCourseAdmin) +admin.site.register(SavedProgram, SavedProgramAdmin) diff --git a/lms/djangoapps/save_for_later/api/v1/views.py b/lms/djangoapps/save_for_later/api/v1/views.py index 82a7f0c3b2..5d7c827d2d 100644 --- a/lms/djangoapps/save_for_later/api/v1/views.py +++ b/lms/djangoapps/save_for_later/api/v1/views.py @@ -24,6 +24,7 @@ log = logging.getLogger(__name__) POST_EMAIL_KEY = 'openedx.core.djangoapps.util.ratelimit.request_data_email' REAL_IP_KEY = 'openedx.core.djangoapps.util.ratelimit.real_ip' +USER_SEND_SAVE_FOR_LATER_EMAIL = 'user.send.save.for.later.email' class CourseSaveForLaterApiView(APIView): @@ -61,6 +62,14 @@ class CourseSaveForLaterApiView(APIView): data = request.data course_id = data.get('course_id') email = data.get('email') + org_img_url = data.get('org_img_url') + marketing_url = data.get('marketing_url') + weeks_to_complete = data.get('weeks_to_complete', 0) + min_effort = data.get('min_effort', 0) + max_effort = data.get('max_effort', 0) + user_id = request.user.id + pref_lang = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME, 'en') + send_to_self = bool(not request.user.is_anonymous and request.user.email == email) if getattr(request, 'limited', False): return Response({'error_code': 'rate-limited'}, status=403) @@ -80,12 +89,28 @@ class CourseSaveForLaterApiView(APIView): user_id=user.id, email=email, course_id=course_id, + org_img_url=org_img_url, + marketing_url=marketing_url, + weeks_to_complete=weeks_to_complete, + min_effort=min_effort, + max_effort=max_effort, + reminder_email_sent=False, ) course_data = { 'course': course, + 'send_to_self': send_to_self, + 'user_id': user_id, + 'pref-lang': pref_lang, + 'org_img_url': org_img_url, + 'marketing_url': marketing_url, + 'weeks_to_complete': weeks_to_complete, + 'min_effort': min_effort, + 'max_effort': max_effort, 'type': 'course', + 'reminder': False, + 'braze_event': USER_SEND_SAVE_FOR_LATER_EMAIL, } - if send_email(request, email, course_data): + if send_email(email, course_data): return Response({'result': 'success'}, status=200) else: return Response({'error_code': 'email-not-send'}, status=400) @@ -120,6 +145,9 @@ class ProgramSaveForLaterApiView(APIView): data = request.data program_uuid = data.get('program_uuid') email = data.get('email') + user_id = request.user.id + pref_lang = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME, 'en') + send_to_self = bool(not request.user.is_anonymous and request.user.email == email) if getattr(request, 'limited', False): return Response({'error_code': 'rate-limited'}, status=403) @@ -139,9 +167,14 @@ class ProgramSaveForLaterApiView(APIView): if program: program_data = { 'program': program, + 'send_to_self': send_to_self, + 'user_id': user_id, + 'pref-lang': pref_lang, 'type': 'program', + 'reminder': False, + 'braze_event': USER_SEND_SAVE_FOR_LATER_EMAIL, } - if send_email(request, email, program_data): + if send_email(email, program_data): return Response({'result': 'success'}, status=200) else: return Response({'error_code': 'email-not-send'}, status=400) diff --git a/lms/djangoapps/save_for_later/helper.py b/lms/djangoapps/save_for_later/helper.py index 7ff0975e46..eb32a8d307 100644 --- a/lms/djangoapps/save_for_later/helper.py +++ b/lms/djangoapps/save_for_later/helper.py @@ -10,9 +10,12 @@ from eventtracking import tracker from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from common.djangoapps.course_modes.models import CourseMode + log = logging.getLogger(__name__) -USER_SENT_EMAIL_SAVE_FOR_LATER = 'edx.bi.user.saveforlater.email.sent' +USER_SAVE_FOR_LATER_EMAIL_SENT = 'edx.bi.user.saveforlater.email.sent' +USER_SAVE_FOR_LATER_REMINDER_EMAIL_SENT = 'edx.bi.user.saveforlater.reminder.email.sent' def _get_program_pacing(course_runs): @@ -25,30 +28,36 @@ def _get_program_pacing(course_runs): return 'Self-paced' if pacing == 'self_paced' else 'Instructor-led' -def _get_event_properties(request, data): +def _get_course_price(course): + """ + Get price of a course + """ + return CourseMode.min_course_price_for_currency(course_id=str(course.id), currency='USD') + + +def _get_event_properties(data): """ set event properties for course and program which are required in braze email template """ lms_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL) event_properties = { 'time': datetime.now().isoformat(), - 'name': 'user.send.save.for.later.email', + 'name': data.get('braze_event'), } if data.get('type') == 'course': course = data.get('course') - data = request.data - org_img_url = data.get('org_img_url') - marketing_url = data.get('marketing_url') + price = _get_course_price(course) event_properties.update({ 'properties': { 'course_image_url': '{base_url}{image_path}'.format( base_url=lms_url, image_path=course.course_image_url ), - 'partner_image_url': org_img_url, + 'partner_image_url': data.get('org_img_url'), 'enroll_course_url': '{base_url}/register?course_id={course_id}&enrollment_action=enroll&email_opt_in=' 'false&save_for_later=true'.format(base_url=lms_url, course_id=course.id), - 'view_course_url': marketing_url + '?save_for_later=true' if marketing_url else '#', + 'view_course_url': data.get('marketing_url') + '?save_for_later=true' if data.get( + 'marketing_url') else '#', 'display_name': course.display_name, 'short_description': course.short_description, 'weeks_to_complete': data.get('weeks_to_complete'), @@ -56,6 +65,7 @@ def _get_event_properties(request, data): 'max_effort': data.get('max_effort'), 'pacing_type': 'Self-paced' if course.self_paced else 'Instructor-led', 'type': 'course', + 'price': 'Free' if price == 0 else f'${price} USD', } }) @@ -84,11 +94,11 @@ def _get_event_properties(request, data): return event_properties -def send_email(request, email, data): +def send_email(email, data): """ Send email through Braze """ - event_properties = _get_event_properties(request, data) + event_properties = _get_event_properties(data) braze_client = BrazeClient( api_key=settings.EDX_BRAZE_API_KEY, api_url=settings.EDX_BRAZE_API_SERVER, @@ -109,18 +119,27 @@ def send_email(request, email, data): event_properties.update({'user_alias': user_alias}) attributes = [{ 'user_alias': user_alias, - 'pref-lang': request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME, 'en') + 'pref-lang': data.get('pref-lang', 'en') }] braze_client.track_user(events=[event_properties], attributes=attributes) + + event_data = { + 'user_id': data.get('user_id'), + 'category': 'save-for-later', + 'type': event_properties.get('type'), + 'send_to_self': data.get('send_to_self'), + } + if data.get('type') == 'program': + program = data.get('program') + event_data.update({'program_uuid': program.get('uuid')}) + elif data.get('type') == 'course': + course = data.get('course') + event_data.update({'course_key': str(course.id)}) + tracker.emit( - USER_SENT_EMAIL_SAVE_FOR_LATER, - { - 'user_id': request.user.id, - 'category': 'save-for-later', - 'type': event_properties.get('type'), - 'send_to_self': bool(not request.user.is_anonymous and request.user.email == email), - } + USER_SAVE_FOR_LATER_REMINDER_EMAIL_SENT if data.get('reminder') else USER_SAVE_FOR_LATER_EMAIL_SENT, + event_data ) except Exception: # pylint: disable=broad-except log.warning('Unable to send save for later email ', exc_info=True) diff --git a/openedx/features/course_search/views/__init__.py b/lms/djangoapps/save_for_later/management/__init__.py similarity index 100% rename from openedx/features/course_search/views/__init__.py rename to lms/djangoapps/save_for_later/management/__init__.py diff --git a/lms/djangoapps/save_for_later/management/commands/__init__.py b/lms/djangoapps/save_for_later/management/commands/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/save_for_later/management/commands/send_course_reminder_emails.py b/lms/djangoapps/save_for_later/management/commands/send_course_reminder_emails.py new file mode 100644 index 0000000000..5669925dd4 --- /dev/null +++ b/lms/djangoapps/save_for_later/management/commands/send_course_reminder_emails.py @@ -0,0 +1,92 @@ +""" +Management command to send reminder emails. +""" + +import logging + +from textwrap import dedent +from datetime import datetime, timedelta + +from django.conf import settings +from django.core.management import BaseCommand +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user + +from lms.djangoapps.save_for_later.helper import send_email +from lms.djangoapps.save_for_later.models import SavedCourse +from common.djangoapps.student.models import CourseEnrollment +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from common.djangoapps.util.query import use_read_replica_if_available + +logger = logging.getLogger(__name__) + +USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL = 'user.send.save.for.later.reminder.email' + + +class Command(BaseCommand): + """ + Command to send reminder emails to those users who + saved course by email but not register within 15 days. + + + Examples: + + ./manage.py lms send_course_reminder_emails --batch-size=100 + """ + help = dedent(__doc__) + + def add_arguments(self, parser): + parser.add_argument( + '--batch-size', + type=int, + default=1000, + help='Maximum number of users to send reminder email in one chunk') + + def handle(self, *args, **options): + """ + Handle the send save for later reminder emails. + """ + + reminder_email_threshold_date = datetime.now() - timedelta( + days=settings.SAVE_FOR_LATER_REMINDER_EMAIL_THRESHOLD) + saved_courses_ids = SavedCourse.objects.filter( + reminder_email_sent=False, modified__lt=reminder_email_threshold_date + ).values_list('id', flat=True) + total = saved_courses_ids.count() + batch_size = max(1, options.get('batch_size')) + num_batches = ((total - 1) / batch_size + 1) if total > 0 else 0 + + for batch_num in range(int(num_batches)): + reminder_email_sent_ids = [] + start = batch_num * batch_size + end = min(start + batch_size, total) - 1 + saved_courses_batch_ids = list(saved_courses_ids)[start:end + 1] + + query = SavedCourse.objects.filter(id__in=saved_courses_batch_ids).order_by('-modified') + saved_courses = use_read_replica_if_available(query) + for saved_course in saved_courses: + user = User.objects.filter(email=saved_course.email).first() + course_overview = CourseOverview.get_from_id(saved_course.course_id) + course_data = { + 'course': course_overview, + 'send_to_self': None, + 'user_id': saved_course.user_id, + 'org_img_url': saved_course.org_img_url, + 'marketing_url': saved_course.marketing_url, + 'weeks_to_complete': saved_course.weeks_to_complete, + 'min_effort': saved_course.min_effort, + 'max_effort': saved_course.max_effort, + 'type': 'course', + 'reminder': True, + 'braze_event': USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL, + } + if user: + enrollment = CourseEnrollment.get_enrollment(user, saved_course.course_id) + if enrollment: + continue + email_sent = send_email(saved_course.email, course_data) + if email_sent: + reminder_email_sent_ids.append(saved_course.id) + else: + logging.info("Unable to send reminder email to {user} for {course} course" + .format(user=str(saved_course.email), course=str(saved_course.course_id))) + SavedCourse.objects.filter(id__in=reminder_email_sent_ids).update(reminder_email_sent=True) diff --git a/lms/djangoapps/save_for_later/management/commands/send_program_reminder_emails.py b/lms/djangoapps/save_for_later/management/commands/send_program_reminder_emails.py new file mode 100644 index 0000000000..650fa42978 --- /dev/null +++ b/lms/djangoapps/save_for_later/management/commands/send_program_reminder_emails.py @@ -0,0 +1,85 @@ +""" +Management command to send program reminder emails. +""" + +import logging + +from textwrap import dedent +from datetime import datetime, timedelta + +from django.conf import settings +from django.core.management import BaseCommand +from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user + +from lms.djangoapps.save_for_later.helper import send_email +from lms.djangoapps.save_for_later.models import SavedProgram +from lms.djangoapps.program_enrollments.api import get_program_enrollment +from openedx.core.djangoapps.catalog.utils import get_programs +from common.djangoapps.util.query import use_read_replica_if_available + +LOGGER = logging.getLogger(__name__) + +USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL = 'user.send.save.for.later.reminder.email' + + +class Command(BaseCommand): + """ + Command to send reminder emails to those users who saved + program by email but not enroll program within 15 days. + + + Examples: + + ./manage.py lms send_program_reminder_emails --batch-size=100 + """ + help = dedent(__doc__) + + def add_arguments(self, parser): + parser.add_argument( + '--batch-size', + type=int, + default=1000, + help='Maximum number of users to send reminder email in one chunk') + + def handle(self, *args, **options): + """ + Handle the send save for later reminder emails. + """ + reminder_email_threshold_date = datetime.now() - timedelta( + days=settings.SAVE_FOR_LATER_REMINDER_EMAIL_THRESHOLD) + saved_program_ids = SavedProgram.objects.filter( + reminder_email_sent=False, modified__lt=reminder_email_threshold_date + ).values_list('id', flat=True) + total = saved_program_ids.count() + batch_size = max(1, options.get('batch_size')) + num_batches = ((total - 1) / batch_size + 1) if total > 0 else 0 + + for batch_num in range(int(num_batches)): + reminder_email_sent_ids = [] + start = batch_num * batch_size + end = min(start + batch_size, total) - 1 + saved_program_batch_ids = list(saved_program_ids)[start:end + 1] + + query = SavedProgram.objects.filter(id__in=saved_program_batch_ids).order_by('-modified') + saved_programs = use_read_replica_if_available(query) + for saved_program in saved_programs: + user = User.objects.filter(email=saved_program.email).first() + program = get_programs(uuid=saved_program.program_uuid) + if program: + program_data = { + 'program': program, + 'send_to_self': None, + 'user_id': saved_program.user_id, + 'type': 'program', + 'reminder': True, + 'braze_event': USER_SEND_SAVE_FOR_LATER_REMINDER_EMAIL, + } + if user and get_program_enrollment(program_uuid=saved_program.uuid, user=user): + continue + email_sent = send_email(saved_program.email, program_data) + if email_sent: + reminder_email_sent_ids.append(saved_program.id) + else: + logging.info("Unable to send reminder email to {user} for {program} program" + .format(user=str(saved_program.email), program=str(saved_program.program_uuid))) + SavedProgram.objects.filter(id__in=reminder_email_sent_ids).update(reminder_email_sent=True) diff --git a/lms/djangoapps/save_for_later/management/commands/tests/__init__.py b/lms/djangoapps/save_for_later/management/commands/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/save_for_later/management/commands/tests/test_send_course_reminder_emails.py b/lms/djangoapps/save_for_later/management/commands/tests/test_send_course_reminder_emails.py new file mode 100644 index 0000000000..6bdc8a6637 --- /dev/null +++ b/lms/djangoapps/save_for_later/management/commands/tests/test_send_course_reminder_emails.py @@ -0,0 +1,39 @@ +""" Test the test_send_course_reminder_emails command line script.""" + +from unittest.mock import patch + +import ddt +from django.core.management import call_command +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + +from openedx.core.djangolib.testing.utils import skip_unless_lms +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.save_for_later.tests.factories import SavedCourseFactory +from lms.djangoapps.save_for_later.models import SavedCourse +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory + + +@ddt.ddt +@skip_unless_lms +class SavedCourseReminderEmailsTest(SharedModuleStoreTestCase): + """ + Test the test_send_course_reminder_emails management command + """ + + def setUp(self): + super().setUp() + self.course_id = 'course-v1:edX+DemoX+Demo_Course' + self.user = UserFactory(email='email@test.com', username='jdoe') + self.saved_course = SavedCourseFactory.create(course_id=self.course_id, user_id=self.user.id) + self.saved_course_1 = SavedCourseFactory.create(course_id=self.course_id) + CourseOverviewFactory.create(id=self.saved_course.course_id) + CourseOverviewFactory.create(id=self.saved_course_1.course_id) + + def test_send_reminder_emails(self): + with patch('lms.djangoapps.save_for_later.helper.BrazeClient') as mock_task: + call_command('send_course_reminder_emails', '--batch-size=1') + mock_task.assert_called() + + saved_course = SavedCourse.objects.filter(course_id=self.course_id).first() + assert saved_course.reminder_email_sent is True + assert saved_course.email_sent_count > 0 diff --git a/lms/djangoapps/save_for_later/management/commands/tests/test_send_program_reminder_emails.py b/lms/djangoapps/save_for_later/management/commands/tests/test_send_program_reminder_emails.py new file mode 100644 index 0000000000..2501ccdb9f --- /dev/null +++ b/lms/djangoapps/save_for_later/management/commands/tests/test_send_program_reminder_emails.py @@ -0,0 +1,38 @@ +""" Test the test_send_program_reminder_emails command line script.""" + + +from unittest.mock import patch + +import ddt +from django.core.management import call_command +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + +from openedx.core.djangolib.testing.utils import skip_unless_lms +from lms.djangoapps.save_for_later.tests.factories import SavedPogramFactory +from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory +from lms.djangoapps.save_for_later.models import SavedProgram + + +@ddt.ddt +@skip_unless_lms +class SavedProgramReminderEmailsTest(SharedModuleStoreTestCase): + """ + Test the send_program_reminder_emails management command + """ + + def setUp(self): + super().setUp() + self.uuid = '587f6abe-bfa4-4125-9fbe-4789bf3f97f1' + self.program = ProgramFactory(uuid=self.uuid) + self.saved_program = SavedPogramFactory.create(program_uuid=self.uuid) + + @patch('lms.djangoapps.save_for_later.management.commands.send_program_reminder_emails.get_programs') + def test_send_reminder_emails(self, mock_get_programs): + mock_get_programs.return_value = self.program + with patch('lms.djangoapps.save_for_later.helper.BrazeClient') as mock_task: + call_command('send_program_reminder_emails', '--batch-size=1') + mock_task.assert_called() + + saved_program = SavedProgram.objects.filter(program_uuid=self.uuid).first() + assert saved_program.reminder_email_sent is True + assert saved_program.email_sent_count > 0 diff --git a/lms/djangoapps/save_for_later/migrations/0002_auto_20220322_1621.py b/lms/djangoapps/save_for_later/migrations/0002_auto_20220322_1621.py new file mode 100644 index 0000000000..318fe56c84 --- /dev/null +++ b/lms/djangoapps/save_for_later/migrations/0002_auto_20220322_1621.py @@ -0,0 +1,66 @@ +# Generated by Django 3.2.12 on 2022-03-22 16:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('save_for_later', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='savedcourse', + name='marketing_url', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='savedcourse', + name='max_effort', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='savedcourse', + name='min_effort', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='savedcourse', + name='org_img_url', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='savedcourse', + name='weeks_to_complete', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='savedcourse', + name='email_sent_count', + field=models.IntegerField(default=0, null=True), + ), + migrations.AlterUniqueTogether( + name='savedcourse', + unique_together={('email', 'course_id')}, + ), + migrations.AddField( + model_name='savedprogram', + name='email_sent_count', + field=models.IntegerField(default=0, null=True), + ), + migrations.AlterUniqueTogether( + name='savedprogram', + unique_together={('email', 'program_uuid')}, + ), + migrations.AddField( + model_name='savedcourse', + name='reminder_email_sent', + field=models.BooleanField(default=False, null=True), + ), + migrations.AddField( + model_name='savedprogram', + name='reminder_email_sent', + field=models.BooleanField(default=False, null=True), + ), + ] diff --git a/lms/djangoapps/save_for_later/models.py b/lms/djangoapps/save_for_later/models.py index 3a6b5ed243..23eef180b0 100644 --- a/lms/djangoapps/save_for_later/models.py +++ b/lms/djangoapps/save_for_later/models.py @@ -21,6 +21,20 @@ class SavedCourse(DeletableByUserValue, TimeStampedModel): user_id = models.IntegerField(null=True, blank=True) email = models.EmailField(db_index=True) course_id = CourseKeyField(max_length=255, db_index=True) + marketing_url = models.CharField(max_length=255, null=True, blank=True) + org_img_url = models.CharField(max_length=255, null=True, blank=True) + weeks_to_complete = models.IntegerField(null=True) + min_effort = models.IntegerField(null=True) + max_effort = models.IntegerField(null=True) + email_sent_count = models.IntegerField(null=True, default=0) + reminder_email_sent = models.BooleanField(default=False, null=True) + + class Meta: + unique_together = ('email', 'course_id',) + + def save(self, *args, **kwargs): + self.email_sent_count = self.email_sent_count + 1 + super().save(*args, **kwargs) class SavedProgram(DeletableByUserValue, TimeStampedModel): @@ -34,3 +48,12 @@ class SavedProgram(DeletableByUserValue, TimeStampedModel): user_id = models.IntegerField(null=True, blank=True) email = models.EmailField(db_index=True) program_uuid = models.UUIDField() + email_sent_count = models.IntegerField(null=True, default=0) + reminder_email_sent = models.BooleanField(default=False, null=True) + + class Meta: + unique_together = ('email', 'program_uuid',) + + def save(self, *args, **kwargs): + self.email_sent_count = self.email_sent_count + 1 + super().save(*args, **kwargs) diff --git a/lms/djangoapps/save_for_later/tests/factories.py b/lms/djangoapps/save_for_later/tests/factories.py new file mode 100644 index 0000000000..5c10295ec5 --- /dev/null +++ b/lms/djangoapps/save_for_later/tests/factories.py @@ -0,0 +1,46 @@ +""" + Provides factories for save_for_later models. +""" + + +from datetime import datetime +from pytz import UTC + +import factory +from factory.django import DjangoModelFactory + +from lms.djangoapps.save_for_later.models import SavedCourse, SavedProgram + + +class SavedCourseFactory(DjangoModelFactory): + """ + Factory for SavedCourses objects + """ + class Meta: + model = SavedCourse + django_get_or_create = ('course_id', 'user_id') + + email = 'abc@test.com' + course_id = factory.Sequence('{}'.format) + user_id = factory.Sequence('{}'.format) + reminder_email_sent = False + email_sent_count = 0 + created = datetime(2020, 1, 1, tzinfo=UTC) + modified = datetime(2020, 2, 1, tzinfo=UTC) + + +class SavedPogramFactory(DjangoModelFactory): + """ + Factory for SavedProgram objects + """ + class Meta: + model = SavedProgram + django_get_or_create = ('program_uuid', ) + + email = 'abc@test.com' + user_id = factory.Sequence('{}'.format) + program_uuid = factory.Sequence('{}'.format) + reminder_email_sent = False + email_sent_count = 0 + created = datetime(2020, 1, 1, tzinfo=UTC) + modified = datetime(2020, 2, 1, tzinfo=UTC) diff --git a/lms/djangoapps/support/serializers.py b/lms/djangoapps/support/serializers.py index 29baa1f563..0d4daa83e9 100644 --- a/lms/djangoapps/support/serializers.py +++ b/lms/djangoapps/support/serializers.py @@ -3,13 +3,12 @@ Serializers for use in the support app. """ import json -from django.urls import reverse from rest_framework import serializers from common.djangoapps.student.models import CourseEnrollment, ManualEnrollmentAudit from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment from openedx.core.djangoapps.catalog.utils import get_programs_by_uuids -from openedx.features.course_experience import default_course_url_name +from openedx.features.course_experience import default_course_url DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%S' # pylint: disable=abstract-method @@ -47,8 +46,7 @@ class ProgramCourseEnrollmentSerializer(serializers.Serializer): model = ProgramCourseEnrollment def get_course_url(self, obj): - course_url_name = default_course_url_name(obj.course_key) - return reverse(course_url_name, kwargs={'course_id': obj.course_key}) + return default_course_url(obj.course_key) class ProgramEnrollmentSerializer(serializers.Serializer): diff --git a/lms/envs/common.py b/lms/envs/common.py index 83709b11db..e9a8dc1862 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -1,22 +1,35 @@ """ -This is the common settings file, intended to set sane defaults. If you have a -piece of configuration that's dependent on a set of feature flags being set, -then create a function that returns the calculated value based on the value of -FEATURES[...]. Modules that extend this one can change the feature -configuration in an environment specific config file and re-calculate those -values. +This is the common settings file, intended to set sane defaults. -We should make a method that calls all these config methods so that you just -make one call at the end of your site-specific dev file to reset all the -dependent variables (like INSTALLED_APPS) for you. +If you wish to override some of the settings set here without needing to specify +everything, you should create a new settings file that imports the content of this +one and then overrides anything you wish to make overridable. -Longer TODO: -1. Right now our treatment of static content in general and in particular - course-specific static content is haphazard. -2. We should have a more disciplined approach to feature flagging, even if it - just means that we stick them in a dict called FEATURES. -3. We need to handle configuration for multiple courses. This could be as - multiple sites, but we do need a way to map their data assets. +Some known files that extend this one: + +- `production.py` - This file loads overrides from a yaml settings file and uses that + to override the settings set in this file. + + +Conventions +----------- + +1. Extending a List Setting + + Sometimes settings take the form of a list and rather than replacing the + whole list, we want to add items to the list. eg. CELERY_IMPORTS. + + In this case, it is recommended that a new variable created in your extended + file that contains the word `EXTRA` and enough of the base variable to easily + let people map between the two items. + + Examples: + - CELERY_EXTRA_IMPORTS (preferred format) + - EXTRA_MIDDLEWARE_CLASSES + - XBLOCK_EXTRA_MIXINS (preferred format) + + The preferred format for the name of the new setting (e.g. `CELERY_EXTRA_IMPORTS`) is to use + the same prefix (e.g. `CELERY`) of the setting that is being appended (e.g. `CELERY_IMPORTS`). """ # We intentionally define lots of variables that aren't used @@ -1023,8 +1036,6 @@ SOFTWARE_SECURE_REQUEST_RETRY_DELAY = 60 * 60 SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS = 6 RETRY_CALENDAR_SYNC_EMAIL_MAX_ATTEMPTS = 5 -# Deadline message configurations -COURSE_MESSAGE_ALERT_DURATION_IN_DAYS = 14 MARKETING_EMAILS_OPT_IN = False @@ -1041,6 +1052,9 @@ ENABLE_COPPA_COMPLIANCE = False # VAN-741 - save for later api put behind a flag to make it only available for edX ENABLE_SAVE_FOR_LATER = False +# VAN-887 - save for later reminder emails threshold days +SAVE_FOR_LATER_REMINDER_EMAIL_THRESHOLD = 15 + ############################# SET PATH INFORMATION ############################# PROJECT_ROOT = path(__file__).abspath().dirname().dirname() # /edx-platform/lms REPO_ROOT = PROJECT_ROOT.dirname() @@ -1071,6 +1085,7 @@ STATUS_MESSAGE_PATH = ENV_ROOT / "status_message.json" DATABASE_ROUTERS = [ 'openedx.core.lib.django_courseware_routers.StudentModuleHistoryExtendedRouter', + 'openedx.core.lib.blockstore_api.db_routers.BlockstoreRouter', 'edx_django_utils.db.read_replica.ReadReplicaRouter', ] @@ -2677,11 +2692,17 @@ DEBUG_TOOLBAR_PATCH_SETTINGS = False ################################# CELERY ###################################### -CELERY_IMPORTS = ( +CELERY_IMPORTS = [ # Since xblock-poll is not a Django app, and XBlocks don't get auto-imported # by celery workers, its tasks will not get auto-discovered: 'poll.tasks', -) +] + +# .. setting_name: CELERY_EXTRA_IMPORTS +# .. setting_default: [] +# .. setting_description: Adds extra packages that don't get auto-imported (Example: XBlocks). +# These packages are added in addition to those added by CELERY_IMPORTS. +CELERY_EXTRA_IMPORTS = [] # Message configuration @@ -3083,10 +3104,6 @@ INSTALLED_APPS = [ 'openedx.core.djangoapps.content.block_structure.apps.BlockStructureConfig', 'lms.djangoapps.course_blocks', - - # Coursegraph - 'openedx.core.djangoapps.coursegraph.apps.CoursegraphConfig', - # Mailchimp Syncing 'lms.djangoapps.mailing', @@ -3168,7 +3185,6 @@ INSTALLED_APPS = [ 'openedx.features.calendar_sync', 'openedx.features.course_bookmarks', 'openedx.features.course_experience', - 'openedx.features.course_search', 'openedx.features.enterprise_support.apps.EnterpriseSupportConfig', 'openedx.features.learner_profile', 'openedx.features.course_duration_limits', @@ -3228,6 +3244,9 @@ INSTALLED_APPS = [ # For save for later 'lms.djangoapps.save_for_later', + + # Blockstore + 'blockstore.apps.bundles', ] ######################### CSRF ######################################### @@ -4608,6 +4627,18 @@ COURSE_ENROLLMENT_MODES = { "display_name": _("Paid Executive Education"), "min_price": 1 }, + "unpaid-bootcamp": { + "id": 11, + "slug": "unpaid-bootcamp", + "display_name": _("Unpaid Bootcamp"), + "min_price": 0 + }, + "paid-bootcamp": { + "id": 12, + "slug": "paid-bootcamp", + "display_name": _("Paid Bootcamp"), + "min_price": 1 + }, } CONTENT_TYPE_GATE_GROUP_IDS = { @@ -4833,6 +4864,30 @@ ENABLE_AUTHN_RESET_PASSWORD_HIBP_POLICY = False ENABLE_AUTHN_REGISTER_HIBP_POLICY = False HIBP_REGISTRATION_PASSWORD_FREQUENCY_THRESHOLD = 3 +# .. toggle_name: ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY +# .. toggle_implementation: DjangoSetting +# .. toggle_default: False +# .. toggle_description: When enabled, this toggle activates the use of the password validation +# on Authn MFE's login. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2022-03-29 +# .. toggle_target_removal_date: None +# .. toggle_tickets: https://openedx.atlassian.net/browse/VAN-668 +ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY = False +HIBP_LOGIN_NUDGE_PASSWORD_FREQUENCY_THRESHOLD = 3 + +# .. toggle_name: ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY +# .. toggle_implementation: DjangoSetting +# .. toggle_default: False +# .. toggle_description: When enabled, this toggle activates the use of the password validation +# on Authn MFE's login. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2022-03-29 +# .. toggle_target_removal_date: None +# .. toggle_tickets: https://openedx.atlassian.net/browse/VAN-667 +ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY = False +HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD = 5 + ############### Settings for the ace_common plugin ################# ACE_ENABLED_CHANNELS = ['django_email'] ACE_ENABLED_POLICIES = ['bulk_email_optout'] @@ -4919,6 +4974,10 @@ MAILCHIMP_NEW_USER_LIST_ID = "" BLOCKSTORE_PUBLIC_URL_ROOT = 'http://localhost:18250' BLOCKSTORE_API_URL = 'http://localhost:18250/api/v1/' +# Disable the Blockstore app API by default. +# See openedx.core.lib.blockstore_api.config for details. +BLOCKSTORE_USE_BLOCKSTORE_APP_API = False + # .. setting_name: XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE # .. setting_default: default # .. setting_description: The django cache key of the cache to use for storing anonymous user state for XBlocks. @@ -4934,6 +4993,40 @@ XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE = 'default' # configured to expire after one hour. BLOCKSTORE_BUNDLE_CACHE_TIMEOUT = 3000 +# .. setting_name: BUNDLE_ASSET_URL_STORAGE_KEY +# .. setting_default: None +# .. setting_description: When this is set, `BUNDLE_ASSET_URL_STORAGE_SECRET` is +# set, and `boto3` is installed, this is used as an AWS IAM access key for +# generating signed, read-only URLs for blockstore assets stored in S3. +# Otherwise, URLs are generated based on the default storage configuration. +# See `blockstore.apps.bundles.storage.LongLivedSignedUrlStorage` for details. +BUNDLE_ASSET_URL_STORAGE_KEY = None + +# .. setting_name: BUNDLE_ASSET_URL_STORAGE_SECRET +# .. setting_default: None +# .. setting_description: When this is set, `BUNDLE_ASSET_URL_STORAGE_KEY` is +# set, and `boto3` is installed, this is used as an AWS IAM secret key for +# generating signed, read-only URLs for blockstore assets stored in S3. +# Otherwise, URLs are generated based on the default storage configuration. +# See `blockstore.apps.bundles.storage.LongLivedSignedUrlStorage` for details. +BUNDLE_ASSET_URL_STORAGE_SECRET = None + +# .. setting_name: BUNDLE_ASSET_STORAGE_SETTINGS +# .. setting_default: dict, appropriate for file system storage. +# .. setting_description: When this is set, `BUNDLE_ASSET_URL_STORAGE_KEY` is +# set, and `boto3` is installed, this provides the bucket name and location for blockstore assets stored in S3. +# See `blockstore.apps.bundles.storage.LongLivedSignedUrlStorage` for details. +BUNDLE_ASSET_STORAGE_SETTINGS = dict( + # Backend storage + # STORAGE_CLASS='storages.backends.s3boto.S3BotoStorage', + # STORAGE_KWARGS=dict(bucket='bundle-asset-bucket', location='/path-to-bundles/'), + STORAGE_CLASS='django.core.files.storage.FileSystemStorage', + STORAGE_KWARGS=dict( + location=MEDIA_ROOT, + base_url=MEDIA_URL, + ), +) + ######################### MICROSITE ############################### MICROSITE_ROOT_DIR = '/edx/app/edxapp/edx-microsite' MICROSITE_CONFIGURATION = {} @@ -5017,3 +5110,13 @@ DISCUSSION_MODERATION_CLOSE_REASON_CODES = { "duplicate": _("Post is a duplicate"), "off-topic": _("Post is off-topic"), } + +################# Settings for edx-financial-assistance ################# +IS_ELIGIBLE_FOR_FINANCIAL_ASSISTANCE_URL = '/core/api/course_eligibility/' +FINANCIAL_ASSISTANCE_APPLICATION_STATUS_URL = "/core/api/financial_assistance_application/status/" +CREATE_FINANCIAL_ASSISTANCE_APPLICATION_URL = '/core/api/financial_assistance_applications' + +######################## Enterprise API Client ######################## +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_KEY = "enterprise-backend-service-key" +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_SECRET = "enterprise-backend-service-secret" +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = "http://127.0.0.1:8000/oauth2" diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index f30bf716d2..58d76aca27 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -19,7 +19,6 @@ from .production import * # pylint: disable=wildcard-import, unused-wildcard-im # Don't use S3 in devstack, fall back to filesystem del DEFAULT_FILE_STORAGE -MEDIA_ROOT = "/edx/var/edxapp/uploads" ORA2_FILEUPLOAD_BACKEND = 'django' @@ -391,6 +390,8 @@ MKTG_URLS = { ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = {} +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = "http://edx.devstack.lms:18000/oauth2" + CREDENTIALS_SERVICE_USERNAME = 'credentials_worker' COURSE_CATALOG_URL_ROOT = 'http://edx.devstack.discovery:18381' @@ -446,6 +447,10 @@ PROCTORING_USER_OBFUSCATION_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' #################### Webpack Configuration Settings ############################## WEBPACK_LOADER['DEFAULT']['TIMEOUT'] = 5 +#################### Network configuration #################### +# Devstack is directly exposed to the caller +CLOSEST_CLIENT_IP_FROM_HEADERS = [] + ################# New settings must go ABOVE this line ################# ######################################################################## # See if the developer has any local overrides. diff --git a/lms/envs/docker-production.py b/lms/envs/docker-production.py new file mode 100644 index 0000000000..73bf5e76ea --- /dev/null +++ b/lms/envs/docker-production.py @@ -0,0 +1,83 @@ +""" +Specific overrides to the base prod settings for a docker production deployment. +""" + +import platform +from .production import * # pylint: disable=wildcard-import, unused-wildcard-import + + +def get_docker_logger_config(log_dir='/var/tmp', + logging_env="no_env", + edx_filename="edx.log", + dev_env=False, + debug=False, + service_variant='lms'): + """ + Return the appropriate logging config dictionary. You should assign the + result of this to the LOGGING var in your settings. + """ + + hostname = platform.node().split(".")[0] + syslog_format = ( + "[service_variant={service_variant}]" + "[%(name)s][env:{logging_env}] %(levelname)s " + "[{hostname} %(process)d] [%(filename)s:%(lineno)d] " + "- %(message)s" + ).format( + service_variant=service_variant, + logging_env=logging_env, hostname=hostname + ) + + handlers = ['console'] + + logger_config = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'standard': { + 'format': '%(asctime)s %(levelname)s %(process)d ' + '[%(name)s] %(filename)s:%(lineno)d - %(message)s', + }, + 'syslog_format': {'format': syslog_format}, + 'raw': {'format': '%(message)s'}, + }, + 'handlers': { + 'console': { + 'level': 'DEBUG' if debug else 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'standard', + 'stream': sys.stdout, + }, + }, + 'loggers': { + 'django': { + 'handlers': handlers, + 'propagate': True, + 'level': 'INFO' + }, + 'requests': { + 'handlers': handlers, + 'propagate': True, + 'level': 'WARNING' + }, + 'factory': { + 'handlers': handlers, + 'propagate': True, + 'level': 'WARNING' + }, + 'django.request': { + 'handlers': handlers, + 'propagate': True, + 'level': 'WARNING' + }, + '': { + 'handlers': handlers, + 'level': 'DEBUG', + 'propagate': False + }, + } + } + + return logger_config + +LOGGING = get_docker_logger_config() diff --git a/lms/envs/production.py b/lms/envs/production.py index e74cff6b58..5e0213da9d 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -82,6 +82,7 @@ with codecs.open(CONFIG_FILE, encoding='utf-8') as f: 'CELERY_QUEUES', 'MKTG_URL_LINK_MAP', 'MKTG_URL_OVERRIDES', + 'REST_FRAMEWORK', ] for key in KEYS_WITH_MERGED_VALUES: if key in __config_copy__: @@ -1064,3 +1065,9 @@ DISCUSSIONS_MICROFRONTEND_URL = ENV_TOKENS.get('DISCUSSIONS_MICROFRONTEND_URL', ################### Discussions micro frontend Feedback URL################### DISCUSSIONS_MFE_FEEDBACK_URL = ENV_TOKENS.get('DISCUSSIONS_MFE_FEEDBACK_URL', DISCUSSIONS_MFE_FEEDBACK_URL) + +############## DRF overrides ############## +REST_FRAMEWORK.update(ENV_TOKENS.get('REST_FRAMEWORK', {})) + +############################# CELERY ############################ +CELERY_IMPORTS.extend(ENV_TOKENS.get('CELERY_EXTRA_IMPORTS', [])) diff --git a/lms/envs/test.py b/lms/envs/test.py index 49f88f9b22..b50f3e5e8a 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -223,16 +223,6 @@ CACHES = { }, } -############################### BLOCKSTORE ##################################### -# Blockstore tests -RUN_BLOCKSTORE_TESTS = os.environ.get('EDXAPP_RUN_BLOCKSTORE_TESTS', 'no').lower() in ('true', 'yes', '1') -BLOCKSTORE_API_URL = os.environ.get('EDXAPP_BLOCKSTORE_API_URL', "http://edx.devstack.blockstore-test:18251/api/v1/") -BLOCKSTORE_API_AUTH_TOKEN = os.environ.get('EDXAPP_BLOCKSTORE_API_AUTH_TOKEN', 'edxapp-test-key') -XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE = 'blockstore' # This must be set to a working cache for the tests to pass - -# Dummy secret key for dev -SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' - ############################# SECURITY SETTINGS ################################ # Default to advanced security in common.py, so tests can reset here to use # a simpler security model @@ -314,7 +304,7 @@ ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = OrderedDict([ ############################ STATIC FILES ############################# DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' MEDIA_ROOT = TEST_ROOT / "uploads" -MEDIA_URL = "/static/uploads/" +MEDIA_URL = "/uploads/" STATICFILES_DIRS.append(("uploads", MEDIA_ROOT)) _NEW_STATICFILES_DIRS = [] @@ -553,6 +543,24 @@ add_plugins(__name__, ProjectType.LMS, SettingsType.TEST) derive_settings(__name__) +############################### BLOCKSTORE ##################################### +# Blockstore tests +RUN_BLOCKSTORE_TESTS = os.environ.get('EDXAPP_RUN_BLOCKSTORE_TESTS', 'no').lower() in ('true', 'yes', '1') +BLOCKSTORE_USE_BLOCKSTORE_APP_API = not RUN_BLOCKSTORE_TESTS +BLOCKSTORE_API_URL = os.environ.get('EDXAPP_BLOCKSTORE_API_URL', "http://edx.devstack.blockstore-test:18251/api/v1/") +BLOCKSTORE_API_AUTH_TOKEN = os.environ.get('EDXAPP_BLOCKSTORE_API_AUTH_TOKEN', 'edxapp-test-key') +XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE = 'blockstore' # This must be set to a working cache for the tests to pass +BUNDLE_ASSET_STORAGE_SETTINGS = dict( + STORAGE_CLASS='django.core.files.storage.FileSystemStorage', + STORAGE_KWARGS=dict( + location=MEDIA_ROOT, + base_url=MEDIA_URL, + ), +) + +# Dummy secret key for dev +SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' + ############### Settings for edx-rbac ############### SYSTEM_WIDE_ROLE_CLASSES = os.environ.get("SYSTEM_WIDE_ROLE_CLASSES", []) @@ -624,3 +632,7 @@ ENABLE_SAVE_FOR_LATER = True # rate limit for /api/v1/save/course/ api SAVE_FOR_LATER_IP_RATE_LIMIT = '5/d' SAVE_FOR_LATER_EMAIL_RATE_LIMIT = '5/m' + +#################### Network configuration #################### +# Tests are not behind any proxies +CLOSEST_CLIENT_IP_FROM_HEADERS = [] diff --git a/lms/templates/courseware/courseware-chromeless.html b/lms/templates/courseware/courseware-chromeless.html index df5d9ee1c2..b99a41807e 100644 --- a/lms/templates/courseware/courseware-chromeless.html +++ b/lms/templates/courseware/courseware-chromeless.html @@ -42,7 +42,7 @@ ${static.get_page_title_breadcrumbs(course_name())} - + ${HTML(fragment.head_html())} % if is_learning_mfe: @@ -118,7 +118,7 @@ ${HTML(fragment.foot_html())} (function() { // If this view is rendered in an iframe within the learning microfrontend app // it will report the height of its contents to the parent window when the - // document loads, window resizes, or DOM mutates. + // document loads, window resizes, or DOM resizes. if (window !== window.parent) { document.body.className += ' view-in-mfe'; var lastHeight = window.offsetHeight; @@ -126,12 +126,11 @@ ${HTML(fragment.foot_html())} var contentElement = document.getElementById('content'); function dispatchResizeMessage(event) { - // Note: event is actually an Array of MutationRecord objects - // when fired from the MutationObserver - var eventType = event.type || 'mutate'; + // Note: event is actually an Array of ResizeObserverEntry objects when fired from the ResizeObserver + var isLoadEvent = event.type === 'load'; var newHeight = contentElement.offsetHeight; var newWidth = contentElement.offsetWidth; - if (eventType !== 'load' && newWidth === lastWidth && newHeight === lastHeight) { + if (!isLoadEvent && newWidth === lastWidth && newHeight === lastHeight) { // Monitor when any anchor tag is clicked, it is checked to make sure // it is referencing an element's id or name (not an external website). If // the href attribute is an id or name, the location of the selected focus @@ -190,11 +189,8 @@ ${HTML(fragment.foot_html())} window.scrollTo(0, 0); } - // Create an observer instance linked to the callback function - const observer = new MutationObserver(dispatchResizeMessage); - - // Start observing the target node for configured mutations - observer.observe(document.body, { attributes: true, childList: true, subtree: true }); + const observer = new ResizeObserver(dispatchResizeMessage); + observer.observe(document.body); window.addEventListener('load', dispatchResizeMessage); window.addEventListener('resize', dispatchResizeMessage); diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 722368ffed..882f453e76 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -25,11 +25,6 @@ from openedx.features.course_experience import course_home_page_title, DISABLE_C completion_aggregator_url = settings.COMPLETION_AGGREGATOR_URL if settings.FEATURES.get("SHOW_PROGRESS_BAR", False) else "" %> -% if display_reset_dates_banner: - -% endif <%def name="course_name()"> <% return _("{course_number} Courseware").format(course_number=course.display_number_with_default) %> diff --git a/lms/templates/courseware/dates.html b/lms/templates/courseware/dates.html deleted file mode 100644 index ff76a25197..0000000000 --- a/lms/templates/courseware/dates.html +++ /dev/null @@ -1,113 +0,0 @@ -<%page expression_filter="h"/> -<%inherit file="/main.html" /> -<%namespace name='static' file='/static_content.html'/> -<%! - -from django.utils.translation import ugettext as _ -from lms.djangoapps.courseware.date_summary import CourseAssignmentDate, TodaysDate, VerificationDeadlineDate, VerifiedUpgradeDeadlineDate -from openedx.core.djangolib.markup import HTML, Text -%> - -<%block name="bodyclass">view-in-course view-progress - -<%block name="headextra"> -<%static:css group='style-course-vendor'/> -<%static:css group='style-course'/> - - -<%block name="pagetitle">${_("{course.display_number_with_default} Course Info").format(course=course)} - -<%include file="/courseware/course_navigation.html" args="active_page='dates'" /> - -
-
-
-
-

- ${_("Important Dates")} -

- <%include file="/dates_banner.html" /> - <% due_next_set = False %> - % for block in course_date_blocks: - <% block_is_verified = (hasattr(block, 'contains_gated_content') and block.contains_gated_content) or isinstance(block, VerificationDeadlineDate) %> - <% learner_has_access = not block_is_verified or learner_is_full_access %> - <% access_class = '' if learner_has_access else 'no-access' %> - <% is_assignment = isinstance(block, CourseAssignmentDate) %> - <% assignment_type = is_assignment and block.assignment_type %> - <% todays_date = 'todays-date' if isinstance(block, TodaysDate) else '' %> - <% past_date = 'past-date' if block.date and block.date < block.current_time else '' %> - <% past_due = 'past-due' if learner_is_full_access and is_assignment and block.past_due else '' %> - <% due_in_future = True if learner_is_full_access and is_assignment and block.date and block.date >= block.current_time else False %> - <% not_released = 'not-released' if learner_is_full_access and is_assignment and not block.title_html else '' %> - % if not (learner_is_full_access and isinstance(block, VerifiedUpgradeDeadlineDate)): -
-
-
-
- % if block.date: -
- -
- % if todays_date: -
${_('Today')}
- % endif - % if not learner_has_access: -
${_('Verified Only')}
- % else: - % if is_assignment and block.complete: -
${_('Completed')}
- % elif is_assignment and block.past_due: -
${_('Past Due')}
- % elif is_assignment and due_in_future and not due_next_set: -
${_('Due Next')}
- <% due_next_set = True %> - % endif - % if not_released: -
${_('Not yet released')}
- % endif - %endif - % endif -
- % if not todays_date: -
- % if assignment_type: - ${assignment_type}:  - % endif - % if block.title_html and is_assignment and learner_has_access: - ${block.title_html} - % else: - ${block.title} - % endif -
-
- ${block.description} -
- % if block.extra_info: -
- ${block.extra_info} -
- % endif - % endif -
-
- % endif - % endfor -
-
-
-
- -<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory"> - DateUtilFactory.transform(iterationKey=".localized_datetime"); - - -<%static:require_module_async module_name="js/commerce/track_ecommerce_events" class_name="TrackECommerceEvents"> - var personalizedLearnerSchedulesLink = $(".personalized_learner_schedules_button"); - - TrackECommerceEvents.trackUpsellClick(personalizedLearnerSchedulesLink, 'dates_upgrade', { - pageName: "dates_tab", - linkType: "button", - linkCategory: "personalized_learner_schedules" - }); - - diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 5c4443c6bb..59c5a77e58 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -214,13 +214,12 @@ from common.djangoapps.student.models import CourseEnrollment course_mode_info = all_course_modes.get(session_id) is_paid_course = True if entitlement else (session_id in enrolled_courses_either_paid) is_course_voucher_refundable = (session_id in enrolled_courses_voucher_refundable) - course_verification_status = verification_status_by_course.get(session_id, {}) course_requirements = courses_requirements_not_met.get(session_id) related_programs = inverted_programs.get(six.text_type(entitlement.course_uuid if is_unfulfilled_entitlement else session_id)) show_consent_link = (session_id in consent_required_courses) resume_button_url = resume_button_urls[dashboard_index] %> - <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, entitlement_expiration_date=entitlement_expiration_date, entitlement_expired_at=entitlement_expired_at, show_courseware_link=show_courseware_link, cert_status=cert_status, can_refund_entitlement=can_refund_entitlement, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_voucher_refundable=is_course_voucher_refundable, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name, resume_button_url=resume_button_url, partner_managed_enrollment=partner_managed_enrollment' /> + <%include file='dashboard/_dashboard_course_listing.html' args='course_overview=course_overview, course_card_index=dashboard_index, enrollment=enrollment, is_unfulfilled_entitlement=is_unfulfilled_entitlement, is_fulfilled_entitlement=is_fulfilled_entitlement, entitlement=entitlement, entitlement_session=entitlement_session, entitlement_available_sessions=entitlement_available_sessions, entitlement_expiration_date=entitlement_expiration_date, entitlement_expired_at=entitlement_expired_at, show_courseware_link=show_courseware_link, cert_status=cert_status, can_refund_entitlement=can_refund_entitlement, can_unenroll=can_unenroll, credit_status=credit_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, is_paid_course=is_paid_course, is_course_voucher_refundable=is_course_voucher_refundable, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings, user=user, related_programs=related_programs, display_course_modes_on_dashboard=display_course_modes_on_dashboard, show_consent_link=show_consent_link, enterprise_customer_name=enterprise_customer_name, resume_button_url=resume_button_url, partner_managed_enrollment=partner_managed_enrollment' /> % endfor % if show_load_all_courses_link:
@@ -301,20 +300,6 @@ from common.djangoapps.student.models import CourseEnrollment <%include file='dashboard/_dashboard_announcements.html' /> % endif - % if display_sidebar_on_dashboard: -
-
- -
- -
- % endif
diff --git a/lms/templates/dashboard/_dashboard_certificate_information.html b/lms/templates/dashboard/_dashboard_certificate_information.html index 861c724c40..51e2c58dc6 100644 --- a/lms/templates/dashboard/_dashboard_certificate_information.html +++ b/lms/templates/dashboard/_dashboard_certificate_information.html @@ -1,4 +1,4 @@ -<%page expression_filter="h" args="cert_status, course_ended_not_passing, course_overview, enrollment, reverify_link" /> +<%page expression_filter="h" args="cert_status, course_ended_not_passing, course_overview, enrollment" /> <%! from django.urls import reverse @@ -157,12 +157,6 @@ else: % endif % endif - % if cert_status['status'] == 'downloadable' and enrollment.mode == 'verified' and cert_status['mode'] == 'honor': -
- ${_('Since we did not have a valid set of verification photos from you when your {cert_name_long} was generated, we could not grant you a verified {cert_name_short}. An honor code {cert_name_short} has been granted instead.').format(cert_name_short=cert_name_short, cert_name_long=cert_name_long)} -
- % endif -
% endif % elif course_ended_not_passing: diff --git a/lms/templates/dashboard/_dashboard_course_listing.html b/lms/templates/dashboard/_dashboard_course_listing.html index b291eeb297..b80ec5d77f 100644 --- a/lms/templates/dashboard/_dashboard_course_listing.html +++ b/lms/templates/dashboard/_dashboard_course_listing.html @@ -1,4 +1,4 @@ -<%page args="course_overview, enrollment, entitlement, entitlement_session, course_card_index, is_unfulfilled_entitlement, is_fulfilled_entitlement, entitlement_available_sessions, entitlement_expiration_date, entitlement_expired_at, show_courseware_link, cert_status, can_refund_entitlement, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_voucher_refundable, verification_status, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name, resume_button_url, partner_managed_enrollment" expression_filter="h"/> +<%page args="course_overview, enrollment, entitlement, entitlement_session, course_card_index, is_unfulfilled_entitlement, is_fulfilled_entitlement, entitlement_available_sessions, entitlement_expiration_date, entitlement_expired_at, show_courseware_link, cert_status, can_refund_entitlement, can_unenroll, credit_status, show_email_settings, course_mode_info, is_paid_course, is_course_voucher_refundable, course_requirements, dashboard_index, share_settings, related_programs, display_course_modes_on_dashboard, show_consent_link, enterprise_customer_name, resume_button_url, partner_managed_enrollment" expression_filter="h"/> <%! import datetime @@ -12,12 +12,10 @@ from django.urls import reverse from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.helpers import enrollment_mode_display from common.djangoapps.student.helpers import user_has_passing_grade_in_course -from lms.djangoapps.course_home_api.toggles import course_home_legacy_is_active from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string from openedx.core.djangolib.markup import HTML, Text -from openedx.features.course_experience import course_home_url_name -from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url +from openedx.features.course_experience import course_home_url from common.djangoapps.student.helpers import ( VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, @@ -65,7 +63,6 @@ from lms.djangoapps.experiments.utils import UPSELL_TRACKING_FLAG <% course_verified_certs = enrollment_mode_display( enrollment.mode, - verification_status.get('status'), course_overview.id ) %> @@ -83,7 +80,7 @@ from lms.djangoapps.experiments.utils import UPSELL_TRACKING_FLAG % endif >
- <% course_target = reverse(course_home_url_name(course_overview.id), args=[str(course_overview.id)]) if course_home_legacy_is_active(course_overview.id) else get_learning_mfe_home_url(course_key=course_overview.id, url_fragment="home") %> + <% course_target = course_home_url(course_overview.id) %>

${_('Course details')}

diff --git a/themes/edx.org/lms/templates/footer.html b/themes/edx.org/lms/templates/footer.html index 9d54b21076..c40267d100 100755 --- a/themes/edx.org/lms/templates/footer.html +++ b/themes/edx.org/lms/templates/footer.html @@ -132,7 +132,7 @@ ## \u2013 is the en-dash. It looks like a year, but it isn't.