diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3f9abcc671fb..a05b78e8837a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -17,6 +17,7 @@ lms/djangoapps/instructor_task/ lms/djangoapps/mobile_api/ openedx/core/djangoapps/credentials @openedx/2U-aperture openedx/core/djangoapps/credit @openedx/2U-aperture +openedx/core/djangoapps/enrollments/ @openedx/2U-aperture openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/oauth_dispatch openedx/core/djangoapps/user_api/ @openedx/2U-aperture @@ -37,8 +38,9 @@ lms/djangoapps/certificates/ @openedx/2U- # Discovery common/djangoapps/course_modes/ common/djangoapps/enrollment/ +lms/djangoapps/branding/ @openedx/2U-aperture lms/djangoapps/commerce/ -lms/djangoapps/experiments/ +lms/djangoapps/experiments/ @openedx/2U-aperture lms/djangoapps/learner_dashboard/ @openedx/2U-aperture lms/djangoapps/learner_home/ @openedx/2U-aperture openedx/features/content_type_gating/ diff --git a/.github/workflows/ci-static-analysis.yml b/.github/workflows/ci-static-analysis.yml index 7e768a456463..a3b0527aad72 100644 --- a/.github/workflows/ci-static-analysis.yml +++ b/.github/workflows/ci-static-analysis.yml @@ -10,7 +10,7 @@ jobs: matrix: python-version: - "3.11" - os: ["ubuntu-20.04"] + os: ["ubuntu-latest"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/compile-python-requirements.yml b/.github/workflows/compile-python-requirements.yml index 0ff99b9c685a..21cb80083f1d 100644 --- a/.github/workflows/compile-python-requirements.yml +++ b/.github/workflows/compile-python-requirements.yml @@ -15,7 +15,7 @@ defaults: jobs: recompile-python-dependencies: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Check out target branch diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 4d025e540163..c9d2d7ab1191 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] + os: [ubuntu-latest] node-version: [18, 20] python-version: - "3.11" diff --git a/.github/workflows/lint-imports.yml b/.github/workflows/lint-imports.yml index 8ead8396bf39..e3c59ec09304 100644 --- a/.github/workflows/lint-imports.yml +++ b/.github/workflows/lint-imports.yml @@ -9,7 +9,7 @@ on: jobs: lint-imports: name: Lint Python Imports - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Check out branch diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index 183b90effa29..f253d48e4f41 100644 --- a/.github/workflows/migrations-check.yml +++ b/.github/workflows/migrations-check.yml @@ -13,7 +13,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] + os: [ubuntu-latest] python-version: - "3.11" # 'pinned' is used to install the latest patch version of Django @@ -52,7 +52,7 @@ jobs: steps: - name: Setup mongodb user run: | - mongosh edxapp --eval ' + docker exec ${{ job.services.mongo.id }} mongosh edxapp --eval ' db.createUser( { user: "edxapp", @@ -67,7 +67,7 @@ jobs: - name: Verify mongo and mysql db credentials run: | mysql -h 127.0.0.1 -uedxapp001 -ppassword -e "select 1;" edxapp - mongosh --host 127.0.0.1 --username edxapp --password password --eval 'use edxapp; db.adminCommand("ping");' edxapp + docker exec ${{ job.services.mongo.id }} mongosh --host 127.0.0.1 --username edxapp --password password --eval 'use edxapp; db.adminCommand("ping");' edxapp - name: Checkout repo uses: actions/checkout@v4 diff --git a/.github/workflows/publish-ci-docker-image.yml b/.github/workflows/publish-ci-docker-image.yml index 0a9f50f6daf9..6a0f3768b7e6 100644 --- a/.github/workflows/publish-ci-docker-image.yml +++ b/.github/workflows/publish-ci-docker-image.yml @@ -7,7 +7,7 @@ on: jobs: push: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index eeb53c24ed98..58560bf3073f 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -8,7 +8,7 @@ on: jobs: run-pylint: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index cf8ffd5d2910..5445d70e3b4b 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -13,7 +13,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] + os: [ubuntu-latest] python-version: - "3.11" node-version: [20] diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 7f2b4925af8e..d880d7351766 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -17,7 +17,7 @@ jobs: runs-on: "${{ matrix.os }}" strategy: matrix: - os: ["ubuntu-20.04"] + os: ["ubuntu-latest"] python-version: - "3.11" diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml index 7bbfd3369b6b..0a417f9b1c79 100644 --- a/.github/workflows/static-assets-check.yml +++ b/.github/workflows/static-assets-check.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] + os: [ubuntu-latest] python-version: - "3.11" node-version: [18, 20] @@ -72,9 +72,6 @@ jobs: run: | pip install -r requirements/edx/assets.txt - - name: Initiate Mongo DB Service - run: sudo systemctl start mongod - - name: Add node_modules bin to $Path run: echo $GITHUB_WORKSPACE/node_modules/.bin >> $GITHUB_PATH diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3e442b75d4e7..5fef1c8352ce 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -15,7 +15,7 @@ concurrency: jobs: run-tests: name: ${{ matrix.shard_name }}(py=${{ matrix.python-version }},dj=${{ matrix.django-version }},mongo=${{ matrix.mongo-version }}) - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest strategy: matrix: python-version: @@ -66,7 +66,29 @@ jobs: - name: install system requirements run: | - sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx + sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx openssl + + # This is needed until the ENABLE_BLAKE2B_HASHING can be removed and we + # can stop using MD4 by default. + - name: enable md4 hashing in libssl + run: | + cat < --unusable-password + ./manage.py lms create_dot_application studio-sso-id studio_worker \ + --grant-type authorization-code \ + --skip-authorization \ + --redirect-uris 'http://localhost:18010/complete/edx-oauth2/' \ + --scopes user_id + +* Log into Django admin (eg. http://localhost:18000/admin/oauth2_provider/application/), + click into the application you created above (``studio-sso-id``), and copy its "Client secret". +* In your private LMS_CFG yaml file or your private Django settings module: + + * Set ``SOCIAL_AUTH_EDX_OAUTH2_KEY`` to the client ID (``studio-sso-id``). + * Set ``SOCIAL_AUTH_EDX_OAUTH2_SECRET`` to the client secret (which you copied). Run the Platform ---------------- @@ -131,11 +160,11 @@ First, ensure MySQL, Mongo, and Memcached are running. Start the LMS:: - ./manage.py lms runserver + ./manage.py lms runserver 18000 Start the CMS:: - ./manage.py cms runserver + ./manage.py cms runserver 18010 This will give you a mostly-headless Open edX platform. Most frontends have been migrated to "Micro-Frontends (MFEs)" which need to be installed and run diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py index eafc2b37aa0c..189f2496a427 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py @@ -1,6 +1,7 @@ """ Unit tests for course index outline. """ +from django.conf import settings from django.test import RequestFactory from django.urls import reverse from rest_framework import status @@ -62,7 +63,7 @@ def test_course_index_response(self): "advance_settings_url": f"/settings/advanced/{self.course.id}" }, "discussions_incontext_feedback_url": "", - "discussions_incontext_learnmore_url": "", + "discussions_incontext_learnmore_url": settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL, "is_custom_relative_dates_active": True, "initial_state": None, "initial_user_clipboard": { @@ -103,7 +104,7 @@ def test_course_index_response_with_show_locators(self): "advance_settings_url": f"/settings/advanced/{self.course.id}" }, "discussions_incontext_feedback_url": "", - "discussions_incontext_learnmore_url": "", + "discussions_incontext_learnmore_url": settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL, "is_custom_relative_dates_active": False, "initial_state": { "expanded_locators": [ diff --git a/cms/djangoapps/contentstore/views/transcripts_ajax.py b/cms/djangoapps/contentstore/views/transcripts_ajax.py index 892b76caae72..8cb7f455013b 100644 --- a/cms/djangoapps/contentstore/views/transcripts_ajax.py +++ b/cms/djangoapps/contentstore/views/transcripts_ajax.py @@ -649,6 +649,9 @@ def _get_item(request, data): Returns the item. """ usage_key = UsageKey.from_string(data.get('locator')) + if not usage_key.context_key.is_course: + # TODO: implement transcript support for learning core / content libraries. + raise TranscriptsRequestValidationException(_('Transcripts are not yet supported in content libraries.')) # This is placed before has_course_author_access() to validate the location, # because has_course_author_access() raises r if location is invalid. item = modulestore().get_item(usage_key) diff --git a/cms/envs/common.py b/cms/envs/common.py index 34dd8503f35e..986b7262d92d 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -2813,8 +2813,14 @@ BRAZE_COURSE_ENROLLMENT_CANVAS_ID = '' +######################## Discussion Forum settings ######################## + +# Feedback link in upgraded discussion notification alert DISCUSSIONS_INCONTEXT_FEEDBACK_URL = '' -DISCUSSIONS_INCONTEXT_LEARNMORE_URL = '' + +# Learn More link in upgraded discussion notification alert +# pylint: disable=line-too-long +DISCUSSIONS_INCONTEXT_LEARNMORE_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/manage_discussions/discussions.html" #### django-simple-history## # disable indexing on date field its coming django-simple-history. @@ -2936,3 +2942,10 @@ def _should_send_learning_badge_events(settings): # See https://www.meilisearch.com/docs/learn/security/tenant_tokens MEILISEARCH_INDEX_PREFIX = "" MEILISEARCH_API_KEY = "devkey" + +# .. setting_name: DISABLED_COUNTRIES +# .. setting_default: [] +# .. setting_description: List of country codes that should be disabled +# .. for now it wil impact country listing in auth flow and user profile. +# .. eg ['US', 'CA'] +DISABLED_COUNTRIES = [] diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index e944d67eda1b..1d3a510cdc4c 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -267,7 +267,8 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ################ Using LMS SSO for login to Studio ################ SOCIAL_AUTH_EDX_OAUTH2_KEY = 'studio-sso-key' SOCIAL_AUTH_EDX_OAUTH2_SECRET = 'studio-sso-secret' # in stage, prod would be high-entropy secret -SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = 'http://edx.devstack.lms:18000' # routed internally server-to-server +# routed internally server-to-server +SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT = ENV_TOKENS.get('SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT', 'http://edx.devstack.lms:18000') SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT = 'http://localhost:18000' # used in browser redirect # Don't form the return redirect URL with HTTPS on devstack diff --git a/cms/envs/production.py b/cms/envs/production.py index 50519b55229b..ad7667772f9a 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -689,3 +689,10 @@ def get_env_setting(setting): } BEAMER_PRODUCT_ID = ENV_TOKENS.get('BEAMER_PRODUCT_ID', BEAMER_PRODUCT_ID) + +# .. setting_name: DISABLED_COUNTRIES +# .. setting_default: [] +# .. setting_description: List of country codes that should be disabled +# .. for now it wil impact country listing in auth flow and user profile. +# .. eg ['US', 'CA'] +DISABLED_COUNTRIES = ENV_TOKENS.get('DISABLED_COUNTRIES', []) diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html index 16d9ccbd4ca5..f44fdcfc8055 100644 --- a/cms/templates/course_outline.html +++ b/cms/templates/course_outline.html @@ -162,7 +162,7 @@

${_("This course has proctored exam settings that are % if mfe_proctored_exam_settings_url: <% url_encoded_course_id = quote(str(context_course.id).encode('utf-8'), safe='') %> ${Text(_("To update these settings go to the {link_start}Proctored Exam Settings page{link_end}.")).format( - link_start=HTML('').format( + link_start=HTML('').format( mfe_proctored_exam_settings_url=mfe_proctored_exam_settings_url ), link_end=HTML("") diff --git a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py index 7b26cb041a0a..4bfc710fe901 100644 --- a/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py +++ b/common/djangoapps/third_party_auth/tests/test_pipeline_integration.py @@ -583,7 +583,7 @@ def test_verification_signal(self): """ Verification signal is sent upon approval. """ - with mock.patch('openedx.core.djangoapps.signals.signals.LEARNER_NOW_VERIFIED.send_robust') as mock_signal: + with mock.patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal: # Begin the pipeline. pipeline.set_id_verification_status( auth_entry=pipeline.AUTH_ENTRY_LOGIN, diff --git a/docs/conf.py b/docs/conf.py index f37fc32f6160..01280c6cd214 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -258,6 +258,16 @@ epub_exclude_files = ['search.html'] +# -- Read the Docs Specific Configuration +# Define the canonical URL if you are using a custom domain on Read the Docs +html_baseurl = os.environ.get("READTHEDOCS_CANONICAL_URL", "") + +# Tell Jinja2 templates the build is running on Read the Docs +if os.environ.get("READTHEDOCS", "") == "True": + if "html_context" not in globals(): + html_context = {} + html_context["READTHEDOCS"] = True + # -- Extension configuration ------------------------------------------------- # -- Options for intersphinx extension --------------------------------------- diff --git a/docs/decisions/0020-upstream-downstream.rst b/docs/decisions/0020-upstream-downstream.rst new file mode 100644 index 000000000000..8ceb9e775274 --- /dev/null +++ b/docs/decisions/0020-upstream-downstream.rst @@ -0,0 +1,402 @@ +4. Upstream and downstream content +################################## + +Status +****** + +Accepted. + +Implementation in progress as of 2024-09-03. + +Context +******* + +We are replacing the existing Legacy ("V1") Content Libraries system, based on +ModuleStore, with a Relaunched ("V2") Content Libraries system, based on +Learning Core. V1 and V2 libraries will coexist for at least one release to +allow for migration; eventually, V1 libraries will be removed entirely. + +Content from V1 libraries can only be included into courses using the +LibraryContentBlock (called "Randomized Content Module" in Studio), which works +like this: + +* Course authors add a LibraryContentBlock to a Unit and configure it with a + library key and a count of N library blocks to select (or `-1` for "all + blocks"). + +* For each block in the chosen library, its *content definition* is copied into + the course as a child of the LibraryContentBlock, whereas its *settings* are + copied into a special "default" settings dictionary in the course's structure + document--this distinction will matter later. The usage key of each copied + block is derived from a hash of the original library block's usage key plus + the LibraryContentBlock's own usage key--this will also matter + later. + +* The course author is free to override the content and settings of the + course-local copies of each library block. + +* When any update is made to the library, the course author is prompted to + update the LibraryContentBlock. This involves re-copying the library blocks' + content definitions and default settings, which clobbers any overrides they + have made to content, but preserves any overrides they have made to settings. + Furthermore, any blocks that were added to the library are newly copied into + the course, and any blocks that were removed from the library are deleted + from the course. For all blocks, usage keys are recalculated using the same + hash derivation described above; for existing blocks, it is important that + this recalculation yields the same usage key so that student state is not + lost. + +* Over in the LMS, when a learner loads LibraryContentBlock, they are shown a + list of N randomly-picked blocks from the library. Subsequent visits show + them the same list, *unless* children were added, children were removed, or N + changed. In those cases, the LibraryContentBlock tries to make the smallest + possible adjustment to their personal list of blocks while respecting N and + the updated list of children. + +This system has several issues: + +#. **Missing defaults after import:** When a course with a LibraryContentBlock + is imported into an Open edX instance *without* the referenced library, the + blocks' *content* will remain intact as will course-local *settings + overrides*. However, any *default settings* defined in the library will be + missing. This can result in content that is completely broken, especially + since critical fields like video URLs and LTI URLs are considered + "settings". For a detailed scenario, see `LibraryContentBlock Curveball 1`_. + +#. **Strange behavior when duplicating content:** Typically, when a + block is duplicated or copy-pasted, the new block's usage key and its + children's usage keys are randomly generated. However, recall that when a + LibraryContentBlock is updated, its children's usage keys are rederived + using a hash function. That would cause the children's usage keys to change, + thus destroying any student state. So, we must work around this with a hack: + upon duplicating or pasting a LibraryContentBlock, we immediately update the + LibraryContentBlock, thus discarding the problematic randomly-generated keys + in favor of hash-derived keys. This works, but: + + * it involves weird code hacks, + * it unexpectedly discards any content overrides the course author made to + the copied LibraryContentBlock's children, + * it unexpectedly uses the latest version of library content, regardless of + which version the copied LibraryContentBlock was using, and + * it fails if the library does not exist on the Open edX instance, which + can happen if the course was imported from another instance. + +#. **Conflation of reference and randomization:** The LibraryContentBlock does + two things: it connects courses to library content, and it shows users a + random subset of content. There is no reason that those two features need to + be coupled together. A course author may want to randomize course-defined + content, or they may want to randomize content from multiple different + libraries. Or, they may want to use content from libraries without + randomizing it at all. While it is feasible to support all these things in a + single XBlock, trying to do so led to a `very complicated XBlock concept`_ + which difficult to explain to product managers and other engineers. + +#. **Unpredictable preservation of overrides:** Recall that *content + definitions* and *settings* are handled differently. This distinction is + defined in the code: every authorable XBlock field is either defined with + `Scope.content` or `Scope.settings`. In theory, XBlock developers would use + the content scope for fields that are core to the meaning of piece of + content, and they would only use the settings scope for fields that would be + reasonable to configure in a local copy of the piece of content. In + practice, though, XBlock developers almost always use `Scope.settings`. The + result of this is that customizations to blocks *almost always* survive + through library updates, except when they don't. Course authors have no way + to know (or even guess) when their customizations they will and won't + survive updates. + +#. **General pain and suffering:** The relationship between courses and V1 + libraries is confusing to content authors, site admins, and developers + alike. The behaviors above toe the line between "quirks" and "known bugs", + and they are not all documented. Past attempts to improve the system have + `triggered series of bugs`_, some of which led to permanent loss of learner + state. In other cases, past Content Libraries improvement efforts have + slowed or completely stalled out in code review due to the overwhelming + amount of context and edge cases that must be understood to safely make any + changes. + +.. _LibraryContentBlock Curveball 1: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3966795804/Fun+with+LibraryContentBlock+export+import+and+duplication#Curveball-1%3A-Import%2FExport +.. _LibraryContentBlock Curveball 2: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3966795804/Fun+with+LibraryContentBlock+export+import+and+duplication#Curveball-2:-Duplication +.. _very complicated XBlock concept: https://github.com/openedx/edx-platform/blob/master/xmodule/docs/decisions/0003-library-content-block-schema.rst +.. _triggered series of bugs: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3858661405/Bugs+from+Content+Libraries+V1 + +We are keen to use the Library Relaunch project to address all of these +problems. So, V2 libraries will interop with courses using a completely +different data model. + + +Decision +******** + +We will create a framework where a *downstream* piece of content (e.g. a course +block) can be *linked* to an *upstream* piece of content (e.g., a library +block) with the following properties: + +* **Portable:** Links can refer to certain content on the current Open edX + instance, and in the future they may be able to refer to content on other + Open edX instances or sites. Links will never include information that is + internal to a particular Open edX instance, such as foreign keys. + +* **Flat:** The *link* is a not a wrapper (like the LibraryContentBlock), + but simply a piece of metadata directly on the downstream content which + points to the upstream content. We will no longer rely on precarious + hash-derived usage keys to establish connection to upstream blocks; + like any other block, an upstream-linked blocks can be granted whatever block + ID that the authoring environment assigns it, whether random or + human-readable. + +* **Forwards-compatible:** If downstream content is created in a course on + an Open edX site that supports upstream and downstreams (e.g., a Teak + instance), and then it is exported and imported into a site that doesn't + (e.g., a Quince instance), the downstream content will simply act like + regular course content. + +* **Independent:** Upstream content and downstream content exist separately + from one another: + + * Modifying upstream content does not affect any downstream content (unless a + sync happens, more on that later). + * Deleting upstream content does not impact its downstream content. By + corollary, pieces of downstream content can completely and correctly render + on Open edX instances that are missing their linked upstream content. + * (Preserving a positive feature of the V1 LibraryContentBlock) The link + persists through export-import and copy-paste, regardless of whether the + upstream content actually exists. A "broken" link to upstream content is + seamlessly "repaired" if the upstream content becomes available again. + +* **Customizable:** On an OLX level, authors can still override the value + of any field for a piece of downstream content. However, we will empower + Studio to be more prescriptive about what authors *can* override versus what + they *should* override: + + * We define a set of *customizable* fields, with platform-level defaults + like display_name and a max_attempts, plus the ability for external + XBlocks to opt their own fields into customizability. + * Studio may use this list to provide an interface for customizing + downstream blocks, separate from the usual "Edit" interface that would + permit them to make unsafe overrides. + * Furthermore, downstream content will record which fields the user has + customized... + + * even if the customization is to simply clear the value of the fields... + * and even if the customization is made redundant in a future version of + the upstream content. For example, if max_attempts is customized from 3 + to 5 in the downstream content, but the next version of the upstream + content also changes max_attempts to 5, the downstream would still + consider max_attempts to be customized. If the following version of the + upstream content again changed max_attempts to 6, the downstream would + retain max_attempts to be 5. + + * Finally, the downstream content will locally save the upstream value of + customizable fields, allowing the author to *revert* back to them + regardless of whether the upstream content is actually available. + +* **Synchronizable, without surprises:** Downstream content can be *synced* + with updates that have been made to its linked upstream. This means that the + latest available upstream content field values will entirely replace all of + the downstream field values, *except* those which were customized, as + described in the previous item. + +* **Concrete, but flexible:** The internal implementation of upstream-downstream + syncing will assume that: + + * upstream content belongs to a V2 content library, + * downstream content belongs to a course on the same instance, and + * the link is the stringified usage key of the upstream library content. + + This will allow us to keep the implementation straightforward. However, we + will *not* expose these assumptions in the Python APIs, the HTTP APIs, or in + the persisted fields, allowing us in the future to generalize to other + upstreams (such as externally-hosted libraries) and other downstreams (such + as a standalone enrollable sequence without a course). + + If any of these assumptions are violated, we will raise an exception or log a + warning, as appropriate. Particularly, if these assumptions are violated at + the OLX level via a course import, then we will probably show a warning at + import time and refuse to sync from the unsupported upstream; however, we + will *not* fail the entire import or mangle the value of upstream link, since + we want to remain forwards-compatible with potential future forms of syncing. + As a concrete example: if a course block has *another course block's usage + key* as an upstream, then we will faithfully keep that value through the + import and export process, but we will not prompt the user to sync updates + for that block. + +* **Decoupled:** Upstream-downstream linking is not tied up with any other + courseware feature; in particular, it is unrelated to content randomization. + Randomized library content will be supported, but it will be a *synthesis* of + two features: (1) a RandomizationBlock that randomly selects a subset of its + children, where (2) some or all of those children are linked to upstream + blocks. + +Consequences +************ + +To support the Libraries Relaunch in Sumac: + +* For every XBlock in CMS, we will use XBlock fields to persist the upstream + link, its versions, its customizable fields, and its set of downstream + overrides. + + * We will avoid exposing these fields to LMS code. + + * We will define an initial set of customizable fields for Problem, Text, and + Video blocks. + +* We will define method(s) for syncing update on the XBlock runtime so that + they are available in the SplitModuleStore's XBlock Runtime + (CachingDescriptorSystem). + + * Either in the initial implementation or in a later implementation, it may + make sense to declare abstract versions of the syncing method(s) higher up + in XBlock Runtime inheritance hierarchy. + +* We will expose a CMS HTTP API for syncing updates to blocks from their + upstreams. + + * We will avoid exposing this API from the LMS. + +For reference, here are some excerpts of a potential implementation. This may +change through development and code review. + +.. code-block:: python + + ########################################################################### + # cms/lib/xblock/upstream_sync.py + ########################################################################### + + class UpstreamSyncMixin(XBlockMixin): + """ + Allows an XBlock in the CMS to be associated & synced with an upstream. + Mixed into CMS's XBLOCK_MIXINS, but not LMS's. + """ + + # Metadata related to upstream synchronization + upstream = String( + help=(""" + The usage key of a block (generally within a content library) + which serves as a source of upstream updates for this block, + or None if there is no such upstream. Please note: It is valid + for this field to hold a usage key for an upstream block + that does not exist (or does not *yet* exist) on this instance, + particularly if this downstream block was imported from a + different instance. + """), + default=None, scope=Scope.settings, hidden=True, enforce_type=True + ) + upstream_version = Integer( + help=(""" + Record of the upstream block's version number at the time this + block was created from it. If upstream_version is smaller + than the upstream block's latest version, then the user will be + able to sync updates into this downstream block. + """), + default=None, scope=Scope.settings, hidden=True, enforce_type=True, + ) + downstream_customized = Set( + help=(""" + Names of the fields which have values set on the upstream + block yet have been explicitly overridden on this downstream + block. Unless explicitly cleared by the user, these + customizations will persist even when updates are synced from + the upstream. + """), + default=[], scope=Scope.settings, hidden=True, enforce_type=True, + ) + + # Store upstream defaults for customizable fields. + upstream_display_name = String(...) + upstream_max_attempts = List(...) + ... # We will probably want to pre-define several more of these. + + def get_upstream_field_names(cls) -> dict[str, str]: + """ + Mapping from each customizable field to field which stores its upstream default. + XBlocks outside of edx-platform can override this in order to set + up their own customizable fields. + """ + return { + "display_name": "upstream_display_name", + "max_attempts": "upstream_max_attempts", + } + + def save(self, *args, **kwargs): + """ + Update `downstream_customized` when a customizable field is modified. + Uses `get_upstream_field_names` keys as the list of fields that are + customizable. + """ + ... + + @dataclass(frozen=True) + class UpstreamInfo: + """ + Metadata about a block's relationship with an upstream. + """ + usage_key: UsageKey + current_version: int + latest_version: int | None + sync_url: str + error: str | None + + @property + def sync_available(self) -> bool: + """ + Should the user be prompted to sync this block with upstream? + """ + return ( + self.latest_version + and self.current_version < self.latest_version + and not self.error + ) + + + ########################################################################### + # xmodule/modulestore/split_mongo/caching_descriptor_system.py + ########################################################################### + + class CachingDescriptorSystem(...): + + def validate_upstream_key(self, usage_key: UsageKey | str) -> UsageKey: + """ + Raise an error if the provided key is not a valid upstream reference. + Instead of explicitly checking whether a key is a LibraryLocatorV2, + callers should validate using this function, and use an `except` clause + to handle the case where the key is not a valid upstream. + Raises: InvalidKeyError, UnsupportedUpstreamKeyType + """ + ... + + def sync_from_upstream(self, *, downstream_key: UsageKey, apply_updates: bool) -> None: + """ + Python API for loading updates from upstream block. + Can choose whether or not to actually apply those updates... + apply_updates=False: Think "get fetch". + Use case: course import. + apply_updates=True: Think "git pull". + Use case: sync_updates handler. + Raises: InvalidKeyError, UnsupportedUpstreamKeyType, XBlockNotFoundError + """ + ... + + def get_upstream_info(self, downstream_key: UsageKey) -> UpstreamInfo | None: + """ + Python API for upstream metadata, or None. + Raises: InvalidKeyError, XBlockNotFoundError + """ + ... + +Finally, here is what the OLX for a library-sourced Problem XBlock in a course +might look like: + +.. code-block:: xml + + + + diff --git a/lms/djangoapps/bulk_email/signals.py b/lms/djangoapps/bulk_email/signals.py index 9f6540651eeb..fb8749bf45a9 100644 --- a/lms/djangoapps/bulk_email/signals.py +++ b/lms/djangoapps/bulk_email/signals.py @@ -1,7 +1,6 @@ """ Signal handlers for the bulk_email app """ -from django.contrib.auth import get_user_model from django.dispatch import receiver from eventtracking import tracker @@ -32,29 +31,26 @@ def ace_email_sent_handler(sender, **kwargs): """ When an email is sent using ACE, this method will create an event to detect ace email success status """ - # Fetch the message object from kwargs, defaulting to None if not present - message = kwargs.get('message', None) - - user_model = get_user_model() - try: - user_id = user_model.objects.get(email=message.recipient.email_address).id - except user_model.DoesNotExist: - user_id = None - course_email = message.context.get('course_email', None) - course_id = message.context.get('course_id') + # Fetch the message dictionary from kwargs, defaulting to {} if not present + message = kwargs.get('message', {}) + recipient = message.get('recipient', {}) + message_name = message.get('name', None) + context = message.get('context', {}) + email_address = recipient.get('email', None) + user_id = recipient.get('user_id', None) + channel = message.get('channel', None) + course_id = context.get('course_id', None) if not course_id: + course_email = context.get('course_email', None) course_id = course_email.course_id if course_email else None - try: - channel = sender.__class__.__name__ - except AttributeError: - channel = 'Other' + tracker.emit( 'edx.ace.message_sent', { - 'message_type': message.name, + 'message_type': message_name, 'channel': channel, 'course_id': course_id, 'user_id': user_id, - 'user_email': message.recipient.email_address, + 'user_email': email_address, } ) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 2b96af786a97..184dfd0e6869 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -474,6 +474,7 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas 'course_id': str(course_email.course_id), 'to_list': [user_obj.get('email', '') for user_obj in to_list], 'total_recipients': total_recipients, + 'ace_enabled_for_bulk_email': is_bulk_email_edx_ace_enabled(), } ) # Exclude optouts (if not a retry): diff --git a/lms/djangoapps/ccx/api/v0/tests/test_views.py b/lms/djangoapps/ccx/api/v0/tests/test_views.py index 7279b9426347..a8c9070df038 100644 --- a/lms/djangoapps/ccx/api/v0/tests/test_views.py +++ b/lms/djangoapps/ccx/api/v0/tests/test_views.py @@ -730,8 +730,8 @@ def make_ccx(self, max_students_allowed=200): course_id=ccx_course_key, student_email=self.coach.email, auto_enroll=True, - email_students=False, - email_params=email_params, + message_students=False, + message_params=email_params, ) return ccx diff --git a/lms/djangoapps/ccx/api/v0/views.py b/lms/djangoapps/ccx/api/v0/views.py index b3e345a77022..8ca15e065006 100644 --- a/lms/djangoapps/ccx/api/v0/views.py +++ b/lms/djangoapps/ccx/api/v0/views.py @@ -505,8 +505,8 @@ def post(self, request): course_id=ccx_course_key, student_email=coach.email, auto_enroll=True, - email_students=True, - email_params=email_params, + message_students=True, + message_params=email_params, ) # assign staff role for the coach to the newly created ccx assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id) @@ -768,8 +768,8 @@ def patch(self, request, ccx_course_id=None): course_id=ccx_course_key, student_email=coach.email, auto_enroll=True, - email_students=True, - email_params=email_params, + message_students=True, + message_params=email_params, ) # make the new coach staff on the CCX assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id) diff --git a/lms/djangoapps/ccx/utils.py b/lms/djangoapps/ccx/utils.py index 9f7c0eff3963..28ecbba34947 100644 --- a/lms/djangoapps/ccx/utils.py +++ b/lms/djangoapps/ccx/utils.py @@ -269,7 +269,13 @@ def ccx_students_enrolling_center(action, identifiers, email_students, course_ke log.info("%s", error) errors.append(error) break - enroll_email(course_key, email, auto_enroll=True, email_students=email_students, email_params=email_params) + enroll_email( + course_key, + email, + auto_enroll=True, + message_students=email_students, + message_params=email_params + ) elif action == 'Unenroll' or action == 'revoke': # lint-amnesty, pylint: disable=consider-using-in for identifier in identifiers: try: @@ -278,7 +284,7 @@ def ccx_students_enrolling_center(action, identifiers, email_students, course_ke log.info("%s", exp) errors.append(f"{exp}") continue - unenroll_email(course_key, email, email_students=email_students, email_params=email_params) + unenroll_email(course_key, email, message_students=email_students, message_params=email_params) return errors @@ -348,8 +354,8 @@ def add_master_course_staff_to_ccx(master_course, ccx_key, display_name, send_em course_id=ccx_key, student_email=staff.email, auto_enroll=True, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) # allow 'staff' access on ccx to staff of master course @@ -373,8 +379,8 @@ def add_master_course_staff_to_ccx(master_course, ccx_key, display_name, send_em course_id=ccx_key, student_email=instructor.email, auto_enroll=True, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) # allow 'instructor' access on ccx to instructor of master course @@ -417,8 +423,8 @@ def remove_master_course_staff_from_ccx(master_course, ccx_key, display_name, se unenroll_email( course_id=ccx_key, student_email=staff.email, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) for instructor in list_instructor: @@ -430,6 +436,6 @@ def remove_master_course_staff_from_ccx(master_course, ccx_key, display_name, se unenroll_email( course_id=ccx_key, student_email=instructor.email, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) diff --git a/lms/djangoapps/ccx/views.py b/lms/djangoapps/ccx/views.py index 3c5f3130a195..7c6a75aaf6d4 100644 --- a/lms/djangoapps/ccx/views.py +++ b/lms/djangoapps/ccx/views.py @@ -223,8 +223,8 @@ def create_ccx(request, course, ccx=None): course_id=ccx_id, student_email=request.user.email, auto_enroll=True, - email_students=True, - email_params=email_params, + message_students=True, + message_params=email_params, ) assign_staff_role_to_ccx(ccx_id, request.user, course.id) diff --git a/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl b/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl index beef611e4393..d7ca8fd9a400 100644 --- a/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl +++ b/lms/djangoapps/certificates/docs/diagrams/certificate_generation.dsl @@ -31,7 +31,7 @@ workspace { } grades_app -> signal_handlers "Emits COURSE_GRADE_NOW_PASSED signal" - verify_student_app -> signal_handlers "Emits LEARNER_NOW_VERIFIED signal" + verify_student_app -> signal_handlers "Emits IDV_ATTEMPT_APPROVED signal" student_app -> signal_handlers "Emits ENROLLMENT_TRACK_UPDATED signal" allowlist -> signal_handlers "Emits APPEND_CERTIFICATE_ALLOWLIST signal" signal_handlers -> generation_handler "Invokes generate_allowlist_certificate()" diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index d8db7bbf9ce8..53055bf9c86e 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -32,9 +32,8 @@ from openedx.core.djangoapps.signals.signals import ( COURSE_GRADE_NOW_FAILED, COURSE_GRADE_NOW_PASSED, - LEARNER_NOW_VERIFIED ) -from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, IDV_ATTEMPT_APPROVED User = get_user_model() @@ -118,14 +117,17 @@ def _listen_for_failing_grade(sender, user, course_id, grade, **kwargs): # pyli log.info(f'Certificate marked not passing for {user.id} : {course_id} via failing grade') -@receiver(LEARNER_NOW_VERIFIED, dispatch_uid="learner_track_changed") -def _listen_for_id_verification_status_changed(sender, user, **kwargs): # pylint: disable=unused-argument +@receiver(IDV_ATTEMPT_APPROVED, dispatch_uid="learner_track_changed") +def _listen_for_id_verification_status_changed(sender, signal, **kwargs): # pylint: disable=unused-argument """ Listen for a signal indicating that the user's id verification status has changed. """ if not auto_certificate_generation_enabled(): return + event_data = kwargs.get('idv_attempt') + user = User.objects.get(id=event_data.user.id) + user_enrollments = CourseEnrollment.enrollments_for_user(user=user) expected_verification_status = IDVerificationService.user_status(user) expected_verification_status = expected_verification_status['status'] diff --git a/lms/djangoapps/certificates/tests/test_signals.py b/lms/djangoapps/certificates/tests/test_signals.py index d475cffbfb66..7b5552801349 100644 --- a/lms/djangoapps/certificates/tests/test_signals.py +++ b/lms/djangoapps/certificates/tests/test_signals.py @@ -13,22 +13,20 @@ from openedx_events.data import EventsMetadata from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from openedx_events.tests.utils import OpenEdxEventsTestMixin from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.certificates.api import has_self_generated_certificates_enabled from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import ( - CertificateGenerationConfiguration, - GeneratedCertificate -) +from lms.djangoapps.certificates.models import CertificateGenerationConfiguration, GeneratedCertificate from lms.djangoapps.certificates.signals import handle_exam_attempt_rejected_event from lms.djangoapps.certificates.tests.factories import CertificateAllowlistFactory, GeneratedCertificateFactory from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from lms.djangoapps.grades.tests.utils import mock_passing_grade from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory class SelfGeneratedCertsSignalTest(ModuleStoreTestCase): @@ -302,10 +300,17 @@ def test_failing_grade_allowlist(self): assert cert.status == CertificateStatuses.downloadable -class LearnerIdVerificationTest(ModuleStoreTestCase): +class LearnerIdVerificationTest(ModuleStoreTestCase, OpenEdxEventsTestMixin): """ Tests for certificate generation task firing on learner id verification """ + ENABLED_OPENEDX_EVENTS = ['org.openedx.learning.idv_attempt.approved.v1'] + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.start_events_isolation() + def setUp(self): super().setUp() self.course_one = CourseFactory.create(self_paced=True) diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index 25abcf80d486..f65faf7f2a67 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -399,4 +399,18 @@ def clean_thread_html_body(html_body): for match in html_body.find_all(tag): match.unwrap() + # Replace tags that are not allowed in email + tags_to_update = [ + {"source": "button", "target": "span"}, + {"source": "h1", "target": "h4"}, + {"source": "h2", "target": "h4"}, + {"source": "h3", "target": "h4"}, + ] + for tag_dict in tags_to_update: + for source_tag in html_body.find_all(tag_dict['source']): + target_tag = html_body.new_tag(tag_dict['target'], **source_tag.attrs) + if source_tag.string: + target_tag.string = source_tag.string + source_tag.replace_with(target_tag) + return str(html_body) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py index f1a71fd1239e..d92e1000feb5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py @@ -168,3 +168,29 @@ def test_only_script_tag(self): result = clean_thread_html_body(html_body) self.assertEqual(result.strip(), expected_output) + + def test_button_tag_replace(self): + """ + Tests that the clean_thread_html_body function replaces the button tag with span tag + """ + # Tests for button replacement tag with text + html_body = '' + expected_output = 'Button' + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) + + # Tests button tag replacement without text + html_body = '' + expected_output = '' + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) + + def test_heading_tag_replace(self): + """ + Tests that the clean_thread_html_body function replaces the h1, h2 and h3 tags with h4 tag + """ + for tag in ['h1', 'h2', 'h3']: + html_body = f'<{tag}>Heading' + expected_output = '

Heading

' + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 2aa7d36456c4..73c19d27858c 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -109,8 +109,10 @@ def create_message_context(comment, site): 'course_id': str(thread.course_id), 'comment_id': comment.id, 'comment_body': comment.body, + 'comment_body_text': comment.body_text, 'comment_author_id': comment.user_id, 'comment_created_at': comment.created_at, # comment_client models dates are already serialized + 'comment_parent_id': comment.parent_id, 'thread_id': thread.id, 'thread_title': thread.title, 'thread_author_id': thread.user_id, diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index d483a82dbd66..3fef4f5f7cef 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -12,6 +12,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.sites.models import Site from edx_ace import ace +from edx_ace.channel import ChannelType from edx_ace.recipient import Recipient from edx_ace.utils import date from edx_django_utils.monitoring import set_code_owner_attribute @@ -74,6 +75,12 @@ def __init__(self, *args, **kwargs): self.options['transactional'] = True +class CommentNotification(BaseMessageType): + """ + Notify discussion participants of new comments. + """ + + @shared_task(base=LoggedTask) @set_code_owner_attribute def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function-docstring @@ -82,16 +89,39 @@ def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function if _should_send_message(context): context['site'] = Site.objects.get(id=context['site_id']) thread_author = User.objects.get(id=context['thread_author_id']) - with emulate_http_request(site=context['site'], user=thread_author): - message_context = _build_message_context(context) + comment_author = User.objects.get(id=context['comment_author_id']) + with emulate_http_request(site=context['site'], user=comment_author): + message_context = _build_message_context(context, notification_type='forum_response') message = ResponseNotification().personalize( Recipient(thread_author.id, thread_author.email), _get_course_language(context['course_id']), message_context ) - log.info('Sending forum comment email notification with context %s', message_context) - ace.send(message) + log.info('Sending forum comment notification with context %s', message_context) + if _is_first_comment(context['comment_id'], context['thread_id']): + limit_to_channels = None + else: + limit_to_channels = [ChannelType.PUSH] + ace.send(message, limit_to_channels=limit_to_channels) + _track_notification_sent(message, context) + + elif _should_send_subcomment_message(context): + context['site'] = Site.objects.get(id=context['site_id']) + comment_author = User.objects.get(id=context['comment_author_id']) + thread_author = User.objects.get(id=context['thread_author_id']) + + with emulate_http_request(site=context['site'], user=comment_author): + message_context = _build_message_context(context) + message = CommentNotification().personalize( + Recipient(thread_author.id, thread_author.email), + _get_course_language(context['course_id']), + message_context + ) + log.info('Sending forum comment notification with context %s', message_context) + ace.send(message, limit_to_channels=[ChannelType.PUSH]) _track_notification_sent(message, context) + else: + return @shared_task(base=LoggedTask) @@ -154,19 +184,36 @@ def _should_send_message(context): return ( _is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) and _is_not_subcomment(context['comment_id']) and - _is_first_comment(context['comment_id'], context['thread_id']) + not _comment_author_is_thread_author(context) ) +def _should_send_subcomment_message(context): + cc_thread_author = cc.User(id=context['thread_author_id'], course_id=context['course_id']) + return ( + _is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) and + _is_subcomment(context['comment_id']) and + not _comment_author_is_thread_author(context) + ) + + +def _comment_author_is_thread_author(context): + return context.get('comment_author_id', '') == context['thread_author_id'] + + def _is_content_still_reported(context): if context.get('comment_id') is not None: return len(cc.Comment.find(context['comment_id']).abuse_flaggers) > 0 return len(cc.Thread.find(context['thread_id']).abuse_flaggers) > 0 -def _is_not_subcomment(comment_id): +def _is_subcomment(comment_id): comment = cc.Comment.find(id=comment_id).retrieve() - return not getattr(comment, 'parent_id', None) + return getattr(comment, 'parent_id', None) + + +def _is_not_subcomment(comment_id): + return not _is_subcomment(comment_id) def _is_first_comment(comment_id, thread_id): # lint-amnesty, pylint: disable=missing-function-docstring @@ -204,7 +251,7 @@ def _get_course_language(course_id): return language -def _build_message_context(context): # lint-amnesty, pylint: disable=missing-function-docstring +def _build_message_context(context, notification_type='forum_comment'): # lint-amnesty, pylint: disable=missing-function-docstring message_context = get_base_template_context(context['site']) message_context.update(context) thread_author = User.objects.get(id=context['thread_author_id']) @@ -218,6 +265,14 @@ def _build_message_context(context): # lint-amnesty, pylint: disable=missing-fu 'thread_username': thread_author.username, 'comment_username': comment_author.username, 'post_link': post_link, + 'push_notification_extra_context': { + 'course_id': str(context['course_id']), + 'parent_id': str(context['comment_parent_id']), + 'notification_type': notification_type, + 'topic_id': str(context['thread_commentable_id']), + 'thread_id': context['thread_id'], + 'comment_id': context['comment_id'], + }, 'comment_created_at': date.deserialize(context['comment_created_at']), 'thread_created_at': date.deserialize(context['thread_created_at']) }) diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt new file mode 100644 index 000000000000..391e3d8ef4d7 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt @@ -0,0 +1,3 @@ +{% load i18n %} +{% blocktrans trimmed %}{{ comment_username }} commented to {{ thread_title }}:{% endblocktrans %} +{{ comment_body_text }} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/title.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/title.txt new file mode 100644 index 000000000000..a9ea6f298c03 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/title.txt @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}Comment to {{ thread_title }}{% endblocktrans %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt new file mode 100644 index 000000000000..ee97a6e329f5 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans trimmed %}{{ comment_username }} replied to {{ thread_title }}: {{ comment_body|truncatechars:200 }}{% endblocktrans %} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/title.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/title.txt new file mode 100644 index 000000000000..03caca997346 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/title.txt @@ -0,0 +1,2 @@ +{% load i18n %} +{% blocktrans %}Response to {{ thread_title }}{% endblocktrans %} diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index f6cce4437546..92dadac9d9ee 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -19,7 +19,11 @@ import openedx.core.djangoapps.django_comment_common.comment_client as cc from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.discussion.signals.handlers import ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY -from lms.djangoapps.discussion.tasks import _should_send_message, _track_notification_sent +from lms.djangoapps.discussion.tasks import ( + _is_first_comment, + _should_send_message, + _track_notification_sent, +) from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.django_comment_common.models import ForumsConfig @@ -222,6 +226,8 @@ def setUp(self): self.ace_send_patcher = mock.patch('edx_ace.ace.send') self.mock_ace_send = self.ace_send_patcher.start() + self.mock_message_patcher = mock.patch('lms.djangoapps.discussion.tasks.ResponseNotification') + self.mock_message = self.mock_message_patcher.start() thread_permalink = '/courses/discussion/dummy_discussion_id' self.permalink_patcher = mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink) @@ -231,10 +237,12 @@ def tearDown(self): super().tearDown() self.request_patcher.stop() self.ace_send_patcher.stop() + self.mock_message_patcher.stop() self.permalink_patcher.stop() @ddt.data(True, False) def test_send_discussion_email_notification(self, user_subscribed): + self.mock_message_patcher.stop() if user_subscribed: non_matching_id = 'not-a-match' # with per_page left with a default value of 1, this ensures @@ -271,8 +279,10 @@ def test_send_discussion_email_notification(self, user_subscribed): expected_message_context.update({ 'comment_author_id': self.comment_author.id, 'comment_body': comment['body'], + 'comment_body_text': comment.body_text, 'comment_created_at': ONE_HOUR_AGO, 'comment_id': comment['id'], + 'comment_parent_id': comment['parent_id'], 'comment_username': self.comment_author.username, 'course_id': self.course.id, 'thread_author_id': self.thread_author.id, @@ -283,7 +293,15 @@ def test_send_discussion_email_notification(self, user_subscribed): 'thread_commentable_id': thread['commentable_id'], 'post_link': f'https://{site.domain}{self.mock_permalink.return_value}', 'site': site, - 'site_id': site.id + 'site_id': site.id, + 'push_notification_extra_context': { + 'notification_type': 'forum_response', + 'topic_id': thread['commentable_id'], + 'course_id': comment['course_id'], + 'parent_id': str(comment['parent_id']), + 'thread_id': thread['id'], + 'comment_id': comment['id'], + }, }) expected_recipient = Recipient(self.thread_author.id, self.thread_author.email) actual_message = self.mock_ace_send.call_args_list[0][0][0] @@ -326,7 +344,9 @@ def run_should_not_send_email_test(self, thread, comment_dict): 'comment_id': comment_dict['id'], 'thread_id': thread['id'], }) - assert actual_result is False + + should_email_send = _is_first_comment(comment_dict['id'], thread['id']) + assert not should_email_send assert not self.mock_ace_send.called def test_subcomment_should_not_send_email(self): diff --git a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py index cd2107ec7c29..656e7e6b4396 100644 --- a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py +++ b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py @@ -302,7 +302,7 @@ def setUpClass(cls): + [ { 'category': 'Homework', - 'detail': 'Homework Average = 0%', + 'detail': 'Homework Average = 0.00%', 'label': 'HW Avg', 'percent': 0.0, 'prominent': True } @@ -332,21 +332,21 @@ def setUpClass(cls): }, { 'category': 'Lab', - 'detail': 'Lab Average = 0%', + 'detail': 'Lab Average = 0.00%', 'label': 'Lab Avg', 'percent': 0.0, 'prominent': True }, { 'category': 'Midterm Exam', - 'detail': 'Midterm Exam = 0%', + 'detail': 'Midterm Exam = 0.00%', 'label': 'Midterm', 'percent': 0.0, 'prominent': True }, { 'category': 'Final Exam', - 'detail': 'Final Exam = 0%', + 'detail': 'Final Exam = 0.00%', 'label': 'Final', 'percent': 0.0, 'prominent': True diff --git a/lms/djangoapps/grades/scores.py b/lms/djangoapps/grades/scores.py index f621d85ea17b..38dd0dc18926 100644 --- a/lms/djangoapps/grades/scores.py +++ b/lms/djangoapps/grades/scores.py @@ -162,8 +162,8 @@ def compute_percent(earned, possible): Returns the percentage of the given earned and possible values. """ if possible > 0: - # Rounds to two decimal places. - return around(earned / possible, decimals=2) + # Rounds to four decimal places. + return around(earned / possible, decimals=4) else: return 0.0 diff --git a/lms/djangoapps/grades/tests/test_course_grade_factory.py b/lms/djangoapps/grades/tests/test_course_grade_factory.py index 4e3bcde0ad95..2066b6cd0d7c 100644 --- a/lms/djangoapps/grades/tests/test_course_grade_factory.py +++ b/lms/djangoapps/grades/tests/test_course_grade_factory.py @@ -185,26 +185,26 @@ def test_course_grade_summary(self): 'section_breakdown': [ { 'category': 'Homework', - 'detail': 'Homework 1 - Test Sequential X with an & Ampersand - 50% (1/2)', + 'detail': 'Homework 1 - Test Sequential X with an & Ampersand - 50.00% (1/2)', 'label': 'HW 01', 'percent': 0.5 }, { 'category': 'Homework', - 'detail': 'Homework 2 - Test Sequential A - 0% (0/1)', + 'detail': 'Homework 2 - Test Sequential A - 0.00% (0/1)', 'label': 'HW 02', 'percent': 0.0 }, { 'category': 'Homework', - 'detail': 'Homework Average = 25%', + 'detail': 'Homework Average = 25.00%', 'label': 'HW Avg', 'percent': 0.25, 'prominent': True }, { 'category': 'NoCredit', - 'detail': 'NoCredit Average = 0%', + 'detail': 'NoCredit Average = 0.00%', 'label': 'NC Avg', 'percent': 0, 'prominent': True diff --git a/lms/djangoapps/grades/tests/test_subsection_grade.py b/lms/djangoapps/grades/tests/test_subsection_grade.py index 7dd39af4ece2..2398e7a71000 100644 --- a/lms/djangoapps/grades/tests/test_subsection_grade.py +++ b/lms/djangoapps/grades/tests/test_subsection_grade.py @@ -14,7 +14,7 @@ @ddt class SubsectionGradeTest(GradeTestBase): # lint-amnesty, pylint: disable=missing-class-docstring - @data((50, 100, .50), (59.49, 100, .59), (59.51, 100, .60), (59.50, 100, .60), (60.5, 100, .60)) + @data((50, 100, .5), (.5949, 100, .0059), (.5951, 100, .006), (.595, 100, .0059), (.605, 100, .006)) @unpack def test_create_and_read(self, mock_earned, mock_possible, expected_result): with mock_get_score(mock_earned, mock_possible): diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py index 9255d113f038..a5d25769ca10 100644 --- a/lms/djangoapps/instructor/access.py +++ b/lms/djangoapps/instructor/access.py @@ -86,8 +86,8 @@ def _change_access(course, user, level, action, send_email=True): course_id=course.id, student_email=user.email, auto_enroll=True, - email_students=send_email, - email_params=email_params, + message_students=send_email, + message_params=email_params, ) role.add_users(user) elif action == 'revoke': diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 896d0deadcd9..ed344876eb42 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -125,7 +125,14 @@ def get_user_email_language(user): return UserPreference.get_value(user, LANGUAGE_KEY) -def enroll_email(course_id, student_email, auto_enroll=False, email_students=False, email_params=None, language=None): +def enroll_email( + course_id, + student_email, + auto_enroll=False, + message_students=False, + message_params=None, + language=None +): """ Enroll a student by email. @@ -133,8 +140,8 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal `auto_enroll` determines what is put in CourseEnrollmentAllowed.auto_enroll if auto_enroll is set, then when the email registers, they will be enrolled in the course automatically. - `email_students` determines if student should be notified of action by email. - `email_params` parameters used while parsing email templates (a `dict`). + `message_students` determines if student should be notified of action by email or push message. + `message_params` parameters used while parsing message templates (a `dict`). `language` is the language used to render the email. returns two EmailEnrollmentState's @@ -142,6 +149,16 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal """ previous_state = EmailEnrollmentState(course_id, student_email) enrollment_obj = None + if message_params: + message_params.update({ + 'app_label': 'instructor', + 'push_notification_extra_context': { + 'notification_type': 'enroll', + 'course_id': str(course_id), + }, + }) + else: + message_params = {} if previous_state.user and previous_state.user.is_active: # if the student is currently unenrolled, don't enroll them in their # previous mode @@ -159,85 +176,99 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal course_mode = previous_state.mode enrollment_obj = CourseEnrollment.enroll_by_email(student_email, course_id, course_mode) - if email_students: - email_params['message_type'] = 'enrolled_enroll' - email_params['email_address'] = student_email - email_params['user_id'] = previous_state.user.id - email_params['full_name'] = previous_state.full_name - send_mail_to_student(student_email, email_params, language=language) + if message_students: + message_params['message_type'] = 'enrolled_enroll' + message_params['email_address'] = student_email + message_params['user_id'] = previous_state.user.id + message_params['full_name'] = previous_state.full_name + send_mail_to_student(student_email, message_params, language=language) elif not is_email_retired(student_email): cea, _ = CourseEnrollmentAllowed.objects.get_or_create(course_id=course_id, email=student_email) cea.auto_enroll = auto_enroll cea.save() - if email_students: - email_params['message_type'] = 'allowed_enroll' - email_params['email_address'] = student_email + if message_students: + message_params['message_type'] = 'allowed_enroll' + message_params['email_address'] = student_email if previous_state.user: - email_params['user_id'] = previous_state.user.id - send_mail_to_student(student_email, email_params, language=language) + message_params['user_id'] = previous_state.user.id + send_mail_to_student(student_email, message_params, language=language) after_state = EmailEnrollmentState(course_id, student_email) return previous_state, after_state, enrollment_obj -def unenroll_email(course_id, student_email, email_students=False, email_params=None, language=None): +def unenroll_email(course_id, student_email, message_students=False, message_params=None, language=None): """ Unenroll a student by email. `student_email` is student's emails e.g. "foo@bar.com" - `email_students` determines if student should be notified of action by email. - `email_params` parameters used while parsing email templates (a `dict`). + `message_students` determines if student should be notified of action by email or push message. + `message_params` parameters used while parsing email templates (a `dict`). `language` is the language used to render the email. returns two EmailEnrollmentState's representing state before and after the action. """ previous_state = EmailEnrollmentState(course_id, student_email) + if message_params: + message_params.update({ + 'app_label': 'instructor', + 'push_notification_extra_context': { + 'notification_type': 'unenroll', + }, + }) + else: + message_params = {} if previous_state.enrollment: CourseEnrollment.unenroll_by_email(student_email, course_id) - if email_students: - email_params['message_type'] = 'enrolled_unenroll' - email_params['email_address'] = student_email + if message_students: + message_params['message_type'] = 'enrolled_unenroll' + message_params['email_address'] = student_email if previous_state.user: - email_params['user_id'] = previous_state.user.id - email_params['full_name'] = previous_state.full_name - send_mail_to_student(student_email, email_params, language=language) + message_params['user_id'] = previous_state.user.id + message_params['full_name'] = previous_state.full_name + send_mail_to_student(student_email, message_params, language=language) if previous_state.allowed: CourseEnrollmentAllowed.objects.get(course_id=course_id, email=student_email).delete() - if email_students: - email_params['message_type'] = 'allowed_unenroll' - email_params['email_address'] = student_email + if message_students: + message_params['message_type'] = 'allowed_unenroll' + message_params['email_address'] = student_email if previous_state.user: - email_params['user_id'] = previous_state.user.id + message_params['user_id'] = previous_state.user.id # Since no User object exists for this student there is no "full_name" available. - send_mail_to_student(student_email, email_params, language=language) + send_mail_to_student(student_email, message_params, language=language) after_state = EmailEnrollmentState(course_id, student_email) return previous_state, after_state -def send_beta_role_email(action, user, email_params): +def send_beta_role_email(action, user, message_params): """ Send an email to a user added or removed as a beta tester. `action` is one of 'add' or 'remove' `user` is the User affected - `email_params` parameters used while parsing email templates (a `dict`). + `message_params` parameters used while parsing email templates (a `dict`). """ if action in ('add', 'remove'): - email_params['message_type'] = '%s_beta_tester' % action - email_params['email_address'] = user.email - email_params['user_id'] = user.id - email_params['full_name'] = user.profile.name + message_params['message_type'] = '%s_beta_tester' % action + message_params['email_address'] = user.email + message_params['user_id'] = user.id + message_params['full_name'] = user.profile.name + message_params['app_label'] = 'instructor' + message_params['push_notification_extra_context'] = { + 'notification_type': message_params['message_type'], + 'course_id': str(getattr(message_params.get('course'), 'id', '')), + } else: raise ValueError(f"Unexpected action received '{action}' - expected 'add' or 'remove'") trying_to_add_inactive_user = not user.is_active and action == 'add' if not trying_to_add_inactive_user: - send_mail_to_student(user.email, email_params, language=get_user_email_language(user)) + send_mail_to_student(user.email, message_params, language=get_user_email_language(user)) @contextmanager diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 6e0a2545f530..51fc514c4879 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -4175,6 +4175,16 @@ def test_change_due_date_with_reason(self): # This operation regenerates the cache, so we can use cached results from edx-when. assert get_date_for_block(self.course, self.week1, self.user1, use_cached=True) == due_date + def test_reset_due_date_with_reason(self): + url = reverse('reset_due_date', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, { + 'student': self.user1.username, + 'url': str(self.week1.location), + 'reason': 'Testing reason.' # this is optional field. + }) + assert response.status_code == 200 + assert 'Successfully reset due date for student' in response.content.decode('utf-8') + def test_change_to_invalid_due_date(self): url = reverse('change_due_date', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { @@ -4704,15 +4714,19 @@ class TestOauthInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollm Test endpoints using Oauth2 authentication. """ - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create( - entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course') - ) - def setUp(self): super().setUp() + self.course = CourseFactory.create( + org='test_org', + course='test_course', + run='test_run', + entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course') + ) + self.problem_location = msk_from_problem_urlname( + self.course.id, + 'robot-some-problem-urlname' + ) + self.problem_urlname = str(self.problem_location) self.other_user = UserFactory() dot_application = ApplicationFactory(user=self.other_user, authorization_grant_type='password') @@ -4744,7 +4758,14 @@ def setUp(self): "send-to": ["myself"], "subject": "This is subject", "message": "message" - }, 'data_researcher') + }, 'data_researcher'), + ('list_instructor_tasks', + { + 'problem_location_str': self.problem_urlname, + 'unique_student_identifier': self.other_user.email + }, + 'data_researcher'), + ('list_instructor_tasks', {}, 'data_researcher') ] self.fake_jwt = ('wyJUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjaGFuZ2UtbWUiLCJleHAiOjE3MjU4OTA2NzIsImdyY' diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index d42e7173b0bf..cae166e7b9be 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -108,7 +108,7 @@ from lms.djangoapps.instructor_task.models import ReportStore from lms.djangoapps.instructor.views.serializer import ( AccessSerializer, BlockDueDateSerializer, RoleNameSerializer, ShowStudentExtensionSerializer, UserSerializer, - SendEmailSerializer, StudentAttemptsSerializer + SendEmailSerializer, StudentAttemptsSerializer, ListInstructorTaskInputSerializer, UniqueStudentIdentifierSerializer ) from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted @@ -137,7 +137,6 @@ handle_dashboard_error, keep_field_private, parse_datetime, - require_student_from_identifier, set_due_date_extension, strip_if_string, ) @@ -515,11 +514,13 @@ def post(self, request, course_id): # pylint: disable=too-many-statements reason='Enrolling via csv upload', state_transition=UNENROLLED_TO_ENROLLED, ) - enroll_email(course_id=course_id, - student_email=email, - auto_enroll=True, - email_students=notify_by_email, - email_params=email_params) + enroll_email( + course_id=course_id, + student_email=email, + auto_enroll=True, + message_students=notify_by_email, + message_params=email_params, + ) else: # update the course mode if already enrolled existing_enrollment = CourseEnrollment.get_enrollment(user, course_id) @@ -1702,60 +1703,55 @@ def post(self, request, course_id): return JsonResponse({"status": success_status}) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.VIEW_ENROLLMENTS) -@require_post_params( - unique_student_identifier="email or username of student for whom to get enrollment status" -) -def get_student_enrollment_status(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class GetStudentEnrollmentStatus(APIView): """ Get the enrollment status of a student. - Limited to staff access. - - Takes query parameter unique_student_identifier """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_ENROLLMENTS - error = '' - user = None - mode = None - is_active = None + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Permission: Limited to staff access. + Takes query parameter unique_student_identifier + """ + error = '' + mode = None + is_active = None - course_id = CourseKey.from_string(course_id) - unique_student_identifier = request.POST.get('unique_student_identifier') + course_id = CourseKey.from_string(course_id) + unique_student_identifier = request.data.get("unique_student_identifier") - try: - user = get_student_from_identifier(unique_student_identifier) - mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course_id) - except User.DoesNotExist: - # The student could have been invited to enroll without having - # registered. We'll also look at CourseEnrollmentAllowed - # records, so let the lack of a User slide. - pass - - enrollment_status = _('Enrollment status for {student}: unknown').format(student=unique_student_identifier) - - if user and mode: - if is_active: - enrollment_status = _('Enrollment status for {student}: active').format(student=user) - else: - enrollment_status = _('Enrollment status for {student}: inactive').format(student=user) - else: - email = user.email if user else unique_student_identifier - allowed = CourseEnrollmentAllowed.may_enroll_and_unenrolled(course_id) - if allowed and email in [cea.email for cea in allowed]: - enrollment_status = _('Enrollment status for {student}: pending').format(student=email) + serializer_data = UniqueStudentIdentifierSerializer(data=request.data) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) + + user = serializer_data.validated_data.get('unique_student_identifier') + if user: + mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course_id) + + if user and mode: + if is_active: + enrollment_status = _('Enrollment status for {student}: active').format(student=user) + else: + enrollment_status = _('Enrollment status for {student}: inactive').format(student=user) else: - enrollment_status = _('Enrollment status for {student}: never enrolled').format(student=email) + email = user.email if user else unique_student_identifier + allowed = CourseEnrollmentAllowed.may_enroll_and_unenrolled(course_id) + if allowed and email in [cea.email for cea in allowed]: + enrollment_status = _('Enrollment status for {student}: pending').format(student=email) + else: + enrollment_status = _('Enrollment status for {student}: never enrolled').format(student=email) - response_payload = { - 'course_id': str(course_id), - 'error': error, - 'enrollment_status': enrollment_status - } + response_payload = { + 'course_id': str(course_id), + 'error': error, + 'enrollment_status': enrollment_status + } - return JsonResponse(response_payload) + return JsonResponse(response_payload) class StudentProgressUrlSerializer(serializers.Serializer): @@ -2373,9 +2369,8 @@ def get(self, request, course_id): return _list_instructor_tasks(request=request, course_id=course_id) -@require_POST -@ensure_csrf_cookie -def list_instructor_tasks(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListInstructorTasks(APIView): """ List instructor tasks. @@ -2385,21 +2380,44 @@ def list_instructor_tasks(request, course_id): - `problem_location_str` and `unique_student_identifier` lists task history for problem AND student (intersection) """ - return _list_instructor_tasks(request=request, course_id=course_id) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.SHOW_TASKS + serializer_class = ListInstructorTaskInputSerializer + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + List instructor tasks. + """ + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + return _list_instructor_tasks( + request=request, course_id=course_id, serialize_data=serializer.validated_data + ) @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_course_permission(permissions.SHOW_TASKS) -def _list_instructor_tasks(request, course_id): +def _list_instructor_tasks(request, course_id, serialize_data=None): """ List instructor tasks. Internal function with common code for both DRF and and tradition views. """ + # This method is also used by other APIs with the GET method. + # The query_params attribute is utilized for GET requests, + # where parameters are passed as query strings. + course_id = CourseKey.from_string(course_id) - params = getattr(request, 'query_params', request.POST) - problem_location_str = strip_if_string(params.get('problem_location_str', False)) - student = params.get('unique_student_identifier', None) + if serialize_data is not None: + problem_location_str = strip_if_string(serialize_data.get('problem_location_str', False)) + student = serialize_data.get('unique_student_identifier', None) + else: + params = getattr(request, 'query_params', request.POST) + problem_location_str = strip_if_string(params.get('problem_location_str', False)) + student = params.get('unique_student_identifier', None) + if student is not None: student = get_student_from_identifier(student) @@ -3013,37 +3031,59 @@ def post(self, request, course_id): due_date.strftime('%Y-%m-%d %H:%M'))) -@handle_dashboard_error -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.GIVE_STUDENT_EXTENSION) -@require_post_params('student', 'url') -def reset_due_date(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ResetDueDate(APIView): """ Rescinds a due date extension for a student on a particular unit. """ - course = get_course_by_id(CourseKey.from_string(course_id)) - student = require_student_from_identifier(request.POST.get('student')) - unit = find_unit(course, request.POST.get('url')) - reason = strip_tags(request.POST.get('reason', '')) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GIVE_STUDENT_EXTENSION + serializer_class = BlockDueDateSerializer - version = getattr(course, 'course_version', None) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + reset a due date extension to a student for a particular unit. + params: + url (str): The URL related to the block that needs the due date update. + student (str): The email or username of the student whose access is being modified. + reason (str): Optional param. + """ + serializer_data = self.serializer_class(data=request.data, context={'disable_due_datetime': True}) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - original_due_date = get_date_for_block(course_id, unit.location, published_version=version) + student = serializer_data.validated_data.get('student') + if not student: + response_payload = { + 'error': f'Could not find student matching identifier: {request.data.get("student")}' + } + return JsonResponse(response_payload) - set_due_date_extension(course, unit, student, None, request.user, reason=reason) - if not original_due_date: - # It's possible the normal due date was deleted after an extension was granted: - return JsonResponse( - _("Successfully removed invalid due date extension (unit has no due date).") - ) + course = get_course_by_id(CourseKey.from_string(course_id)) + unit = find_unit(course, serializer_data.validated_data.get('url')) + reason = strip_tags(serializer_data.validated_data.get('reason', '')) - original_due_date_str = original_due_date.strftime('%Y-%m-%d %H:%M') - return JsonResponse(_( - 'Successfully reset due date for student {0} for {1} ' - 'to {2}').format(student.profile.name, _display_unit(unit), - original_due_date_str)) + version = getattr(course, 'course_version', None) + + original_due_date = get_date_for_block(course_id, unit.location, published_version=version) + + try: + set_due_date_extension(course, unit, student, None, request.user, reason=reason) + if not original_due_date: + # It's possible the normal due date was deleted after an extension was granted: + return JsonResponse( + _("Successfully removed invalid due date extension (unit has no due date).") + ) + + original_due_date_str = original_due_date.strftime('%Y-%m-%d %H:%M') + return JsonResponse(_( + 'Successfully reset due date for student {0} for {1} ' + 'to {2}').format(student.profile.name, _display_unit(unit), + original_due_date_str)) + + except Exception as error: # pylint: disable=broad-except + return JsonResponse({'error': str(error)}, status=400) @handle_dashboard_error diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 0cb80238f7c2..92d5f46bc70e 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -32,7 +32,8 @@ path('get_issued_certificates/', api.get_issued_certificates, name='get_issued_certificates'), path('get_students_who_may_enroll', api.GetStudentsWhoMayEnroll.as_view(), name='get_students_who_may_enroll'), path('get_anon_ids', api.GetAnonIds.as_view(), name='get_anon_ids'), - path('get_student_enrollment_status', api.get_student_enrollment_status, name="get_student_enrollment_status"), + path('get_student_enrollment_status', api.GetStudentEnrollmentStatus.as_view(), + name="get_student_enrollment_status"), path('get_student_progress_url', api.StudentProgressUrl.as_view(), name='get_student_progress_url'), path('reset_student_attempts', api.ResetStudentAttempts.as_view(), name='reset_student_attempts'), path('rescore_problem', api.rescore_problem, name='rescore_problem'), @@ -44,14 +45,14 @@ name='list_entrance_exam_instructor_tasks'), path('mark_student_can_skip_entrance_exam', api.mark_student_can_skip_entrance_exam, name='mark_student_can_skip_entrance_exam'), - path('list_instructor_tasks', api.list_instructor_tasks, name='list_instructor_tasks'), + path('list_instructor_tasks', api.ListInstructorTasks.as_view(), name='list_instructor_tasks'), path('list_background_email_tasks', api.list_background_email_tasks, name='list_background_email_tasks'), path('list_email_content', api.ListEmailContent.as_view(), name='list_email_content'), path('list_forum_members', api.list_forum_members, name='list_forum_members'), path('update_forum_role_membership', api.update_forum_role_membership, name='update_forum_role_membership'), path('change_due_date', api.ChangeDueDate.as_view(), name='change_due_date'), path('send_email', api.SendEmail.as_view(), name='send_email'), - path('reset_due_date', api.reset_due_date, name='reset_due_date'), + path('reset_due_date', api.ResetDueDate.as_view(), name='reset_due_date'), path('show_unit_extensions', api.show_unit_extensions, name='show_unit_extensions'), path('show_student_extensions', api.ShowStudentExtensions.as_view(), name='show_student_extensions'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 793acc9c6137..59ac66ab838b 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -31,23 +31,14 @@ class Meta: fields = ['username', 'email', 'first_name', 'last_name'] -class AccessSerializer(serializers.Serializer): +class UniqueStudentIdentifierSerializer(serializers.Serializer): """ - Serializer for managing user access changes. - This serializer validates and processes the data required to modify - user access within a system. + Serializer for identifying unique_student. """ unique_student_identifier = serializers.CharField( max_length=255, help_text="Email or username of user to change access" ) - rolename = serializers.CharField( - help_text="Role name to assign to the user" - ) - action = serializers.ChoiceField( - choices=['allow', 'revoke'], - help_text="Action to perform on the user's access" - ) def validate_unique_student_identifier(self, value): """ @@ -61,6 +52,58 @@ def validate_unique_student_identifier(self, value): return user +class AccessSerializer(UniqueStudentIdentifierSerializer): + """ + Serializer for managing user access changes. + This serializer validates and processes the data required to modify + user access within a system. + """ + rolename = serializers.CharField( + help_text="Role name to assign to the user" + ) + action = serializers.ChoiceField( + choices=['allow', 'revoke'], + help_text="Action to perform on the user's access" + ) + + +class ListInstructorTaskInputSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializer for handling the input data for the problem response report generation API. + +Attributes: + unique_student_identifier (str): The email or username of the student. + This field is optional, but if provided, the `problem_location_str` + must also be provided. + problem_location_str (str): The string representing the location of the problem within the course. + This field is optional, unless `unique_student_identifier` is provided. + """ + unique_student_identifier = serializers.CharField( + max_length=255, + help_text="Email or username of student", + required=False + ) + problem_location_str = serializers.CharField( + help_text="Problem location", + required=False + ) + + def validate(self, data): + """ + Validate the data to ensure that if unique_student_identifier is provided, + problem_location_str must also be provided. + """ + unique_student_identifier = data.get('unique_student_identifier') + problem_location_str = data.get('problem_location_str') + + if unique_student_identifier and not problem_location_str: + raise serializers.ValidationError( + "unique_student_identifier must accompany problem_location_str" + ) + + return data + + class ShowStudentExtensionSerializer(serializers.Serializer): """ Serializer for validating and processing the student identifier. @@ -178,3 +221,10 @@ def validate_student(self, value): return None return user + + def __init__(self, *args, **kwargs): + # Get context to check if `due_datetime` should be optional + disable_due_datetime = kwargs.get('context', {}).get('disable_due_datetime', False) + super().__init__(*args, **kwargs) + if disable_due_datetime: + self.fields['due_datetime'].required = False diff --git a/lms/djangoapps/learner_home/serializers.py b/lms/djangoapps/learner_home/serializers.py index b3471715b9dc..3d156f3640ca 100644 --- a/lms/djangoapps/learner_home/serializers.py +++ b/lms/djangoapps/learner_home/serializers.py @@ -3,7 +3,7 @@ """ from datetime import date, timedelta -from urllib.parse import urljoin +from urllib.parse import urlencode, urljoin from django.conf import settings from django.urls import reverse @@ -132,7 +132,13 @@ def get_upgradeUrl(self, instance): ) if ecommerce_payment_page and verified_sku: - return f"{ecommerce_payment_page}?sku={verified_sku}" + query_params = { + 'sku': verified_sku, + 'course_run_key': str(instance.course_id) + } + encoded_params = urlencode(query_params) + upgrade_url = f"{ecommerce_payment_page}?{encoded_params}" + return upgrade_url def get_resumeUrl(self, instance): return self.context.get("resume_course_urls", {}).get(instance.course_id) diff --git a/lms/djangoapps/mobile_api/notifications/__init__.py b/lms/djangoapps/mobile_api/notifications/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/notifications/urls.py b/lms/djangoapps/mobile_api/notifications/urls.py new file mode 100644 index 000000000000..120fa39a975a --- /dev/null +++ b/lms/djangoapps/mobile_api/notifications/urls.py @@ -0,0 +1,12 @@ +""" +URLs for the mobile_api.notifications APIs. +""" +from django.urls import path +from .views import GCMDeviceViewSet + + +create_gcm_device_post_view = GCMDeviceViewSet.as_view({'post': 'create'}) + +urlpatterns = [ + path('create-token/', create_gcm_device_post_view, name='gcmdevice-list'), +] diff --git a/lms/djangoapps/mobile_api/notifications/views.py b/lms/djangoapps/mobile_api/notifications/views.py new file mode 100644 index 000000000000..2621c2a3a2fb --- /dev/null +++ b/lms/djangoapps/mobile_api/notifications/views.py @@ -0,0 +1,53 @@ +""" +This module contains the view for registering a device for push notifications. +""" +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response + +from edx_ace.push_notifications.views import GCMDeviceViewSet as GCMDeviceViewSetBase + +from ..decorators import mobile_view + + +@mobile_view() +class GCMDeviceViewSet(GCMDeviceViewSetBase): + """ + **Use Case** + This endpoint allows clients to register a device for push notifications. + + If the device is already registered, the existing registration will be updated. + If setting PUSH_NOTIFICATIONS_SETTINGS is not configured, the endpoint will return a 501 error. + + **Example Request** + POST /api/mobile/{version}/notifications/create-token/ + **POST Parameters** + The body of the POST request can include the following parameters. + * name (optional) - A name of the device. + * registration_id (required) - The device token of the device. + * device_id (optional) - ANDROID_ID / TelephonyManager.getDeviceId() (always as hex) + * active (optional) - Whether the device is active, default is True. + If False, the device will not receive notifications. + * cloud_message_type (required) - You should choose FCM or GCM. Currently, only FCM is supported. + * application_id (optional) - Opaque application identity, should be filled in for multiple + key/certificate access. Should be equal settings.FCM_APP_NAME. + **Example Response** + ```json + { + "id": 1, + "name": "My Device", + "registration_id": "fj3j4", + "device_id": 1234, + "active": true, + "date_created": "2024-04-18T07:39:37.132787Z", + "cloud_message_type": "FCM", + "application_id": "my_app_id" + } + ``` + """ + + def create(self, request, *args, **kwargs): + if not getattr(settings, 'PUSH_NOTIFICATIONS_SETTINGS', None): + return Response('Push notifications are not configured.', status.HTTP_501_NOT_IMPLEMENTED) + + return super().create(request, *args, **kwargs) diff --git a/lms/djangoapps/mobile_api/urls.py b/lms/djangoapps/mobile_api/urls.py index 1ad34ced5de9..c7aacc0b669a 100644 --- a/lms/djangoapps/mobile_api/urls.py +++ b/lms/djangoapps/mobile_api/urls.py @@ -10,5 +10,6 @@ urlpatterns = [ path('users/', include('lms.djangoapps.mobile_api.users.urls')), path('my_user_info', my_user_info, name='user-info'), + path('notifications/', include('lms.djangoapps.mobile_api.notifications.urls')), path('course_info/', include('lms.djangoapps.mobile_api.course_info.urls')), ] diff --git a/lms/djangoapps/verify_student/admin.py b/lms/djangoapps/verify_student/admin.py index 6de066e6f797..1d3124580ad1 100644 --- a/lms/djangoapps/verify_student/admin.py +++ b/lms/djangoapps/verify_student/admin.py @@ -2,14 +2,14 @@ Admin site configurations for verify_student. """ - from django.contrib import admin from lms.djangoapps.verify_student.models import ( ManualVerification, SoftwareSecurePhotoVerification, SSOVerification, - SSPVerificationRetryConfig + SSPVerificationRetryConfig, + VerificationAttempt ) @@ -50,3 +50,13 @@ class SSPVerificationRetryAdmin(admin.ModelAdmin): Admin for the SSPVerificationRetryConfig table. """ pass # lint-amnesty, pylint: disable=unnecessary-pass + + +@admin.register(VerificationAttempt) +class VerificationAttemptAdmin(admin.ModelAdmin): + """ + Admin for the VerificationAttempt table. + """ + list_display = ('id', 'user', 'name', 'status', 'expiration_datetime',) + raw_id_fields = ('user',) + search_fields = ('user__username', 'name',) diff --git a/lms/djangoapps/verify_student/api.py b/lms/djangoapps/verify_student/api.py index f61b90d682ff..7b8310fde030 100644 --- a/lms/djangoapps/verify_student/api.py +++ b/lms/djangoapps/verify_student/api.py @@ -13,6 +13,12 @@ from lms.djangoapps.verify_student.emails import send_verification_approved_email from lms.djangoapps.verify_student.exceptions import VerificationAttemptInvalidStatus from lms.djangoapps.verify_student.models import VerificationAttempt +from lms.djangoapps.verify_student.signals.signals import ( + emit_idv_attempt_approved_event, + emit_idv_attempt_created_event, + emit_idv_attempt_denied_event, + emit_idv_attempt_pending_event, +) from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus from lms.djangoapps.verify_student.tasks import send_verification_status_email @@ -48,7 +54,13 @@ def send_approval_email(attempt): send_verification_approved_email(context=email_context) -def create_verification_attempt(user: User, name: str, status: str, expiration_datetime: Optional[datetime] = None): +def create_verification_attempt( + user: User, + name: str, + status: str, + expiration_datetime: Optional[datetime] = None, + hide_status_from_user: Optional[bool] = False, +): """ Create a verification attempt. @@ -68,6 +80,15 @@ def create_verification_attempt(user: User, name: str, status: str, expiration_d name=name, status=status, expiration_datetime=expiration_datetime, + hide_status_from_user=hide_status_from_user, + ) + + emit_idv_attempt_created_event( + attempt_id=verification_attempt.id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, ) return verification_attempt.id @@ -77,7 +98,7 @@ def update_verification_attempt( attempt_id: int, name: Optional[str] = None, status: Optional[str] = None, - expiration_datetime: Optional[datetime] = None + expiration_datetime: Optional[datetime] = None, ): """ Update a verification attempt. @@ -115,7 +136,7 @@ def update_verification_attempt( 'Status must be one of: %(status_list)s', { 'status': status, - 'status_list': VerificationAttempt.STATUS_CHOICES, + 'status_list': VerificationAttempt.STATUS, }, ) raise VerificationAttemptInvalidStatus @@ -125,3 +146,29 @@ def update_verification_attempt( attempt.expiration_datetime = expiration_datetime attempt.save() + + user = attempt.user + if status == VerificationAttemptStatus.PENDING: + emit_idv_attempt_pending_event( + attempt_id=attempt_id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.APPROVED: + emit_idv_attempt_approved_event( + attempt_id=attempt_id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.DENIED: + emit_idv_attempt_denied_event( + attempt_id=attempt_id, + user=user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) diff --git a/lms/djangoapps/verify_student/apps.py b/lms/djangoapps/verify_student/apps.py index f01bdef7e908..d553b9e0cf9a 100644 --- a/lms/djangoapps/verify_student/apps.py +++ b/lms/djangoapps/verify_student/apps.py @@ -17,5 +17,5 @@ def ready(self): """ Connect signal handlers. """ - from lms.djangoapps.verify_student import signals # pylint: disable=unused-import + from lms.djangoapps.verify_student.signals import signals # pylint: disable=unused-import from lms.djangoapps.verify_student import tasks # pylint: disable=unused-import diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py b/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py index 4a93aa19f169..891ff9fda5d8 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_backfill_sso_verifications_for_old_account_links.py @@ -54,7 +54,7 @@ def test_performance(self): #self.assertNumQueries(100) def test_signal_called(self): - with patch('openedx.core.djangoapps.signals.signals.LEARNER_NOW_VERIFIED.send_robust') as mock_signal: + with patch('openedx_events.learning.signals.IDV_ATTEMPT_APPROVED.send_event') as mock_signal: call_command('backfill_sso_verifications_for_old_account_links', '--provider-slug', self.provider.provider_id) # lint-amnesty, pylint: disable=line-too-long assert mock_signal.call_count == 1 diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py b/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py index 1c3f22aa30cd..8fa84efe3a85 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_retry_failed_photo_verifications.py @@ -121,7 +121,7 @@ def _create_attempts(self, num_attempts): for _ in range(num_attempts): self.create_upload_and_submit_attempt_for_user() - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_resubmit_in_date_range(self, send_idv_update_mock): call_command('retry_failed_photo_verifications', status="submitted", diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py b/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py index 99fd4ecd3a5f..c9e98a94dec0 100644 --- a/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py +++ b/lms/djangoapps/verify_student/management/commands/tests/test_trigger_softwaresecurephotoverifications_post_save_signal.py @@ -38,7 +38,7 @@ def _create_attempts(self, num_attempts): for _ in range(num_attempts): self.create_and_submit_attempt_for_user() - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_command(self, send_idv_update_mock): call_command('trigger_softwaresecurephotoverifications_post_save_signal', start_date_time='2021-10-31 06:00:00') diff --git a/lms/djangoapps/verify_student/migrations/0016_remove_verificationattempt_created_and_more.py b/lms/djangoapps/verify_student/migrations/0016_remove_verificationattempt_created_and_more.py new file mode 100644 index 000000000000..d972dba3dbbd --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0016_remove_verificationattempt_created_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.2.15 on 2024-09-26 20:08 + +from django.db import migrations, models +import django.utils.timezone +import lms.djangoapps.verify_student.statuses +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('verify_student', '0015_verificationattempt'), + ] + + operations = [ + migrations.RemoveField( + model_name='verificationattempt', + name='created', + ), + migrations.RemoveField( + model_name='verificationattempt', + name='modified', + ), + migrations.AddField( + model_name='verificationattempt', + name='created_at', + field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='verificationattempt', + name='hide_status_from_user', + field=models.BooleanField(default=False, null=True), + ), + migrations.AddField( + model_name='verificationattempt', + name='status_changed', + field=model_utils.fields.MonitorField(default=django.utils.timezone.now, monitor='status', verbose_name='status changed'), + ), + migrations.AddField( + model_name='verificationattempt', + name='updated_at', + field=models.DateTimeField(auto_now=True, db_index=True), + ), + migrations.AlterField( + model_name='verificationattempt', + name='status', + field=model_utils.fields.StatusField(choices=[(lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['CREATED'], lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['CREATED']), (lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['PENDING'], lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['PENDING']), (lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['APPROVED'], lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['APPROVED']), (lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['DENIED'], lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['DENIED'])], default=lms.djangoapps.verify_student.statuses.VerificationAttemptStatus['CREATED'], max_length=100, no_check_for_status=True, verbose_name='status'), + ), + ] diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index 9d2195d1e5b0..9a0ac369640a 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -15,9 +15,11 @@ import logging import os.path import uuid + from datetime import timedelta from email.utils import formatdate + import requests from config_models.models import ConfigurationModel from django.conf import settings @@ -42,8 +44,9 @@ rsa_decrypt, rsa_encrypt ) -from openedx.core.djangoapps.signals.signals import LEARNER_NOW_VERIFIED from openedx.core.storage import get_storage +from openedx_events.learning.signals import IDV_ATTEMPT_APPROVED +from openedx_events.learning.data import UserData, VerificationAttemptData from .utils import auto_verify_for_testing_enabled, earliest_allowed_verification_date, submit_request_to_ss @@ -248,13 +251,23 @@ def send_approval_signal(self, approved_by='None'): user_id=self.user, reviewer=approved_by )) - # Emit signal to find and generate eligible certificates - LEARNER_NOW_VERIFIED.send_robust( - sender=SSOVerification, - user=self.user + # Emit event to find and generate eligible certificates + verification_data = VerificationAttemptData( + attempt_id=self.id, + user=UserData( + pii=None, + id=self.user.id, + is_active=self.user.is_active, + ), + status=self.status, + name=self.name, + expiration_date=self.expiration_datetime, + ) + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=verification_data, ) - message = 'LEARNER_NOW_VERIFIED signal fired for {user} from SSOVerification' + message = 'IDV_ATTEMPT_APPROVED signal fired for {user} from SSOVerification' log.info(message.format(user=self.user.username)) @@ -451,13 +464,24 @@ def approve(self, user_id=None, service=""): days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"] ) self.save() - # Emit signal to find and generate eligible certificates - LEARNER_NOW_VERIFIED.send_robust( - sender=PhotoVerification, - user=self.user + + # Emit event to find and generate eligible certificates + verification_data = VerificationAttemptData( + attempt_id=self.id, + user=UserData( + pii=None, + id=self.user.id, + is_active=self.user.is_active, + ), + status=self.status, + name=self.name, + expiration_date=self.expiration_datetime, + ) + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=verification_data, ) - message = 'LEARNER_NOW_VERIFIED signal fired for {user} from PhotoVerification' + message = 'IDV_ATTEMPT_APPROVED signal fired for {user} from PhotoVerification' log.info(message.format(user=self.user.username)) @status_before_must_be("ready", "must_retry") @@ -1192,7 +1216,7 @@ def __str__(self): return str(self.arguments) -class VerificationAttempt(TimeStampedModel): +class VerificationAttempt(StatusModel): """ The model represents impelementation-agnostic information about identity verification (IDV) attempts. @@ -1202,23 +1226,29 @@ class VerificationAttempt(TimeStampedModel): user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) name = models.CharField(blank=True, max_length=255) - STATUS_CHOICES = [ + STATUS = Choices( VerificationAttemptStatus.CREATED, VerificationAttemptStatus.PENDING, VerificationAttemptStatus.APPROVED, VerificationAttemptStatus.DENIED, - ] - status = models.CharField(max_length=64, choices=[(status, status) for status in STATUS_CHOICES]) + ) expiration_datetime = models.DateTimeField( null=True, blank=True, ) - @property - def updated_at(self): - """Backwards compatibility with existing IDVerification models""" - return self.modified + hide_status_from_user = models.BooleanField( + default=False, + null=True, + ) + + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True, db_index=True) + + def should_display_status_to_user(self): + """When called, returns true or false based on the type of VerificationAttempt""" + return not self.hide_status_from_user @classmethod def retire_user(cls, user_id): diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index f1c5543e8536..f0d8a8631482 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -11,6 +11,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.utils.timezone import now from django.utils.translation import gettext as _ +from openedx_filters.learning.filters import IDVPageURLRequested from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import User @@ -75,7 +76,7 @@ def verifications_for_user(cls, user): Return a list of all verifications associated with the given user. """ verifications = [] - for verification in chain(VerificationAttempt.objects.filter(user=user).order_by('-created'), + for verification in chain(VerificationAttempt.objects.filter(user=user).order_by('-created_at'), SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-created_at'), SSOVerification.objects.filter(user=user).order_by('-created_at'), ManualVerification.objects.filter(user=user).order_by('-created_at')): @@ -96,7 +97,7 @@ def get_verified_user_ids(cls, users): VerificationAttempt.objects.filter(**{ 'user__in': users, 'status': 'approved', - 'created__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + 'created_at__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) }).values_list('user_id', flat=True), SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True), SSOVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True), @@ -244,7 +245,10 @@ def get_verify_location(cls, course_id=None): location = f'{settings.ACCOUNT_MICROFRONTEND_URL}/id-verification' if course_id: location += f'?course_id={quote(str(course_id))}' - return location + + # .. filter_implemented_name: IDVPageURLRequested + # .. filter_type: org.openedx.learning.idv.page.url.requested.v1 + return IDVPageURLRequested.run_filter(location) @classmethod def get_verification_details_by_id(cls, attempt_id): diff --git a/lms/djangoapps/verify_student/signals/__init__.py b/lms/djangoapps/verify_student/signals/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/verify_student/signals.py b/lms/djangoapps/verify_student/signals/handlers.py similarity index 90% rename from lms/djangoapps/verify_student/signals.py rename to lms/djangoapps/verify_student/signals/handlers.py index ae54deb74214..8a1d7b542b00 100644 --- a/lms/djangoapps/verify_student/signals.py +++ b/lms/djangoapps/verify_student/signals/handlers.py @@ -5,23 +5,23 @@ from django.core.exceptions import ObjectDoesNotExist from django.db.models.signals import post_save -from django.dispatch import Signal from django.dispatch.dispatcher import receiver from xmodule.modulestore.django import SignalHandler, modulestore from common.djangoapps.student.models_api import get_name, get_pending_name_change +from lms.djangoapps.verify_student.apps import VerifyStudentConfig # pylint: disable=unused-import +from lms.djangoapps.verify_student.signals.signals import idv_update_signal from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC -from .models import SoftwareSecurePhotoVerification, VerificationDeadline, VerificationAttempt +from lms.djangoapps.verify_student.models import ( + SoftwareSecurePhotoVerification, + VerificationDeadline, + VerificationAttempt +) log = logging.getLogger(__name__) -# Signal for emitting IDV submission and review updates -# providing_args = ["attempt_id", "user_id", "status", "full_name", "profile_name"] -idv_update_signal = Signal() - - @receiver(SignalHandler.course_published) def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument """ diff --git a/lms/djangoapps/verify_student/signals/signals.py b/lms/djangoapps/verify_student/signals/signals.py new file mode 100644 index 000000000000..c03d5f263191 --- /dev/null +++ b/lms/djangoapps/verify_student/signals/signals.py @@ -0,0 +1,109 @@ +""" +Signal definitions and functions to send those signals for the verify_student application. +""" + +from django.dispatch import Signal + +from openedx_events.learning.data import UserData, UserPersonalData, VerificationAttemptData +from openedx_events.learning.signals import ( + IDV_ATTEMPT_CREATED, + IDV_ATTEMPT_PENDING, + IDV_ATTEMPT_APPROVED, + IDV_ATTEMPT_DENIED, +) + +# Signal for emitting IDV submission and review updates +# providing_args = ["attempt_id", "user_id", "status", "full_name", "profile_name"] +idv_update_signal = Signal() + + +def _create_user_data(user): + """ + Helper function to create a UserData object. + """ + user_data = UserData( + id=user.id, + is_active=user.is_active, + pii=UserPersonalData( + username=user.username, + email=user.email, + name=user.get_full_name() + ) + ) + + return user_data + + +def emit_idv_attempt_created_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_CREATED Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_CREATED + IDV_ATTEMPT_CREATED.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) + return user_data + + +def emit_idv_attempt_pending_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_PENDING Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_PENDING + IDV_ATTEMPT_PENDING.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) + return user_data + + +def emit_idv_attempt_approved_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_APPROVED Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_APPROVED + IDV_ATTEMPT_APPROVED.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) + return user_data + + +def emit_idv_attempt_denied_event(attempt_id, user, status, name, expiration_date): + """ + Emit the IDV_ATTEMPT_DENIED Open edX event. + """ + user_data = _create_user_data(user) + + # .. event_implemented_name: IDV_ATTEMPT_DENIED + IDV_ATTEMPT_DENIED.send_event( + idv_attempt=VerificationAttemptData( + attempt_id=attempt_id, + user=user_data, + status=status, + name=name, + expiration_date=expiration_date, + ) + ) diff --git a/lms/djangoapps/verify_student/tests/test_api.py b/lms/djangoapps/verify_student/tests/test_api.py index 747c76f82b61..2be7b6580905 100644 --- a/lms/djangoapps/verify_student/tests/test_api.py +++ b/lms/djangoapps/verify_student/tests/test_api.py @@ -69,7 +69,8 @@ def setUp(self): ) self.attempt.save() - def test_create_verification_attempt(self): + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_created_event') + def test_create_verification_attempt(self, mock_created_event): expected_id = 2 self.assertEqual( create_verification_attempt( @@ -86,6 +87,13 @@ def test_create_verification_attempt(self): self.assertEqual(verification_attempt.name, 'Tester McTest') self.assertEqual(verification_attempt.status, VerificationAttemptStatus.CREATED) self.assertEqual(verification_attempt.expiration_datetime, datetime(2024, 12, 31, tzinfo=timezone.utc)) + mock_created_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=VerificationAttemptStatus.CREATED, + name='Tester McTest', + expiration_date=datetime(2024, 12, 31, tzinfo=timezone.utc), + ) def test_create_verification_attempt_no_expiration_datetime(self): expected_id = 2 @@ -129,7 +137,18 @@ def setUp(self): ('Tester McTest3', VerificationAttemptStatus.DENIED, datetime(2026, 12, 31, tzinfo=timezone.utc)), ) @ddt.unpack - def test_update_verification_attempt(self, name, status, expiration_datetime): + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_pending_event') + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_approved_event') + @patch('lms.djangoapps.verify_student.api.emit_idv_attempt_denied_event') + def test_update_verification_attempt( + self, + name, + status, + expiration_datetime, + mock_denied_event, + mock_approved_event, + mock_pending_event, + ): update_verification_attempt( attempt_id=self.attempt.id, name=name, @@ -145,6 +164,31 @@ def test_update_verification_attempt(self, name, status, expiration_datetime): self.assertEqual(verification_attempt.status, status) self.assertEqual(verification_attempt.expiration_datetime, expiration_datetime) + if status == VerificationAttemptStatus.PENDING: + mock_pending_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.APPROVED: + mock_approved_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + elif status == VerificationAttemptStatus.DENIED: + mock_denied_event.assert_called_with( + attempt_id=verification_attempt.id, + user=self.user, + status=status, + name=name, + expiration_date=expiration_datetime, + ) + def test_update_verification_attempt_none_values(self): update_verification_attempt( attempt_id=self.attempt.id, @@ -166,6 +210,7 @@ def test_update_verification_attempt_not_found(self): VerificationAttempt.DoesNotExist, update_verification_attempt, attempt_id=999999, + name=None, status=VerificationAttemptStatus.APPROVED, ) diff --git a/lms/djangoapps/verify_student/tests/test_signals.py b/lms/djangoapps/verify_student/tests/test_handlers.py similarity index 88% rename from lms/djangoapps/verify_student/tests/test_signals.py rename to lms/djangoapps/verify_student/tests/test_handlers.py index 8d607988d4b4..40d80712f19d 100644 --- a/lms/djangoapps/verify_student/tests/test_signals.py +++ b/lms/djangoapps/verify_student/tests/test_handlers.py @@ -15,7 +15,7 @@ VerificationDeadline, VerificationAttempt ) -from lms.djangoapps.verify_student.signals import ( +from lms.djangoapps.verify_student.signals.handlers import ( _listen_for_course_publish, _listen_for_lms_retire, _listen_for_lms_retire_verification_attempts @@ -29,9 +29,9 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order -class VerificationDeadlineSignalTest(ModuleStoreTestCase): +class VerificationDeadlineHandlerTest(ModuleStoreTestCase): """ - Tests for the VerificationDeadline signal + Tests for the VerificationDeadline handler """ def setUp(self): @@ -41,13 +41,13 @@ def setUp(self): VerificationDeadline.objects.all().delete() def test_no_deadline(self): - """ Verify the signal sets deadline to course end when no deadline exists.""" + """ Verify the handler sets deadline to course end when no deadline exists.""" _listen_for_course_publish('store', self.course.id) assert VerificationDeadline.deadline_for_course(self.course.id) == self.course.end def test_deadline(self): - """ Verify deadline is set to course end date by signal when changed. """ + """ Verify deadline is set to course end date by handler when changed. """ deadline = now() - timedelta(days=7) VerificationDeadline.set_deadline(self.course.id, deadline) @@ -55,7 +55,7 @@ def test_deadline(self): assert VerificationDeadline.deadline_for_course(self.course.id) == self.course.end def test_deadline_explicit(self): - """ Verify deadline is unchanged by signal when explicitly set. """ + """ Verify deadline is unchanged by handler when explicitly set. """ deadline = now() - timedelta(days=7) VerificationDeadline.set_deadline(self.course.id, deadline, is_explicit=True) @@ -66,9 +66,9 @@ def test_deadline_explicit(self): assert actual_deadline == deadline -class RetirementSignalTest(ModuleStoreTestCase): +class RetirementHandlerTest(ModuleStoreTestCase): """ - Tests for the VerificationDeadline signal + Tests for the VerificationDeadline handler """ def _create_entry(self): @@ -119,8 +119,8 @@ def test_idempotent(self): class PostSavePhotoVerificationTest(ModuleStoreTestCase): """ - Tests for the post_save signal on the SoftwareSecurePhotoVerification model. - This receiver should emit another signal that contains limited data about + Tests for the post_save handler on the SoftwareSecurePhotoVerification model. + This receiver should emit another handler that contains limited data about the verification attempt that was updated. """ @@ -132,7 +132,7 @@ def setUp(self): self.photo_id_image_url = 'https://test.photo' self.photo_id_key = 'test+key' - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_post_save_signal(self, mock_signal): # create new softwaresecureverification attempt = SoftwareSecurePhotoVerification.objects.create( @@ -165,7 +165,7 @@ def test_post_save_signal(self, mock_signal): full_name=attempt.user.profile.name ) - @patch('lms.djangoapps.verify_student.signals.idv_update_signal.send') + @patch('lms.djangoapps.verify_student.signals.signals.idv_update_signal.send') def test_post_save_signal_pending_name(self, mock_signal): pending_name_change = do_name_change_request(self.user, 'Pending Name', 'test')[0] @@ -187,7 +187,7 @@ def test_post_save_signal_pending_name(self, mock_signal): ) -class RetirementSignalVerificationAttemptsTest(ModuleStoreTestCase): +class RetirementHandlerVerificationAttemptsTest(ModuleStoreTestCase): """ Tests for the LMS User Retirement signal for Verification Attempts """ diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py index 5351e3ede699..d57993d368af 100644 --- a/lms/djangoapps/verify_student/tests/test_services.py +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -9,10 +9,11 @@ import ddt from django.conf import settings -from django.test import TestCase +from django.test import TestCase, override_settings from django.utils.timezone import now from django.utils.translation import gettext as _ from freezegun import freeze_time +from openedx_filters import PipelineStep from pytz import utc from common.djangoapps.student.tests.factories import UserFactory @@ -33,6 +34,16 @@ } +class TestIdvPageUrlRequestedPipelineStep(PipelineStep): + """ Utility function to test a configured pipeline step """ + TEST_URL = 'example.com/verify' + + def run_filter(self, url): # pylint: disable=arguments-differ + return { + "url": self.TEST_URL + } + + @patch.dict(settings.VERIFY_STUDENT, FAKE_SETTINGS) @ddt.ddt class TestIDVerificationService(ModuleStoreTestCase): @@ -167,6 +178,26 @@ def test_get_verify_location_from_string(self): expected_path = f'{settings.ACCOUNT_MICROFRONTEND_URL}/id-verification' assert path == (expected_path + '?course_id=course-v1%3AedX%2BDemoX%2BDemo_Course') + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.idv.page.url.requested.v1": { + "pipeline": [ + "lms.djangoapps.verify_student.tests.test_services.TestIdvPageUrlRequestedPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_get_verify_location_with_filter_step(self): + """ + Test IDV flow location can be customized with an openedx filter + """ + url = IDVerificationService.get_verify_location() + assert url == TestIdvPageUrlRequestedPipelineStep.TEST_URL + + url = IDVerificationService.get_verify_location('course-v1:edX+DemoX+Demo_Course') + assert url == TestIdvPageUrlRequestedPipelineStep.TEST_URL + def test_get_expiration_datetime(self): """ Test that the latest expiration datetime is returned if there are multiple records diff --git a/lms/envs/common.py b/lms/envs/common.py index 334669215397..f2bcfa822b6e 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5544,3 +5544,11 @@ def _should_send_learning_badge_events(settings): # .. setting_default: empty dictionary # .. setting_description: Dictionary with additional information that you want to share in the report. SURVEY_REPORT_EXTRA_DATA = {} + + +# .. setting_name: DISABLED_COUNTRIES +# .. setting_default: [] +# .. setting_description: List of country codes that should be disabled +# .. for now it wil impact country listing in auth flow and user profile. +# .. eg ['US', 'CA'] +DISABLED_COUNTRIES = [] diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 7a06f717996c..01100e924059 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -546,6 +546,7 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing 'http://localhost:1992', # frontend-app-ora 'http://localhost:2002', # frontend-app-discussions 'http://localhost:1991', # frontend-app-admin-portal + 'http://localhost:8734', # frontend-app-learner-portal-enterprise 'http://localhost:1999', # frontend-app-authn 'http://localhost:18450', # frontend-app-support-tools 'http://localhost:1994', # frontend-app-gradebook diff --git a/lms/envs/minimal.yml b/lms/envs/minimal.yml index d455d1f3dbf8..51d7bbf499c4 100644 --- a/lms/envs/minimal.yml +++ b/lms/envs/minimal.yml @@ -36,3 +36,6 @@ LMS_INTERNAL_ROOT_URL: "http://localhost" # So that Swagger config code doesn't complain API_ACCESS_MANAGER_EMAIL: "api-access@example.com" + +# So that you can login to studio on bare-metal +SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT: 'http://localhost:18000' diff --git a/lms/envs/production.py b/lms/envs/production.py index a1acd692f4e1..6dc6be634178 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -1124,3 +1124,10 @@ def get_env_setting(setting): EVENT_BUS_PRODUCER_CONFIG = merge_producer_configs(EVENT_BUS_PRODUCER_CONFIG, ENV_TOKENS.get('EVENT_BUS_PRODUCER_CONFIG', {})) BEAMER_PRODUCT_ID = ENV_TOKENS.get('BEAMER_PRODUCT_ID', BEAMER_PRODUCT_ID) + +# .. setting_name: DISABLED_COUNTRIES +# .. setting_default: [] +# .. setting_description: List of country codes that should be disabled +# .. for now it wil impact country listing in auth flow and user profile. +# .. eg ['US', 'CA'] +DISABLED_COUNTRIES = ENV_TOKENS.get('DISABLED_COUNTRIES', []) diff --git a/lms/static/js/instructor_dashboard/instructor_dashboard.js b/lms/static/js/instructor_dashboard/instructor_dashboard.js index 02972a93b6c4..f87e9db8e814 100644 --- a/lms/static/js/instructor_dashboard/instructor_dashboard.js +++ b/lms/static/js/instructor_dashboard/instructor_dashboard.js @@ -50,6 +50,12 @@ such that the value can be defined later than this assignment (file load order). $activeSection = null; + var usesProctoringLegacyView = function () { + // If the element #proctoring-mfe-view is present, then uses the new MFE + // and the legacy views should not be initialized. + return !document.getElementById('proctoring-mfe-view'); + } + SafeWaiter = (function() { function safeWaiter() { this.after_handlers = []; @@ -200,7 +206,7 @@ such that the value can be defined later than this assignment (file load order). } ]; // eslint-disable-next-line no-void - if (edx.instructor_dashboard.proctoring !== void 0) { + if (usesProctoringLegacyView() && edx.instructor_dashboard.proctoring !== void 0) { sectionsToInitialize = sectionsToInitialize.concat([ { constructor: edx.instructor_dashboard.proctoring.ProctoredExamAllowanceView, diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/body.txt b/lms/templates/instructor/edx_ace/addbetatester/push/body.txt new file mode 100644 index 000000000000..8373638fb41f --- /dev/null +++ b/lms/templates/instructor/edx_ace/addbetatester/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been invited to be a beta tester for {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/title.txt b/lms/templates/instructor/edx_ace/addbetatester/push/title.txt new file mode 100644 index 000000000000..f1c4c6826cfa --- /dev/null +++ b/lms/templates/instructor/edx_ace/addbetatester/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been invited to a beta test for {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt new file mode 100644 index 000000000000..41ff994310e3 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been enrolled in {{ course_name }} at {{ site_name }}. This course will now appear on your {{ site_name }} dashboard.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/title.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/title.txt new file mode 100644 index 000000000000..865657f1fcb1 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedenroll/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been invited to register for {{ course_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt new file mode 100644 index 000000000000..c7342b6830b5 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been unenrolled from the course {{ course_name }}. Please disregard the invitation previously sent.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/title.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/title.txt new file mode 100644 index 000000000000..99aaa1a9c305 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedunenroll/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been unenrolled from {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt b/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt new file mode 100644 index 000000000000..2bc61a840b48 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been unenrolled from {{ course_name }} at {{ site_name }}. This course will no longer appear on your {{ site_name }} dashboard.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrolledunenroll/push/title.txt b/lms/templates/instructor/edx_ace/enrolledunenroll/push/title.txt new file mode 100644 index 000000000000..99aaa1a9c305 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrolledunenroll/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been unenrolled from {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt b/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt new file mode 100644 index 000000000000..e5ef12dc5f75 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been invited to join {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrollenrolled/push/title.txt b/lms/templates/instructor/edx_ace/enrollenrolled/push/title.txt new file mode 100644 index 000000000000..ebe884b30f08 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrollenrolled/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been enrolled in {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/removebetatester/push/body.txt b/lms/templates/instructor/edx_ace/removebetatester/push/body.txt new file mode 100644 index 000000000000..89573aa4be1d --- /dev/null +++ b/lms/templates/instructor/edx_ace/removebetatester/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }},{% endblocktrans %} +{% blocktrans %}You have been removed as a beta tester for {{ course_name }} at {{ site_name }}. This course will remain on your dashboard, but you will no longer be part of the beta testing group.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/removebetatester/push/title.txt b/lms/templates/instructor/edx_ace/removebetatester/push/title.txt new file mode 100644 index 000000000000..c09febbb455c --- /dev/null +++ b/lms/templates/instructor/edx_ace/removebetatester/push/title.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been removed as a beta tester for {{ course_name }} at {{ site_name }}.{% endblocktrans %} +{% endautoescape %} diff --git a/openedx/core/djangoapps/ace_common/settings/common.py b/openedx/core/djangoapps/ace_common/settings/common.py index 11bfbce5c59f..634ab328ba6b 100644 --- a/openedx/core/djangoapps/ace_common/settings/common.py +++ b/openedx/core/djangoapps/ace_common/settings/common.py @@ -1,11 +1,14 @@ """ Settings for ace_common app. """ +from openedx.core.djangoapps.ace_common.utils import setup_firebase_app ACE_ROUTING_KEY = 'edx.lms.core.default' def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function-docstring, missing-module-docstring + if 'push_notifications' not in settings.INSTALLED_APPS: + settings.INSTALLED_APPS.append('push_notifications') settings.ACE_ENABLED_CHANNELS = [ 'django_email' ] @@ -22,3 +25,30 @@ def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function settings.ACE_ROUTING_KEY = ACE_ROUTING_KEY settings.FEATURES['test_django_plugin'] = True + settings.FCM_APP_NAME = 'fcm-edx-platform' + + settings.ACE_CHANNEL_DEFAULT_PUSH = 'push_notification' + # Note: To local development with Firebase, you must set FIREBASE_CREDENTIALS_PATH + # (path to json file with FIREBASE_CREDENTIALS) + # or FIREBASE_CREDENTIALS dictionary. + settings.FIREBASE_CREDENTIALS_PATH = None + settings.FIREBASE_CREDENTIALS = None + + settings.FIREBASE_APP = setup_firebase_app( + settings.FIREBASE_CREDENTIALS_PATH or settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME + ) + + if getattr(settings, 'FIREBASE_APP', None): + settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append('course_push_notification_optout') + + settings.PUSH_NOTIFICATIONS_SETTINGS = { + 'CONFIG': 'push_notifications.conf.AppConfig', + 'APPLICATIONS': { + settings.FCM_APP_NAME: { + 'PLATFORM': 'FCM', + 'FIREBASE_APP': settings.FIREBASE_APP, + }, + }, + 'UPDATE_ON_DUPLICATE_REG_ID': True, + } diff --git a/openedx/core/djangoapps/ace_common/settings/production.py b/openedx/core/djangoapps/ace_common/settings/production.py index cc4da91c18db..9ff56292012e 100644 --- a/openedx/core/djangoapps/ace_common/settings/production.py +++ b/openedx/core/djangoapps/ace_common/settings/production.py @@ -1,4 +1,5 @@ """Common environment variables unique to the ace_common plugin.""" +from openedx.core.djangoapps.ace_common.utils import setup_firebase_app def plugin_settings(settings): @@ -26,3 +27,26 @@ def plugin_settings(settings): settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL = settings.ENV_TOKENS.get( 'ACE_CHANNEL_TRANSACTIONAL_EMAIL', settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL ) + settings.FCM_APP_NAME = settings.ENV_TOKENS.get('FCM_APP_NAME', settings.FCM_APP_NAME) + settings.FIREBASE_CREDENTIALS_PATH = settings.ENV_TOKENS.get( + 'FIREBASE_CREDENTIALS_PATH', settings.FIREBASE_CREDENTIALS_PATH + ) + settings.FIREBASE_CREDENTIALS = settings.ENV_TOKENS.get('FIREBASE_CREDENTIALS', settings.FIREBASE_CREDENTIALS) + + settings.FIREBASE_APP = setup_firebase_app( + settings.FIREBASE_CREDENTIALS_PATH or settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME + ) + if settings.FIREBASE_APP: + settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append('course_push_notification_optout') + + settings.PUSH_NOTIFICATIONS_SETTINGS = { + 'CONFIG': 'push_notifications.conf.AppConfig', + 'APPLICATIONS': { + settings.FCM_APP_NAME: { + 'PLATFORM': 'FCM', + 'FIREBASE_APP': settings.FIREBASE_APP, + }, + }, + 'UPDATE_ON_DUPLICATE_REG_ID': True, + } diff --git a/openedx/core/djangoapps/ace_common/utils.py b/openedx/core/djangoapps/ace_common/utils.py new file mode 100644 index 000000000000..7cf38c821976 --- /dev/null +++ b/openedx/core/djangoapps/ace_common/utils.py @@ -0,0 +1,21 @@ +""" +Utility functions for edx-ace. +""" +import logging + +log = logging.getLogger(__name__) + + +def setup_firebase_app(firebase_credentials, app_name='fcm-app'): + """ + Returns a Firebase app instance if the Firebase credentials are provided. + """ + import firebase_admin # pylint: disable=import-outside-toplevel + + if firebase_credentials: + try: + app = firebase_admin.get_app(app_name) + except ValueError: + certificate = firebase_admin.credentials.Certificate(firebase_credentials) + app = firebase_admin.initialize_app(certificate, name=app_name) + return app diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index 4a775a710da6..b5ed1bde78e1 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -15,10 +15,10 @@ from django.core.cache import cache from django.core.paginator import Paginator from meilisearch import Client as MeilisearchClient -from meilisearch.errors import MeilisearchError +from meilisearch.errors import MeilisearchApiError, MeilisearchError from meilisearch.models.task import TaskInfo from opaque_keys.edx.keys import UsageKey -from opaque_keys.edx.locator import LibraryLocatorV2 +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator from openedx_learning.api import authoring as authoring_api from common.djangoapps.student.roles import GlobalStaff from rest_framework.request import Request @@ -34,8 +34,10 @@ searchable_doc_for_course_block, searchable_doc_for_collection, searchable_doc_for_library_block, + searchable_doc_for_usage_key, searchable_doc_collections, searchable_doc_tags, + searchable_doc_tags_for_collection, ) log = logging.getLogger(__name__) @@ -314,6 +316,8 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: client.index(temp_index_name).update_distinct_attribute(Fields.usage_key) # Mark which attributes can be used for filtering/faceted search: client.index(temp_index_name).update_filterable_attributes([ + # Get specific block/collection using combination of block_id and context_key + Fields.block_id, Fields.block_type, Fields.context_key, Fields.org, @@ -395,13 +399,12 @@ def index_library(lib_key: str) -> list: return docs ############## Collections ############## - def index_collection_batch(batch, num_done) -> int: + def index_collection_batch(batch, num_done, library_key) -> int: docs = [] for collection in batch: try: - doc = searchable_doc_for_collection(collection) - # Uncomment below line once collections are tagged. - # doc.update(searchable_doc_tags(collection.id)) + doc = searchable_doc_for_collection(library_key, collection.key, collection=collection) + doc.update(searchable_doc_tags_for_collection(library_key, collection.key)) docs.append(doc) except Exception as err: # pylint: disable=broad-except status_cb(f"Error indexing collection {collection}: {err}") @@ -428,7 +431,11 @@ def index_collection_batch(batch, num_done) -> int: status_cb(f"{num_collections_done + 1}/{num_collections}. Now indexing collections in library {lib_key}") paginator = Paginator(collections, 100) for p in paginator.page_range: - num_collections_done = index_collection_batch(paginator.page(p).object_list, num_collections_done) + num_collections_done = index_collection_batch( + paginator.page(p).object_list, + num_collections_done, + lib_key, + ) status_cb(f"{num_collections_done}/{num_collections} collections indexed for library {lib_key}") num_contexts_done += 1 @@ -506,15 +513,28 @@ def delete_index_doc(usage_key: UsageKey) -> None: Args: usage_key (UsageKey): The usage key of the XBlock to be removed from the index """ - current_rebuild_index_name = _get_running_rebuild_index_name() + doc = searchable_doc_for_usage_key(usage_key) + _delete_index_doc(doc[Fields.id]) + + +def _delete_index_doc(doc_id) -> None: + """ + Helper function that deletes the document with the given ID from the search index + + If there is a rebuild in progress, the document will also be removed from the new index. + """ + if not doc_id: + return client = _get_meilisearch_client() + current_rebuild_index_name = _get_running_rebuild_index_name() tasks = [] if current_rebuild_index_name: - # If there is a rebuild in progress, the document will also be deleted from the new index. - tasks.append(client.index(current_rebuild_index_name).delete_document(meili_id_from_opaque_key(usage_key))) - tasks.append(client.index(STUDIO_INDEX_NAME).delete_document(meili_id_from_opaque_key(usage_key))) + # If there is a rebuild in progress, the document will also be removed from the new index. + tasks.append(client.index(current_rebuild_index_name).delete_document(doc_id)) + + tasks.append(client.index(STUDIO_INDEX_NAME).delete_document(doc_id)) _wait_for_meili_tasks(tasks) @@ -557,20 +577,94 @@ def upsert_library_block_index_doc(usage_key: UsageKey) -> None: _update_index_docs(docs) +def _get_document_from_index(document_id: str) -> dict: + """ + Returns the Document identified by the given ID, from the given index. + + Returns None if the document or index do not exist. + """ + client = _get_meilisearch_client() + document = None + index_name = STUDIO_INDEX_NAME + try: + index = client.get_index(index_name) + document = index.get_document(document_id) + except (MeilisearchError, MeilisearchApiError) as err: + # The index or document doesn't exist + log.warning(f"Unable to fetch document {document_id} from {index_name}: {err}") + + return document + + def upsert_library_collection_index_doc(library_key: LibraryLocatorV2, collection_key: str) -> None: """ - Creates or updates the document for the given Library Collection in the search index + Creates, updates, or deletes the document for the given Library Collection in the search index. + + If the Collection is not found or disabled (i.e. soft-deleted), then delete it from the search index. """ - content_library = lib_api.ContentLibrary.objects.get_by_key(library_key) - collection = authoring_api.get_collection( - learning_package_id=content_library.learning_package_id, - collection_key=collection_key, - ) - docs = [ - searchable_doc_for_collection(collection) - ] + doc = searchable_doc_for_collection(library_key, collection_key) + update_components = False - _update_index_docs(docs) + # Soft-deleted/disabled collections are removed from the index + # and their components updated. + if doc.get('_disabled'): + + _delete_index_doc(doc[Fields.id]) + + update_components = True + + # Hard-deleted collections are also deleted from the index, + # but their components are automatically updated as part of the deletion process, so we don't have to. + elif not doc.get(Fields.type): + + _delete_index_doc(doc[Fields.id]) + + # Otherwise, upsert the collection. + # Newly-added/restored collection get their components updated too. + else: + already_indexed = _get_document_from_index(doc[Fields.id]) + if not already_indexed: + update_components = True + + _update_index_docs([doc]) + + # Asynchronously update the collection's components "collections" field + if update_components: + from .tasks import update_library_components_collections as update_task + + update_task.delay(str(library_key), collection_key) + + +def update_library_components_collections( + library_key: LibraryLocatorV2, + collection_key: str, + batch_size: int = 1000, +) -> None: + """ + Updates the "collections" field for all components associated with a given Library Collection. + + Because there may be a lot of components, we send these updates to Meilisearch in batches. + """ + library = lib_api.get_library(library_key) + components = authoring_api.get_collection_components(library.learning_package.id, collection_key) + + paginator = Paginator(components, batch_size) + for page in paginator.page_range: + docs = [] + + for component in paginator.page(page).object_list: + usage_key = lib_api.library_component_usage_key( + library_key, + component, + ) + doc = searchable_doc_collections(usage_key) + docs.append(doc) + + log.info( + f"Updating document.collections for library {library_key} components" + f" page {page} / {paginator.num_pages}" + ) + _update_index_docs(docs) def upsert_content_library_index_docs(library_key: LibraryLocatorV2) -> None: @@ -604,6 +698,15 @@ def upsert_block_collections_index_docs(usage_key: UsageKey): _update_index_docs([doc]) +def upsert_collection_tags_index_docs(collection_usage_key: LibraryCollectionLocator): + """ + Updates the tags data in documents for the given library collection + """ + + doc = searchable_doc_tags_for_collection(collection_usage_key.library_key, collection_usage_key.collection_id) + _update_index_docs([doc]) + + def _get_user_orgs(request: Request) -> list[str]: """ Get the org.short_names for the organizations that the requesting user has OrgStaffRole or OrgInstructorRole. diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 6f19b610fe86..eabeab9654ca 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -10,12 +10,13 @@ from django.core.exceptions import ObjectDoesNotExist from opaque_keys.edx.keys import LearningContextKey, UsageKey from openedx_learning.api import authoring as authoring_api +from opaque_keys.edx.locator import LibraryLocatorV2 from openedx.core.djangoapps.content.search.models import SearchAccess from openedx.core.djangoapps.content_libraries import api as lib_api from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.xblock import api as xblock_api -from openedx_learning.api.authoring_models import LearningPackage +from openedx_learning.api.authoring_models import Collection log = logging.getLogger(__name__) @@ -111,6 +112,15 @@ def _meili_access_id_from_context_key(context_key: LearningContextKey) -> int: return access.id +def searchable_doc_for_usage_key(usage_key: UsageKey) -> dict: + """ + Generates a base document identified by its usage key. + """ + return { + Fields.id: meili_id_from_opaque_key(usage_key), + } + + def _fields_from_block(block) -> dict: """ Given an XBlock instance, call its index_dictionary() method to load any @@ -296,14 +306,14 @@ def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetad library_name = lib_api.get_library(xblock_metadata.usage_key.context_key).title block = xblock_api.load_block(xblock_metadata.usage_key, user=None) - doc = { - Fields.id: meili_id_from_opaque_key(xblock_metadata.usage_key), + doc = searchable_doc_for_usage_key(xblock_metadata.usage_key) + doc.update({ Fields.type: DocType.library_block, Fields.breadcrumbs: [], Fields.created: xblock_metadata.created.timestamp(), Fields.modified: xblock_metadata.modified.timestamp(), Fields.last_published: xblock_metadata.last_published.timestamp() if xblock_metadata.last_published else None, - } + }) doc.update(_fields_from_block(block)) @@ -318,9 +328,7 @@ def searchable_doc_tags(usage_key: UsageKey) -> dict: Generate a dictionary document suitable for ingestion into a search engine like Meilisearch or Elasticsearch, with the tags data for the given content object. """ - doc = { - Fields.id: meili_id_from_opaque_key(usage_key), - } + doc = searchable_doc_for_usage_key(usage_key) doc.update(_tags_for_content_object(usage_key)) return doc @@ -331,62 +339,95 @@ def searchable_doc_collections(usage_key: UsageKey) -> dict: Generate a dictionary document suitable for ingestion into a search engine like Meilisearch or Elasticsearch, with the collections data for the given content object. """ - doc = { - Fields.id: meili_id_from_opaque_key(usage_key), - } + doc = searchable_doc_for_usage_key(usage_key) doc.update(_collections_for_content_object(usage_key)) return doc +def searchable_doc_tags_for_collection( + library_key: LibraryLocatorV2, + collection_key: str, +) -> dict: + """ + Generate a dictionary document suitable for ingestion into a search engine + like Meilisearch or Elasticsearch, with the tags data for the given library collection. + """ + collection_usage_key = lib_api.get_library_collection_usage_key( + library_key, + collection_key, + ) + doc = searchable_doc_for_usage_key(collection_usage_key) + doc.update(_tags_for_content_object(collection_usage_key)) + + return doc + + def searchable_doc_for_course_block(block) -> dict: """ Generate a dictionary document suitable for ingestion into a search engine like Meilisearch or Elasticsearch, so that the given course block can be found using faceted search. """ - doc = { - Fields.id: meili_id_from_opaque_key(block.usage_key), + doc = searchable_doc_for_usage_key(block.usage_key) + doc.update({ Fields.type: DocType.course_block, - } + }) doc.update(_fields_from_block(block)) return doc -def searchable_doc_for_collection(collection) -> dict: +def searchable_doc_for_collection( + library_key: LibraryLocatorV2, + collection_key: str, + *, + # Optionally provide the collection if we've already fetched one + collection: Collection | None = None, +) -> dict: """ Generate a dictionary document suitable for ingestion into a search engine like Meilisearch or Elasticsearch, so that the given collection can be found using faceted search. + + If no collection is found for the given library_key + collection_key, the returned document will contain only basic + information derived from the collection usage key, and no Fields.type value will be included in the returned dict. """ - doc = { - Fields.id: collection.id, - Fields.block_id: collection.key, - Fields.type: DocType.collection, - Fields.display_name: collection.title, - Fields.description: collection.description, - Fields.created: collection.created.timestamp(), - Fields.modified: collection.modified.timestamp(), - # Add related learning_package.key as context_key by default. - # If related contentlibrary is found, it will override this value below. - # Mostly contentlibrary.library_key == learning_package.key - Fields.context_key: collection.learning_package.key, - Fields.num_children: collection.entities.count(), - } - # Just in case learning_package is not related to a library + collection_usage_key = lib_api.get_library_collection_usage_key( + library_key, + collection_key, + ) + + doc = searchable_doc_for_usage_key(collection_usage_key) + try: - context_key = collection.learning_package.contentlibrary.library_key - org = str(context_key.org) + collection = collection or lib_api.get_library_collection_from_usage_key(collection_usage_key) + except lib_api.ContentLibraryCollectionNotFound: + # Collection not found, so we can only return the base doc + pass + + if collection: + assert collection.key == collection_key + doc.update({ - Fields.context_key: str(context_key), - Fields.org: org, + Fields.context_key: str(library_key), + Fields.org: str(library_key.org), + Fields.usage_key: str(collection_usage_key), + Fields.block_id: collection.key, + Fields.type: DocType.collection, + Fields.display_name: collection.title, + Fields.description: collection.description, + Fields.created: collection.created.timestamp(), + Fields.modified: collection.modified.timestamp(), + Fields.num_children: collection.entities.count(), + Fields.access_id: _meili_access_id_from_context_key(library_key), + Fields.breadcrumbs: [{"display_name": collection.learning_package.title}], }) - except LearningPackage.contentlibrary.RelatedObjectDoesNotExist: - log.warning(f"Related library not found for {collection}") - doc[Fields.access_id] = _meili_access_id_from_context_key(doc[Fields.context_key]) - # Add the breadcrumbs. - doc[Fields.breadcrumbs] = [{"display_name": collection.learning_package.title}] + + # Disabled collections should be removed from the search index, + # so we mark them as _disabled + if not collection.enabled: + doc['_disabled'] = True return doc diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py index 6a341c92ed2b..085387d336b1 100644 --- a/openedx/core/djangoapps/content/search/handlers.py +++ b/openedx/core/djangoapps/content/search/handlers.py @@ -8,6 +8,7 @@ from django.dispatch import receiver from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import LibraryCollectionLocator from openedx_events.content_authoring.data import ( ContentLibraryData, ContentObjectChangedData, @@ -22,6 +23,7 @@ LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, XBLOCK_CREATED, XBLOCK_DELETED, @@ -32,7 +34,12 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.search.models import SearchAccess -from .api import only_if_meilisearch_enabled, upsert_block_collections_index_docs, upsert_block_tags_index_docs +from .api import ( + only_if_meilisearch_enabled, + upsert_block_collections_index_docs, + upsert_block_tags_index_docs, + upsert_collection_tags_index_docs, +) from .tasks import ( delete_library_block_index_doc, delete_xblock_index_doc, @@ -118,7 +125,9 @@ def library_block_updated_handler(**kwargs) -> None: log.error("Received null or incorrect data for event") return - upsert_library_block_index_doc.delay(str(library_block_data.usage_key)) + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches results. This is only a single document update so is very fast. + upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) @receiver(LIBRARY_BLOCK_DELETED) @@ -132,7 +141,9 @@ def library_block_deleted(**kwargs) -> None: log.error("Received null or incorrect data for event") return - delete_library_block_index_doc.delay(str(library_block_data.usage_key)) + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches results. This is only a single document update so is very fast. + delete_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) @receiver(CONTENT_LIBRARY_UPDATED) @@ -156,6 +167,7 @@ def content_library_updated_handler(**kwargs) -> None: @receiver(LIBRARY_COLLECTION_CREATED) +@receiver(LIBRARY_COLLECTION_DELETED) @receiver(LIBRARY_COLLECTION_UPDATED) @only_if_meilisearch_enabled def library_collection_updated_handler(**kwargs) -> None: @@ -191,12 +203,19 @@ def content_object_associations_changed_handler(**kwargs) -> None: # Check if valid if course or library block usage_key = UsageKey.from_string(str(content_object.object_id)) except InvalidKeyError: - log.error("Received invalid content object id") - return + try: + # Check if valid if library collection + usage_key = LibraryCollectionLocator.from_string(str(content_object.object_id)) + except InvalidKeyError: + log.error("Received invalid content object id") + return # This event's changes may contain both "tags" and "collections", but this will happen rarely, if ever. # So we allow a potential double "upsert" here. if not content_object.changes or "tags" in content_object.changes: - upsert_block_tags_index_docs(usage_key) + if isinstance(usage_key, LibraryCollectionLocator): + upsert_collection_tags_index_docs(usage_key) + else: + upsert_block_tags_index_docs(usage_key) if not content_object.changes or "collections" in content_object.changes: upsert_block_collections_index_docs(usage_key) diff --git a/openedx/core/djangoapps/content/search/tasks.py b/openedx/core/djangoapps/content/search/tasks.py index d9dad834db29..98390a12f3b3 100644 --- a/openedx/core/djangoapps/content/search/tasks.py +++ b/openedx/core/djangoapps/content/search/tasks.py @@ -90,10 +90,23 @@ def update_content_library_index_docs(library_key_str: str) -> None: @set_code_owner_attribute def update_library_collection_index_doc(library_key_str: str, collection_key: str) -> None: """ - Celery task to update the content index documents for a library collection + Celery task to update the content index document for a library collection """ library_key = LibraryLocatorV2.from_string(library_key_str) log.info("Updating content index documents for collection %s in library%s", collection_key, library_key) api.upsert_library_collection_index_doc(library_key, collection_key) + + +@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) +@set_code_owner_attribute +def update_library_components_collections(library_key_str: str, collection_key: str) -> None: + """ + Celery task to update the "collections" field for components in the given content library collection. + """ + library_key = LibraryLocatorV2.from_string(library_key_str) + + log.info("Updating document.collections for library %s collection %s components", library_key, collection_key) + + api.update_library_components_collections(library_key, collection_key) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 023265f4d0f5..4c6227af309f 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -141,7 +141,7 @@ def setUp(self): "context_key": "lib:org1:lib", "org": "org1", "breadcrumbs": [{"display_name": "Library"}], - "content": {"problem_types": [], "capa_content": " "}, + "content": {"problem_types": [], "capa_content": ""}, "type": "library_block", "access_id": lib_access.id, "last_published": None, @@ -157,7 +157,7 @@ def setUp(self): "context_key": "lib:org1:lib", "org": "org1", "breadcrumbs": [{"display_name": "Library"}], - "content": {"problem_types": [], "capa_content": " "}, + "content": {"problem_types": [], "capa_content": ""}, "type": "library_block", "access_id": lib_access.id, "last_published": None, @@ -185,9 +185,11 @@ def setUp(self): created_by=None, description="my collection description" ) + self.collection_usage_key = "lib-collection:org1:lib:MYCOL" self.collection_dict = { - "id": self.collection.id, + "id": "lib-collectionorg1libmycol-5b647617", "block_id": self.collection.key, + "usage_key": self.collection_usage_key, "type": "collection", "display_name": "my_collection", "description": "my collection description", @@ -221,6 +223,8 @@ def test_reindex_meilisearch(self, mock_meilisearch): doc_problem2 = copy.deepcopy(self.doc_problem2) doc_problem2["tags"] = {} doc_problem2["collections"] = {} + doc_collection = copy.deepcopy(self.collection_dict) + doc_collection["tags"] = {} api.rebuild_index() assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 3 @@ -228,7 +232,7 @@ def test_reindex_meilisearch(self, mock_meilisearch): [ call([doc_sequential, doc_vertical]), call([doc_problem1, doc_problem2]), - call([self.collection_dict]), + call([doc_collection]), ], any_order=True, ) @@ -457,8 +461,9 @@ def test_index_library_block_and_collections(self, mock_meilisearch): # Build expected docs at each stage lib_access, _ = SearchAccess.objects.get_or_create(context_key=self.library.key) doc_collection1_created = { - "id": collection1.id, + "id": "lib-collectionorg1libcol1-283a79c9", "block_id": collection1.key, + "usage_key": f"lib-collection:org1:lib:{collection1.key}", "type": "collection", "display_name": "Collection 1", "description": "First Collection", @@ -471,8 +476,9 @@ def test_index_library_block_and_collections(self, mock_meilisearch): "breadcrumbs": [{"display_name": "Library"}], } doc_collection2_created = { - "id": collection2.id, + "id": "lib-collectionorg1libcol2-46823d4d", "block_id": collection2.key, + "usage_key": f"lib-collection:org1:lib:{collection2.key}", "type": "collection", "display_name": "Collection 2", "description": "Second Collection", @@ -485,8 +491,9 @@ def test_index_library_block_and_collections(self, mock_meilisearch): "breadcrumbs": [{"display_name": "Library"}], } doc_collection2_updated = { - "id": collection2.id, + "id": "lib-collectionorg1libcol2-46823d4d", "block_id": collection2.key, + "usage_key": f"lib-collection:org1:lib:{collection2.key}", "type": "collection", "display_name": "Collection 2", "description": "Second Collection", @@ -499,8 +506,9 @@ def test_index_library_block_and_collections(self, mock_meilisearch): "breadcrumbs": [{"display_name": "Library"}], } doc_collection1_updated = { - "id": collection1.id, + "id": "lib-collectionorg1libcol1-283a79c9", "block_id": collection1.key, + "usage_key": f"lib-collection:org1:lib:{collection1.key}", "type": "collection", "display_name": "Collection 1", "description": "First Collection", @@ -576,3 +584,136 @@ def test_delete_all_drafts(self, mock_meilisearch): mock_meilisearch.return_value.index.return_value.delete_documents.assert_called_once_with( filter=delete_filter ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_index_tags_in_collections(self, mock_meilisearch): + # Tag collection + tagging_api.tag_object(self.collection_usage_key, self.taxonomyA, ["one", "two"]) + tagging_api.tag_object(self.collection_usage_key, self.taxonomyB, ["three", "four"]) + + # Build expected docs with tags at each stage + doc_collection_with_tags1 = { + "id": "lib-collectionorg1libmycol-5b647617", + "tags": { + 'taxonomy': ['A'], + 'level0': ['A > one', 'A > two'] + } + } + doc_collection_with_tags2 = { + "id": "lib-collectionorg1libmycol-5b647617", + "tags": { + 'taxonomy': ['A', 'B'], + 'level0': ['A > one', 'A > two', 'B > four', 'B > three'] + } + } + + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_collection_with_tags1]), + call([doc_collection_with_tags2]), + ], + any_order=True, + ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_delete_collection(self, mock_meilisearch): + """ + Test soft-deleting, restoring, and hard-deleting a collection. + """ + # Add a component to the collection + updated_date = datetime(2023, 6, 7, 8, 9, 10, tzinfo=timezone.utc) + with freeze_time(updated_date): + library_api.update_library_collection_components( + self.library.key, + collection_key=self.collection.key, + usage_keys=[ + self.problem1.usage_key, + ], + ) + + doc_collection = copy.deepcopy(self.collection_dict) + doc_collection["num_children"] = 1 + doc_collection["modified"] = updated_date.timestamp() + doc_problem_with_collection = { + "id": self.doc_problem1["id"], + "collections": { + "display_name": [self.collection.title], + "key": [self.collection.key], + }, + } + + # Should update the collection and its component + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_collection]), + call([doc_problem_with_collection]), + ], + any_order=True, + ) + mock_meilisearch.return_value.index.reset_mock() + + # Soft-delete the collection + authoring_api.delete_collection( + self.collection.learning_package_id, + self.collection.key, + ) + + doc_problem_without_collection = { + "id": self.doc_problem1["id"], + "collections": {}, + } + + # Should delete the collection document + mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with( + self.collection_dict["id"], + ) + # ...and update the component's "collections" field + mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([ + doc_problem_without_collection, + ]) + mock_meilisearch.return_value.index.reset_mock() + + # We need to mock get_document here so that when we restore the collection below, meilisearch knows the + # collection is being re-added, so it will update its components too. + mock_meilisearch.return_value.get_index.return_value.get_document.return_value = None + + # Restore the collection + restored_date = datetime(2023, 8, 9, 10, 11, 12, tzinfo=timezone.utc) + with freeze_time(restored_date): + authoring_api.restore_collection( + self.collection.learning_package_id, + self.collection.key, + ) + + doc_collection = copy.deepcopy(self.collection_dict) + doc_collection["num_children"] = 1 + doc_collection["modified"] = restored_date.timestamp() + + # Should update the collection and its component's "collections" field + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_collection]), + call([doc_problem_with_collection]), + ], + any_order=True, + ) + mock_meilisearch.return_value.index.reset_mock() + + # Hard-delete the collection + authoring_api.delete_collection( + self.collection.learning_package_id, + self.collection.key, + hard_delete=True, + ) + + # Should delete the collection document + mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with( + self.collection_dict["id"], + ) + # ...and cascade delete updates the "collections" field for the associated components + mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([ + doc_problem_without_collection, + ]) diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index 7ff330c0b491..755ab4d19ad3 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -5,7 +5,6 @@ from organizations.models import Organization from freezegun import freeze_time -from openedx_learning.api import authoring as authoring_api from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.content_libraries import api as library_api @@ -19,6 +18,7 @@ from ..documents import ( searchable_doc_for_course_block, searchable_doc_tags, + searchable_doc_tags_for_collection, searchable_doc_collections, searchable_doc_for_collection, searchable_doc_for_library_block, @@ -27,6 +27,7 @@ except RuntimeError: searchable_doc_for_course_block = lambda x: x searchable_doc_tags = lambda x: x + searchable_doc_tags_for_collection = lambda x: x searchable_doc_for_collection = lambda x: x searchable_doc_for_library_block = lambda x: x SearchAccess = {} @@ -76,6 +77,7 @@ def setUpClass(cls): created_by=None, description="my toy collection description" ) + cls.collection_usage_key = "lib-collection:edX:2012_Fall:TOY_COLLECTION" cls.library_block = library_api.create_library_block( cls.library.key, "html", @@ -109,6 +111,7 @@ def setUpClass(cls): tagging_api.tag_object(str(cls.html_block_key), cls.subject_tags, tags=["Chinese", "Jump Links"]) tagging_api.tag_object(str(cls.html_block_key), cls.difficulty_tags, tags=["Normal"]) tagging_api.tag_object(str(cls.library_block.usage_key), cls.difficulty_tags, tags=["Normal"]) + tagging_api.tag_object(cls.collection_usage_key, cls.difficulty_tags, tags=["Normal"]) @property def toy_course_access_id(self): @@ -295,10 +298,13 @@ def test_html_library_block(self): } def test_collection_with_library(self): - doc = searchable_doc_for_collection(self.collection) + doc = searchable_doc_for_collection(self.library.key, self.collection.key) + doc.update(searchable_doc_tags_for_collection(self.library.key, self.collection.key)) + assert doc == { - "id": self.collection.id, + "id": "lib-collectionedx2012_falltoy_collection-d1d907a4", "block_id": self.collection.key, + "usage_key": self.collection_usage_key, "type": "collection", "org": "edX", "display_name": "Toy Collection", @@ -309,34 +315,8 @@ def test_collection_with_library(self): "breadcrumbs": [{"display_name": "some content_library"}], "created": 1680674828.0, "modified": 1680674828.0, - } - - def test_collection_with_no_library(self): - created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) - with freeze_time(created_date): - learning_package = authoring_api.create_learning_package( - key="course-v1:edX+toy+2012_Fall", - title="some learning_package", - description="some description", - ) - collection = authoring_api.create_collection( - learning_package_id=learning_package.id, - key="MYCOL", - title="my_collection", - created_by=None, - description="my collection description" - ) - doc = searchable_doc_for_collection(collection) - assert doc == { - "id": collection.id, - "block_id": collection.key, - "type": "collection", - "display_name": "my_collection", - "description": "my collection description", - "num_children": 0, - "context_key": learning_package.key, - "access_id": self.toy_course_access_id, - "breadcrumbs": [{"display_name": "some learning_package"}], - "created": created_date.timestamp(), - "modified": created_date.timestamp(), + 'tags': { + 'taxonomy': ['Difficulty'], + 'level0': ['Difficulty > Normal'] + } } diff --git a/openedx/core/djangoapps/content/search/tests/test_handlers.py b/openedx/core/djangoapps/content/search/tests/test_handlers.py index 8a6627e3902d..bdc4814d1c8f 100644 --- a/openedx/core/djangoapps/content/search/tests/test_handlers.py +++ b/openedx/core/djangoapps/content/search/tests/test_handlers.py @@ -148,7 +148,7 @@ def test_create_delete_library_block(self, meilisearch_client): "context_key": "lib:orgA:lib_a", "org": "orgA", "breadcrumbs": [{"display_name": "Library Org A"}], - "content": {"problem_types": [], "capa_content": " "}, + "content": {"problem_types": [], "capa_content": ""}, "access_id": lib_access.id, "last_published": None, "created": created_date.timestamp(), diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py index c19c9bf880d0..a9601a4e70a7 100644 --- a/openedx/core/djangoapps/content_libraries/api.py +++ b/openedx/core/djangoapps/content_libraries/api.py @@ -73,25 +73,21 @@ from opaque_keys.edx.locator import ( LibraryLocatorV2, LibraryUsageLocatorV2, - LibraryLocator as LibraryLocatorV1 + LibraryLocator as LibraryLocatorV1, + LibraryCollectionLocator, ) from opaque_keys import InvalidKeyError from openedx_events.content_authoring.data import ( ContentLibraryData, - ContentObjectChangedData, LibraryBlockData, - LibraryCollectionData, ) from openedx_events.content_authoring.signals import ( - CONTENT_OBJECT_ASSOCIATIONS_CHANGED, CONTENT_LIBRARY_CREATED, CONTENT_LIBRARY_DELETED, CONTENT_LIBRARY_UPDATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, - LIBRARY_COLLECTION_CREATED, - LIBRARY_COLLECTION_UPDATED, ) from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import Collection, Component, MediaType, LearningPackage, PublishableEntity @@ -218,8 +214,12 @@ class LibraryXBlockMetadata: modified = attr.ib(type=datetime) display_name = attr.ib("") last_published = attr.ib(default=None, type=datetime) + last_draft_created = attr.ib(default=None, type=datetime) + last_draft_created_by = attr.ib("") + published_by = attr.ib("") has_unpublished_changes = attr.ib(False) tags_count = attr.ib(0) + created = attr.ib(default=None, type=datetime) @classmethod def from_component(cls, library_key, component): @@ -228,17 +228,27 @@ def from_component(cls, library_key, component): """ last_publish_log = component.versioning.last_publish_log + published_by = None + if last_publish_log and last_publish_log.published_by: + published_by = last_publish_log.published_by.username + + draft = component.versioning.draft + last_draft_created = draft.created if draft else None + last_draft_created_by = draft.publishable_entity_version.created_by if draft else None + return cls( - usage_key=LibraryUsageLocatorV2( + usage_key=library_component_usage_key( library_key, - component.component_type.name, - component.local_key, + component, ), display_name=component.versioning.draft.title, created=component.created, modified=component.versioning.draft.created, last_published=None if last_publish_log is None else last_publish_log.published_at, - has_unpublished_changes=component.versioning.has_unpublished_changes + published_by=published_by, + last_draft_created=last_draft_created, + last_draft_created_by=last_draft_created_by, + has_unpublished_changes=component.versioning.has_unpublished_changes, ) @@ -771,6 +781,20 @@ def set_library_block_olx(usage_key, new_olx_str): ) +def library_component_usage_key( + library_key: LibraryLocatorV2, + component: Component, +) -> LibraryUsageLocatorV2: + """ + Returns a LibraryUsageLocatorV2 for the given library + component. + """ + return LibraryUsageLocatorV2( # type: ignore[abstract] + library_key, + block_type=component.component_type.name, + usage_id=component.local_key, + ) + + def validate_can_add_block_to_library( library_key: LibraryLocatorV2, block_type: str, @@ -1087,8 +1111,7 @@ def create_library_collection( content_library: ContentLibrary | None = None, ) -> Collection: """ - Creates a Collection in the given ContentLibrary, - and emits a LIBRARY_COLLECTION_CREATED event. + Creates a Collection in the given ContentLibrary. If you've already fetched a ContentLibrary for the given library_key, pass it in here to avoid refetching. """ @@ -1109,14 +1132,6 @@ def create_library_collection( except IntegrityError as err: raise LibraryCollectionAlreadyExists from err - # Emit event for library collection created - LIBRARY_COLLECTION_CREATED.send_event( - library_collection=LibraryCollectionData( - library_key=library_key, - collection_key=collection.key, - ) - ) - return collection @@ -1130,8 +1145,7 @@ def update_library_collection( content_library: ContentLibrary | None = None, ) -> Collection: """ - Creates a Collection in the given ContentLibrary, - and emits a LIBRARY_COLLECTION_CREATED event. + Updates a Collection in the given ContentLibrary. """ if not content_library: content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] @@ -1149,14 +1163,6 @@ def update_library_collection( except Collection.DoesNotExist as exc: raise ContentLibraryCollectionNotFound from exc - # Emit event for library collection updated - LIBRARY_COLLECTION_UPDATED.send_event( - library_collection=LibraryCollectionData( - library_key=library_key, - collection_key=collection.key, - ) - ) - return collection @@ -1227,24 +1233,37 @@ def update_library_collection_components( created_by=created_by, ) - # Emit event for library collection updated - LIBRARY_COLLECTION_UPDATED.send_event( - library_collection=LibraryCollectionData( - library_key=library_key, - collection_key=collection.key, - ) - ) + return collection - # Emit a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for each of the objects added/removed - for usage_key in usage_keys: - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( - content_object=ContentObjectChangedData( - object_id=str(usage_key), - changes=["collections"], - ), - ) - return collection +def get_library_collection_usage_key( + library_key: LibraryLocatorV2, + collection_key: str, +) -> LibraryCollectionLocator: + """ + Returns the LibraryCollectionLocator associated to a collection + """ + + return LibraryCollectionLocator(library_key, collection_key) + + +def get_library_collection_from_usage_key( + collection_usage_key: LibraryCollectionLocator, +) -> Collection: + """ + Return a Collection using the LibraryCollectionLocator + """ + + library_key = collection_usage_key.library_key + collection_key = collection_usage_key.collection_id + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + try: + return authoring_api.get_collection( + content_library.learning_package_id, + collection_key, + ) + except Collection.DoesNotExist as exc: + raise ContentLibraryCollectionNotFound from exc # V1/V2 Compatibility Helpers diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py index 2062f96d93ae..51ba55cd6b48 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/serializers.py @@ -148,7 +148,13 @@ class LibraryXBlockMetadataSerializer(serializers.Serializer): block_type = serializers.CharField(source="usage_key.block_type") display_name = serializers.CharField(read_only=True) + last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + published_by = serializers.CharField(read_only=True) + last_draft_created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + last_draft_created_by = serializers.CharField(read_only=True) has_unpublished_changes = serializers.BooleanField(read_only=True) + created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) + modified = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) # When creating a new XBlock in a library, the slug becomes the ID part of # the definition key and usage key: diff --git a/openedx/core/djangoapps/content_libraries/signal_handlers.py b/openedx/core/djangoapps/content_libraries/signal_handlers.py index 768b49d55f22..fedee045a9f6 100644 --- a/openedx/core/djangoapps/content_libraries/signal_handlers.py +++ b/openedx/core/djangoapps/content_libraries/signal_handlers.py @@ -5,13 +5,28 @@ import logging from django.conf import settings +from django.db.models.signals import post_save, post_delete, m2m_changed from django.dispatch import receiver +from opaque_keys import InvalidKeyError +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_events.content_authoring.data import ( + ContentObjectChangedData, + LibraryCollectionData, +) +from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, + LIBRARY_COLLECTION_UPDATED, +) +from openedx_learning.api.authoring import get_collection_components, get_component, get_components +from openedx_learning.api.authoring_models import Collection, CollectionPublishableEntity, Component + from lms.djangoapps.grades.api import signals as grades_signals -from opaque_keys import InvalidKeyError # lint-amnesty, pylint: disable=wrong-import-order -from opaque_keys.edx.locator import LibraryUsageLocatorV2 # lint-amnesty, pylint: disable=wrong-import-order -from .models import LtiGradedResource +from .api import library_component_usage_key +from .models import ContentLibrary, LtiGradedResource log = logging.getLogger(__name__) @@ -55,3 +70,139 @@ def score_changed_handler(sender, **kwargs): # pylint: disable=unused-argument resource.update_score(weighted_earned, weighted_possible, modified) log.info("LTI 1.3: Score Signal: Grade upgraded: resource; %s", resource) + + +@receiver(post_save, sender=Collection, dispatch_uid="library_collection_saved") +def library_collection_saved(sender, instance, created, **kwargs): + """ + Raises LIBRARY_COLLECTION_CREATED if the Collection is new, + or LIBRARY_COLLECTION_UPDATED if updated an existing Collection. + """ + try: + library = ContentLibrary.objects.get(learning_package_id=instance.learning_package_id) + except ContentLibrary.DoesNotExist: + log.error("{instance} is not associated with a content library.") + return + + if created: + LIBRARY_COLLECTION_CREATED.send_event( + library_collection=LibraryCollectionData( + library_key=library.library_key, + collection_key=instance.key, + ) + ) + else: + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + library_key=library.library_key, + collection_key=instance.key, + ) + ) + + +@receiver(post_delete, sender=Collection, dispatch_uid="library_collection_deleted") +def library_collection_deleted(sender, instance, **kwargs): + """ + Raises LIBRARY_COLLECTION_DELETED for the deleted Collection. + """ + try: + library = ContentLibrary.objects.get(learning_package_id=instance.learning_package_id) + except ContentLibrary.DoesNotExist: + log.error("{instance} is not associated with a content library.") + return + + LIBRARY_COLLECTION_DELETED.send_event( + library_collection=LibraryCollectionData( + library_key=library.library_key, + collection_key=instance.key, + ) + ) + + +def _library_collection_component_changed( + component: Component, + library_key: LibraryLocatorV2 | None = None, +) -> None: + """ + Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for the component. + """ + if not library_key: + try: + library = ContentLibrary.objects.get( + learning_package_id=component.learning_package_id, + ) + except ContentLibrary.DoesNotExist: + log.error("{component} is not associated with a content library.") + return + + library_key = library.library_key + + assert library_key + + usage_key = library_component_usage_key( + library_key, + component, + ) + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(usage_key), + changes=["collections"], + ), + ) + + +@receiver(post_save, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entity_saved") +def library_collection_entity_saved(sender, instance, created, **kwargs): + """ + Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components added to a collection. + """ + if created: + # Component.pk matches its entity.pk + component = get_component(instance.entity_id) + _library_collection_component_changed(component) + + +@receiver(post_delete, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entity_deleted") +def library_collection_entity_deleted(sender, instance, **kwargs): + """ + Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components removed from a collection. + """ + # Component.pk matches its entity.pk + component = get_component(instance.entity_id) + _library_collection_component_changed(component) + + +@receiver(m2m_changed, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entities_changed") +def library_collection_entities_changed(sender, instance, action, pk_set, **kwargs): + """ + Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components added/removed/cleared from a collection. + """ + if not isinstance(instance, Collection): + return + + if action not in ["post_add", "post_remove", "post_clear"]: + return + + try: + library = ContentLibrary.objects.get( + learning_package_id=instance.learning_package_id, + ) + except ContentLibrary.DoesNotExist: + log.error("{instance} is not associated with a content library.") + return + + if pk_set: + components = get_collection_components( + instance.learning_package_id, + instance.key, + ).filter(pk__in=pk_set) + else: + # When action=="post_clear", pk_set==None + # Since the collection instance now has an empty entities set, + # we don't know which ones were removed, so we need to update associations for all library components. + components = get_components( + instance.learning_package_id, + ) + + for component in components.all(): + _library_collection_component_changed(component, library.library_key) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index b02e71b002a3..8041c508dc31 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -20,9 +20,11 @@ from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, ) from openedx_events.tests.utils import OpenEdxEventsTestMixin +from openedx_learning.api import authoring as authoring_api from .. import api from ..models import ContentLibrary @@ -264,6 +266,7 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe ENABLED_OPENEDX_EVENTS = [ CONTENT_OBJECT_ASSOCIATIONS_CHANGED.event_type, LIBRARY_COLLECTION_CREATED.event_type, + LIBRARY_COLLECTION_DELETED.event_type, LIBRARY_COLLECTION_UPDATED.event_type, ] @@ -386,6 +389,29 @@ def test_update_library_collection_wrong_library(self): self.col2.key, ) + def test_delete_library_collection(self): + event_receiver = mock.Mock() + LIBRARY_COLLECTION_DELETED.connect(event_receiver) + + authoring_api.delete_collection( + self.lib1.learning_package_id, + self.col1.key, + hard_delete=True, + ) + + assert event_receiver.call_count == 1 + self.assertDictContainsSubset( + { + "signal": LIBRARY_COLLECTION_DELETED, + "sender": None, + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key="COL1", + ), + }, + event_receiver.call_args_list[0].kwargs, + ) + def test_update_library_collection_components(self): assert not list(self.col1.entities.all()) @@ -429,11 +455,11 @@ def test_update_library_collection_components_event(self): assert event_receiver.call_count == 3 self.assertDictContainsSubset( { - "signal": LIBRARY_COLLECTION_UPDATED, + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, "sender": None, - "library_collection": LibraryCollectionData( - self.lib1.library_key, - collection_key="COL1", + "content_object": ContentObjectChangedData( + object_id=self.lib1_problem_block["id"], + changes=["collections"], ), }, event_receiver.call_args_list[0].kwargs, @@ -443,7 +469,7 @@ def test_update_library_collection_components_event(self): "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, "sender": None, "content_object": ContentObjectChangedData( - object_id=self.lib1_problem_block["id"], + object_id=self.lib1_html_block["id"], changes=["collections"], ), }, @@ -451,11 +477,11 @@ def test_update_library_collection_components_event(self): ) self.assertDictContainsSubset( { - "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "signal": LIBRARY_COLLECTION_UPDATED, "sender": None, - "content_object": ContentObjectChangedData( - object_id=self.lib1_html_block["id"], - changes=["collections"], + "library_collection": LibraryCollectionData( + self.lib1.library_key, + collection_key="COL1", ), }, event_receiver.call_args_list[2].kwargs, diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index 95b7309b3cd1..d995a2c79683 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -1,16 +1,15 @@ """ Tests for Learning-Core-based Content Libraries """ -from unittest.mock import Mock, patch +from datetime import datetime, timezone from unittest import skip +from unittest.mock import Mock, patch +from uuid import uuid4 import ddt -from uuid import uuid4 from django.contrib.auth.models import Group from django.test.client import Client -from organizations.models import Organization -from rest_framework.test import APITestCase - +from freezegun import freeze_time from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.content_authoring.data import ContentLibraryData, LibraryBlockData from openedx_events.content_authoring.signals import ( @@ -19,20 +18,23 @@ CONTENT_LIBRARY_UPDATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, - LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_UPDATED ) from openedx_events.tests.utils import OpenEdxEventsTestMixin +from organizations.models import Organization +from rest_framework.test import APITestCase + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.content_libraries.constants import CC_4_BY, COMPLEX, PROBLEM, VIDEO from openedx.core.djangoapps.content_libraries.tests.base import ( - ContentLibrariesRestApiTest, + URL_BLOCK_GET_HANDLER_URL, URL_BLOCK_METADATA_URL, URL_BLOCK_RENDER_VIEW, - URL_BLOCK_GET_HANDLER_URL, URL_BLOCK_XBLOCK_HANDLER, + ContentLibrariesRestApiTest ) -from openedx.core.djangoapps.content_libraries.constants import VIDEO, COMPLEX, PROBLEM, CC_4_BY from openedx.core.djangoapps.xblock import api as xblock_api from openedx.core.djangolib.testing.utils import skip_unless_cms -from common.djangoapps.student.tests.factories import UserFactory @skip_unless_cms @@ -270,12 +272,18 @@ def test_library_blocks(self): assert self._get_library_blocks(lib_id)['results'] == [] # Add a 'problem' XBlock to the library: - block_data = self._add_block_to_library(lib_id, "problem", "ࠒröblæm1") + create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=timezone.utc) + with freeze_time(create_date): + block_data = self._add_block_to_library(lib_id, "problem", "ࠒröblæm1") self.assertDictContainsEntries(block_data, { "id": "lb:CL-TEST:téstlꜟط:problem:ࠒröblæm1", "display_name": "Blank Problem", "block_type": "problem", "has_unpublished_changes": True, + "last_published": None, + "published_by": None, + "last_draft_created": create_date.isoformat().replace('+00:00', 'Z'), + "last_draft_created_by": "Bob", }) block_id = block_data["id"] # Confirm that the result contains a definition key, but don't check its value, @@ -287,10 +295,14 @@ def test_library_blocks(self): assert self._get_library(lib_id)['has_unpublished_changes'] is True # Publish the changes: - self._commit_library_changes(lib_id) + publish_date = datetime(2024, 7, 7, 7, 7, 7, tzinfo=timezone.utc) + with freeze_time(publish_date): + self._commit_library_changes(lib_id) assert self._get_library(lib_id)['has_unpublished_changes'] is False # And now the block information should also show that block has no unpublished changes: block_data["has_unpublished_changes"] = False + block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z') + block_data["published_by"] = "Bob" self.assertDictContainsEntries(self._get_library_block(block_id), block_data) assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -311,13 +323,16 @@ def test_library_blocks(self): """.strip() - self._set_library_block_olx(block_id, new_olx) + update_date = datetime(2024, 8, 8, 8, 8, 8, tzinfo=timezone.utc) + with freeze_time(update_date): + self._set_library_block_olx(block_id, new_olx) # now reading it back, we should get that exact OLX (no change to whitespace etc.): assert self._get_library_block_olx(block_id) == new_olx # And the display name and "unpublished changes" status of the block should be updated: self.assertDictContainsEntries(self._get_library_block(block_id), { "display_name": "New Multi Choice Question", "has_unpublished_changes": True, + "last_draft_created": update_date.isoformat().replace('+00:00', 'Z') }) # Now view the XBlock's student_view (including draft changes): @@ -358,12 +373,18 @@ def test_library_blocks_studio_view(self): assert self._get_library_blocks(lib_id)['results'] == [] # Add a 'html' XBlock to the library: - block_data = self._add_block_to_library(lib_id, "html", "html1") + create_date = datetime(2024, 6, 6, 6, 6, 6, tzinfo=timezone.utc) + with freeze_time(create_date): + block_data = self._add_block_to_library(lib_id, "html", "html1") self.assertDictContainsEntries(block_data, { "id": "lb:CL-TEST:testlib2:html:html1", "display_name": "Text", "block_type": "html", "has_unpublished_changes": True, + "last_published": None, + "published_by": None, + "last_draft_created": create_date.isoformat().replace('+00:00', 'Z'), + "last_draft_created_by": "Bob", }) block_id = block_data["id"] @@ -372,10 +393,14 @@ def test_library_blocks_studio_view(self): assert self._get_library(lib_id)['has_unpublished_changes'] is True # Publish the changes: - self._commit_library_changes(lib_id) + publish_date = datetime(2024, 7, 7, 7, 7, 7, tzinfo=timezone.utc) + with freeze_time(publish_date): + self._commit_library_changes(lib_id) assert self._get_library(lib_id)['has_unpublished_changes'] is False # And now the block information should also show that block has no unpublished changes: block_data["has_unpublished_changes"] = False + block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z') + block_data["published_by"] = "Bob" self.assertDictContainsEntries(self._get_library_block(block_id), block_data) assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -383,13 +408,17 @@ def test_library_blocks_studio_view(self): orig_olx = self._get_library_block_olx(block_id) assert ' +
+

There is a space on either side of this sentence.

+

\tThere is a tab on either side of this sentence.\t

+

🙃There is an emoji on either side of this sentence.🙂

+

There is nothing on either side of this sentence.

+
+

\t ]]>

+ +''' + + # The OLX containing the HTML also has some extraneous stuff, which do *not* expect to survive the round-trip. + olx_1 = f'''\ + + ''' + + # Here is what we expect the OLX to settle down to. Notable changes: + # * url_name is added. + # * some_fake_field is gone. + # * The OLX comment is gone. + # * A trailing newline is added at the end of the export. + # DEVS: If you are purposefully tweaking the formatting of the xblock serializer, then it's fine to + # update the value of this variable, as long as: + # 1. the {block_content} remains unchanged, and + # 2. the canonical_olx remains stable through the 2nd round trip. + canonical_olx = ( + f'\n' + ) + + # Save the block to LC, and re-load it. + library_api.set_library_block_olx(usage_key, olx_1) + library_api.publish_changes(self.library.key) + block_saved_1 = xblock_api.load_block(usage_key, self.staff_user) + + # Content should be preserved... + assert block_saved_1.data == block_content + + # ...but the serialized OLX will have changed to match the 'canonical' OLX. + olx_2 = serializer_api.serialize_xblock_to_olx(block_saved_1).olx_str + assert olx_2 == canonical_olx + + # Now, save that OLX back to LC, and re-load it again. + library_api.set_library_block_olx(usage_key, olx_2) + library_api.publish_changes(self.library.key) + block_saved_2 = xblock_api.load_block(usage_key, self.staff_user) + + # Again, content should be preserved... + assert block_saved_2.data == block_saved_1.data == block_content + + # ...and this time, the OLX should have settled too. + olx_3 = serializer_api.serialize_xblock_to_olx(block_saved_2).olx_str + assert olx_3 == olx_2 == canonical_olx + + class ContentLibraryRuntimeTests(ContentLibraryContentTestMixin): """ Basic tests of the Learning-Core-based XBlock runtime using XBlocks in a diff --git a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py index bc600759b5b3..43c1627c2c76 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py @@ -17,6 +17,7 @@ URL_PREFIX = '/api/libraries/v2/{lib_key}/' URL_LIB_COLLECTIONS = URL_PREFIX + 'collections/' URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' +URL_LIB_COLLECTION_RESTORE = URL_LIB_COLLECTIONS + '{collection_key}/restore/' URL_LIB_COLLECTION_COMPONENTS = URL_LIB_COLLECTION + 'components/' @@ -330,15 +331,33 @@ def test_update_invalid_library_collection(self): def test_delete_library_collection(self): """ - Test deleting a Content Library Collection - - Note: Currently not implemented and should return a 405 + Test soft-deleting and restoring a Content Library Collection """ resp = self.client.delete( URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) ) + assert resp.status_code == 204 - assert resp.status_code == 405 + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + assert resp.status_code == 404 + + resp = self.client.post( + URL_LIB_COLLECTION_RESTORE.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + assert resp.status_code == 204 + + resp = self.client.get( + URL_LIB_COLLECTION.format(lib_key=self.lib2.library_key, collection_key=self.col3.key) + ) + # Check that correct Content Library Collection data retrieved + expected_collection = { + "title": "Collection 3", + "description": "Description for Collection 3", + } + assert resp.status_code == 200 + self.assertDictContainsEntries(resp.data, expected_collection) def test_get_components(self): """ diff --git a/openedx/core/djangoapps/content_libraries/views_collections.py b/openedx/core/djangoapps/content_libraries/views_collections.py index 2f40a1788628..b6c1c999ba94 100644 --- a/openedx/core/djangoapps/content_libraries/views_collections.py +++ b/openedx/core/djangoapps/content_libraries/views_collections.py @@ -11,7 +11,7 @@ from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet -from rest_framework.status import HTTP_405_METHOD_NOT_ALLOWED +from rest_framework.status import HTTP_204_NO_CONTENT from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_learning.api import authoring as authoring_api @@ -163,13 +163,31 @@ def partial_update(self, request, *args, **kwargs) -> Response: @convert_exceptions def destroy(self, request, *args, **kwargs) -> Response: """ - Deletes a Collection that belongs to a Content Library - - Note: (currently not allowed) + Soft-deletes a Collection that belongs to a Content Library """ - # TODO: Implement the deletion logic and emit event signal + collection = super().get_object() + assert collection.learning_package_id + authoring_api.delete_collection( + collection.learning_package_id, + collection.key, + hard_delete=False, + ) + return Response(None, status=HTTP_204_NO_CONTENT) - return Response(None, status=HTTP_405_METHOD_NOT_ALLOWED) + @convert_exceptions + @action(detail=True, methods=['post'], url_path='restore', url_name='collection-restore') + def restore(self, request, *args, **kwargs) -> Response: + """ + Restores a soft-deleted Collection that belongs to a Content Library + """ + content_library = self.get_content_library() + assert content_library.learning_package_id + collection_key = kwargs["key"] + authoring_api.restore_collection( + content_library.learning_package_id, + collection_key, + ) + return Response(None, status=HTTP_204_NO_CONTENT) @convert_exceptions @action(detail=True, methods=['delete', 'patch'], url_path='components', url_name='components-update') diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index 00c4466b7d48..551f94e90e1a 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -159,7 +159,7 @@ def test_copy_html(self): Sample ]]> - """).lstrip() + """).replace("\n", "") + "\n" # No newlines, expect one trailing newline. # Now if we GET the clipboard again, the GET response should exactly equal the last POST response: assert client.get(CLIPBOARD_ENDPOINT).json() == response_data diff --git a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py index d3306844ac40..f196549dd1a9 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py @@ -4,14 +4,15 @@ import time from unittest.mock import patch +from openedx_tagging.core.tagging.models import ObjectTag +from organizations.models import Organization + from openedx.core.djangoapps.content_libraries import api as library_api from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory from .. import api from ..helpers.objecttag_export_helpers import TaggedContent, build_object_tree_with_objecttags, iterate_with_level -from openedx_tagging.core.tagging.models import ObjectTag -from organizations.models import Organization class TestGetAllObjectTagsMixin: diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index 0b7a695a1c3e..c86f7eb40515 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -1,5 +1,5 @@ # pylint: disable=missing-docstring,protected-access - +from bs4 import BeautifulSoup from openedx.core.djangoapps.django_comment_common.comment_client import models, settings @@ -99,6 +99,14 @@ def unFlagAbuse(self, user, voteable, removeAll): ) voteable._update_from_response(response) + @property + def body_text(self): + """ + Return the text content of the comment html body. + """ + soup = BeautifulSoup(self.body, 'html.parser') + return soup.get_text() + def _url_for_thread_comments(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/comments" diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index a417d4540588..b57d88cea616 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -207,6 +207,26 @@ 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE], 'visible_to': [CourseStaffRole.ROLE, CourseInstructorRole.ROLE] }, + 'ora_grade_assigned': { + 'notification_app': 'grading', + 'name': 'ora_grade_assigned', + 'is_core': False, + 'info': '', + 'web': True, + 'email': True, + 'push': False, + 'email_cadence': EmailCadence.DAILY, + 'non_editable': [], + 'content_template': _('<{p}>You have received {points_earned} out of {points_possible} on your assessment: ' + '<{strong}>{ora_name}'), + 'content_context': { + 'ora_name': 'Name of ORA in course', + 'points_earned': 'Points earned', + 'points_possible': 'Points possible', + }, + 'email_template': '', + 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE], + }, } COURSE_NOTIFICATION_APPS = { diff --git a/openedx/core/djangoapps/notifications/config/waffle.py b/openedx/core/djangoapps/notifications/config/waffle.py index af89bb68574f..862dd32f7485 100644 --- a/openedx/core/djangoapps/notifications/config/waffle.py +++ b/openedx/core/djangoapps/notifications/config/waffle.py @@ -28,3 +28,13 @@ # .. toggle_warning: When the flag is ON, Email Notifications feature is enabled. # .. toggle_tickets: INF-1259 ENABLE_EMAIL_NOTIFICATIONS = WaffleFlag(f'{WAFFLE_NAMESPACE}.enable_email_notifications', __name__) + +# .. toggle_name: notifications.enable_ora_grade_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable ORA grade notifications +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2024-09-10 +# .. toggle_target_removal_date: 2024-10-10 +# .. toggle_tickets: INF-1304 +ENABLE_ORA_GRADE_NOTIFICATION = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_ora_grade_notifications", __name__) diff --git a/openedx/core/djangoapps/notifications/handlers.py b/openedx/core/djangoapps/notifications/handlers.py index 505f4b5e7024..f28cb594ea6f 100644 --- a/openedx/core/djangoapps/notifications/handlers.py +++ b/openedx/core/djangoapps/notifications/handlers.py @@ -21,7 +21,7 @@ ForumRoleAudienceFilter, TeamAudienceFilter ) -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_ORA_GRADE_NOTIFICATION from openedx.core.djangoapps.notifications.models import CourseNotificationPreference log = logging.getLogger(__name__) @@ -72,6 +72,12 @@ def generate_user_notifications(signal, sender, notification_data, metadata, **k """ Watches for USER_NOTIFICATION_REQUESTED signal and calls send_web_notifications task """ + if ( + notification_data.notification_type == 'ora_grade_assigned' + and not ENABLE_ORA_GRADE_NOTIFICATION.is_enabled(notification_data.course_key) + ): + return + from openedx.core.djangoapps.notifications.tasks import send_notifications notification_data = notification_data.__dict__ notification_data['course_key'] = str(notification_data['course_key']) diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index e1bdf94acc33..77f7b991b546 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -23,7 +23,7 @@ ADDITIONAL_NOTIFICATION_CHANNEL_SETTINGS = ['email_cadence'] # Update this version when there is a change to any course specific notification type or app. -COURSE_NOTIFICATION_CONFIG_VERSION = 11 +COURSE_NOTIFICATION_CONFIG_VERSION = 12 def get_course_notification_preference_config(): diff --git a/openedx/core/djangoapps/notifications/policies.py b/openedx/core/djangoapps/notifications/policies.py new file mode 100644 index 000000000000..8d0a2d8d43a5 --- /dev/null +++ b/openedx/core/djangoapps/notifications/policies.py @@ -0,0 +1,41 @@ +"""Policies for the notifications app.""" + +from edx_ace.channel import ChannelType +from edx_ace.policy import Policy, PolicyResult +from opaque_keys.edx.keys import CourseKey + +from .models import CourseNotificationPreference + + +class CoursePushNotificationOptout(Policy): + """ + Course Push Notification optOut Policy. + """ + + def check(self, message): + """ + Check if the user has opted out of push notifications for the given course. + :param message: + :return: PolicyResult + """ + course_ids = message.context.get('course_ids', []) + app_label = message.context.get('app_label') + + if not (app_label or message.context.get('push_notification_extra_context', {})): + return PolicyResult(deny={ChannelType.PUSH}) + + course_keys = [CourseKey.from_string(course_id) for course_id in course_ids] + for course_key in course_keys: + course_notification_preference = CourseNotificationPreference.get_user_course_preference( + message.recipient.lms_user_id, + course_key + ) + push_notification_preference = course_notification_preference.get_notification_type_config( + app_label, + notification_type='push', + ).get('push', False) + + if not push_notification_preference: + return PolicyResult(deny={ChannelType.PUSH}) + + return PolicyResult(deny=frozenset()) diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index e40e52078989..b7bd0414a27f 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -313,7 +313,14 @@ def _expected_api_response(self, course=None): 'push': True, 'email_cadence': 'Daily', 'info': 'Notifications for submission grading.' - } + }, + 'ora_grade_assigned': { + 'web': True, + 'email': True, + 'push': False, + 'email_cadence': 'Daily', + 'info': '' + }, }, 'non_editable': {} } diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index ca693b4d109b..495389152f7a 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -36,9 +36,5 @@ # ] COURSE_GRADE_NOW_FAILED = Signal() -# Signal that indicates that a user has become verified for certificate purposes -# providing_args=['user'] -LEARNER_NOW_VERIFIED = Signal() - # providing_args=['user'] USER_ACCOUNT_ACTIVATED = Signal() # Signal indicating email verification diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 6e7a21118852..6cc466ba0038 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -3,7 +3,6 @@ Programmatic integration point for User API Accounts sub-application """ - import datetime import re @@ -152,6 +151,12 @@ def update_account_settings(requesting_user, update, username=None): _validate_email_change(user, update, field_errors) _validate_secondary_email(user, update, field_errors) + if update.get('country', '') in settings.DISABLED_COUNTRIES: + field_errors['country'] = { + 'developer_message': 'Country is disabled for registration', + 'user_message': 'This country cannot be selected for user registration' + } + old_name = _validate_name_change(user_profile, update, field_errors) old_language_proficiencies = _get_old_language_proficiencies_if_updating(user_profile, update) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 81a3158eb5bb..5123c4cf41c2 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -3,7 +3,6 @@ Most of the functionality is covered in test_views.py. """ - import datetime import itertools import unicodedata @@ -16,6 +15,7 @@ from django.http import HttpResponse from django.test import TestCase from django.test.client import RequestFactory +from django.test.utils import override_settings from django.urls import reverse from pytz import UTC from social_django.models import UserSocialAuth @@ -82,7 +82,8 @@ def create_account(self, username, password, email): @skip_unless_lms @ddt.ddt -@patch('common.djangoapps.student.views.management.render_to_response', Mock(side_effect=mock_render_to_response, autospec=True)) # lint-amnesty, pylint: disable=line-too-long +@patch('common.djangoapps.student.views.management.render_to_response', + Mock(side_effect=mock_render_to_response, autospec=True)) # lint-amnesty, pylint: disable=line-too-long class TestAccountApi(UserSettingsEventTestMixin, EmailTemplateTagMixin, CreateAccountMixin, RetirementTestCase): """ These tests specifically cover the parts of the API methods that are not covered by test_views.py. @@ -205,7 +206,7 @@ def test_add_social_links(self): account_settings = get_account_settings(self.default_request)[0] assert account_settings['social_links'] == \ - sorted((original_social_links + extra_social_links), key=(lambda s: s['platform'])) + sorted((original_social_links + extra_social_links), key=(lambda s: s['platform'])) def test_replace_social_links(self): original_facebook_link = dict(platform="facebook", social_link="https://www.facebook.com/myself") @@ -306,7 +307,7 @@ def test_update_validation_error_for_enterprise( with pytest.raises(AccountValidationError) as validation_error: update_account_settings(self.user, update_data) field_errors = validation_error.value.field_errors - assert 'This field is not editable via this API' ==\ + assert 'This field is not editable via this API' == \ field_errors[field_name_value[0]]['developer_message'] else: update_account_settings(self.user, update_data) @@ -424,8 +425,8 @@ def test_name_update_does_not_require_idv(self, has_passable_cert, enrolled_in_v """ Test that the user can change their name if change does not require IDV. """ - with patch('openedx.core.djangoapps.user_api.accounts.api.get_certificates_for_user') as mock_get_certs,\ - patch('openedx.core.djangoapps.user_api.accounts.api.get_verified_enrollments') as \ + with patch('openedx.core.djangoapps.user_api.accounts.api.get_certificates_for_user') as mock_get_certs, \ + patch('openedx.core.djangoapps.user_api.accounts.api.get_verified_enrollments') as \ mock_get_verified_enrollments: mock_get_certs.return_value = ( [{'status': CertificateStatuses.downloadable}] if @@ -439,7 +440,8 @@ def test_name_update_does_not_require_idv(self, has_passable_cert, enrolled_in_v assert account_settings['name'] == 'New Name' @patch('django.core.mail.EmailMultiAlternatives.send') - @patch('common.djangoapps.student.views.management.render_to_string', Mock(side_effect=mock_render_to_string, autospec=True)) # lint-amnesty, pylint: disable=line-too-long + @patch('common.djangoapps.student.views.management.render_to_string', + Mock(side_effect=mock_render_to_string, autospec=True)) def test_update_sending_email_fails(self, send_mail): """Test what happens if all validation checks pass, but sending the email for email change fails.""" send_mail.side_effect = [Exception, None] @@ -514,6 +516,7 @@ def test_language_proficiency_eventing(self): """ Test that eventing of language proficiencies, which happens update_account_settings method, behaves correctly. """ + def verify_event_emitted(new_value, old_value): """ Confirm that the user setting event was properly emitted @@ -571,6 +574,20 @@ def test_change_country_removes_state(self): assert account_settings['country'] is None assert account_settings['state'] is None + @override_settings(DISABLED_COUNTRIES=['KP']) + def test_change_to_disabled_country(self): + """ + Test that changing the country to a disabled country is not allowed + """ + # First set the country and state + update_account_settings(self.user, {"country": UserProfile.COUNTRY_WITH_STATES, "state": "MA"}) + account_settings = get_account_settings(self.default_request)[0] + assert account_settings['country'] == UserProfile.COUNTRY_WITH_STATES + assert account_settings['state'] == 'MA' + + with self.assertRaises(AccountValidationError): + update_account_settings(self.user, {"country": "KP"}) + def test_get_name_validation_error_too_long(self): """ Test validation error when the name is too long. diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py index 874497664d9d..badee6e87554 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py @@ -9,7 +9,7 @@ from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse -from edx_rest_api_client import exceptions +from requests import exceptions from edx_toggles.toggles.testutils import override_waffle_flag from lms.djangoapps.commerce.models import CommerceConfiguration @@ -210,7 +210,7 @@ def test_commerce_order_detail(self): assert order_detail[i] == expected def test_commerce_order_detail_exception(self): - with mock_get_orders(exception=exceptions.HttpNotFoundError): + with mock_get_orders(exception=exceptions.HTTPError): order_detail = get_user_orders(self.user) assert not order_detail diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 981cc52dfd8f..75740cf5d2b6 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -635,7 +635,7 @@ def _assert_time_zone_is_valid(self, time_zone_info): assert time_zone_info['description'] == get_display_time_zone(time_zone_name) # The time zones count may need to change each time we upgrade pytz - @ddt.data((ALL_TIME_ZONES_URI, 433), + @ddt.data((ALL_TIME_ZONES_URI, 432), (COUNTRY_TIME_ZONES_URI, 23)) @ddt.unpack def test_get_basic(self, country_uri, expected_count): diff --git a/openedx/core/djangoapps/user_authn/config/waffle.py b/openedx/core/djangoapps/user_authn/config/waffle.py index c34c81e1d063..3cbb0cb2e18b 100644 --- a/openedx/core/djangoapps/user_authn/config/waffle.py +++ b/openedx/core/djangoapps/user_authn/config/waffle.py @@ -2,7 +2,6 @@ Waffle flags and switches for user authn. """ - from edx_toggles.toggles import WaffleSwitch _WAFFLE_NAMESPACE = 'user_authn' diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index 1d1089ce0214..7a0207f8b93c 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -23,6 +23,7 @@ from openedx.core.djangoapps.user_api import accounts from openedx.core.djangoapps.user_api.helpers import FormDescription from openedx.core.djangoapps.user_authn.utils import check_pwned_password, is_registration_api_v1 as is_api_v1 +from openedx.core.djangoapps.user_authn.views.utils import remove_disabled_country_from_list from openedx.core.djangolib.markup import HTML, Text from openedx.features.enterprise_support.api import enterprise_customer_for_request from common.djangoapps.student.models import ( @@ -297,6 +298,15 @@ def cleaned_extended_profile(self): if key in self.extended_profile_fields and value is not None } + def clean_country(self): + """ + Check if the user's country is in the embargoed countries list. + """ + country = self.cleaned_data.get("country") + if country in settings.DISABLED_COUNTRIES: + raise ValidationError(_("Registration from this country is not allowed due to restrictions.")) + return self.cleaned_data.get("country") + def get_registration_extension_form(*args, **kwargs): """ @@ -686,7 +696,7 @@ def _add_marketing_emails_opt_in_field(self, form_desc, required=False): """ opt_in_label = _( 'I agree that {platform_name} may send me marketing messages.').format( - platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), ) form_desc.add_field( @@ -974,7 +984,7 @@ def _add_country_field(self, form_desc, required=True): label=country_label, instructions=country_instructions, field_type="select", - options=list(countries), + options=list(remove_disabled_country_from_list(dict(countries)).items()), include_default_option=True, required=required, error_messages={ diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 02d5a72074f5..16f7da8010d6 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -65,6 +65,7 @@ password_validators_instruction_texts, password_validators_restrictions ) + ENABLE_AUTO_GENERATED_USERNAME = settings.FEATURES.copy() ENABLE_AUTO_GENERATED_USERNAME['ENABLE_AUTO_GENERATED_USERNAME'] = True @@ -1556,7 +1557,7 @@ def test_activation_email(self): assert len(mail.outbox) == 1 sent_email = mail.outbox[0] assert sent_email.to == [self.EMAIL] - assert sent_email.subject ==\ + assert sent_email.subject == \ f'Action Required: Activate your {settings.PLATFORM_NAME} account' assert f'high-quality {settings.PLATFORM_NAME} courses' in sent_email.body @@ -2468,6 +2469,31 @@ def test_register_error_with_pwned_password(self, emit): }) assert response.status_code == 400 + @override_settings(DISABLED_COUNTRIES=['KP']) + def test_register_with_disabled_country(self): + """ + Test case to check user registration is forbidden when registration is disabled for a country + """ + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + "honor_code": "true", + "country": "KP", + }) + assert response.status_code == 400 + response_json = json.loads(response.content.decode('utf-8')) + self.assertDictEqual( + response_json, + {'country': + [ + { + 'user_message': 'Registration from this country is not allowed due to restrictions.' + } + ], 'error_code': 'validation-error'} + ) + @httpretty.activate @ddt.ddt @@ -2575,6 +2601,24 @@ def test_success(self): self._verify_user_existence(user_exists=True, social_link_exists=True, user_is_active=False) + @override_settings(DISABLED_COUNTRIES=['US']) + def test_with_disabled_country(self): + """ + Test case to check user registration is forbidden when registration is disabled for a country + """ + self._verify_user_existence(user_exists=False, social_link_exists=False) + self._setup_provider_response(success=True) + response = self.client.post(self.url, self.data()) + assert response.status_code == 400 + assert response.json() == { + 'country': [ + { + 'user_message': 'Registration from this country is not allowed due to restrictions.' + } + ], 'error_code': 'validation-error' + } + self._verify_user_existence(user_exists=False, social_link_exists=False, user_is_active=False) + def test_unlinked_active_user(self): user = UserFactory() response = self.client.post(self.url, self.data(user)) diff --git a/openedx/core/djangoapps/user_authn/views/utils.py b/openedx/core/djangoapps/user_authn/views/utils.py index 9b4054bd4037..b9fb096621f2 100644 --- a/openedx/core/djangoapps/user_authn/views/utils.py +++ b/openedx/core/djangoapps/user_authn/views/utils.py @@ -3,6 +3,8 @@ """ import logging import re +from typing import Dict + from django.conf import settings from django.contrib import messages from django.utils.translation import gettext as _ @@ -177,3 +179,18 @@ def get_auto_generated_username(data): # We generate the username regardless of whether the name is empty or invalid. We do this # because the name validations occur later, ensuring that users cannot create an account without a valid name. return f"{username_prefix}_{username_suffix}" if username_prefix else username_suffix + + +def remove_disabled_country_from_list(countries: Dict) -> Dict: + """ + Remove disabled countries from the list of countries. + + Args: + - countries (dict): List of countries. + + Returns: + - dict: Dict of countries with disabled countries removed. + """ + for country_code in settings.DISABLED_COUNTRIES: + del countries[country_code] + return countries diff --git a/openedx/core/lib/xblock_serializer/block_serializer.py b/openedx/core/lib/xblock_serializer/block_serializer.py index 966380f25061..f12bf5336af5 100644 --- a/openedx/core/lib/xblock_serializer/block_serializer.py +++ b/openedx/core/lib/xblock_serializer/block_serializer.py @@ -133,7 +133,7 @@ def _serialize_html_block(self, block) -> etree.Element: # Escape any CDATA special chars escaped_block_data = block.data.replace("]]>", "]]>") - olx_node.text = etree.CDATA("\n" + escaped_block_data + "\n") + olx_node.text = etree.CDATA(escaped_block_data) return olx_node diff --git a/package-lock.json b/package-lock.json index 87c75af69044..82a6611d3abb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,7 +67,7 @@ "style-loader": "0.18.2", "svg-inline-loader": "0.8.2", "uglify-js": "2.7.0", - "underscore": "1.12.1", + "underscore": "1.13.1", "underscore.string": "3.3.6", "webpack": "^5.90.3", "webpack-bundle-tracker": "0.4.3", @@ -24564,9 +24564,9 @@ } }, "node_modules/underscore": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.12.1.tgz", - "integrity": "sha512-hEQt0+ZLDVUMhebKxL4x1BTtDY7bavVofhZ9KZ4aI26X9SRaE+Y3m83XUL1UP2jn8ynjndwCCpEHdUG+9pP1Tw==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", + "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==", "license": "MIT" }, "node_modules/underscore.string": { diff --git a/package.json b/package.json index 3bd526c3b481..1f48500e6e27 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "style-loader": "0.18.2", "svg-inline-loader": "0.8.2", "uglify-js": "2.7.0", - "underscore": "1.12.1", + "underscore": "1.13.1", "underscore.string": "3.3.6", "webpack": "^5.90.3", "webpack-bundle-tracker": "0.4.3", diff --git a/requirements/constraints.txt b/requirements/constraints.txt index a87d41292189..71e09f532a52 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ celery>=5.2.2,<6.0.0 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.25.11 +edx-enterprise==4.25.17 # Stay on LTS version, remove once this is added to common constraint Django<5.0 @@ -93,7 +93,7 @@ libsass==0.10.0 click==8.1.6 # pinning this version to avoid updates while the library is being developed -openedx-learning==0.11.4 +openedx-learning==0.13.1 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. openai<=0.28.1 @@ -142,7 +142,3 @@ django-storages<1.14.4 # We are pinning this until after all the smaller migrations get handled and then we can migrate this all at once. # Ticket to unpin: https://github.com/edx/edx-arch-experiments/issues/760 social-auth-app-django<=5.4.1 - -# Temporary pin as to prevent a new version of edx-name-affirmation from being merged before we modify it to work -# properly along with work in this PR: https://github.com/openedx/edx-platform/pull/35468 -edx-name-affirmation==2.4.0 diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt index 4a7b0c0a7d35..1d94a4649a5f 100644 --- a/requirements/edx-sandbox/base.txt +++ b/requirements/edx-sandbox/base.txt @@ -4,7 +4,7 @@ # # make upgrade # -cffi==1.17.0 +cffi==1.17.1 # via cryptography chem==1.3.0 # via -r requirements/edx-sandbox/base.in @@ -14,17 +14,17 @@ click==8.1.6 # nltk codejail-includes==1.0.0 # via -r requirements/edx-sandbox/base.in -contourpy==1.2.1 +contourpy==1.3.0 # via matplotlib -cryptography==43.0.0 +cryptography==43.0.1 # via -r requirements/edx-sandbox/base.in cycler==0.12.1 # via matplotlib -fonttools==4.53.1 +fonttools==4.54.1 # via matplotlib joblib==1.4.2 # via nltk -kiwisolver==1.4.5 +kiwisolver==1.4.7 # via matplotlib lxml==4.9.4 # via @@ -61,7 +61,7 @@ pillow==10.4.0 # via matplotlib pycparser==2.22 # via cffi -pyparsing==3.1.2 +pyparsing==3.1.4 # via # -r requirements/edx-sandbox/base.in # chem @@ -71,9 +71,9 @@ python-dateutil==2.9.0.post0 # via matplotlib random2==1.0.2 # via -r requirements/edx-sandbox/base.in -regex==2024.7.24 +regex==2024.9.11 # via nltk -scipy==1.14.0 +scipy==1.14.1 # via # -r requirements/edx-sandbox/base.in # chem @@ -82,7 +82,7 @@ six==1.16.0 # via # codejail-includes # python-dateutil -sympy==1.13.2 +sympy==1.13.3 # via # -r requirements/edx-sandbox/base.in # openedx-calc diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 07c26e0844ea..ccb8266e4525 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -10,7 +10,7 @@ acid-xblock==0.3.1 # via -r requirements/edx/kernel.in aiohappyeyeballs==2.4.0 # via aiohttp -aiohttp==3.10.5 +aiohttp==3.10.6 # via # geoip2 # openai @@ -58,7 +58,7 @@ bcrypt==4.2.0 # via paramiko beautifulsoup4==4.12.3 # via pynliner -billiard==4.2.0 +billiard==4.2.1 # via celery bleach[css]==6.1.0 # via @@ -70,13 +70,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/kernel.in -boto3==1.35.1 +boto3==1.35.27 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.35.1 +botocore==1.35.27 # via # -r requirements/edx/kernel.in # boto3 @@ -99,14 +99,14 @@ celery==5.4.0 # edx-enterprise # event-tracking # openedx-learning -certifi==2024.7.4 +certifi==2024.8.30 # via # -r requirements/edx/paver.txt # elasticsearch # py2neo # requests # snowflake-connector-python -cffi==1.17.0 +cffi==1.17.1 # via # cryptography # pynacl @@ -146,7 +146,7 @@ codejail-includes==1.0.0 # via -r requirements/edx/kernel.in crowdsourcehinter-xblock==0.7 # via -r requirements/edx/bundled.in -cryptography==42.0.8 +cryptography==43.0.1 # via # -r requirements/edx/kernel.in # django-fernet-fields-v2 @@ -168,7 +168,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -django==4.2.15 +django==4.2.16 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -286,7 +286,7 @@ django-js-asset==2.2.0 # via django-mptt django-method-override==1.0.4 # via -r requirements/edx/kernel.in -django-model-utils==4.5.1 +django-model-utils==5.0.0 # via # -r requirements/edx/kernel.in # django-user-tasks @@ -317,7 +317,7 @@ django-oauth-toolkit==1.7.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise -django-object-actions==4.2.0 +django-object-actions==4.3.0 # via edx-enterprise django-pipeline==3.1.0 # via -r requirements/edx/kernel.in @@ -329,7 +329,7 @@ django-sekizai==4.1.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki -django-ses==4.1.0 +django-ses==4.1.1 # via -r requirements/edx/bundled.in django-simple-history==3.4.0 # via @@ -393,7 +393,7 @@ dnspython==2.6.1 # via # -r requirements/edx/paver.txt # pymongo -done-xblock==2.3.0 +done-xblock==2.4.0 # via -r requirements/edx/bundled.in drf-jwt==1.19.2 # via edx-drf-extensions @@ -403,13 +403,13 @@ drf-yasg==1.21.7 # via # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via -r requirements/edx/kernel.in -edx-api-doc-tools==1.8.0 +edx-api-doc-tools==2.0.0 # via # -r requirements/edx/kernel.in # edx-name-affirmation -edx-auth-backends==4.3.0 +edx-auth-backends==4.4.0 # via -r requirements/edx/kernel.in edx-braze-client==0.2.5 # via @@ -431,7 +431,7 @@ edx-celeryutils==1.3.0 # super-csv edx-codejail==3.4.1 # via -r requirements/edx/kernel.in -edx-completion==4.6.7 +edx-completion==4.7.1 # via -r requirements/edx/kernel.in edx-django-release-util==1.4.0 # via @@ -457,7 +457,7 @@ edx-django-utils==5.15.0 # openedx-events # ora2 # super-csv -edx-drf-extensions==10.3.0 +edx-drf-extensions==10.4.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -469,7 +469,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.11 +edx-enterprise==4.25.17 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -484,10 +484,8 @@ edx-i18n-tools==1.5.0 # ora2 edx-milestones==0.6.0 # via -r requirements/edx/kernel.in -edx-name-affirmation==2.4.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/kernel.in +edx-name-affirmation==2.4.2 + # via -r requirements/edx/kernel.in edx-opaque-keys[django]==2.11.0 # via # -r requirements/edx/kernel.in @@ -510,9 +508,9 @@ edx-proctoring==4.18.1 # via # -r requirements/edx/kernel.in # edx-proctoring-proctortrack -edx-rbac==1.9.0 +edx-rbac==1.10.0 # via edx-enterprise -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -521,7 +519,7 @@ edx-search==4.0.0 # via -r requirements/edx/kernel.in edx-sga==0.25.0 # via -r requirements/edx/bundled.in -edx-submissions==3.7.7 +edx-submissions==3.8.0 # via # -r requirements/edx/kernel.in # ora2 @@ -545,7 +543,7 @@ edx-when==2.5.0 # via # -r requirements/edx/kernel.in # edx-proctoring -edxval==2.5.0 +edxval==2.6.0 # via -r requirements/edx/kernel.in elasticsearch==7.13.4 # via @@ -563,9 +561,9 @@ event-tracking==3.0.0 # edx-completion # edx-proctoring # edx-search -fastavro==1.9.5 +fastavro==1.9.7 # via openedx-events -filelock==3.15.4 +filelock==3.16.1 # via snowflake-connector-python firebase-admin==6.5.0 # via edx-ace @@ -591,16 +589,16 @@ geoip2==4.8.0 # via -r requirements/edx/kernel.in glob2==0.7 # via -r requirements/edx/kernel.in -google-api-core[grpc]==2.19.1 +google-api-core[grpc]==2.20.0 # via # firebase-admin # google-api-python-client # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via firebase-admin -google-auth==2.34.0 +google-auth==2.35.0 # via # google-api-core # google-api-python-client @@ -614,25 +612,25 @@ google-cloud-core==2.4.1 # via # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.2 +google-cloud-firestore==2.19.0 # via firebase-admin google-cloud-storage==2.18.2 # via firebase-admin -google-crc32c==1.5.0 +google-crc32c==1.6.0 # via # google-cloud-storage # google-resumable-media google-resumable-media==2.7.2 # via google-cloud-storage -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via # google-api-core # grpcio-status -grpcio==1.65.5 +grpcio==1.66.1 # via # google-api-core # grpcio-status -grpcio-status==1.65.5 +grpcio-status==1.66.1 # via google-api-core gunicorn==23.0.0 # via -r requirements/edx/kernel.in @@ -648,14 +646,14 @@ httplib2==0.22.0 # google-auth-httplib2 icalendar==5.0.13 # via -r requirements/edx/kernel.in -idna==3.7 +idna==3.10 # via # -r requirements/edx/paver.txt # optimizely-sdk # requests # snowflake-connector-python # yarl -importlib-metadata==8.3.0 +importlib-metadata==8.5.0 # via -r requirements/edx/kernel.in inflection==0.5.1 # via @@ -675,7 +673,7 @@ jmespath==1.0.1 # botocore joblib==1.4.2 # via nltk -jsondiff==2.2.0 +jsondiff==2.2.1 # via edx-enterprise jsonfield==3.1.0 # via @@ -696,7 +694,7 @@ jwcrypto==1.5.6 # via # django-oauth-toolkit # pylti1p3 -kombu==5.4.0 +kombu==5.4.2 # via celery laboratory==1.0.2 # via -r requirements/edx/kernel.in @@ -754,23 +752,23 @@ markupsafe==2.1.5 # xblock maxminddb==2.6.2 # via geoip2 -meilisearch==0.31.4 +meilisearch==0.31.5 # via -r requirements/edx/kernel.in mock==5.1.0 # via -r requirements/edx/paver.txt -mongoengine==0.28.2 +mongoengine==0.29.1 # via -r requirements/edx/kernel.in monotonic==1.6 # via # analytics-python # py2neo -more-itertools==10.4.0 +more-itertools==10.5.0 # via cssutils mpmath==1.3.0 # via sympy -msgpack==1.0.8 +msgpack==1.1.0 # via cachecontrol -multidict==6.0.5 +multidict==6.1.0 # via # aiohttp # yarl @@ -806,13 +804,13 @@ openai==0.28.1 # via # -c requirements/edx/../constraints.txt # edx-enterprise -openedx-atlas==0.6.1 +openedx-atlas==0.6.2 # via # -r requirements/edx/kernel.in # forum openedx-calc==3.1.0 # via -r requirements/edx/kernel.in -openedx-django-pyfs==3.6.0 +openedx-django-pyfs==3.7.0 # via # lti-consumer-xblock # xblock @@ -820,7 +818,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 # via -r requirements/edx/kernel.in -openedx-events==9.14.0 +openedx-events==9.14.1 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -828,12 +826,12 @@ openedx-events==9.14.0 # edx-event-bus-redis # event-tracking # ora2 -openedx-filters==1.9.0 +openedx-filters==1.10.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.11.4 +openedx-learning==0.13.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -843,7 +841,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in -ora2==6.11.2 +ora2==6.12.0 # via -r requirements/edx/bundled.in packaging==24.1 # via @@ -853,7 +851,7 @@ packaging==24.1 # snowflake-connector-python pansi==2020.7.3 # via py2neo -paramiko==3.4.1 +paramiko==3.5.0 # via edx-enterprise path==16.11.0 # via @@ -869,7 +867,7 @@ path-py==12.5.0 # staff-graded-xblock paver==1.3.4 # via -r requirements/edx/paver.txt -pbr==6.0.0 +pbr==6.1.0 # via # -r requirements/edx/paver.txt # stevedore @@ -883,17 +881,17 @@ pillow==10.4.0 # edx-enterprise # edx-organizations # edxval -platformdirs==4.2.2 +platformdirs==4.3.6 # via snowflake-connector-python polib==1.2.0 # via edx-i18n-tools -prompt-toolkit==3.0.47 +prompt-toolkit==3.0.48 # via click-repl proto-plus==1.24.0 # via # google-api-core # google-cloud-firestore -protobuf==5.27.3 +protobuf==5.28.2 # via # google-api-core # google-cloud-firestore @@ -908,12 +906,12 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in -pyasn1==0.6.0 +pyasn1==0.6.1 # via # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via google-auth pycountry==24.6.1 # via -r requirements/edx/kernel.in @@ -925,9 +923,9 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.8.2 +pydantic==2.9.2 # via camel-converter -pydantic-core==2.20.1 +pydantic-core==2.23.4 # via pydantic pygments==2.18.0 # via @@ -976,7 +974,7 @@ pyopenssl==24.2.1 # via # optimizely-sdk # snowflake-connector-python -pyparsing==3.1.2 +pyparsing==3.1.4 # via # chem # httplib2 @@ -1014,10 +1012,9 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/kernel.in -pytz==2024.1 +pytz==2024.2 # via # -r requirements/edx/kernel.in - # django-ses # djangorestframework # drf-yasg # edx-completion @@ -1047,7 +1044,7 @@ pyyaml==6.0.2 # xblock random2==1.0.2 # via -r requirements/edx/kernel.in -recommender-xblock==2.2.0 +recommender-xblock==2.2.1 # via -r requirements/edx/bundled.in redis==5.0.8 # via @@ -1057,7 +1054,7 @@ referencing==0.35.1 # via # jsonschema # jsonschema-specifications -regex==2024.7.24 +regex==2024.9.11 # via nltk requests==2.32.3 # via @@ -1097,7 +1094,7 @@ rpds-py==0.20.0 # referencing rsa==4.9 # via google-auth -rules==3.4 +rules==3.5 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -1107,7 +1104,7 @@ s3transfer==0.10.2 # via boto3 sailthru-client==2.2.3 # via edx-ace -scipy==1.14.0 +scipy==1.14.1 # via # chem # openedx-calc @@ -1155,8 +1152,7 @@ slumber==0.7.1 # -r requirements/edx/kernel.in # edx-bulk-grades # edx-enterprise - # edx-rest-api-client -snowflake-connector-python==3.12.0 +snowflake-connector-python==3.12.2 # via edx-enterprise social-auth-app-django==5.4.1 # via @@ -1168,7 +1164,7 @@ social-auth-core==4.5.4 # -r requirements/edx/kernel.in # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.10.0 +sorl-thumbnail==12.11.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki @@ -1182,7 +1178,7 @@ sqlparse==0.5.1 # via django staff-graded-xblock==2.3.0 # via -r requirements/edx/bundled.in -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/edx/kernel.in # -r requirements/edx/paver.txt @@ -1193,7 +1189,7 @@ stevedore==5.2.0 # edx-opaque-keys super-csv==3.2.0 # via edx-bulk-grades -sympy==1.13.2 +sympy==1.13.3 # via openedx-calc testfixtures==8.3.0 # via edx-enterprise @@ -1217,8 +1213,10 @@ typing-extensions==4.12.2 # pydantic-core # pylti1p3 # snowflake-connector-python -tzdata==2024.1 - # via celery +tzdata==2024.2 + # via + # celery + # kombu unicodecsv==0.14.1 # via # -r requirements/edx/kernel.in @@ -1228,7 +1226,7 @@ uritemplate==4.1.1 # drf-spectacular # drf-yasg # google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/paver.txt @@ -1247,7 +1245,7 @@ voluptuous==0.15.2 # via ora2 walrus==0.9.4 # via edx-event-bus-redis -watchdog==4.0.2 +watchdog==5.0.2 # via -r requirements/edx/paver.txt wcwidth==0.2.13 # via prompt-toolkit @@ -1301,9 +1299,9 @@ xmlsec==1.3.13 # python3-saml xss-utils==0.6.0 # via -r requirements/edx/kernel.in -yarl==1.9.4 +yarl==1.12.1 # via aiohttp -zipp==3.20.0 +zipp==3.20.2 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/edx/coverage.txt b/requirements/edx/coverage.txt index a004eeeb9ffa..a1faf5e74025 100644 --- a/requirements/edx/coverage.txt +++ b/requirements/edx/coverage.txt @@ -8,7 +8,7 @@ chardet==5.2.0 # via diff-cover coverage==7.6.1 # via -r requirements/edx/coverage.in -diff-cover==9.1.1 +diff-cover==9.2.0 # via -r requirements/edx/coverage.in jinja2==3.1.4 # via diff-cover diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 1fb2de8c3669..26e2846e8c3b 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -21,7 +21,7 @@ aiohappyeyeballs==2.4.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # aiohttp -aiohttp==3.10.5 +aiohttp==3.10.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -60,7 +60,7 @@ annotated-types==0.7.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pydantic -anyio==4.4.0 +anyio==4.6.0 # via # -r requirements/edx/testing.txt # starlette @@ -121,7 +121,7 @@ beautifulsoup4==4.12.3 # -r requirements/edx/testing.txt # pydata-sphinx-theme # pynliner -billiard==4.2.0 +billiard==4.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -140,14 +140,14 @@ boto==2.49.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -boto3==1.35.1 +boto3==1.35.27 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ses # fs-s3fs # ora2 -botocore==1.35.1 +botocore==1.35.27 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -157,7 +157,7 @@ bridgekeeper==0.9 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -build==1.2.1 +build==1.2.2 # via # -r requirements/edx/../pip-tools.txt # pip-tools @@ -188,7 +188,7 @@ celery==5.4.0 # edx-enterprise # event-tracking # openedx-learning -certifi==2024.7.4 +certifi==2024.8.30 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -196,7 +196,7 @@ certifi==2024.7.4 # py2neo # requests # snowflake-connector-python -cffi==1.17.0 +cffi==1.17.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -286,7 +286,7 @@ crowdsourcehinter-xblock==0.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -cryptography==42.0.8 +cryptography==43.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -311,7 +311,7 @@ cssutils==2.11.1 # pynliner ddt==1.7.2 # via -r requirements/edx/testing.txt -deepmerge==1.1.1 +deepmerge==2.0 # via # -r requirements/edx/doc.txt # sphinxcontrib-openapi @@ -323,7 +323,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -diff-cover==9.1.1 +diff-cover==9.2.0 # via -r requirements/edx/testing.txt dill==0.3.8 # via @@ -333,7 +333,7 @@ distlib==0.3.8 # via # -r requirements/edx/testing.txt # virtualenv -django==4.2.15 +django==4.2.16 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -483,7 +483,7 @@ django-method-override==1.0.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -django-model-utils==4.5.1 +django-model-utils==5.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -522,7 +522,7 @@ django-oauth-toolkit==1.7.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -django-object-actions==4.2.0 +django-object-actions==4.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -545,7 +545,7 @@ django-sekizai==4.1.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-django-wiki -django-ses==4.1.0 +django-ses==4.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -577,7 +577,7 @@ django-stubs==1.16.0 # -c requirements/edx/../constraints.txt # -r requirements/edx/development.in # djangorestframework-stubs -django-stubs-ext==5.0.4 +django-stubs-ext==5.1.0 # via django-stubs django-user-tasks==3.2.0 # via @@ -640,7 +640,7 @@ docutils==0.21.2 # pydata-sphinx-theme # sphinx # sphinx-mdinclude -done-xblock==2.3.0 +done-xblock==2.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -659,16 +659,16 @@ drf-yasg==1.21.7 # -r requirements/edx/testing.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-api-doc-tools==1.8.0 +edx-api-doc-tools==2.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-name-affirmation -edx-auth-backends==4.3.0 +edx-auth-backends==4.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -698,7 +698,7 @@ edx-codejail==3.4.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-completion==4.6.7 +edx-completion==4.7.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -730,7 +730,7 @@ edx-django-utils==5.15.0 # openedx-events # ora2 # super-csv -edx-drf-extensions==10.3.0 +edx-drf-extensions==10.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -743,7 +743,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.11 +edx-enterprise==4.25.17 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -762,15 +762,14 @@ edx-i18n-tools==1.5.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -edx-lint==5.3.7 +edx-lint==5.4.0 # via -r requirements/edx/testing.txt edx-milestones==0.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-name-affirmation==2.4.0 +edx-name-affirmation==2.4.2 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt edx-opaque-keys[django]==2.11.0 @@ -798,12 +797,12 @@ edx-proctoring==4.18.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring-proctortrack -edx-rbac==1.9.0 +edx-rbac==1.10.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -817,7 +816,7 @@ edx-sga==0.25.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-submissions==3.7.7 +edx-submissions==3.8.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -849,7 +848,7 @@ edx-when==2.5.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring -edxval==2.5.0 +edxval==2.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -883,20 +882,20 @@ execnet==2.1.1 # pytest-xdist factory-boy==3.3.1 # via -r requirements/edx/testing.txt -faker==27.0.0 +faker==30.0.0 # via # -r requirements/edx/testing.txt # factory-boy -fastapi==0.112.1 +fastapi==0.115.0 # via # -r requirements/edx/testing.txt # pact-python -fastavro==1.9.5 +fastavro==1.9.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-events -filelock==3.15.4 +filelock==3.16.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -951,7 +950,7 @@ glob2==0.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -google-api-core[grpc]==2.19.1 +google-api-core[grpc]==2.20.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -960,12 +959,12 @@ google-api-core[grpc]==2.19.1 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-auth==2.34.0 +google-auth==2.35.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -986,7 +985,7 @@ google-cloud-core==2.4.1 # -r requirements/edx/testing.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.2 +google-cloud-firestore==2.19.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -996,7 +995,7 @@ google-cloud-storage==2.18.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-crc32c==1.5.0 +google-crc32c==1.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1007,7 +1006,7 @@ google-resumable-media==2.7.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-cloud-storage -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1017,13 +1016,13 @@ grimp==3.4.1 # via # -r requirements/edx/testing.txt # import-linter -grpcio==1.65.5 +grpcio==1.66.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-api-core # grpcio-status -grpcio-status==1.65.5 +grpcio-status==1.66.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1057,7 +1056,7 @@ icalendar==5.0.13 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -idna==3.7 +idna==3.10 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1072,7 +1071,7 @@ imagesize==1.4.1 # sphinx import-linter==2.0 # via -r requirements/edx/testing.txt -importlib-metadata==8.3.0 +importlib-metadata==8.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1122,7 +1121,7 @@ joblib==1.4.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # nltk -jsondiff==2.2.0 +jsondiff==2.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1155,7 +1154,7 @@ jwcrypto==1.5.6 # -r requirements/edx/testing.txt # django-oauth-toolkit # pylti1p3 -kombu==5.4.0 +kombu==5.4.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1244,7 +1243,7 @@ mccabe==0.7.0 # via # -r requirements/edx/testing.txt # pylint -meilisearch==0.31.4 +meilisearch==0.31.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1256,7 +1255,7 @@ mock==5.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -mongoengine==0.28.2 +mongoengine==0.29.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1266,7 +1265,7 @@ monotonic==1.6 # -r requirements/edx/testing.txt # analytics-python # py2neo -more-itertools==10.4.0 +more-itertools==10.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1276,18 +1275,18 @@ mpmath==1.3.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # sympy -msgpack==1.0.8 +msgpack==1.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # cachecontrol -multidict==6.0.5 +multidict==6.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # aiohttp # yarl -mypy==1.11.1 +mypy==1.11.2 # via # -r requirements/edx/development.in # django-stubs @@ -1344,7 +1343,7 @@ openai==0.28.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -openedx-atlas==0.6.1 +openedx-atlas==0.6.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1353,7 +1352,7 @@ openedx-calc==3.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-django-pyfs==3.6.0 +openedx-django-pyfs==3.7.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1367,7 +1366,7 @@ openedx-django-wiki==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==9.14.0 +openedx-events==9.14.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1376,13 +1375,13 @@ openedx-events==9.14.0 # edx-event-bus-redis # event-tracking # ora2 -openedx-filters==1.9.0 +openedx-filters==1.10.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.4 +openedx-learning==0.13.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -1396,7 +1395,7 @@ optimizely-sdk==4.1.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -ora2==6.11.2 +ora2==6.12.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1422,7 +1421,7 @@ pansi==2020.7.3 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # py2neo -paramiko==3.4.1 +paramiko==3.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1445,7 +1444,7 @@ paver==1.3.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pbr==6.0.0 +pbr==6.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1472,7 +1471,7 @@ pillow==10.4.0 # edxval pip-tools==7.4.1 # via -r requirements/edx/../pip-tools.txt -platformdirs==4.2.2 +platformdirs==4.3.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1491,7 +1490,7 @@ polib==1.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-i18n-tools -prompt-toolkit==3.0.47 +prompt-toolkit==3.0.48 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1502,7 +1501,7 @@ proto-plus==1.24.0 # -r requirements/edx/testing.txt # google-api-core # google-cloud-firestore -protobuf==5.27.3 +protobuf==5.28.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1525,14 +1524,14 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pyasn1==0.6.0 +pyasn1==0.6.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1557,13 +1556,13 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.8.2 +pydantic==2.9.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # camel-converter # fastapi -pydantic-core==2.20.1 +pydantic-core==2.23.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1664,14 +1663,14 @@ pyopenssl==24.2.1 # -r requirements/edx/testing.txt # optimizely-sdk # snowflake-connector-python -pyparsing==3.1.2 +pyparsing==3.1.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # chem # httplib2 # openedx-calc -pyproject-api==1.7.1 +pyproject-api==1.8.0 # via # -r requirements/edx/testing.txt # tox @@ -1680,7 +1679,7 @@ pyproject-hooks==1.1.0 # -r requirements/edx/../pip-tools.txt # build # pip-tools -pyquery==2.0.0 +pyquery==2.0.1 # via -r requirements/edx/testing.txt pyrsistent==0.20.0 # via @@ -1692,7 +1691,7 @@ pysrt==1.1.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edxval -pytest==8.3.2 +pytest==8.3.3 # via # -r requirements/edx/testing.txt # pylint-pytest @@ -1707,7 +1706,7 @@ pytest-attrib==0.1.3 # via -r requirements/edx/testing.txt pytest-cov==5.0.0 # via -r requirements/edx/testing.txt -pytest-django==4.8.0 +pytest-django==4.9.0 # via -r requirements/edx/testing.txt pytest-json-report==1.5.0 # via -r requirements/edx/testing.txt @@ -1763,11 +1762,10 @@ python3-saml==1.16.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pytz==2024.1 +pytz==2024.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # django-ses # djangorestframework # drf-yasg # edx-completion @@ -1805,7 +1803,7 @@ random2==1.0.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -recommender-xblock==2.2.0 +recommender-xblock==2.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1820,7 +1818,7 @@ referencing==0.35.1 # -r requirements/edx/testing.txt # jsonschema # jsonschema-specifications -regex==2024.7.24 +regex==2024.9.11 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1873,7 +1871,7 @@ rsa==4.9 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-auth -rules==3.4 +rules==3.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1890,7 +1888,7 @@ sailthru-client==2.2.3 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-ace -scipy==1.14.0 +scipy==1.14.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1953,7 +1951,6 @@ slumber==0.7.1 # -r requirements/edx/testing.txt # edx-bulk-grades # edx-enterprise - # edx-rest-api-client smmap==5.0.1 # via # -r requirements/edx/doc.txt @@ -1966,7 +1963,7 @@ snowballstemmer==2.2.0 # via # -r requirements/edx/doc.txt # sphinx -snowflake-connector-python==3.12.0 +snowflake-connector-python==3.12.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1983,7 +1980,7 @@ social-auth-core==4.5.4 # -r requirements/edx/testing.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.10.0 +sorl-thumbnail==12.11.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2061,11 +2058,11 @@ staff-graded-xblock==2.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -starlette==0.38.2 +starlette==0.38.6 # via # -r requirements/edx/testing.txt # fastapi -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2079,7 +2076,7 @@ super-csv==3.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-bulk-grades -sympy==1.13.2 +sympy==1.13.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2107,7 +2104,7 @@ tomlkit==0.13.2 # -r requirements/edx/testing.txt # pylint # snowflake-connector-python -tox==4.18.0 +tox==4.20.0 # via -r requirements/edx/testing.txt tqdm==4.66.5 # via @@ -2115,9 +2112,9 @@ tqdm==4.66.5 # -r requirements/edx/testing.txt # nltk # openai -types-pytz==2024.1.0.20240417 +types-pytz==2024.2.0.20240913 # via django-stubs -types-pyyaml==6.0.12.20240808 +types-pyyaml==6.0.12.20240917 # via # django-stubs # djangorestframework-stubs @@ -2144,11 +2141,12 @@ typing-extensions==4.12.2 # pydata-sphinx-theme # pylti1p3 # snowflake-connector-python -tzdata==2024.1 +tzdata==2024.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # celery + # kombu unicodecsv==0.14.1 # via # -r requirements/edx/doc.txt @@ -2163,7 +2161,7 @@ uritemplate==4.1.1 # drf-spectacular # drf-yasg # google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -2187,7 +2185,7 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.26.3 +virtualenv==20.26.5 # via # -r requirements/edx/testing.txt # tox @@ -2196,14 +2194,14 @@ voluptuous==0.15.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -vulture==2.11 +vulture==2.12 # via -r requirements/edx/development.in walrus==0.9.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-event-bus-redis -watchdog==4.0.2 +watchdog==5.0.2 # via # -r requirements/edx/development.in # -r requirements/edx/doc.txt @@ -2287,13 +2285,13 @@ xss-utils==0.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -yarl==1.9.4 +yarl==1.12.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # aiohttp # pact-python -zipp==3.20.0 +zipp==3.20.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 466394da4360..80ccce710009 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -14,7 +14,7 @@ aiohappyeyeballs==2.4.0 # via # -r requirements/edx/base.txt # aiohttp -aiohttp==3.10.5 +aiohttp==3.10.6 # via # -r requirements/edx/base.txt # geoip2 @@ -87,7 +87,7 @@ beautifulsoup4==4.12.3 # -r requirements/edx/base.txt # pydata-sphinx-theme # pynliner -billiard==4.2.0 +billiard==4.2.1 # via # -r requirements/edx/base.txt # celery @@ -102,13 +102,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.35.1 +boto3==1.35.27 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.35.1 +botocore==1.35.27 # via # -r requirements/edx/base.txt # boto3 @@ -137,14 +137,14 @@ celery==5.4.0 # edx-enterprise # event-tracking # openedx-learning -certifi==2024.7.4 +certifi==2024.8.30 # via # -r requirements/edx/base.txt # elasticsearch # py2neo # requests # snowflake-connector-python -cffi==1.17.0 +cffi==1.17.1 # via # -r requirements/edx/base.txt # cryptography @@ -196,7 +196,7 @@ codejail-includes==1.0.0 # via -r requirements/edx/base.txt crowdsourcehinter-xblock==0.7 # via -r requirements/edx/base.txt -cryptography==42.0.8 +cryptography==43.0.1 # via # -r requirements/edx/base.txt # django-fernet-fields-v2 @@ -213,7 +213,7 @@ cssutils==2.11.1 # via # -r requirements/edx/base.txt # pynliner -deepmerge==1.1.1 +deepmerge==2.0 # via sphinxcontrib-openapi defusedxml==0.7.1 # via @@ -222,7 +222,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -django==4.2.15 +django==4.2.16 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -350,7 +350,7 @@ django-js-asset==2.2.0 # django-mptt django-method-override==1.0.4 # via -r requirements/edx/base.txt -django-model-utils==4.5.1 +django-model-utils==5.0.0 # via # -r requirements/edx/base.txt # django-user-tasks @@ -383,7 +383,7 @@ django-oauth-toolkit==1.7.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -django-object-actions==4.2.0 +django-object-actions==4.3.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -399,7 +399,7 @@ django-sekizai==4.1.0 # via # -r requirements/edx/base.txt # openedx-django-wiki -django-ses==4.1.0 +django-ses==4.1.1 # via -r requirements/edx/base.txt django-simple-history==3.4.0 # via @@ -470,7 +470,7 @@ docutils==0.21.2 # pydata-sphinx-theme # sphinx # sphinx-mdinclude -done-xblock==2.3.0 +done-xblock==2.4.0 # via -r requirements/edx/base.txt drf-jwt==1.19.2 # via @@ -483,13 +483,13 @@ drf-yasg==1.21.7 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via -r requirements/edx/base.txt -edx-api-doc-tools==1.8.0 +edx-api-doc-tools==2.0.0 # via # -r requirements/edx/base.txt # edx-name-affirmation -edx-auth-backends==4.3.0 +edx-auth-backends==4.4.0 # via -r requirements/edx/base.txt edx-braze-client==0.2.5 # via @@ -511,7 +511,7 @@ edx-celeryutils==1.3.0 # super-csv edx-codejail==3.4.1 # via -r requirements/edx/base.txt -edx-completion==4.6.7 +edx-completion==4.7.1 # via -r requirements/edx/base.txt edx-django-release-util==1.4.0 # via @@ -537,7 +537,7 @@ edx-django-utils==5.15.0 # openedx-events # ora2 # super-csv -edx-drf-extensions==10.3.0 +edx-drf-extensions==10.4.0 # via # -r requirements/edx/base.txt # edx-completion @@ -549,7 +549,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.11 +edx-enterprise==4.25.17 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -564,10 +564,8 @@ edx-i18n-tools==1.5.0 # ora2 edx-milestones==0.6.0 # via -r requirements/edx/base.txt -edx-name-affirmation==2.4.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt +edx-name-affirmation==2.4.2 + # via -r requirements/edx/base.txt edx-opaque-keys[django]==2.11.0 # via # -r requirements/edx/base.txt @@ -589,11 +587,11 @@ edx-proctoring==4.18.1 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack -edx-rbac==1.9.0 +edx-rbac==1.10.0 # via # -r requirements/edx/base.txt # edx-enterprise -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -602,7 +600,7 @@ edx-search==4.0.0 # via -r requirements/edx/base.txt edx-sga==0.25.0 # via -r requirements/edx/base.txt -edx-submissions==3.7.7 +edx-submissions==3.8.0 # via # -r requirements/edx/base.txt # ora2 @@ -628,7 +626,7 @@ edx-when==2.5.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==2.5.0 +edxval==2.6.0 # via -r requirements/edx/base.txt elasticsearch==7.13.4 # via @@ -649,11 +647,11 @@ event-tracking==3.0.0 # edx-completion # edx-proctoring # edx-search -fastavro==1.9.5 +fastavro==1.9.7 # via # -r requirements/edx/base.txt # openedx-events -filelock==3.15.4 +filelock==3.16.1 # via # -r requirements/edx/base.txt # snowflake-connector-python @@ -690,7 +688,7 @@ gitpython==3.1.43 # via -r requirements/edx/doc.in glob2==0.7 # via -r requirements/edx/base.txt -google-api-core[grpc]==2.19.1 +google-api-core[grpc]==2.20.0 # via # -r requirements/edx/base.txt # firebase-admin @@ -698,11 +696,11 @@ google-api-core[grpc]==2.19.1 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.34.0 +google-auth==2.35.0 # via # -r requirements/edx/base.txt # google-api-core @@ -720,7 +718,7 @@ google-cloud-core==2.4.1 # -r requirements/edx/base.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.2 +google-cloud-firestore==2.19.0 # via # -r requirements/edx/base.txt # firebase-admin @@ -728,7 +726,7 @@ google-cloud-storage==2.18.2 # via # -r requirements/edx/base.txt # firebase-admin -google-crc32c==1.5.0 +google-crc32c==1.6.0 # via # -r requirements/edx/base.txt # google-cloud-storage @@ -737,17 +735,17 @@ google-resumable-media==2.7.2 # via # -r requirements/edx/base.txt # google-cloud-storage -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio==1.65.5 +grpcio==1.66.1 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.65.5 +grpcio-status==1.66.1 # via # -r requirements/edx/base.txt # google-api-core @@ -766,7 +764,7 @@ httplib2==0.22.0 # google-auth-httplib2 icalendar==5.0.13 # via -r requirements/edx/base.txt -idna==3.7 +idna==3.10 # via # -r requirements/edx/base.txt # optimizely-sdk @@ -775,7 +773,7 @@ idna==3.7 # yarl imagesize==1.4.1 # via sphinx -importlib-metadata==8.3.0 +importlib-metadata==8.5.0 # via -r requirements/edx/base.txt inflection==0.5.1 # via @@ -806,7 +804,7 @@ joblib==1.4.2 # via # -r requirements/edx/base.txt # nltk -jsondiff==2.2.0 +jsondiff==2.2.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -834,7 +832,7 @@ jwcrypto==1.5.6 # -r requirements/edx/base.txt # django-oauth-toolkit # pylti1p3 -kombu==5.4.0 +kombu==5.4.2 # via # -r requirements/edx/base.txt # celery @@ -898,20 +896,20 @@ maxminddb==2.6.2 # via # -r requirements/edx/base.txt # geoip2 -meilisearch==0.31.4 +meilisearch==0.31.5 # via -r requirements/edx/base.txt mistune==3.0.2 # via sphinx-mdinclude mock==5.1.0 # via -r requirements/edx/base.txt -mongoengine==0.28.2 +mongoengine==0.29.1 # via -r requirements/edx/base.txt monotonic==1.6 # via # -r requirements/edx/base.txt # analytics-python # py2neo -more-itertools==10.4.0 +more-itertools==10.5.0 # via # -r requirements/edx/base.txt # cssutils @@ -919,11 +917,11 @@ mpmath==1.3.0 # via # -r requirements/edx/base.txt # sympy -msgpack==1.0.8 +msgpack==1.1.0 # via # -r requirements/edx/base.txt # cachecontrol -multidict==6.0.5 +multidict==6.1.0 # via # -r requirements/edx/base.txt # aiohttp @@ -964,13 +962,13 @@ openai==0.28.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -openedx-atlas==0.6.1 +openedx-atlas==0.6.2 # via # -r requirements/edx/base.txt # forum openedx-calc==3.1.0 # via -r requirements/edx/base.txt -openedx-django-pyfs==3.6.0 +openedx-django-pyfs==3.7.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock @@ -979,7 +977,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.14.0 +openedx-events==9.14.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -987,12 +985,12 @@ openedx-events==9.14.0 # edx-event-bus-redis # event-tracking # ora2 -openedx-filters==1.9.0 +openedx-filters==1.10.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.4 +openedx-learning==0.13.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1002,7 +1000,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -ora2==6.11.2 +ora2==6.12.0 # via -r requirements/edx/base.txt packaging==24.1 # via @@ -1017,7 +1015,7 @@ pansi==2020.7.3 # via # -r requirements/edx/base.txt # py2neo -paramiko==3.4.1 +paramiko==3.5.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1035,7 +1033,7 @@ path-py==12.5.0 # staff-graded-xblock paver==1.3.4 # via -r requirements/edx/base.txt -pbr==6.0.0 +pbr==6.1.0 # via # -r requirements/edx/base.txt # stevedore @@ -1053,7 +1051,7 @@ pillow==10.4.0 # edx-enterprise # edx-organizations # edxval -platformdirs==4.2.2 +platformdirs==4.3.6 # via # -r requirements/edx/base.txt # snowflake-connector-python @@ -1061,7 +1059,7 @@ polib==1.2.0 # via # -r requirements/edx/base.txt # edx-i18n-tools -prompt-toolkit==3.0.47 +prompt-toolkit==3.0.48 # via # -r requirements/edx/base.txt # click-repl @@ -1070,7 +1068,7 @@ proto-plus==1.24.0 # -r requirements/edx/base.txt # google-api-core # google-cloud-firestore -protobuf==5.27.3 +protobuf==5.28.2 # via # -r requirements/edx/base.txt # google-api-core @@ -1086,13 +1084,13 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -pyasn1==0.6.0 +pyasn1==0.6.1 # via # -r requirements/edx/base.txt # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via # -r requirements/edx/base.txt # google-auth @@ -1108,11 +1106,11 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.8.2 +pydantic==2.9.2 # via # -r requirements/edx/base.txt # camel-converter -pydantic-core==2.20.1 +pydantic-core==2.23.4 # via # -r requirements/edx/base.txt # pydantic @@ -1172,7 +1170,7 @@ pyopenssl==24.2.1 # -r requirements/edx/base.txt # optimizely-sdk # snowflake-connector-python -pyparsing==3.1.2 +pyparsing==3.1.4 # via # -r requirements/edx/base.txt # chem @@ -1219,10 +1217,9 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/base.txt -pytz==2024.1 +pytz==2024.2 # via # -r requirements/edx/base.txt - # django-ses # djangorestframework # drf-yasg # edx-completion @@ -1253,7 +1250,7 @@ pyyaml==6.0.2 # xblock random2==1.0.2 # via -r requirements/edx/base.txt -recommender-xblock==2.2.0 +recommender-xblock==2.2.1 # via -r requirements/edx/base.txt redis==5.0.8 # via @@ -1264,7 +1261,7 @@ referencing==0.35.1 # -r requirements/edx/base.txt # jsonschema # jsonschema-specifications -regex==2024.7.24 +regex==2024.9.11 # via # -r requirements/edx/base.txt # nltk @@ -1310,7 +1307,7 @@ rsa==4.9 # via # -r requirements/edx/base.txt # google-auth -rules==3.4 +rules==3.5 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1324,7 +1321,7 @@ sailthru-client==2.2.3 # via # -r requirements/edx/base.txt # edx-ace -scipy==1.14.0 +scipy==1.14.1 # via # -r requirements/edx/base.txt # chem @@ -1375,12 +1372,11 @@ slumber==0.7.1 # -r requirements/edx/base.txt # edx-bulk-grades # edx-enterprise - # edx-rest-api-client smmap==5.0.1 # via gitdb snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.12.0 +snowflake-connector-python==3.12.2 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1394,7 +1390,7 @@ social-auth-core==4.5.4 # -r requirements/edx/base.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.10.0 +sorl-thumbnail==12.11.0 # via # -r requirements/edx/base.txt # openedx-django-wiki @@ -1449,7 +1445,7 @@ sqlparse==0.5.1 # django staff-graded-xblock==2.3.0 # via -r requirements/edx/base.txt -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/edx/base.txt # code-annotations @@ -1461,7 +1457,7 @@ super-csv==3.2.0 # via # -r requirements/edx/base.txt # edx-bulk-grades -sympy==1.13.2 +sympy==1.13.3 # via # -r requirements/edx/base.txt # openedx-calc @@ -1497,10 +1493,11 @@ typing-extensions==4.12.2 # pydata-sphinx-theme # pylti1p3 # snowflake-connector-python -tzdata==2024.1 +tzdata==2024.2 # via # -r requirements/edx/base.txt # celery + # kombu unicodecsv==0.14.1 # via # -r requirements/edx/base.txt @@ -1511,7 +1508,7 @@ uritemplate==4.1.1 # drf-spectacular # drf-yasg # google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1535,7 +1532,7 @@ walrus==0.9.4 # via # -r requirements/edx/base.txt # edx-event-bus-redis -watchdog==4.0.2 +watchdog==5.0.2 # via -r requirements/edx/base.txt wcwidth==0.2.13 # via @@ -1594,11 +1591,11 @@ xmlsec==1.3.13 # python3-saml xss-utils==0.6.0 # via -r requirements/edx/base.txt -yarl==1.9.4 +yarl==1.12.1 # via # -r requirements/edx/base.txt # aiohttp -zipp==3.20.0 +zipp==3.20.2 # via # -r requirements/edx/base.txt # importlib-metadata diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt index d86acae05f4f..a0b1896919d4 100644 --- a/requirements/edx/paver.txt +++ b/requirements/edx/paver.txt @@ -4,7 +4,7 @@ # # make upgrade # -certifi==2024.7.4 +certifi==2024.8.30 # via requests charset-normalizer==2.0.12 # via @@ -14,7 +14,7 @@ dnspython==2.6.1 # via pymongo edx-opaque-keys==2.11.0 # via -r requirements/edx/paver.in -idna==3.7 +idna==3.10 # via requests lazy==1.6 # via -r requirements/edx/paver.in @@ -32,7 +32,7 @@ path==16.11.0 # -r requirements/edx/paver.in paver==1.3.4 # via -r requirements/edx/paver.in -pbr==6.0.0 +pbr==6.1.0 # via stevedore psutil==6.0.0 # via -r requirements/edx/paver.in @@ -51,17 +51,17 @@ six==1.16.0 # via # libsass # paver -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/edx/paver.in # edx-opaque-keys typing-extensions==4.12.2 # via edx-opaque-keys -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # requests -watchdog==4.0.2 +watchdog==5.0.2 # via -r requirements/edx/paver.in wrapt==1.16.0 # via -r requirements/edx/paver.in diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt index 292f1319048d..102289def277 100644 --- a/requirements/edx/semgrep.txt +++ b/requirements/edx/semgrep.txt @@ -17,7 +17,7 @@ boltons==21.0.0 # semgrep bracex==2.5 # via wcmatch -certifi==2024.7.4 +certifi==2024.8.30 # via requests charset-normalizer==2.0.12 # via @@ -38,7 +38,7 @@ face==22.0.0 # via glom glom==22.1.0 # via semgrep -idna==3.7 +idna==3.10 # via requests jsonschema==4.23.0 # via semgrep @@ -60,7 +60,7 @@ referencing==0.35.1 # jsonschema-specifications requests==2.32.3 # via semgrep -rich==13.7.1 +rich==13.8.1 # via semgrep rpds-py==0.20.0 # via @@ -76,7 +76,7 @@ tomli==2.0.1 # via semgrep typing-extensions==4.12.2 # via semgrep -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # requests diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 8d3a2baf68d8..1ad14da129e4 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -12,7 +12,7 @@ aiohappyeyeballs==2.4.0 # via # -r requirements/edx/base.txt # aiohttp -aiohttp==3.10.5 +aiohttp==3.10.6 # via # -r requirements/edx/base.txt # geoip2 @@ -39,7 +39,7 @@ annotated-types==0.7.0 # via # -r requirements/edx/base.txt # pydantic -anyio==4.4.0 +anyio==4.6.0 # via starlette appdirs==1.4.4 # via @@ -87,7 +87,7 @@ beautifulsoup4==4.12.3 # -r requirements/edx/base.txt # -r requirements/edx/testing.in # pynliner -billiard==4.2.0 +billiard==4.2.1 # via # -r requirements/edx/base.txt # celery @@ -102,13 +102,13 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.35.1 +boto3==1.35.27 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.35.1 +botocore==1.35.27 # via # -r requirements/edx/base.txt # boto3 @@ -138,14 +138,14 @@ celery==5.4.0 # edx-enterprise # event-tracking # openedx-learning -certifi==2024.7.4 +certifi==2024.8.30 # via # -r requirements/edx/base.txt # elasticsearch # py2neo # requests # snowflake-connector-python -cffi==1.17.0 +cffi==1.17.1 # via # -r requirements/edx/base.txt # cryptography @@ -215,7 +215,7 @@ coverage[toml]==7.6.1 # pytest-cov crowdsourcehinter-xblock==0.7 # via -r requirements/edx/base.txt -cryptography==42.0.8 +cryptography==43.0.1 # via # -r requirements/edx/base.txt # django-fernet-fields-v2 @@ -245,13 +245,13 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -diff-cover==9.1.1 +diff-cover==9.2.0 # via -r requirements/edx/coverage.txt dill==0.3.8 # via pylint distlib==0.3.8 # via virtualenv -django==4.2.15 +django==4.2.16 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt @@ -379,7 +379,7 @@ django-js-asset==2.2.0 # django-mptt django-method-override==1.0.4 # via -r requirements/edx/base.txt -django-model-utils==4.5.1 +django-model-utils==5.0.0 # via # -r requirements/edx/base.txt # django-user-tasks @@ -412,7 +412,7 @@ django-oauth-toolkit==1.7.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -django-object-actions==4.2.0 +django-object-actions==4.3.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -428,7 +428,7 @@ django-sekizai==4.1.0 # via # -r requirements/edx/base.txt # openedx-django-wiki -django-ses==4.1.0 +django-ses==4.1.1 # via -r requirements/edx/base.txt django-simple-history==3.4.0 # via @@ -494,7 +494,7 @@ dnspython==2.6.1 # via # -r requirements/edx/base.txt # pymongo -done-xblock==2.3.0 +done-xblock==2.4.0 # via -r requirements/edx/base.txt drf-jwt==1.19.2 # via @@ -507,13 +507,13 @@ drf-yasg==1.21.7 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.1 +edx-ace==1.11.2 # via -r requirements/edx/base.txt -edx-api-doc-tools==1.8.0 +edx-api-doc-tools==2.0.0 # via # -r requirements/edx/base.txt # edx-name-affirmation -edx-auth-backends==4.3.0 +edx-auth-backends==4.4.0 # via -r requirements/edx/base.txt edx-braze-client==0.2.5 # via @@ -535,7 +535,7 @@ edx-celeryutils==1.3.0 # super-csv edx-codejail==3.4.1 # via -r requirements/edx/base.txt -edx-completion==4.6.7 +edx-completion==4.7.1 # via -r requirements/edx/base.txt edx-django-release-util==1.4.0 # via @@ -561,7 +561,7 @@ edx-django-utils==5.15.0 # openedx-events # ora2 # super-csv -edx-drf-extensions==10.3.0 +edx-drf-extensions==10.4.0 # via # -r requirements/edx/base.txt # edx-completion @@ -573,7 +573,7 @@ edx-drf-extensions==10.3.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.25.11 +edx-enterprise==4.25.17 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -586,14 +586,12 @@ edx-i18n-tools==1.5.0 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # ora2 -edx-lint==5.3.7 +edx-lint==5.4.0 # via -r requirements/edx/testing.in edx-milestones==0.6.0 # via -r requirements/edx/base.txt -edx-name-affirmation==2.4.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt +edx-name-affirmation==2.4.2 + # via -r requirements/edx/base.txt edx-opaque-keys[django]==2.11.0 # via # -r requirements/edx/base.txt @@ -615,11 +613,11 @@ edx-proctoring==4.18.1 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack -edx-rbac==1.9.0 +edx-rbac==1.10.0 # via # -r requirements/edx/base.txt # edx-enterprise -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -628,7 +626,7 @@ edx-search==4.0.0 # via -r requirements/edx/base.txt edx-sga==0.25.0 # via -r requirements/edx/base.txt -edx-submissions==3.7.7 +edx-submissions==3.8.0 # via # -r requirements/edx/base.txt # ora2 @@ -654,7 +652,7 @@ edx-when==2.5.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==2.5.0 +edxval==2.6.0 # via -r requirements/edx/base.txt elasticsearch==7.13.4 # via @@ -679,15 +677,15 @@ execnet==2.1.1 # via pytest-xdist factory-boy==3.3.1 # via -r requirements/edx/testing.in -faker==27.0.0 +faker==30.0.0 # via factory-boy -fastapi==0.112.1 +fastapi==0.115.0 # via pact-python -fastavro==1.9.5 +fastavro==1.9.7 # via # -r requirements/edx/base.txt # openedx-events -filelock==3.15.4 +filelock==3.16.1 # via # -r requirements/edx/base.txt # snowflake-connector-python @@ -724,7 +722,7 @@ geoip2==4.8.0 # via -r requirements/edx/base.txt glob2==0.7 # via -r requirements/edx/base.txt -google-api-core[grpc]==2.19.1 +google-api-core[grpc]==2.20.0 # via # -r requirements/edx/base.txt # firebase-admin @@ -732,11 +730,11 @@ google-api-core[grpc]==2.19.1 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.34.0 +google-auth==2.35.0 # via # -r requirements/edx/base.txt # google-api-core @@ -754,7 +752,7 @@ google-cloud-core==2.4.1 # -r requirements/edx/base.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.17.2 +google-cloud-firestore==2.19.0 # via # -r requirements/edx/base.txt # firebase-admin @@ -762,7 +760,7 @@ google-cloud-storage==2.18.2 # via # -r requirements/edx/base.txt # firebase-admin -google-crc32c==1.5.0 +google-crc32c==1.6.0 # via # -r requirements/edx/base.txt # google-cloud-storage @@ -771,19 +769,19 @@ google-resumable-media==2.7.2 # via # -r requirements/edx/base.txt # google-cloud-storage -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status grimp==3.4.1 # via import-linter -grpcio==1.65.5 +grpcio==1.66.1 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.65.5 +grpcio-status==1.66.1 # via # -r requirements/edx/base.txt # google-api-core @@ -806,7 +804,7 @@ httpretty==1.1.4 # via -r requirements/edx/testing.in icalendar==5.0.13 # via -r requirements/edx/base.txt -idna==3.7 +idna==3.10 # via # -r requirements/edx/base.txt # anyio @@ -816,7 +814,7 @@ idna==3.7 # yarl import-linter==2.0 # via -r requirements/edx/testing.in -importlib-metadata==8.3.0 +importlib-metadata==8.5.0 # via -r requirements/edx/base.txt inflection==0.5.1 # via @@ -854,7 +852,7 @@ joblib==1.4.2 # via # -r requirements/edx/base.txt # nltk -jsondiff==2.2.0 +jsondiff==2.2.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -881,7 +879,7 @@ jwcrypto==1.5.6 # -r requirements/edx/base.txt # django-oauth-toolkit # pylti1p3 -kombu==5.4.0 +kombu==5.4.2 # via # -r requirements/edx/base.txt # celery @@ -951,18 +949,18 @@ maxminddb==2.6.2 # geoip2 mccabe==0.7.0 # via pylint -meilisearch==0.31.4 +meilisearch==0.31.5 # via -r requirements/edx/base.txt mock==5.1.0 # via -r requirements/edx/base.txt -mongoengine==0.28.2 +mongoengine==0.29.1 # via -r requirements/edx/base.txt monotonic==1.6 # via # -r requirements/edx/base.txt # analytics-python # py2neo -more-itertools==10.4.0 +more-itertools==10.5.0 # via # -r requirements/edx/base.txt # cssutils @@ -970,11 +968,11 @@ mpmath==1.3.0 # via # -r requirements/edx/base.txt # sympy -msgpack==1.0.8 +msgpack==1.1.0 # via # -r requirements/edx/base.txt # cachecontrol -multidict==6.0.5 +multidict==6.1.0 # via # -r requirements/edx/base.txt # aiohttp @@ -1015,13 +1013,13 @@ openai==0.28.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -openedx-atlas==0.6.1 +openedx-atlas==0.6.2 # via # -r requirements/edx/base.txt # forum openedx-calc==3.1.0 # via -r requirements/edx/base.txt -openedx-django-pyfs==3.6.0 +openedx-django-pyfs==3.7.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock @@ -1030,7 +1028,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.14.0 +openedx-events==9.14.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1038,12 +1036,12 @@ openedx-events==9.14.0 # edx-event-bus-redis # event-tracking # ora2 -openedx-filters==1.9.0 +openedx-filters==1.10.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.11.4 +openedx-learning==0.13.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1053,7 +1051,7 @@ optimizely-sdk==4.1.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -ora2==6.11.2 +ora2==6.12.0 # via -r requirements/edx/base.txt packaging==24.1 # via @@ -1071,7 +1069,7 @@ pansi==2020.7.3 # via # -r requirements/edx/base.txt # py2neo -paramiko==3.4.1 +paramiko==3.5.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1089,7 +1087,7 @@ path-py==12.5.0 # staff-graded-xblock paver==1.3.4 # via -r requirements/edx/base.txt -pbr==6.0.0 +pbr==6.1.0 # via # -r requirements/edx/base.txt # stevedore @@ -1105,7 +1103,7 @@ pillow==10.4.0 # edx-enterprise # edx-organizations # edxval -platformdirs==4.2.2 +platformdirs==4.3.6 # via # -r requirements/edx/base.txt # pylint @@ -1123,7 +1121,7 @@ polib==1.2.0 # -r requirements/edx/base.txt # -r requirements/edx/testing.in # edx-i18n-tools -prompt-toolkit==3.0.47 +prompt-toolkit==3.0.48 # via # -r requirements/edx/base.txt # click-repl @@ -1132,7 +1130,7 @@ proto-plus==1.24.0 # -r requirements/edx/base.txt # google-api-core # google-cloud-firestore -protobuf==5.27.3 +protobuf==5.28.2 # via # -r requirements/edx/base.txt # google-api-core @@ -1152,13 +1150,13 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -pyasn1==0.6.0 +pyasn1==0.6.1 # via # -r requirements/edx/base.txt # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via # -r requirements/edx/base.txt # google-auth @@ -1178,12 +1176,12 @@ pycryptodomex==3.20.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.8.2 +pydantic==2.9.2 # via # -r requirements/edx/base.txt # camel-converter # fastapi -pydantic-core==2.20.1 +pydantic-core==2.23.4 # via # -r requirements/edx/base.txt # pydantic @@ -1257,15 +1255,15 @@ pyopenssl==24.2.1 # -r requirements/edx/base.txt # optimizely-sdk # snowflake-connector-python -pyparsing==3.1.2 +pyparsing==3.1.4 # via # -r requirements/edx/base.txt # chem # httplib2 # openedx-calc -pyproject-api==1.7.1 +pyproject-api==1.8.0 # via tox -pyquery==2.0.0 +pyquery==2.0.1 # via -r requirements/edx/testing.in pyrsistent==0.20.0 # via @@ -1275,7 +1273,7 @@ pysrt==1.1.2 # via # -r requirements/edx/base.txt # edxval -pytest==8.3.2 +pytest==8.3.3 # via # -r requirements/edx/testing.in # pylint-pytest @@ -1290,7 +1288,7 @@ pytest-attrib==0.1.3 # via -r requirements/edx/testing.in pytest-cov==5.0.0 # via -r requirements/edx/testing.in -pytest-django==4.8.0 +pytest-django==4.9.0 # via -r requirements/edx/testing.in pytest-json-report==1.5.0 # via -r requirements/edx/testing.in @@ -1337,10 +1335,9 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/base.txt -pytz==2024.1 +pytz==2024.2 # via # -r requirements/edx/base.txt - # django-ses # djangorestframework # drf-yasg # edx-completion @@ -1370,7 +1367,7 @@ pyyaml==6.0.2 # xblock random2==1.0.2 # via -r requirements/edx/base.txt -recommender-xblock==2.2.0 +recommender-xblock==2.2.1 # via -r requirements/edx/base.txt redis==5.0.8 # via @@ -1381,7 +1378,7 @@ referencing==0.35.1 # -r requirements/edx/base.txt # jsonschema # jsonschema-specifications -regex==2024.7.24 +regex==2024.9.11 # via # -r requirements/edx/base.txt # nltk @@ -1427,7 +1424,7 @@ rsa==4.9 # via # -r requirements/edx/base.txt # google-auth -rules==3.4 +rules==3.5 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1441,7 +1438,7 @@ sailthru-client==2.2.3 # via # -r requirements/edx/base.txt # edx-ace -scipy==1.14.0 +scipy==1.14.1 # via # -r requirements/edx/base.txt # chem @@ -1495,10 +1492,9 @@ slumber==0.7.1 # -r requirements/edx/base.txt # edx-bulk-grades # edx-enterprise - # edx-rest-api-client sniffio==1.3.1 # via anyio -snowflake-connector-python==3.12.0 +snowflake-connector-python==3.12.2 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1512,7 +1508,7 @@ social-auth-core==4.5.4 # -r requirements/edx/base.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.10.0 +sorl-thumbnail==12.11.0 # via # -r requirements/edx/base.txt # openedx-django-wiki @@ -1530,9 +1526,9 @@ sqlparse==0.5.1 # django staff-graded-xblock==2.3.0 # via -r requirements/edx/base.txt -starlette==0.38.2 +starlette==0.38.6 # via fastapi -stevedore==5.2.0 +stevedore==5.3.0 # via # -r requirements/edx/base.txt # code-annotations @@ -1544,7 +1540,7 @@ super-csv==3.2.0 # via # -r requirements/edx/base.txt # edx-bulk-grades -sympy==1.13.2 +sympy==1.13.3 # via # -r requirements/edx/base.txt # openedx-calc @@ -1566,7 +1562,7 @@ tomlkit==0.13.2 # -r requirements/edx/base.txt # pylint # snowflake-connector-python -tox==4.18.0 +tox==4.20.0 # via -r requirements/edx/testing.in tqdm==4.66.5 # via @@ -1586,10 +1582,11 @@ typing-extensions==4.12.2 # pydantic-core # pylti1p3 # snowflake-connector-python -tzdata==2024.1 +tzdata==2024.2 # via # -r requirements/edx/base.txt # celery + # kombu unicodecsv==0.14.1 # via # -r requirements/edx/base.txt @@ -1602,7 +1599,7 @@ uritemplate==4.1.1 # drf-spectacular # drf-yasg # google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1620,7 +1617,7 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.26.3 +virtualenv==20.26.5 # via tox voluptuous==0.15.2 # via @@ -1630,7 +1627,7 @@ walrus==0.9.4 # via # -r requirements/edx/base.txt # edx-event-bus-redis -watchdog==4.0.2 +watchdog==5.0.2 # via -r requirements/edx/base.txt wcwidth==0.2.13 # via @@ -1691,12 +1688,12 @@ xmlsec==1.3.13 # python3-saml xss-utils==0.6.0 # via -r requirements/edx/base.txt -yarl==1.9.4 +yarl==1.12.1 # via # -r requirements/edx/base.txt # aiohttp # pact-python -zipp==3.20.0 +zipp==3.20.2 # via # -r requirements/edx/base.txt # importlib-metadata diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index f7b35489c353..5bcb2aa55084 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -4,7 +4,7 @@ # # make upgrade # -build==1.2.1 +build==1.2.2 # via pip-tools click==8.1.6 # via diff --git a/requirements/pip.txt b/requirements/pip.txt index f0cf3d109992..36c777e21656 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -10,5 +10,5 @@ wheel==0.44.0 # The following packages are considered to be unsafe in a requirements file: pip==24.2 # via -r requirements/pip.in -setuptools==73.0.0 +setuptools==75.1.0 # via -r requirements/pip.in diff --git a/scripts/structures_pruning/requirements/base.txt b/scripts/structures_pruning/requirements/base.txt index 828a81a8d4ed..b80c660b8749 100644 --- a/scripts/structures_pruning/requirements/base.txt +++ b/scripts/structures_pruning/requirements/base.txt @@ -13,16 +13,16 @@ click-log==0.4.0 # via -r scripts/structures_pruning/requirements/base.in dnspython==2.6.1 # via pymongo -edx-opaque-keys==2.10.0 +edx-opaque-keys==2.11.0 # via -r scripts/structures_pruning/requirements/base.in -pbr==6.0.0 +pbr==6.1.0 # via stevedore pymongo==4.4.0 # via # -c scripts/structures_pruning/requirements/../../../requirements/constraints.txt # -r scripts/structures_pruning/requirements/base.in # edx-opaque-keys -stevedore==5.2.0 +stevedore==5.3.0 # via edx-opaque-keys typing-extensions==4.12.2 # via edx-opaque-keys diff --git a/scripts/structures_pruning/requirements/testing.txt b/scripts/structures_pruning/requirements/testing.txt index 47648d50fddb..8be2e15973d0 100644 --- a/scripts/structures_pruning/requirements/testing.txt +++ b/scripts/structures_pruning/requirements/testing.txt @@ -16,13 +16,13 @@ dnspython==2.6.1 # via # -r scripts/structures_pruning/requirements/base.txt # pymongo -edx-opaque-keys==2.10.0 +edx-opaque-keys==2.11.0 # via -r scripts/structures_pruning/requirements/base.txt iniconfig==2.0.0 # via pytest packaging==24.1 # via pytest -pbr==6.0.0 +pbr==6.1.0 # via # -r scripts/structures_pruning/requirements/base.txt # stevedore @@ -32,9 +32,9 @@ pymongo==4.4.0 # via # -r scripts/structures_pruning/requirements/base.txt # edx-opaque-keys -pytest==8.3.2 +pytest==8.3.3 # via -r scripts/structures_pruning/requirements/testing.in -stevedore==5.2.0 +stevedore==5.3.0 # via # -r scripts/structures_pruning/requirements/base.txt # edx-opaque-keys diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt index 3e5ed4738070..2fb9d4543e21 100644 --- a/scripts/user_retirement/requirements/base.txt +++ b/scripts/user_retirement/requirements/base.txt @@ -10,17 +10,17 @@ attrs==24.2.0 # via zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.in -boto3==1.35.1 +boto3==1.35.27 # via -r scripts/user_retirement/requirements/base.in -botocore==1.35.1 +botocore==1.35.27 # via # boto3 # s3transfer cachetools==5.5.0 # via google-auth -certifi==2024.7.4 +certifi==2024.8.30 # via requests -cffi==1.17.0 +cffi==1.17.1 # via # cryptography # pynacl @@ -33,9 +33,9 @@ click==8.1.6 # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt # -r scripts/user_retirement/requirements/base.in # edx-django-utils -cryptography==43.0.0 +cryptography==43.0.1 # via pyjwt -django==4.2.15 +django==4.2.16 # via # -c scripts/user_retirement/requirements/../../../requirements/common_constraints.txt # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt @@ -48,26 +48,26 @@ django-waffle==4.1.0 # via edx-django-utils edx-django-utils==5.15.0 # via edx-rest-api-client -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via -r scripts/user_retirement/requirements/base.in -google-api-core==2.19.1 +google-api-core==2.20.0 # via google-api-python-client -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via -r scripts/user_retirement/requirements/base.in -google-auth==2.34.0 +google-auth==2.35.0 # via # google-api-core # google-api-python-client # google-auth-httplib2 google-auth-httplib2==0.2.0 # via google-api-python-client -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via google-api-core httplib2==0.22.0 # via # google-api-python-client # google-auth-httplib2 -idna==3.7 +idna==3.10 # via requests isodate==0.6.1 # via zeep @@ -81,28 +81,28 @@ lxml==4.9.4 # via # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt # zeep -more-itertools==10.4.0 +more-itertools==10.5.0 # via simple-salesforce newrelic==9.13.0 # via edx-django-utils -pbr==6.0.0 +pbr==6.1.0 # via stevedore -platformdirs==4.2.2 +platformdirs==4.3.6 # via zeep proto-plus==1.24.0 # via google-api-core -protobuf==5.27.3 +protobuf==5.28.2 # via # google-api-core # googleapis-common-protos # proto-plus psutil==6.0.0 # via edx-django-utils -pyasn1==0.6.0 +pyasn1==0.6.1 # via # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via google-auth pycparser==2.22 # via cffi @@ -112,11 +112,11 @@ pyjwt[crypto]==2.9.0 # simple-salesforce pynacl==1.5.0 # via edx-django-utils -pyparsing==3.1.2 +pyparsing==3.1.4 # via httplib2 python-dateutil==2.9.0.post0 # via botocore -pytz==2024.1 +pytz==2024.2 # via # jenkinsapi # zeep @@ -131,7 +131,6 @@ requests==2.32.3 # requests-file # requests-toolbelt # simple-salesforce - # slumber # zeep requests-file==2.1.0 # via zeep @@ -150,11 +149,9 @@ six==1.16.0 # isodate # jenkinsapi # python-dateutil -slumber==0.7.1 - # via edx-rest-api-client sqlparse==0.5.1 # via django -stevedore==5.2.0 +stevedore==5.3.0 # via edx-django-utils typing-extensions==4.12.2 # via simple-salesforce @@ -162,7 +159,7 @@ unicodecsv==0.14.1 # via -r scripts/user_retirement/requirements/base.in uritemplate==4.1.1 # via google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt # botocore diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt index f7464dfa0602..73795fc6223e 100644 --- a/scripts/user_retirement/requirements/testing.txt +++ b/scripts/user_retirement/requirements/testing.txt @@ -14,11 +14,11 @@ attrs==24.2.0 # zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.txt -boto3==1.35.1 +boto3==1.35.27 # via # -r scripts/user_retirement/requirements/base.txt # moto -botocore==1.35.1 +botocore==1.35.27 # via # -r scripts/user_retirement/requirements/base.txt # boto3 @@ -28,11 +28,11 @@ cachetools==5.5.0 # via # -r scripts/user_retirement/requirements/base.txt # google-auth -certifi==2024.7.4 +certifi==2024.8.30 # via # -r scripts/user_retirement/requirements/base.txt # requests -cffi==1.17.0 +cffi==1.17.1 # via # -r scripts/user_retirement/requirements/base.txt # cryptography @@ -45,14 +45,14 @@ click==8.1.6 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -cryptography==43.0.0 +cryptography==43.0.1 # via # -r scripts/user_retirement/requirements/base.txt # moto # pyjwt ddt==1.7.2 # via -r scripts/user_retirement/requirements/testing.in -django==4.2.15 +django==4.2.16 # via # -r scripts/user_retirement/requirements/base.txt # django-crum @@ -70,15 +70,15 @@ edx-django-utils==5.15.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-rest-api-client -edx-rest-api-client==5.7.1 +edx-rest-api-client==6.0.0 # via -r scripts/user_retirement/requirements/base.txt -google-api-core==2.19.1 +google-api-core==2.20.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-python-client -google-api-python-client==2.141.0 +google-api-python-client==2.147.0 # via -r scripts/user_retirement/requirements/base.txt -google-auth==2.34.0 +google-auth==2.35.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -88,7 +88,7 @@ google-auth-httplib2==0.2.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-python-client -googleapis-common-protos==1.63.2 +googleapis-common-protos==1.65.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -97,7 +97,7 @@ httplib2==0.22.0 # -r scripts/user_retirement/requirements/base.txt # google-api-python-client # google-auth-httplib2 -idna==3.7 +idna==3.10 # via # -r scripts/user_retirement/requirements/base.txt # requests @@ -126,7 +126,7 @@ markupsafe==2.1.5 # werkzeug mock==5.1.0 # via -r scripts/user_retirement/requirements/testing.in -more-itertools==10.4.0 +more-itertools==10.5.0 # via # -r scripts/user_retirement/requirements/base.txt # simple-salesforce @@ -138,11 +138,11 @@ newrelic==9.13.0 # edx-django-utils packaging==24.1 # via pytest -pbr==6.0.0 +pbr==6.1.0 # via # -r scripts/user_retirement/requirements/base.txt # stevedore -platformdirs==4.2.2 +platformdirs==4.3.6 # via # -r scripts/user_retirement/requirements/base.txt # zeep @@ -152,7 +152,7 @@ proto-plus==1.24.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core -protobuf==5.27.3 +protobuf==5.28.2 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -162,12 +162,12 @@ psutil==6.0.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -pyasn1==0.6.0 +pyasn1==0.6.1 # via # -r scripts/user_retirement/requirements/base.txt # pyasn1-modules # rsa -pyasn1-modules==0.4.0 +pyasn1-modules==0.4.1 # via # -r scripts/user_retirement/requirements/base.txt # google-auth @@ -184,18 +184,18 @@ pynacl==1.5.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -pyparsing==3.1.2 +pyparsing==3.1.4 # via # -r scripts/user_retirement/requirements/base.txt # httplib2 -pytest==8.3.2 +pytest==8.3.3 # via -r scripts/user_retirement/requirements/testing.in python-dateutil==2.9.0.post0 # via # -r scripts/user_retirement/requirements/base.txt # botocore # moto -pytz==2024.1 +pytz==2024.2 # via # -r scripts/user_retirement/requirements/base.txt # jenkinsapi @@ -216,7 +216,6 @@ requests==2.32.3 # requests-toolbelt # responses # simple-salesforce - # slumber # zeep requests-file==2.1.0 # via @@ -250,15 +249,11 @@ six==1.16.0 # isodate # jenkinsapi # python-dateutil -slumber==0.7.1 - # via - # -r scripts/user_retirement/requirements/base.txt - # edx-rest-api-client sqlparse==0.5.1 # via # -r scripts/user_retirement/requirements/base.txt # django -stevedore==5.2.0 +stevedore==5.3.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils @@ -272,13 +267,13 @@ uritemplate==4.1.1 # via # -r scripts/user_retirement/requirements/base.txt # google-api-python-client -urllib3==1.26.19 +urllib3==1.26.20 # via # -r scripts/user_retirement/requirements/base.txt # botocore # requests # responses -werkzeug==3.0.3 +werkzeug==3.0.4 # via moto xmltodict==0.13.0 # via moto diff --git a/scripts/xblock/requirements.txt b/scripts/xblock/requirements.txt index 5b7f07242e6d..81ed56ea694a 100644 --- a/scripts/xblock/requirements.txt +++ b/scripts/xblock/requirements.txt @@ -4,17 +4,17 @@ # # make upgrade # -certifi==2024.7.4 +certifi==2024.8.30 # via requests charset-normalizer==2.0.12 # via # -c scripts/xblock/../../requirements/constraints.txt # requests -idna==3.7 +idna==3.10 # via requests requests==2.32.3 # via -r scripts/xblock/requirements.in -urllib3==1.26.19 +urllib3==1.26.20 # via # -c scripts/xblock/../../requirements/constraints.txt # requests diff --git a/setup.py b/setup.py index bf662b563c7f..28a25cc91476 100644 --- a/setup.py +++ b/setup.py @@ -129,7 +129,8 @@ 'discussions_link = openedx.core.djangoapps.discussions.transformers:DiscussionsTopicLinkTransformer', ], "openedx.ace.policy": [ - "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout" + "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout", + "course_push_notification_optout = openedx.core.djangoapps.notifications.policies:CoursePushNotificationOptout", # lint-amnesty, pylint: disable=line-too-long ], "openedx.call_to_action": [ "personalized_learner_schedules = openedx.features.personalized_learner_schedules.call_to_action:PersonalizedLearnerScheduleCallToAction" # lint-amnesty, pylint: disable=line-too-long diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index 54ca0cbc312f..c1c650144b05 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -616,11 +616,15 @@ def index_dictionary(self): "", capa_content ) + # Strip out all other tags, leaving their content. But we want spaces between adjacent tags, so that + #
Option A
Option B
+ # becomes "Option A Option B" not "Option AOption B" (these will appear in search results) + capa_content = re.sub(r"<([^>]+)>", r" <\2>", capa_content) capa_content = re.sub( r"(\s| |//)+", " ", nh3.clean(capa_content, tags=set()) - ) + ).strip() capa_body = { "capa_content": capa_content, diff --git a/xmodule/docs/decisions/0003-library-content-block-schema.rst b/xmodule/docs/decisions/0003-library-content-block-schema.rst index cf49f72864e8..bf183dab7375 100644 --- a/xmodule/docs/decisions/0003-library-content-block-schema.rst +++ b/xmodule/docs/decisions/0003-library-content-block-schema.rst @@ -5,9 +5,9 @@ Evolving the library_content block schema Status ****** -**Provisional** +**Replaced** by the `Upstream-Downstream ADR`_. -Subject to change due to implementation learnings and stakeholder feedback. +.. _Upstream-Downstream ADR: https://docs/decisions/0020-upstream-block.rst Context ******* diff --git a/xmodule/graders.py b/xmodule/graders.py index a587204d682e..5261b9f4479a 100644 --- a/xmodule/graders.py +++ b/xmodule/graders.py @@ -387,7 +387,7 @@ def grade(self, grade_sheet, generate_random_scores=False): section_name = scores[i].display_name percentage = scores[i].percent_graded - summary_format = "{section_type} {index} - {name} - {percent:.0%} ({earned:.3n}/{possible:.3n})" + summary_format = "{section_type} {index} - {name} - {percent:.2%} ({earned:.3n}/{possible:.3n})" summary = summary_format.format( index=i + self.starting_index, section_type=self.section_type, @@ -421,7 +421,7 @@ def grade(self, grade_sheet, generate_random_scores=False): if len(breakdown) == 1: # if there is only one entry in a section, suppress the existing individual entry and the average, # and just display a single entry for the section. - total_detail = "{section_type} = {percent:.0%}".format( + total_detail = "{section_type} = {percent:.2%}".format( percent=total_percent, section_type=self.section_type, ) @@ -430,7 +430,7 @@ def grade(self, grade_sheet, generate_random_scores=False): 'detail': total_detail, 'category': self.category, 'prominent': True}, ] else: # Translators: "Homework Average = 0%" - total_detail = _("{section_type} Average = {percent:.0%}").format( + total_detail = _("{section_type} Average = {percent:.2%}").format( percent=total_percent, section_type=self.section_type ) diff --git a/xmodule/partitions/tests/test_partitions.py b/xmodule/partitions/tests/test_partitions.py index fde6afd3141b..41f26b14db52 100644 --- a/xmodule/partitions/tests/test_partitions.py +++ b/xmodule/partitions/tests/test_partitions.py @@ -462,21 +462,6 @@ class TestPartitionService(PartitionServiceBaseClass): Test getting a user's group out of a partition """ - def test_get_user_group_id_for_partition(self): - # assign the first group to be returned - user_partition_id = self.user_partition.id - groups = self.user_partition.groups - self.user_partition.scheme.current_group = groups[0] - - # get a group assigned to the user - group1_id = self.partition_service.get_user_group_id_for_partition(self.user, user_partition_id) - assert group1_id == groups[0].id - - # switch to the second group and verify that it is returned for the user - self.user_partition.scheme.current_group = groups[1] - group2_id = self.partition_service.get_user_group_id_for_partition(self.user, user_partition_id) - assert group2_id == groups[1].id - def test_caching(self): username = "psvc_cache_user" user_partition_id = self.user_partition.id diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py index d1c01e109718..c81b137f1b23 100644 --- a/xmodule/tests/test_capa_block.py +++ b/xmodule/tests/test_capa_block.py @@ -3290,7 +3290,7 @@ def test_response_types_ignores_non_response_tags(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['multiplechoiceresponse'], - 'content': {'display_name': name, 'capa_content': ' Label Some comment Apple Banana Chocolate Donut '}} + 'content': {'display_name': name, 'capa_content': 'Label Some comment Apple Banana Chocolate Donut'}} def test_response_types_multiple_tags(self): xml = textwrap.dedent(""" @@ -3328,7 +3328,7 @@ def test_response_types_multiple_tags(self): 'problem_types': {"optionresponse", "multiplechoiceresponse"}, 'content': { 'display_name': name, - 'capa_content': " Label Some comment Donut Buggy '1','2' " + 'capa_content': "Label Some comment Donut Buggy '1','2'" }, } ) @@ -3369,7 +3369,7 @@ def test_solutions_not_indexed(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': [], - 'content': {'display_name': name, 'capa_content': ' '}} + 'content': {'display_name': name, 'capa_content': ''}} def test_indexing_checkboxes(self): name = "Checkboxes" @@ -3390,7 +3390,7 @@ def test_indexing_checkboxes(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['choiceresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_dropdown(self): name = "Dropdown" @@ -3405,7 +3405,7 @@ def test_indexing_dropdown(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['optionresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_multiple_choice(self): name = "Multiple Choice" @@ -3424,7 +3424,7 @@ def test_indexing_multiple_choice(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['multiplechoiceresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_numerical_input(self): name = "Numerical Input" @@ -3446,7 +3446,7 @@ def test_indexing_numerical_input(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['numericalresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_text_input(self): name = "Text Input" @@ -3465,7 +3465,7 @@ def test_indexing_text_input(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['stringresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_non_latin_problem(self): sample_text_input_problem_xml = textwrap.dedent(""" @@ -3476,7 +3476,7 @@ def test_indexing_non_latin_problem(self): """) name = "Non latin Input" block = self._create_block(sample_text_input_problem_xml, name=name) - capa_content = " Δοκιμή με μεταβλητές με Ελληνικούς χαρακτήρες μέσα σε python: $FX1_VAL " + capa_content = "Δοκιμή με μεταβλητές με Ελληνικούς χαρακτήρες μέσα σε python: $FX1_VAL" block_dict = block.index_dictionary() assert block_dict['content']['capa_content'] == smart_str(capa_content) @@ -3503,7 +3503,7 @@ def test_indexing_checkboxes_with_hints_and_feedback(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['choiceresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_dropdown_with_hints_and_feedback(self): name = "Dropdown with Hints and Feedback" @@ -3523,7 +3523,7 @@ def test_indexing_dropdown_with_hints_and_feedback(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['optionresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_multiple_choice_with_hints_and_feedback(self): name = "Multiple Choice with Hints and Feedback" @@ -3543,7 +3543,7 @@ def test_indexing_multiple_choice_with_hints_and_feedback(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['multiplechoiceresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_numerical_input_with_hints_and_feedback(self): name = "Numerical Input with Hints and Feedback" @@ -3561,7 +3561,7 @@ def test_indexing_numerical_input_with_hints_and_feedback(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['numericalresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_text_input_with_hints_and_feedback(self): name = "Text Input with Hints and Feedback" @@ -3579,7 +3579,7 @@ def test_indexing_text_input_with_hints_and_feedback(self): assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': ['stringresponse'], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ').strip()}} def test_indexing_problem_with_html_tags(self): sample_problem_xml = textwrap.dedent(""" @@ -3598,14 +3598,33 @@ def test_indexing_problem_with_html_tags(self): """) name = "Mixed business" block = self._create_block(sample_problem_xml, name=name) - capa_content = textwrap.dedent(""" - This has HTML comment in it. - HTML end. - """) + capa_content = "This has HTML comment in it. HTML end." assert block.index_dictionary() ==\ {'content_type': ProblemBlock.INDEX_CONTENT_TYPE, 'problem_types': [], - 'content': {'display_name': name, 'capa_content': capa_content.replace('\n', ' ')}} + 'content': {'display_name': name, 'capa_content': capa_content}} + + def test_indexing_problem_with_no_whitespace_between_tags(self): + """ + The new (MFE) visual editor for capa problems renders the OLX without spaces between the tags. + We want to make sure the index description is still readable and has whitespace. + """ + sample_problem_xml = ( + "" + "
Question text here.
" + "
Option A
" + "
Option B
" + "
" + "
" + ) + name = "No spaces" + block = self._create_block(sample_problem_xml, name=name) + capa_content = "Question text here. Option A Option B" + assert block.index_dictionary() == { + 'content_type': ProblemBlock.INDEX_CONTENT_TYPE, + 'problem_types': ['choiceresponse'], + 'content': {'display_name': name, 'capa_content': capa_content}, + } def test_invalid_xml_handling(self): """ diff --git a/xmodule/video_block/transcripts_utils.py b/xmodule/video_block/transcripts_utils.py index e41f925295f0..132b8cff1e14 100644 --- a/xmodule/video_block/transcripts_utils.py +++ b/xmodule/video_block/transcripts_utils.py @@ -1074,7 +1074,7 @@ def get_transcript_from_learning_core(video_block, language, output_format, tran """ # TODO: Update to use Learning Core data models once static assets support # has been added. - raise NotImplementedError("Transcripts not supported.") + raise NotFoundError("No transcript - transcripts not supported yet by learning core components.") def get_transcript(video, lang=None, output_format=Transcript.SRT, youtube_id=None): diff --git a/xmodule/video_block/video_block.py b/xmodule/video_block/video_block.py index b4fddb63fa7a..782645c373b0 100644 --- a/xmodule/video_block/video_block.py +++ b/xmodule/video_block/video_block.py @@ -482,7 +482,7 @@ def get_html(self, view=STUDENT_VIEW, context=None): # lint-amnesty, pylint: di 'hide_downloads': is_public_view or is_embed, 'id': self.location.html_id(), 'block_id': str(self.location), - 'course_id': str(self.location.course_key), + 'course_id': str(self.context_key), 'video_id': str(self.edx_video_id), 'user_id': self.get_user_id(), 'is_embed': is_embed, @@ -510,8 +510,10 @@ def get_course_video_sharing_override(self): """ Return course video sharing options override or None """ + if not self.context_key.is_course: + return False # Only courses support this feature at all (not libraries) try: - course = get_course_by_id(self.course_id) + course = get_course_by_id(self.context_key) return getattr(course, 'video_sharing_options', None) # In case the course / modulestore does something weird @@ -523,11 +525,13 @@ def is_public_sharing_enabled(self): """ Is public sharing enabled for this video? """ + if not self.context_key.is_course: + return False # Only courses support this feature at all (not libraries) try: # Video share feature must be enabled for sharing settings to take effect - feature_enabled = PUBLIC_VIDEO_SHARE.is_enabled(self.location.course_key) + feature_enabled = PUBLIC_VIDEO_SHARE.is_enabled(self.context_key) except Exception as err: # pylint: disable=broad-except - log.exception(f"Error retrieving course for course ID: {self.location.course_key}") + log.exception(f"Error retrieving course for course ID: {self.context_key}") return False if not feature_enabled: return False @@ -552,11 +556,13 @@ def is_transcript_feedback_enabled(self): """ Is transcript feedback enabled for this video? """ + if not self.context_key.is_course: + return False # Only courses support this feature at all (not libraries) try: # Video transcript feedback must be enabled in order to show the widget - feature_enabled = TRANSCRIPT_FEEDBACK.is_enabled(self.location.course_key) + feature_enabled = TRANSCRIPT_FEEDBACK.is_enabled(self.context_key) except Exception as err: # pylint: disable=broad-except - log.exception(f"Error retrieving course for course ID: {self.location.course_key}") + log.exception(f"Error retrieving course for course ID: {self.context_key}") return False return feature_enabled